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:
1 parent
70534bf6e2
commit
7f7cb97a65
19 files changed
+1117
-40
No files matched your search
@@ -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{}
|
||||
|
||||
@@ -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 }
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user