Ikon otomatis dari internet + cursor mengikuti tema host

- Cari ikon online (Iconify) dari dialog install, plus tombol sync ikon per shortcut
- Auto ikon: file terdekat -> favicon situs (termasuk ikon manifest PWA) -> ikon brand
  yang namanya cocok -> ikon default; sumber ikon disimpan di metadata
- Query dibersihkan (web.whatsapp.com -> whatsapp) dan kata <4 huruf tidak dipakai
  supaya aplikasi tanpa ikon resmi tidak dapat ikon acak
- Unduhan thumbnail dibatasi 4 bersamaan + retry sekali (rate limit Iconify)
- Cursor: XCURSOR_THEME/XCURSOR_SIZE diambil dari config KDE/GTK/~/.icons default,
  dan AppImage tidak lagi membundel libX*/libwayland*/libxkbcommon (dipakai dari host)
- README: bagian ikon + integrasi desktop
This commit is contained in:
oxpoe committed 2026-10-13 08:00:12 +07:00
1 parent 70534bf6e2
commit 7f7cb97a65
19 files changed
+1117 -40

No files matched your search

+63
View File
@@ -3,6 +3,7 @@ package icon
import (
"bytes"
"encoding/json"
"errors"
"fmt"
"io"
@@ -26,6 +27,7 @@ var (
)
// Fetch mengambil favicon dari halaman. Return data + ekstensi file (".png", ".jpg", ".svg").
// Urutan: ikon dari web app manifest (paling besar) → <link rel=icon> → /favicon.ico.
func Fetch(pageURL string) ([]byte, string, error) {
base, err := url.Parse(pageURL)
if err != nil {
@@ -53,6 +55,66 @@ func Fetch(pageURL string) ([]byte, string, error) {
return nil, "", lastErr
}
// manifestIcons membaca <link rel=manifest> lalu daftar ikonnya,
// diurutkan dari ukuran terbesar. Gagal parse bukan error: cukup dilewati.
func manifestIcons(page *url.URL) []iconCandidate {
html, err := get(page.String())
if err != nil {
return nil
}
var manifestURL string
for _, tag := range linkTag.FindAllString(string(html), -1) {
if !strings.Contains(strings.ToLower(attr(relAttr, tag)), "manifest") {
continue
}
if href := attr(hrefAttr, tag); href != "" {
if ref, err := url.Parse(href); err == nil {
manifestURL = page.ResolveReference(ref).String()
}
break
}
}
if manifestURL == "" {
return nil
}
data, err := get(manifestURL)
if err != nil {
return nil
}
var manifest struct {
Icons []struct {
Src string `json:"src"`
Sizes string `json:"sizes"`
} `json:"icons"`
}
if err := json.Unmarshal(data, &manifest); err != nil {
return nil
}
base, err := url.Parse(manifestURL)
if err != nil {
return nil
}
var candidates []iconCandidate
for _, entry := range manifest.Icons {
if entry.Src == "" {
continue
}
ref, err := url.Parse(entry.Src)
if err != nil {
continue
}
score := 0
if size := attr(sizeAttr, `sizes="`+entry.Sizes+`"`); size != "" {
fmt.Sscanf(size, "%d", &score)
}
candidates = append(candidates, iconCandidate{base.ResolveReference(ref).String(), score})
}
sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].score > candidates[j].score })
return candidates
}
// iconCandidate adalah kandidat URL ikon dengan skor prioritas.
type iconCandidate struct {
url string
@@ -90,6 +152,7 @@ func iconCandidates(base *url.URL) []string {
}
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)
seen := map[string]bool{}
+17
View File
@@ -36,6 +36,14 @@ func Default(slug string) (string, error) {
return SaveBytes(assets.Icon, ".png", slug)
}
// Nearby mencari file ikon bernama base.* di folder dir (dipakai untuk auto-deteksi).
func Nearby(dir, base string) string {
if dir == "" || base == "" {
return ""
}
return findNearby(dir, base)
}
func findNearby(dir, base string) string {
base = strings.ToLower(base)
for _, ext := range exts {
@@ -78,5 +86,14 @@ func SaveBytes(data []byte, ext, slug string) (string, error) {
return dest, os.WriteFile(dest, data, 0o644)
}
// SaveData menyimpan ikon yang sudah ada di memori (mis. hasil pencarian online).
func SaveData(data []byte, slug string) (string, error) {
ext := sniffExt(data)
if ext == "" {
return "", errors.New("unsupported icon format")
}
return SaveBytes(data, ext, slug)
}
// IconBytes adalah ikon default LazyRun.
func IconBytes() []byte { return assets.Icon }
+174
View File
@@ -0,0 +1,174 @@
package icon
import (
"encoding/json"
"errors"
"fmt"
"net/url"
"sort"
"strings"
"time"
"unicode"
)
var iconifyBase = "https://api.iconify.design"
// SetSearchEndpoint mengarahkan pencarian ikon ke endpoint lain (dipakai tes).
func SetSearchEndpoint(base string) { iconifyBase = base }
// Result adalah satu ikon dari pencarian online.
type Result struct {
Name string // contoh: "logos:whatsapp-icon"
URL string // URL SVG siap diunduh
}
// ShortName mengembalikan nama ikon tanpa prefix koleksi.
func (r Result) ShortName() string {
_, name, ok := strings.Cut(r.Name, ":")
if !ok {
return r.Name
}
return name
}
// Search mencari ikon di Iconify berdasarkan kata kunci.
func Search(query string, limit int) ([]Result, error) {
query = strings.TrimSpace(query)
if query == "" {
return nil, errors.New("search query is empty")
}
if limit <= 0 {
limit = 32
}
endpoint := fmt.Sprintf("%s/search?query=%s&limit=%d", iconifyBase, url.QueryEscape(query), limit)
data, err := get(endpoint)
if err != nil {
return nil, err
}
var payload struct {
Icons []string `json:"icons"`
}
if err := json.Unmarshal(data, &payload); err != nil {
return nil, err
}
results := make([]Result, 0, len(payload.Icons))
for _, name := range payload.Icons {
prefix, icon, ok := strings.Cut(name, ":")
if !ok {
continue
}
// height=256 supaya SVG-nya punya ukuran eksplisit (bukan "1em")
results = append(results, Result{
Name: name,
URL: fmt.Sprintf("%s/%s/%s.svg?height=256", iconifyBase, url.PathEscape(prefix), url.PathEscape(icon)),
})
}
if len(results) == 0 {
return nil, errors.New("no icon found for " + query)
}
return results, nil
}
// SearchBest mencoba beberapa variasi kata kunci supaya nama seperti
// "web.whatsapp.com" atau "WhatsApp Web" tetap menemukan ikon.
func SearchBest(query string, limit int) ([]Result, error) {
for _, candidate := range queryVariants(query) {
if results, err := Search(candidate, limit); err == nil {
return results, nil
}
}
return nil, errors.New("no icon found for " + strings.TrimSpace(query))
}
// Find mengambil hasil pertama yang namanya cocok dengan kata kunci,
// supaya ikon acak tidak dipasang ke aplikasi yang namanya tidak dikenal.
func Find(query string) (Result, bool) {
for _, candidate := range queryVariants(query) {
results, err := Search(candidate, 32)
if err != nil {
continue
}
want := slug(candidate)
for _, r := range results {
name := slug(r.ShortName())
if name == want || strings.Contains(name, want) || strings.Contains(want, name) {
return r, true
}
}
}
return Result{}, false
}
// QueryHint mengembalikan kata kunci paling bermakna dari sebuah nama,
// dipakai untuk mengisi kolom pencarian ikon.
func QueryHint(query string) string {
variants := queryVariants(query)
if len(variants) > 1 {
return variants[1]
}
return strings.TrimSpace(query)
}
// queryVariants menyusun kandidat kata kunci: teks asli dulu, lalu kata-kata
// yang lebih sederhana (kata terpanjang didahulukan). Kata < 4 huruf dibuang
// supaya pencarian tidak jatuh ke ikon acak.
func queryVariants(query string) []string {
query = strings.TrimSpace(query)
if query == "" {
return nil
}
out := []string{query}
base := query
// host seperti "web.whatsapp.com" → buang TLD-nya
if !strings.Contains(base, " ") && strings.Contains(base, ".") {
parts := strings.Split(base, ".")
if len(parts) > 1 {
base = strings.Join(parts[:len(parts)-1], " ")
}
}
words := strings.FieldsFunc(base, func(r rune) bool {
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
})
sort.SliceStable(words, func(i, j int) bool { return len(words[i]) > len(words[j]) })
for _, word := range words {
if len(word) < 4 || strings.EqualFold(word, query) {
continue
}
out = append(out, word)
}
return out
}
// FromURL mengunduh ikon dari URL dan menebak ekstensinya dari isi file.
func FromURL(rawURL string) ([]byte, string, error) {
data, err := get(rawURL)
if err != nil {
// sekali coba lagi: API ikon publik kadang membatasi permintaan beruntun
time.Sleep(600 * time.Millisecond)
data, err = get(rawURL)
}
if err != nil {
return nil, "", err
}
ext := sniffExt(data)
if ext == "" {
return nil, "", fmt.Errorf("unsupported icon format at %s", rawURL)
}
return data, ext, nil
}
// slug menyamakan nama untuk perbandingan (huruf kecil, tanpa pemisah).
func slug(s string) string {
var b strings.Builder
for _, r := range strings.ToLower(s) {
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
b.WriteRune(r)
}
}
return b.String()
}
+135
View File
@@ -0,0 +1,135 @@
package icon
import (
"net/http"
"net/http/httptest"
"strings"
"testing"
)
func fakeIconify(t *testing.T) *httptest.Server {
t.Helper()
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/search":
got := r.URL.Query().Get("query")
if got == "" {
http.Error(w, "no query", http.StatusBadRequest)
return
}
// ikon whatsapp hanya untuk query "whatsapp" polos, supaya fallback ikut teruji
if !strings.Contains(strings.ToLower(got), "whatsapp") || strings.Contains(got, ".") {
_, _ = w.Write([]byte(`{"icons":[],"total":0}`))
return
}
_, _ = w.Write([]byte(`{"icons":["ic:baseline-whatsapp","mdi:whatsapp","logos:whatsapp-icon"],"total":3}`))
case "/logos/whatsapp-icon.svg":
w.Header().Set("Content-Type", "image/svg+xml")
_, _ = w.Write([]byte(`<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256"></svg>`))
default:
http.NotFound(w, r)
}
}))
t.Cleanup(srv.Close)
return srv
}
func TestSearchBuildsDownloadURLs(t *testing.T) {
srv := fakeIconify(t)
SetSearchEndpoint(srv.URL)
t.Cleanup(func() { SetSearchEndpoint("https://api.iconify.design") })
results, err := Search("whatsapp", 5)
if err != nil {
t.Fatal(err)
}
if len(results) != 3 {
t.Fatalf("hasil pencarian = %d, mau 3", len(results))
}
if results[0].Name != "ic:baseline-whatsapp" || results[0].ShortName() != "baseline-whatsapp" {
t.Errorf("hasil pertama salah: %+v", results[0])
}
if results[2].URL != srv.URL+"/logos/whatsapp-icon.svg?height=256" {
t.Errorf("URL unduhan salah: %s", results[2].URL)
}
}
func TestQueryHintAndSearchBest(t *testing.T) {
srv := fakeIconify(t)
SetSearchEndpoint(srv.URL)
t.Cleanup(func() { SetSearchEndpoint("https://api.iconify.design") })
// nama seperti host: TLD dibuang, kata terpanjang dipakai
if got := QueryHint("web.whatsapp.com"); got != "whatsapp" {
t.Errorf("QueryHint(host) = %q", got)
}
if got := QueryHint("WhatsApp Web"); got != "WhatsApp" {
t.Errorf("QueryHint(nama) = %q", got)
}
if got := QueryHint(""); got != "" {
t.Errorf("QueryHint(kosong) = %q", got)
}
// query mentah gagal, tapi varian "whatsapp" berhasil
results, err := SearchBest("web.whatsapp.com", 5)
if err != nil {
t.Fatal(err)
}
if len(results) != 3 {
t.Errorf("SearchBest() = %d hasil", len(results))
}
}
func TestFindIgnoresGenericWords(t *testing.T) {
srv := fakeIconify(t)
SetSearchEndpoint(srv.URL)
t.Cleanup(func() { SetSearchEndpoint("https://api.iconify.design") })
// kata <= 3 huruf ("my", "go", "app") tidak boleh dipakai untuk mencari
if _, ok := Find("my-go-app"); ok {
t.Error("nama tanpa ikon resmi harus gagal, bukan dapat ikon acak")
}
}
func TestSearchEmptyQuery(t *testing.T) {
if _, err := Search(" ", 5); err == nil {
t.Error("query kosong harus error")
}
}
func TestFindPicksMatchingName(t *testing.T) {
srv := fakeIconify(t)
SetSearchEndpoint(srv.URL)
t.Cleanup(func() { SetSearchEndpoint("https://api.iconify.design") })
// "WhatsApp Web" harus cocok dengan ikon bernama whatsapp, bukan hasil acak.
// (hasil pertama "ic:baseline-whatsapp" tidak cocok karena namanya lebih panjang,
// jadi yang dipakai "mdi:whatsapp")
got, ok := Find("WhatsApp Web")
if !ok {
t.Fatal("Find() tidak menemukan kandidat")
}
if got.Name != "mdi:whatsapp" {
t.Errorf("ikon terpilih = %s", got.Name)
}
// Nama yang tidak berhubungan tidak boleh dipaksa memakai ikon apa pun.
if _, ok := Find("zzz-tidak-ada-xyz"); ok {
t.Error("nama asing tidak boleh dapat ikon acak")
}
}
func TestFromURLSniffsFormat(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 {
t.Errorf("FromURL = ext %q, %d byte", ext, len(data))
}
if _, _, err := FromURL(srv.URL + "/tidak-ada"); err == nil {
t.Error("URL 404 harus error")
}
}