LazyRun v1.0.0: shortcut maker untuk executable dan PWA
- Fungsi A: shortcut dari file executable (ELF/AppImage/script) - Fungsi B: shortcut PWA/URL dengan deteksi browser (native, /opt, Flatpak) - Checkbox Menu Aplikasi / Desktop, desktop-only pakai NoDisplay=true - List + uninstall + deteksi shortcut yang file .desktop-nya hilang - Metadata ~/.config/lazyrun/shortcuts.json, refresh cache XDG/KDE - AppImage + zsync update info via build.sh
This commit is contained in:
1 parent
9cd5a1f82d
commit
da31f7d7d7
31 files changed
+2916
No files matched your search
@@ -0,0 +1,175 @@
|
||||
// Package icon: favicon.go mengambil ikon dari halaman web untuk shortcut PWA.
|
||||
package icon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var client = &http.Client{Timeout: 8 * time.Second}
|
||||
|
||||
// shortcut: parsing HTML pakai regex, bukan parser penuh; cukup untuk tag <link rel=icon>.
|
||||
var (
|
||||
linkTag = regexp.MustCompile(`(?is)<link\b[^>]*>`)
|
||||
relAttr = regexp.MustCompile(`(?is)\brel\s*=\s*["']?([^"'>]+)`)
|
||||
hrefAttr = regexp.MustCompile(`(?is)\bhref\s*=\s*["']?([^"'\s>]+)`)
|
||||
typeAttr = regexp.MustCompile(`(?is)\btype\s*=\s*["']?([^"'\s>]+)`)
|
||||
sizeAttr = regexp.MustCompile(`(?is)\bsizes\s*=\s*["']?(\d+)`)
|
||||
)
|
||||
|
||||
// Fetch mengambil favicon dari halaman. Return data + ekstensi file (".png", ".jpg", ".svg").
|
||||
func Fetch(pageURL string) ([]byte, string, error) {
|
||||
base, err := url.Parse(pageURL)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, candidate := range iconCandidates(base) {
|
||||
data, err := get(candidate)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if ext := sniffExt(data); ext != "" {
|
||||
return data, ext, nil
|
||||
}
|
||||
if png := largestPNGFromICO(data); png != nil {
|
||||
return png, ".png", nil
|
||||
}
|
||||
lastErr = errors.New("unsupported icon format at " + candidate)
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("no favicon found")
|
||||
}
|
||||
return nil, "", lastErr
|
||||
}
|
||||
|
||||
// iconCandidate adalah kandidat URL ikon dengan skor prioritas.
|
||||
type iconCandidate struct {
|
||||
url string
|
||||
score int
|
||||
}
|
||||
|
||||
// iconCandidates mengumpulkan URL ikon dari HTML, ikon besar didahulukan, favicon.ico terakhir.
|
||||
func iconCandidates(base *url.URL) []string {
|
||||
var found []iconCandidate
|
||||
|
||||
if page, err := get(base.String()); err == nil {
|
||||
for _, tag := range linkTag.FindAllString(string(page), -1) {
|
||||
rel := strings.ToLower(attr(relAttr, tag))
|
||||
if !strings.Contains(rel, "icon") || strings.Contains(rel, "mask-icon") {
|
||||
continue
|
||||
}
|
||||
href := attr(hrefAttr, tag)
|
||||
if href == "" {
|
||||
continue
|
||||
}
|
||||
ref, err := url.Parse(href)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
score := 1
|
||||
if strings.Contains(strings.ToLower(attr(typeAttr, tag)), "png") {
|
||||
score += 10
|
||||
}
|
||||
if size := attr(sizeAttr, tag); size != "" {
|
||||
fmt.Sscanf(size, "%d", &score)
|
||||
score += 10
|
||||
}
|
||||
found = append(found, iconCandidate{base.ResolveReference(ref).String(), score})
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(found, func(i, j int) bool { return found[i].score > found[j].score })
|
||||
|
||||
out := make([]string, 0, len(found)+1)
|
||||
seen := map[string]bool{}
|
||||
for _, c := range found {
|
||||
if !seen[c.url] {
|
||||
seen[c.url] = true
|
||||
out = append(out, c.url)
|
||||
}
|
||||
}
|
||||
fallback := base.ResolveReference(&url.URL{Path: "/favicon.ico"}).String()
|
||||
if !seen[fallback] {
|
||||
out = append(out, fallback)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func attr(re *regexp.Regexp, tag string) string {
|
||||
if m := re.FindStringSubmatch(tag); m != nil {
|
||||
return m[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func get(rawURL string) ([]byte, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) LazyRun")
|
||||
req.Header.Set("Accept", "text/html,image/*;q=0.9,*/*;q=0.8")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s: %s", rawURL, resp.Status)
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||
}
|
||||
|
||||
func sniffExt(data []byte) string {
|
||||
head := data
|
||||
if len(head) > 512 {
|
||||
head = head[:512]
|
||||
}
|
||||
switch {
|
||||
case bytes.HasPrefix(data, []byte("\x89PNG\r\n\x1a\n")):
|
||||
return ".png"
|
||||
case bytes.HasPrefix(data, []byte{0xff, 0xd8, 0xff}):
|
||||
return ".jpg"
|
||||
case bytes.Contains(bytes.ToLower(head), []byte("<svg")):
|
||||
return ".svg"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// largestPNGFromICO mengambil PNG terbesar di dalam file .ico.
|
||||
// ICO dari BMP lama tidak didukung (langka untuk favicon).
|
||||
func largestPNGFromICO(data []byte) []byte {
|
||||
if len(data) < 22 || data[0] != 0 || data[1] != 0 || data[2] != 1 || data[3] != 0 {
|
||||
return nil
|
||||
}
|
||||
count := int(data[4]) | int(data[5])<<8
|
||||
var best []byte
|
||||
for i := 0; i < count; i++ {
|
||||
off := 6 + i*16
|
||||
if off+16 > len(data) {
|
||||
break
|
||||
}
|
||||
size := int(data[off+8]) | int(data[off+9])<<8 | int(data[off+10])<<16 | int(data[off+11])<<24
|
||||
start := int(data[off+12]) | int(data[off+13])<<8 | int(data[off+14])<<16 | int(data[off+15])<<24
|
||||
if size <= 0 || start < 0 || start+size > len(data) {
|
||||
continue
|
||||
}
|
||||
blob := data[start : start+size]
|
||||
if bytes.HasPrefix(blob, []byte("\x89PNG\r\n\x1a\n")) && len(blob) > len(best) {
|
||||
best = blob
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package icon
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func icoWithPNG(png []byte) []byte {
|
||||
ico := make([]byte, 22+len(png))
|
||||
ico[2], ico[3] = 1, 0 // type = icon
|
||||
ico[4], ico[5] = 1, 0 // jumlah gambar
|
||||
put32 := func(off, v int) {
|
||||
ico[off] = byte(v)
|
||||
ico[off+1] = byte(v >> 8)
|
||||
ico[off+2] = byte(v >> 16)
|
||||
ico[off+3] = byte(v >> 24)
|
||||
}
|
||||
put32(14, len(png)) // bytesInRes
|
||||
put32(18, 22) // imageOffset
|
||||
copy(ico[22:], png)
|
||||
return ico
|
||||
}
|
||||
|
||||
var png = append([]byte("\x89PNG\r\n\x1a\n"), []byte("payload")...)
|
||||
|
||||
func TestFetchUsesLinkedIcon(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/":
|
||||
io.WriteString(w, `<html><head>`+
|
||||
`<link rel="apple-touch-icon" href="/small.ico">`+
|
||||
`<link rel="icon" type="image/png" sizes="192x192" href="/big.png">`+
|
||||
`</head></html>`)
|
||||
case "/big.png":
|
||||
w.Write(png)
|
||||
default:
|
||||
w.Write(icoWithPNG(png))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
data, ext, err := Fetch(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ext != ".png" || string(data) != string(png) {
|
||||
t.Errorf("ikon terpilih salah: ext=%s data=%q", ext, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchFallsBackToFaviconICO(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
io.WriteString(w, `<html><head><title>no icon</title></head></html>`)
|
||||
return
|
||||
}
|
||||
w.Write(icoWithPNG(png))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
data, ext, err := Fetch(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ext != ".png" || string(data) != string(png) {
|
||||
t.Errorf("PNG di dalam .ico harus dipakai: ext=%s data=%q", ext, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchFailsWithoutIcon(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if _, _, err := Fetch(srv.URL); err == nil {
|
||||
t.Error("tanpa favicon harus error supaya UI pakai ikon default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLargestPNGFromICO(t *testing.T) {
|
||||
if got := largestPNGFromICO(icoWithPNG(png)); string(got) != string(png) {
|
||||
t.Errorf("PNG di dalam .ico tidak terbaca: %q", got)
|
||||
}
|
||||
if got := largestPNGFromICO([]byte("bukan ico")); got != nil {
|
||||
t.Errorf("data bukan ICO harus menghasilkan nil, dapat %q", got)
|
||||
}
|
||||
// ICO berisi BMP lama (bukan PNG) tidak didukung.
|
||||
bmp := make([]byte, 40)
|
||||
bmp[0], bmp[1], bmp[2], bmp[3] = 0, 0, 1, 0
|
||||
bmp[4], bmp[5] = 1, 0
|
||||
if got := largestPNGFromICO(bmp); got != nil {
|
||||
t.Errorf("ICO BMP harus mengembalikan nil, dapat %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSniffExt(t *testing.T) {
|
||||
if ext := sniffExt(png); ext != ".png" {
|
||||
t.Errorf("sniffExt(png) = %s", ext)
|
||||
}
|
||||
if ext := sniffExt([]byte(`<?xml version="1.0"?><svg xmlns="..."><path/></svg>`)); ext != ".svg" {
|
||||
t.Errorf("sniffExt(svg) = %s", ext)
|
||||
}
|
||||
if ext := sniffExt([]byte("\xff\xd8\xff\xe0jpeg")); ext != ".jpg" {
|
||||
t.Errorf("sniffExt(jpg) = %s", ext)
|
||||
}
|
||||
if ext := sniffExt([]byte("plain text")); ext != "" {
|
||||
t.Errorf("sniffExt(text) = %s", ext)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
// Package icon menyimpan ikon shortcut ke direktori ikon XDG.
|
||||
package icon
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"lazyrun/assets"
|
||||
"lazyrun/internal/xdg"
|
||||
)
|
||||
|
||||
var exts = []string{".png", ".svg", ".jpg", ".jpeg", ".xpm"}
|
||||
var nearbyNames = []string{"icon", "logo"}
|
||||
|
||||
// Save memasang ikon untuk shortcut dan mengembalikan path absolutnya.
|
||||
// Urutan pilihan: ikon dari user, ikon di folder binary, lalu ikon default LazyRun.
|
||||
func Save(userIcon, nearbyDir, nearbyBase, slug string) (string, error) {
|
||||
if userIcon != "" {
|
||||
return fromFile(userIcon, slug)
|
||||
}
|
||||
for _, name := range append([]string{nearbyBase}, nearbyNames...) {
|
||||
if name == "" {
|
||||
continue
|
||||
}
|
||||
if p := findNearby(nearbyDir, name); p != "" {
|
||||
return fromFile(p, slug)
|
||||
}
|
||||
}
|
||||
return Default(slug)
|
||||
}
|
||||
|
||||
// Default memasang ikon bawaan LazyRun.
|
||||
func Default(slug string) (string, error) {
|
||||
return SaveBytes(assets.Icon, ".png", slug)
|
||||
}
|
||||
|
||||
func findNearby(dir, base string) string {
|
||||
base = strings.ToLower(base)
|
||||
for _, ext := range exts {
|
||||
p := filepath.Join(dir, base+ext)
|
||||
if st, err := os.Stat(p); err == nil && !st.IsDir() {
|
||||
return p
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func fromFile(src, slug string) (string, error) {
|
||||
data, err := os.ReadFile(src)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
ext := strings.ToLower(filepath.Ext(src))
|
||||
// ICO tidak dipakai desktop theme; pakai PNG di dalamnya kalau ada.
|
||||
if ext == ".ico" {
|
||||
png := largestPNGFromICO(data)
|
||||
if png == nil {
|
||||
return "", errors.New("this .ico has no PNG inside it, pick another icon")
|
||||
}
|
||||
return SaveBytes(png, ".png", slug)
|
||||
}
|
||||
return SaveBytes(data, ext, slug)
|
||||
}
|
||||
|
||||
// SaveBytes menulis data ikon ke direktori ikon XDG.
|
||||
func SaveBytes(data []byte, ext, slug string) (string, error) {
|
||||
if ext == "" {
|
||||
ext = ".png"
|
||||
}
|
||||
if err := os.MkdirAll(xdg.IconsDir(), 0o755); err != nil {
|
||||
return "", err
|
||||
}
|
||||
// shortcut: ikon disimpan apa adanya di hicolor/512x512 tanpa resize;
|
||||
// desktop environment men-skalanya sendiri. Tambah resize kalau ikon kecil terlihat pecah.
|
||||
dest := filepath.Join(xdg.IconsDir(), slug+ext)
|
||||
return dest, os.WriteFile(dest, data, 0o644)
|
||||
}
|
||||
|
||||
// IconBytes adalah ikon default LazyRun.
|
||||
func IconBytes() []byte { return assets.Icon }
|
||||
Reference in new issue
Block a user