From 59145910b59b549b166d8cc939cea16dcb8c95c3 Mon Sep 17 00:00:00 2001 From: oxpoe Date: Tue, 13 Oct 2026 07:56:35 +0700 Subject: [PATCH] Ikon: cari per domain/URL gambar + semua format dikonversi ke PNG - Dialog ikon: nama aplikasi -> Iconify; domain (mikrotik.com) -> ikon resmi situs (manifest 512, apple-touch-icon, favicon) + layanan favicon; URL gambar -> langsung - Contoh kasus: 'winbox' cuma punya 1 ikon tidak nyambung di Iconify, tapi 'mikrotik.com' memberi ikon resminya (5 pilihan, semuanya PNG) - Decoder ICO: entri BMP 24/32-bit (favicon.ico lama) ikut didukung, bukan cuma PNG - FromURL/Fetch selalu mengembalikan PNG; hasil raster/konversi seragam 512px - Tes: deteksi URL gambar/domain, daftar ikon situs, decode ICO BMP, konversi format --- README.md | 18 +++--- internal/icon/favicon.go | 93 ++++++++++++++++++++++++----- internal/icon/favicon_test.go | 16 ++++- internal/icon/ico.go | 109 ++++++++++++++++++++++++++++++++++ internal/icon/install.go | 6 +- internal/icon/online.go | 66 ++++++++++++++++++-- internal/icon/online_test.go | 82 ++++++++++++++++++++++++- internal/icon/raster_test.go | 65 ++++++++++++++++++++ internal/ui/dialog_icon.go | 19 +++++- 9 files changed, 441 insertions(+), 33 deletions(-) create mode 100644 internal/icon/ico.go diff --git a/README.md b/README.md index 8b8f62f..1fbd49f 100644 --- a/README.md +++ b/README.md @@ -12,12 +12,16 @@ AppImage, no terminal needed. - **Web app shortcut** — opens the URL as an app: `--app=` for Chromium-based browsers (Chrome, Chromium, Brave, Edge, Vivaldi, Helium, including Flatpak builds), `--new-window` for Firefox. -- **Icons** — three ways: pick a file, search an online icon library - ([Iconify](https://iconify.design), with the app name as the query), or let LazyRun - do it automatically: a nearby image file, the site's favicon (including high - resolution icons from the web app manifest), or a brand icon matched by name. - Every downloaded icon remembers its source, so the *sync icon* button in the list - can fetch it again later. +- **Icons** — pick a file, or search in the *Icon from the internet* dialog: + - **app name** (`whatsapp`) → search of the [Iconify](https://iconify.design) icon library; + - **website** (`mikrotik.com`) → the site's own icons: web app manifest (up to + 512 px), apple-touch-icon, favicon, plus a favicon service. Best option for apps + that have no icon in icon libraries (e.g. Winbox → `mikrotik.com`); + - **image URL** (`https://…/icon.png`) → that image directly. + Installation also tries automatically: a nearby image file, the site's icons, then a + brand icon matched by name. Every icon is converted to PNG (SVG, webp, gif, bmp, + ico and jpeg included) at up to 512 px, and remembers its source so the *sync icon* + button in the list can fetch it again later. - **Shortcut location** — checkbox for *Application menu* and/or *Desktop*, at least one is required. Desktop-only entries are written with `NoDisplay=true` so they do not show up in the menu. @@ -56,7 +60,7 @@ Keep it wherever you like (for example `~/.local/bin/`). Automatic updates need | Purpose | Path | |---|---| | Copied binary | `~/.local/bin/` | -| Icon | `~/.local/share/icons/hicolor/512x512/apps/.png` | +| Icon (always PNG) | `~/.local/share/icons/hicolor/512x512/apps/.png` | | Menu entry (source of truth) | `~/.local/share/applications/.desktop` | | Desktop copy | `~/Desktop/.desktop` (from `xdg-user-dir DESKTOP`) | | LazyRun metadata | `~/.config/lazyrun/shortcuts.json` | diff --git a/internal/icon/favicon.go b/internal/icon/favicon.go index a9df312..c5c3769 100644 --- a/internal/icon/favicon.go +++ b/internal/icon/favicon.go @@ -36,18 +36,17 @@ func Fetch(pageURL string) ([]byte, string, error) { var lastErr error for _, candidate := range iconCandidates(base) { - data, err := get(candidate) + data, err := get(candidate.url) if err != nil { lastErr = err continue } - if ext := sniffExt(data); ext != "" { - return data, ext, nil + converted, err := ToPNG(data) + if err != nil { + lastErr = fmt.Errorf("%s: %w", candidate.url, err) + continue } - if png := largestPNGFromICO(data); png != nil { - return png, ".png", nil - } - lastErr = errors.New("unsupported icon format at " + candidate) + return converted, ".png", nil } if lastErr == nil { lastErr = errors.New("no favicon found") @@ -105,24 +104,86 @@ func manifestIcons(page *url.URL) []iconCandidate { if err != nil { continue } - score := 0 + score, label := 0, "manifest" + if entry.Sizes != "" { + label = "manifest " + entry.Sizes + } if size := attr(sizeAttr, `sizes="`+entry.Sizes+`"`); size != "" { fmt.Sscanf(size, "%d", &score) } - candidates = append(candidates, iconCandidate{base.ResolveReference(ref).String(), score}) + candidates = append(candidates, iconCandidate{url: base.ResolveReference(ref).String(), label: label, score: score}) } sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].score > candidates[j].score }) return candidates } +// SiteIcons mengembalikan semua ikon yang ditawarkan sebuah situs (manifest, +// apple-touch-icon, favicon) plus versi dari layanan favicon. +// Dipakai untuk memasang ikon aplikasi dari domain resminya. +func SiteIcons(pageURL string) ([]Result, error) { + base, err := normalizePageURL(pageURL) + if err != nil { + return nil, err + } + + var out []Result + seen := map[string]bool{} + add := func(c iconCandidate) { + if c.url == "" || seen[c.url] { + return + } + seen[c.url] = true + out = append(out, Result{Name: c.label, URL: c.url}) + } + for _, c := range iconCandidates(base) { + add(c) + } + if base.Hostname() != "" { + add(faviconService(base.Hostname())) + } + if len(out) == 0 { + return nil, errors.New("situs ini tidak menyediakan ikon") + } + return out, nil +} + +// faviconService memakai layanan favicon Google sebagai pilihan tambahan. +func faviconService(domain string) iconCandidate { + return iconCandidate{ + url: fmt.Sprintf("https://www.google.com/s2/favicons?domain=%s&sz=128", url.QueryEscape(domain)), + label: "google " + domain, + score: 5, + } +} + +// normalizePageURL menambah skema https kalau user menulis domain saja. +func normalizePageURL(raw string) (*url.URL, error) { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil, errors.New("alamat kosong") + } + if !strings.Contains(raw, "://") { + raw = "https://" + raw + } + base, err := url.Parse(raw) + if err != nil { + return nil, err + } + if base.Host == "" { + return nil, errors.New("domain tidak valid: " + raw) + } + return base, nil +} + // iconCandidate adalah kandidat URL ikon dengan skor prioritas. type iconCandidate struct { url string + label string // keterangan untuk ditampilkan di UI (mis. "manifest 512x512") score int } // iconCandidates mengumpulkan URL ikon dari HTML, ikon besar didahulukan, favicon.ico terakhir. -func iconCandidates(base *url.URL) []string { +func iconCandidates(base *url.URL) []iconCandidate { var found []iconCandidate if page, err := get(base.String()); err == nil { @@ -147,24 +208,28 @@ func iconCandidates(base *url.URL) []string { fmt.Sscanf(size, "%d", &score) score += 10 } - found = append(found, iconCandidate{base.ResolveReference(ref).String(), score}) + label := rel + if size := attr(sizeAttr, tag); size != "" { + label += " " + size + } + found = append(found, iconCandidate{url: base.ResolveReference(ref).String(), label: label, score: score}) } } sort.SliceStable(found, func(i, j int) bool { return found[i].score > found[j].score }) found = append(manifestIcons(base), found...) - out := make([]string, 0, len(found)+1) + out := make([]iconCandidate, 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) + out = append(out, c) } } fallback := base.ResolveReference(&url.URL{Path: "/favicon.ico"}).String() if !seen[fallback] { - out = append(out, fallback) + out = append(out, iconCandidate{url: fallback, label: "favicon.ico"}) } return out } diff --git a/internal/icon/favicon_test.go b/internal/icon/favicon_test.go index 79f90ae..e129761 100644 --- a/internal/icon/favicon_test.go +++ b/internal/icon/favicon_test.go @@ -1,6 +1,10 @@ package icon import ( + "bytes" + "image" + "image/color" + "image/png" "io" "net/http" "net/http/httptest" @@ -23,7 +27,17 @@ func icoWithPNG(png []byte) []byte { return ico } -var pngBytes = append([]byte("\x89PNG\r\n\x1a\n"), []byte("payload")...) +// pngBytes adalah PNG asli (kecil) untuk tes: ICO yang berisi PNG harus benar-benar +// bisa di-decode, bukan sekadar punya magic bytes. +var pngBytes = func() []byte { + img := image.NewNRGBA(image.Rect(0, 0, 8, 8)) + img.Set(4, 4, color.NRGBA{R: 0x20, G: 0x80, B: 0xE0, A: 0xFF}) + var buf bytes.Buffer + if err := png.Encode(&buf, img); err != nil { + panic(err) + } + return buf.Bytes() +}() func TestFetchUsesLinkedIcon(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { diff --git a/internal/icon/ico.go b/internal/icon/ico.go new file mode 100644 index 0000000..277b80d --- /dev/null +++ b/internal/icon/ico.go @@ -0,0 +1,109 @@ +package icon + +import ( + "bytes" + "encoding/binary" + "errors" + "image" + "image/color" + "image/png" +) + +// decodeICO mengambil gambar terbesar di dalam file .ico. +// +// ICO bisa berisi PNG (modern) atau DIB/BMP (format lama, masih sering dipakai +// favicon). Keduanya didukung: BMP 32-bit pakai alpha, 24-bit pakai AND mask. +// Entri dengan palet (<= 8-bit) tidak didukung. +func decodeICO(data []byte) (image.Image, error) { + if pngData := largestPNGFromICO(data); pngData != nil { + return png.Decode(bytes.NewReader(pngData)) + } + + if len(data) < 22 || data[0] != 0 || data[1] != 0 || data[2] != 1 || data[3] != 0 { + return nil, errors.New("bukan file ICO") + } + count := int(data[4]) | int(data[5])<<8 + + var best image.Image + bestSize := 0 + for i := 0; i < count; i++ { + off := 6 + i*16 + if off+16 > len(data) { + break + } + width := int(data[off]) + if width == 0 { + width = 256 + } + 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 + } + if width*width <= bestSize { + continue + } + img, err := decodeDIB(data[start : start+size]) + if err != nil { + continue + } + best, bestSize = img, width*width + } + if best == nil { + return nil, errors.New("tidak ada gambar yang bisa dibaca di dalam .ico") + } + return best, nil +} + +// decodeDIB membaca gambar DIB/BMP di dalam entri ICO. +func decodeDIB(dib []byte) (image.Image, error) { + if len(dib) < 40 { + return nil, errors.New("entri ICO terlalu pendek") + } + headerSize := int(binary.LittleEndian.Uint32(dib[0:4])) + width := int(int32(binary.LittleEndian.Uint32(dib[4:8]))) + height := int(int32(binary.LittleEndian.Uint32(dib[8:12]))) / 2 // tinggi DIB = XOR + AND mask + bitCount := int(binary.LittleEndian.Uint16(dib[14:16])) + compression := int(binary.LittleEndian.Uint32(dib[16:20])) + + if compression != 0 { + return nil, errors.New("entri ICO terkompresi tidak didukung") + } + if width <= 0 || height <= 0 || width > 512 || height > 512 { + return nil, errors.New("ukuran entri ICO tidak wajar") + } + if bitCount != 24 && bitCount != 32 { + return nil, errors.New("hanya ICO 24/32-bit yang didukung") + } + + stride := (width*bitCount/8 + 3) & ^3 + start := headerSize + if start+stride*height > len(dib) { + return nil, errors.New("data piksel ICO terpotong") + } + + img := image.NewNRGBA(image.Rect(0, 0, width, height)) + allTransparent := bitCount == 32 + for y := 0; y < height; y++ { + row := dib[start+(height-1-y)*stride:] // DIB disimpan dari bawah ke atas + for x := 0; x < width; x++ { + px := row[x*bitCount/8:] + var alpha uint8 = 0xFF + if bitCount == 32 { + alpha = px[3] + if alpha != 0 { + allTransparent = false + } + } + img.SetNRGBA(x, y, color.NRGBA{R: px[2], G: px[1], B: px[0], A: alpha}) + } + } + + // sebagian ICO 32-bit tidak mengisi kanal alpha sama sekali + if allTransparent { + for i := 3; i < len(img.Pix); i += 4 { + img.Pix[i] = 0xFF + } + } + return img, nil +} diff --git a/internal/icon/install.go b/internal/icon/install.go index 09bf519..6cb06ff 100644 --- a/internal/icon/install.go +++ b/internal/icon/install.go @@ -86,9 +86,9 @@ func ToPNG(data []byte) ([]byte, error) { return encodePNG(img) } - // ico bisa berisi PNG langsung - if png := largestPNGFromICO(data); png != nil { - return png, nil + // ico: bisa berisi PNG langsung atau gambar BMP di dalamnya + if img, err := decodeICO(data); err == nil { + return encodePNG(img) } return nil, errors.New("format ikon tidak didukung") } diff --git a/internal/icon/online.go b/internal/icon/online.go index 5ae8f66..7c7c2ce 100644 --- a/internal/icon/online.go +++ b/internal/icon/online.go @@ -161,7 +161,9 @@ func queryVariants(query string) []string { return out } -// FromURL mengunduh ikon dari URL dan menebak ekstensinya dari isi file. +// FromURL mengunduh ikon dari URL. Hasilnya selalu PNG: format apa pun +// (svg, webp, gif, ico, jpeg) dikonversi dulu supaya desktop environment +// tidak perlu mendukung banyak format. func FromURL(rawURL string) ([]byte, string, error) { data, err := get(rawURL) if err != nil { @@ -172,11 +174,65 @@ func FromURL(rawURL string) ([]byte, string, error) { if err != nil { return nil, "", err } - ext := sniffExt(data) - if ext == "" { - return nil, "", fmt.Errorf("unsupported icon format at %s", rawURL) + converted, err := ToPNG(data) + if err != nil { + return nil, "", fmt.Errorf("%s: %w", rawURL, err) } - return data, ext, nil + return converted, ".png", nil +} + +// imageExts adalah ekstensi gambar yang bisa dipakai langsung sebagai ikon. +var imageExts = []string{".png", ".svg", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".ico", ".xpm"} + +// IsImageURL benar kalau teks yang diketik user berupa URL gambar. +func IsImageURL(text string) bool { + text = strings.TrimSpace(text) + if !strings.Contains(text, "://") { + return false + } + lower := strings.ToLower(text) + for _, ext := range imageExts { + if strings.HasSuffix(strings.SplitN(lower, "?", 2)[0], ext) { + return true + } + } + return false +} + +// Domain mengembalikan domain kalau user mengetik alamat situs (mis. "mikrotik.com"), +// bukan nama aplikasi biasa. +func Domain(text string) string { + text = strings.TrimSpace(text) + if text == "" || strings.ContainsAny(text, " \t") || IsImageURL(text) { + return "" + } + // nama file gambar (mis. "lazyrun.png") bukan domain + lower := strings.ToLower(strings.SplitN(text, "?", 2)[0]) + for _, ext := range imageExts { + if strings.HasSuffix(lower, ext) { + return "" + } + } + host := text + if strings.Contains(text, "://") { + if u, err := url.Parse(text); err == nil { + host = u.Hostname() + } + } + parts := strings.Split(strings.Trim(host, "."), ".") + if len(parts) < 2 { + return "" + } + tld := parts[len(parts)-1] + if len(tld) < 2 || len(tld) > 24 { + return "" + } + for _, r := range strings.Join(parts, "") { + if !(r == '-' || r == '.' || r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9') { + return "" + } + } + return host } // slug menyamakan nama untuk perbandingan (huruf kecil, tanpa pemisah). diff --git a/internal/icon/online_test.go b/internal/icon/online_test.go index f92136c..cbac1f2 100644 --- a/internal/icon/online_test.go +++ b/internal/icon/online_test.go @@ -1,6 +1,7 @@ package icon import ( + "bytes" "net/http" "net/http/httptest" "strings" @@ -119,17 +120,94 @@ func TestFindPicksMatchingName(t *testing.T) { } } -func TestFromURLSniffsFormat(t *testing.T) { +func TestFromURLAlwaysReturnsPNG(t *testing.T) { srv := fakeIconify(t) data, ext, err := FromURL(srv.URL + "/logos/whatsapp-icon.svg") if err != nil { t.Fatal(err) } - if ext != ".svg" || len(data) == 0 { + // SVG dari internet dikonversi ke PNG supaya seragam + if ext != ".png" || len(data) == 0 { t.Errorf("FromURL = ext %q, %d byte", ext, len(data)) } + if !bytes.HasPrefix(data, pngMagic) { + t.Errorf("hasil FromURL bukan PNG: %q", data[:min(8, len(data))]) + } if _, _, err := FromURL(srv.URL + "/tidak-ada"); err == nil { t.Error("URL 404 harus error") } } + +func TestIsImageURLAndDomain(t *testing.T) { + images := []string{"https://contoh.com/ikon.png", "http://a.b/logo.svg", "https://x.id/i.JPEG?v=2"} + for _, u := range images { + if !IsImageURL(u) { + t.Errorf("IsImageURL(%q) = false", u) + } + } + if IsImageURL("https://contoh.com") || IsImageURL("winbox") { + t.Error("URL halaman/nama biasa bukan gambar") + } + + domains := map[string]string{ + "mikrotik.com": "mikrotik.com", + " web.whatsapp.com ": "web.whatsapp.com", + "https://mikrotik.com/": "mikrotik.com", + "winbox": "", + "WhatsApp Web": "", + "my-app v1.2": "", + "lazyrun.png": "", + "file:///home/x/icon.png": "", + } + for in, want := range domains { + if got := Domain(in); got != want { + t.Errorf("Domain(%q) = %q, mau %q", in, got, want) + } + } +} + +func TestSiteIconsListsSiteAndService(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/": + _, _ = w.Write([]byte(`` + + `` + + `` + + `` + + ``)) + case "/manifest.json": + _, _ = w.Write([]byte(`{"icons":[{"src":"/big-192.png","sizes":"192x192"},{"src":"/big-512.png","sizes":"512x512"}]}`)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + list, err := SiteIcons(srv.URL) + if err != nil { + t.Fatal(err) + } + if len(list) < 3 { + t.Fatalf("ikon situs = %d, mau minimal 3: %+v", len(list), list) + } + // ikon manifest terbesar harus di depan, lalu ikon halaman, lalu layanan favicon + if list[0].URL != srv.URL+"/big-512.png" { + t.Errorf("kandidat pertama = %+s", list[0]) + } + found := false + for _, r := range list { + if strings.HasPrefix(r.URL, "https://www.google.com/s2/favicons") { + found = true + } + } + if !found { + t.Error("layanan favicon tidak disertakan sebagai pilihan") + } +} + +func TestSiteIconsNeedsValidDomain(t *testing.T) { + if _, err := SiteIcons(""); err == nil { + t.Error("alamat kosong harus error") + } +} diff --git a/internal/icon/raster_test.go b/internal/icon/raster_test.go index 2978420..3b1106e 100644 --- a/internal/icon/raster_test.go +++ b/internal/icon/raster_test.go @@ -2,6 +2,7 @@ package icon import ( "bytes" + "encoding/binary" "image/png" "testing" ) @@ -80,3 +81,67 @@ func TestRasterizeColorsCurrentColorIcons(t *testing.T) { t.Errorf("warna pengganti bukan brand indigo: r=%d g=%d b=%d", r>>8, g>>8, b>>8) } } + +// ico32bpp membuat ICO berisi gambar BMP 32-bit (format favicon lama). +func ico32bpp(width int, pixel func(x, y int) (byte, byte, byte, byte)) []byte { + stride := (width*4 + 3) & ^3 + dib := make([]byte, 40+stride*width) + binary.LittleEndian.PutUint32(dib[0:], 40) + binary.LittleEndian.PutUint32(dib[4:], uint32(width)) + binary.LittleEndian.PutUint32(dib[8:], uint32(width*2)) // XOR + AND + binary.LittleEndian.PutUint16(dib[12:], 1) + binary.LittleEndian.PutUint16(dib[14:], 32) + + for y := 0; y < width; y++ { + row := dib[40+(width-1-y)*stride:] + for x := 0; x < width; x++ { + b, g, r, a := pixel(x, y) + row[x*4], row[x*4+1], row[x*4+2], row[x*4+3] = b, g, r, a + } + } + + ico := make([]byte, 22+len(dib)) + ico[2], ico[3] = 1, 0 + ico[4], ico[5] = 1, 0 + put32 := func(off, v int) { + binary.LittleEndian.PutUint32(ico[off:], uint32(v)) + } + ico[6] = byte(width) // lebar (0 = 256) + ico[7] = byte(width) + put32(14, len(dib)) + put32(18, 22) + copy(ico[22:], dib) + return ico +} + +func TestToPNGConvertsBMPIcon(t *testing.T) { + ico := ico32bpp(16, func(x, y int) (byte, byte, byte, byte) { + return 0x20, 0x80, 0xE0, 0xFF // BGR + alpha + }) + + out, err := ToPNG(ico) + if err != nil { + t.Fatalf("ico BMP harus bisa dikonversi: %v", err) + } + img, err := png.Decode(bytes.NewReader(out)) + if err != nil { + t.Fatal(err) + } + r, g, b, a := img.At(8, 8).RGBA() + if a == 0 { + t.Fatal("piksel ICO BMP transparan, seharusnya opaque") + } + // warna BGR 0x20,0x80,0xE0 = RGB (0xE0,0x80,0x20) + if r>>8 != 0xE0 || g>>8 != 0x80 || b>>8 != 0x20 { + t.Errorf("warna salah: r=%#x g=%#x b=%#x", r>>8, g>>8, b>>8) + } +} + +func TestDecodeICORejectsGarbage(t *testing.T) { + if _, err := decodeICO([]byte("bukan ico")); err == nil { + t.Error("data bukan ICO harus error") + } + if _, err := decodeICO(icoWithPNG([]byte("bukan png"))); err == nil { + t.Error("entry PNG rusak harus error") + } +} diff --git a/internal/ui/dialog_icon.go b/internal/ui/dialog_icon.go index 803b734..cc2f809 100644 --- a/internal/ui/dialog_icon.go +++ b/internal/ui/dialog_icon.go @@ -56,6 +56,7 @@ func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) { entry.SetPlaceHolder("whatsapp") status := muted(T("search_hint")) + entry.SetPlaceHolder("whatsapp / mikrotik.com") grid := container.NewGridWithColumns(6) results := container.NewVScroll(container.NewPadded(grid)) slots := make(chan struct{}, 3) @@ -69,7 +70,7 @@ func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) { status.SetText(T("searching")) grid.RemoveAll() go func() { - list, err := icon.SearchBest(entry.Text, 24) + list, err := lookupIcons(entry.Text) fyne.Do(func() { if err != nil { status.SetText(T("search_failed")) @@ -102,6 +103,22 @@ func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) { search() } +// lookupIcons memilih sumber sesuai yang diketik user: +// URL gambar → dipakai langsung, domain situs → ikon resmi situs itu, +// selain itu → pencarian nama di Iconify. +func lookupIcons(query string) ([]icon.Result, error) { + query = strings.TrimSpace(query) + if icon.IsImageURL(query) { + return []icon.Result{{Name: "URL ikon", URL: query}}, nil + } + if domain := icon.Domain(query); domain != "" { + if list, err := icon.SiteIcons(domain); err == nil && len(list) > 0 { + return list, nil + } + } + return icon.SearchBest(query, 24) +} + // iconChoice adalah satu ikon hasil pencarian. Gambarnya diunduh di latar belakang // dan digambar sebagai PNG: SVG dari internet kadang tidak bisa digambar sehingga // thumbnail tidak muncul atau proses menggambar berhenti.