From 7f7cb97a65b769aab5af16ccadea9453c791c2f9 Mon Sep 17 00:00:00 2001 From: oxpoe Date: Tue, 13 Oct 2026 07:27:27 +0700 Subject: [PATCH] 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 --- README.md | 9 ++ build.sh | 6 + cmd/lazyrun/main.go | 2 + internal/cursor/cursor.go | 89 ++++++++++++++ internal/cursor/cursor_test.go | 76 ++++++++++++ internal/icon/favicon.go | 63 ++++++++++ internal/icon/install.go | 17 +++ internal/icon/online.go | 174 +++++++++++++++++++++++++++ internal/icon/online_test.go | 135 +++++++++++++++++++++ internal/shortcut/executable.go | 40 +++--- internal/shortcut/icons.go | 134 +++++++++++++++++++++ internal/shortcut/icons_test.go | 194 ++++++++++++++++++++++++++++++ internal/shortcut/install_test.go | 3 + internal/store/store.go | 1 + internal/ui/dialog_executable.go | 17 ++- internal/ui/dialog_icon.go | 96 +++++++++++++++ internal/ui/dialog_webapp.go | 17 ++- internal/ui/main_window.go | 38 ++++++ internal/ui/strings.go | 46 ++++--- 19 files changed, 1117 insertions(+), 40 deletions(-) create mode 100644 internal/cursor/cursor.go create mode 100644 internal/cursor/cursor_test.go create mode 100644 internal/icon/online.go create mode 100644 internal/icon/online_test.go create mode 100644 internal/shortcut/icons.go create mode 100644 internal/shortcut/icons_test.go create mode 100644 internal/ui/dialog_icon.go diff --git a/README.md b/README.md index e528769..8b8f62f 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,12 @@ 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. - **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. @@ -23,6 +29,9 @@ AppImage, no terminal needed. - **i18n** — UI in English or Indonesian, chosen from `$LANG`. - **Self update** — AppImage with embedded zsync update information, optional `AppImageUpdate` integration via *Check for Updates*. +- **Desktop integration** — uses the host cursor theme (KDE/GNOME setting is + forwarded to the toolkit when the session does not export it), live desktop + menu refresh via `update-desktop-database` and `kbuildsycoca`. ## Install diff --git a/build.sh b/build.sh index 344d528..be9a435 100755 --- a/build.sh +++ b/build.sh @@ -46,6 +46,8 @@ else export LDAI_NO_APPSTREAM=1 fi +# Library X11/Wayland/xkb/cursor tidak boleh ikut dibundel: kalau versinya beda +# dengan host, cursor & rendering jadi tidak native (mis. cursor theme desktop tidak terpakai). echo "==> bundle + package AppImage" # hapus output lama dulu: file yang sedang dijalankan tidak bisa ditimpa langsung rm -f "$OUT"/lazyrun-*.AppImage "$OUT"/lazyrun-*.AppImage.zsync @@ -53,6 +55,10 @@ rm -f "$OUT"/lazyrun-*.AppImage "$OUT"/lazyrun-*.AppImage.zsync --appdir "$APPDIR" \ --desktop-file "$APPDIR/lazyrun.desktop" \ --icon-file "$APPDIR/lazyrun.png" \ + --exclude-library "libwayland-*.so*" \ + --exclude-library "libX*.so*" \ + --exclude-library "libxkbcommon.so*" \ + --exclude-library "libffi.so*" \ --output appimage ls -lh "$OUT"/lazyrun-*.AppImage* 2>/dev/null || true diff --git a/cmd/lazyrun/main.go b/cmd/lazyrun/main.go index eb40181..b113ef8 100644 --- a/cmd/lazyrun/main.go +++ b/cmd/lazyrun/main.go @@ -3,6 +3,7 @@ package main import ( "fyne.io/fyne/v2/app" + "lazyrun/internal/cursor" "lazyrun/internal/ui" ) @@ -10,5 +11,6 @@ import ( var version = "dev" func main() { + cursor.Apply() // sebelum app dibuat: GLFW membaca env cursor saat init ui.Run(app.NewWithID("io.github.lazyrun.LazyRun"), version) } diff --git a/internal/cursor/cursor.go b/internal/cursor/cursor.go new file mode 100644 index 0000000..9de672d --- /dev/null +++ b/internal/cursor/cursor.go @@ -0,0 +1,89 @@ +// Package cursor meneruskan cursor theme desktop (KDE/GNOME) ke Fyne/GLFW. +package cursor + +import ( + "os" + "path/filepath" + "strings" +) + +// Apply men-set XCURSOR_THEME dan XCURSOR_SIZE kalau sesi belum men-set-nya. +// Fyne (GLFW) membaca env ini waktu memuat cursor, jadi panggil sebelum app dibuat. +func Apply() { + if os.Getenv("XCURSOR_SIZE") == "" { + if _, size := desktopCursor(); size != "" { + os.Setenv("XCURSOR_SIZE", size) + } + } + if os.Getenv("XCURSOR_THEME") != "" { + return + } + if theme, _ := desktopCursor(); theme != "" { + os.Setenv("XCURSOR_THEME", theme) + } +} + +func home() string { + h, _ := os.UserHomeDir() + return h +} + +func configHome() string { + if v := os.Getenv("XDG_CONFIG_HOME"); v != "" { + return v + } + return filepath.Join(home(), ".config") +} + +// desktopCursor mengambil nama & ukuran cursor dari config KDE, GTK, atau ~/.icons/default. +func desktopCursor() (theme, size string) { + // KDE Plasma: ~/.config/kcminputrc + kde := ini(filepath.Join(configHome(), "kcminputrc")) + if t := kde["mouse"]["cursortheme"]; t != "" { + return t, kde["mouse"]["cursorsize"] + } + + for _, gtk := range []string{"gtk-4.0", "gtk-3.0"} { + cfg := ini(filepath.Join(configHome(), gtk, "settings.ini")) + if t := cfg["settings"]["gtk-cursor-theme-name"]; t != "" { + return t, cfg["settings"]["gtk-cursor-theme-size"] + } + } + + // fallback: theme default di ~/.icons/default/index.theme (format "Inherits=") + fallback := filepath.Join(home(), ".icons", "default", "index.theme") + if t := ini(fallback)["icon theme"]["inherits"]; t != "" { + return t, "" + } + return "", "" +} + +// ini membaca file config berformat INI sederhana; key disimpan huruf kecil. +func ini(path string) map[string]map[string]string { + out := map[string]map[string]string{} + data, err := os.ReadFile(path) + if err != nil { + return out + } + + section := "" + for _, line := range strings.Split(string(data), "\n") { + line = strings.TrimSpace(line) + if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") { + continue + } + if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") { + section = strings.ToLower(strings.Trim(line, "[]")) + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok { + continue + } + if out[section] == nil { + out[section] = map[string]string{} + } + out[section][strings.ToLower(strings.TrimSpace(key))] = strings.TrimSpace(value) + } + return out +} diff --git a/internal/cursor/cursor_test.go b/internal/cursor/cursor_test.go new file mode 100644 index 0000000..0dd4eec --- /dev/null +++ b/internal/cursor/cursor_test.go @@ -0,0 +1,76 @@ +package cursor + +import ( + "os" + "path/filepath" + "testing" +) + +func TestIniParsesSections(t *testing.T) { + path := filepath.Join(t.TempDir(), "index.theme") + body := "# komentar\n[Icon Theme]\nName=default\nInherits=Breeze_Cursors\n\n[Other]\nX=1\n" + if err := os.WriteFile(path, []byte(body), 0o644); err != nil { + t.Fatal(err) + } + + got := ini(path) + if got["icon theme"]["inherits"] != "Breeze_Cursors" { + t.Errorf("Inherits salah: %q", got["icon theme"]["inherits"]) + } + if got["other"]["x"] != "1" { + t.Errorf("section lain salah: %+v", got) + } +} + +func TestDesktopCursorFromKDE(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + + dir := filepath.Join(home, ".config") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "kcminputrc"), []byte("[Mouse]\ncursorTheme=material_cursors\ncursorSize=36\n"), 0o644); err != nil { + t.Fatal(err) + } + + theme, size := desktopCursor() + if theme != "material_cursors" || size != "36" { + t.Errorf("desktopCursor() = %q, %q", theme, size) + } +} + +func TestApplySetsEnvWhenEmpty(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) + t.Setenv("XCURSOR_THEME", "") + t.Setenv("XCURSOR_SIZE", "") + + dir := filepath.Join(home, ".config") + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "kcminputrc"), []byte("[Mouse]\ncursorTheme=material_cursors\ncursorSize=36\n"), 0o644); err != nil { + t.Fatal(err) + } + + Apply() + if got := os.Getenv("XCURSOR_THEME"); got != "material_cursors" { + t.Errorf("XCURSOR_THEME = %q", got) + } + if got := os.Getenv("XCURSOR_SIZE"); got != "36" { + t.Errorf("XCURSOR_SIZE = %q", got) + } +} + +func TestApplyKeepsExistingEnv(t *testing.T) { + t.Setenv("XCURSOR_THEME", "Breeze_Cursors") + t.Setenv("XCURSOR_SIZE", "24") + + Apply() + if got := os.Getenv("XCURSOR_THEME"); got != "Breeze_Cursors" { + t.Errorf("env yang sudah ada tidak boleh ditimpa, dapat %q", got) + } +} diff --git a/internal/icon/favicon.go b/internal/icon/favicon.go index ff67dd5..a9df312 100644 --- a/internal/icon/favicon.go +++ b/internal/icon/favicon.go @@ -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) → → /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 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{} diff --git a/internal/icon/install.go b/internal/icon/install.go index 9caf11d..9001443 100644 --- a/internal/icon/install.go +++ b/internal/icon/install.go @@ -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 } diff --git a/internal/icon/online.go b/internal/icon/online.go new file mode 100644 index 0000000..95e65a1 --- /dev/null +++ b/internal/icon/online.go @@ -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() +} diff --git a/internal/icon/online_test.go b/internal/icon/online_test.go new file mode 100644 index 0000000..f92136c --- /dev/null +++ b/internal/icon/online_test.go @@ -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(``)) + 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") + } +} diff --git a/internal/shortcut/executable.go b/internal/shortcut/executable.go index f5ff260..f2a3bb3 100644 --- a/internal/shortcut/executable.go +++ b/internal/shortcut/executable.go @@ -9,7 +9,6 @@ import ( "strings" "time" - "lazyrun/internal/icon" "lazyrun/internal/store" "lazyrun/internal/xdg" ) @@ -17,7 +16,9 @@ import ( // ExecOptions adalah input Fungsi A: shortcut dari file executable. type ExecOptions struct { BinaryPath string - IconPath string // opsional + IconPath string // opsional: file ikon + IconURL string // opsional: URL ikon + IconData []byte // opsional: ikon yang sudah diunduh UI Name string Category string Menu bool @@ -51,7 +52,15 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) { return sc, err } } - iconPath, err := icon.Save(o.IconPath, filepath.Dir(o.BinaryPath), strings.TrimSuffix(filepath.Base(o.BinaryPath), filepath.Ext(o.BinaryPath)), sl) + iconPath, iconSource, err := installIconFor(iconOptions{ + IconPath: o.IconPath, + IconURL: o.IconURL, + IconData: o.IconData, + NearbyDir: filepath.Dir(o.BinaryPath), + NearbyBase: strings.TrimSuffix(filepath.Base(o.BinaryPath), filepath.Ext(o.BinaryPath)), + Query: name, + Slug: sl, + }) if err != nil { return sc, err } @@ -74,6 +83,7 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) { ExecArgs: entry.ExecArgs, BinaryPath: binDst, IconPath: iconPath, + IconSource: iconSource, DesktopPath: deskDst, Targets: store.Targets{Menu: o.Menu, Desktop: o.Desktop}, InstalledAt: time.Now().UTC(), @@ -94,7 +104,9 @@ type WebOptions struct { URL string Name string Browser BrowserArg - IconPath string // opsional; kosong = auto-fetch favicon + IconPath string // opsional: file ikon + IconURL string // opsional: URL ikon + IconData []byte // opsional: ikon yang sudah diunduh UI Menu bool Desktop bool } @@ -129,7 +141,14 @@ func InstallWebApp(o WebOptions) (store.Shortcut, error) { return sc, fmt.Errorf("shortcut %q already exists, uninstall it first", name) } - iconPath, err := installWebIcon(o.IconPath, pageURL, sl) + iconPath, iconSource, err := installIconFor(iconOptions{ + IconPath: o.IconPath, + IconURL: o.IconURL, + IconData: o.IconData, + PageURL: pageURL, + Query: name, + Slug: sl, + }) if err != nil { return sc, err } @@ -159,6 +178,7 @@ func InstallWebApp(o WebOptions) (store.Shortcut, error) { Browser: strings.Join(o.Browser.Exec, " "), ExecArgs: entry.ExecArgs, IconPath: iconPath, + IconSource: iconSource, DesktopPath: deskDst, Targets: store.Targets{Menu: o.Menu, Desktop: o.Desktop}, InstalledAt: time.Now().UTC(), @@ -174,16 +194,6 @@ func InstallWebApp(o WebOptions) (store.Shortcut, error) { return sc, nil } -func installWebIcon(userIcon, pageURL, slug string) (string, error) { - if userIcon != "" { - return icon.Save(userIcon, "", "", slug) - } - if data, ext, err := icon.Fetch(pageURL); err == nil { - return icon.SaveBytes(data, ext, slug) - } - return icon.Default(slug) -} - func category(c, fallback string) string { c = strings.TrimSpace(c) if c == "" { diff --git a/internal/shortcut/icons.go b/internal/shortcut/icons.go new file mode 100644 index 0000000..89e86d6 --- /dev/null +++ b/internal/shortcut/icons.go @@ -0,0 +1,134 @@ +package shortcut + +import ( + "errors" + "os" + "strings" + + "lazyrun/internal/icon" + "lazyrun/internal/store" +) + +// Prefix sumber ikon yang disimpan di metadata, dipakai untuk sync ulang. +const ( + iconSourceFile = "file:" + iconSourceFavicon = "favicon:" + iconSourceSearch = "search:" +) + +type iconOptions struct { + IconPath string // file pilihan user + IconURL string // URL ikon pilihan user + IconData []byte // ikon pilihan user yang sudah diunduh UI + NearbyDir string // folder binary, untuk auto-deteksi ikon di sebelahnya + NearbyBase string + PageURL string // halaman PWA, untuk favicon + Query string // nama aplikasi, untuk cari ikon online + Slug string +} + +// installIconFor menentukan ikon shortcut dan sumbernya. +// Urutan: file user → URL user → ikon di folder binary → favicon situs → cari online → ikon default. +func installIconFor(o iconOptions) (path, source string, err error) { + if o.IconPath != "" { + path, err = icon.Save(o.IconPath, "", "", o.Slug) + return path, iconSourceFile + o.IconPath, err + } + if len(o.IconData) > 0 && o.IconURL != "" { + if path, err := icon.SaveData(o.IconData, o.Slug); err == nil { + return path, o.IconURL, nil + } + } + if o.IconURL != "" { + if data, ext, err := icon.FromURL(o.IconURL); err == nil { + path, err := icon.SaveBytes(data, ext, o.Slug) + return path, o.IconURL, err + } + } + if nearby := icon.Nearby(o.NearbyDir, o.NearbyBase); nearby != "" { + path, err = icon.Save(nearby, "", "", o.Slug) + return path, iconSourceFile + nearby, err + } + if o.PageURL != "" { + if data, ext, err := icon.Fetch(o.PageURL); err == nil { + path, err := icon.SaveBytes(data, ext, o.Slug) + return path, iconSourceFavicon + o.PageURL, err + } + } + if o.Query != "" { + if result, ok := icon.Find(o.Query); ok { + if data, ext, err := icon.FromURL(result.URL); err == nil { + path, err := icon.SaveBytes(data, ext, o.Slug) + return path, iconSourceSearch + o.Query, err + } + } + } + path, err = icon.Default(o.Slug) + return path, "", err +} + +// RefreshIcon mengunduh ulang ikon shortcut dari sumber online-nya (sync manual). +func RefreshIcon(sc store.Shortcut) (string, error) { + var data []byte + var ext string + var err error + + switch { + case sc.IconSource == "": + return "", errors.New("this icon has no online source") + case strings.HasPrefix(sc.IconSource, iconSourceFavicon): + data, ext, err = icon.Fetch(strings.TrimPrefix(sc.IconSource, iconSourceFavicon)) + case strings.HasPrefix(sc.IconSource, iconSourceSearch): + result, ok := icon.Find(strings.TrimPrefix(sc.IconSource, iconSourceSearch)) + if !ok { + return "", errors.New("no matching icon found online anymore") + } + data, ext, err = icon.FromURL(result.URL) + case strings.HasPrefix(sc.IconSource, "http"): + data, ext, err = icon.FromURL(sc.IconSource) + default: + return "", errors.New("this icon comes from a local file, not the internet") + } + if err != nil { + return "", err + } + + path, err := icon.SaveBytes(data, ext, sc.Slug) + if err != nil { + return "", err + } + if sc.IconPath != "" && sc.IconPath != path { + _ = os.Remove(sc.IconPath) + } + if err := replaceIconLine(sc.DesktopPath, path); err != nil { + return path, err + } + if sc.DesktopCopyPath != "" { + if err := replaceIconLine(sc.DesktopCopyPath, path); err != nil { + return path, err + } + } + go RefreshCache() + return path, nil +} + +// replaceIconLine menimpa baris Icon= di file .desktop tanpa mengubah mode file. +func replaceIconLine(path, iconPath string) error { + info, err := os.Stat(path) + if err != nil { + return err + } + data, err := os.ReadFile(path) + if err != nil { + return err + } + + lines := strings.Split(string(data), "\n") + for i, line := range lines { + if strings.HasPrefix(line, "Icon=") { + lines[i] = "Icon=" + escapeValue(iconPath) + break + } + } + return os.WriteFile(path, []byte(strings.Join(lines, "\n")), info.Mode().Perm()) +} diff --git a/internal/shortcut/icons_test.go b/internal/shortcut/icons_test.go new file mode 100644 index 0000000..add894d --- /dev/null +++ b/internal/shortcut/icons_test.go @@ -0,0 +1,194 @@ +package shortcut + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "lazyrun/internal/icon" + "lazyrun/internal/store" +) + +// iconSetEndpoint mengarahkan pencarian ikon ke server tes. +func iconSetEndpoint(t *testing.T, base string) { + t.Helper() + icon.SetSearchEndpoint(base) + t.Cleanup(func() { icon.SetSearchEndpoint("http://127.0.0.1:1") }) +} + +func TestRefreshIconFromURL(t *testing.T) { + testEnv(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(append(pngMagic, []byte("ikon-baru")...)) + })) + defer srv.Close() + + deskDst := filepath.Join(t.TempDir(), "x.desktop") + old := Entry{Name: "X", ExecArgs: []string{"/x"}, Icon: "/lama/icon.png"} + if err := WriteDesktop(deskDst, old); err != nil { + t.Fatal(err) + } + + sc := store.Shortcut{ + Slug: "x", + Name: "X", + IconPath: "/lama/icon.png", + IconSource: srv.URL + "/icon.png", + DesktopPath: deskDst, + } + path, err := RefreshIcon(sc) + if err != nil { + t.Fatal(err) + } + if filepath.Base(path) != "x.png" { + t.Errorf("ikon baru di %s", path) + } + if data, err := os.ReadFile(path); err != nil || len(data) == 0 { + t.Errorf("ikon tidak tertulis: %v", err) + } + + content, err := os.ReadFile(deskDst) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "Icon="+path+"\n") { + t.Errorf("baris Icon= tidak diperbarui:\n%s", content) + } +} + +func TestRefreshIconKeepsDesktopCopyMode(t *testing.T) { + home := testEnv(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(append(pngMagic, []byte("baru")...)) + })) + defer srv.Close() + + deskDst := filepath.Join(home, ".local/share/applications/y.desktop") + copyDst := filepath.Join(home, "Desktop", "y.desktop") + for _, path := range []string{deskDst, copyDst} { + if err := WriteDesktop(path, Entry{Name: "Y", ExecArgs: []string{"/y"}, Icon: "/lama.png"}); err != nil { + t.Fatal(err) + } + } + if err := os.Chmod(copyDst, 0o755); err != nil { + t.Fatal(err) + } + + sc := store.Shortcut{ + Slug: "y", + IconSource: srv.URL, + DesktopPath: deskDst, + DesktopCopyPath: copyDst, + } + if _, err := RefreshIcon(sc); err != nil { + t.Fatal(err) + } + st, err := os.Stat(copyDst) + if err != nil { + t.Fatal(err) + } + if st.Mode().Perm()&0o111 == 0 { + t.Errorf("salinan desktop kehilangan mode executable: %v", st.Mode()) + } +} + +func TestRefreshIconWithoutOnlineSource(t *testing.T) { + testEnv(t) + sc := store.Shortcut{Slug: "z", IconSource: "file:/tmp/icon.png", DesktopPath: "/tmp/z.desktop"} + if _, err := RefreshIcon(sc); err == nil { + t.Error("ikon dari file lokal harus error, bukan diunduh") + } +} + +func TestInstallExecutableSearchesIconOnline(t *testing.T) { + home := testEnv(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch { + case strings.HasPrefix(r.URL.Path, "/search"): + _, _ = w.Write([]byte(`{"icons":["mdi:winbox"]}`)) + case r.URL.Path == "/mdi/winbox.svg": + _, _ = w.Write([]byte(``)) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + iconSetEndpoint(t, srv.URL) + + src := fakeBinary(t, home, "winbox") + sc, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Winbox", Menu: true}) + if err != nil { + t.Fatal(err) + } + if sc.IconSource != "search:Winbox" { + t.Errorf("icon_source = %q", sc.IconSource) + } + if filepath.Ext(sc.IconPath) != ".svg" { + t.Errorf("ikon online harus tersimpan sebagai .svg, dapat %s", sc.IconPath) + } +} + +func TestInstallWebAppUsesIconURL(t *testing.T) { + testEnv(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/pilih.svg": + w.Header().Set("Content-Type", "image/svg+xml") + _, _ = w.Write([]byte(``)) + case "/": + _, _ = w.Write([]byte(``)) + default: + _, _ = w.Write(append(pngMagic, []byte("favicon-situs")...)) + } + })) + defer srv.Close() + + sc, err := InstallWebApp(WebOptions{ + URL: "https://example.com", + Name: "Example", + Browser: BrowserArg{Name: "Chromium", Exec: []string{"/usr/bin/chromium"}}, + IconURL: srv.URL + "/pilih.svg", + Menu: true, + Desktop: true, + }) + if err != nil { + t.Fatal(err) + } + if sc.IconSource != srv.URL+"/pilih.svg" { + t.Errorf("ikon pilihan user harus dipakai, icon_source = %q", sc.IconSource) + } + if filepath.Ext(sc.IconPath) != ".svg" { + t.Errorf("ikon = %s", sc.IconPath) + } +} + +func TestInstallWebAppUsesIconDataFromUI(t *testing.T) { + testEnv(t) + + svg := []byte(``) + sc, err := InstallWebApp(WebOptions{ + URL: "https://example.com", + Name: "Example", + Browser: BrowserArg{Name: "Chromium", Exec: []string{"/usr/bin/chromium"}}, + IconURL: "https://api.iconify.design/mdi/example.svg?height=256", + IconData: svg, // sudah diunduh UI: tidak perlu request ulang + Menu: true, + }) + if err != nil { + t.Fatal(err) + } + if sc.IconSource != "https://api.iconify.design/mdi/example.svg?height=256" { + t.Errorf("icon_source = %q", sc.IconSource) + } + data, err := os.ReadFile(sc.IconPath) + if err != nil || string(data) != string(svg) { + t.Errorf("ikon tersimpan salah: %v %d byte", err, len(data)) + } +} diff --git a/internal/shortcut/install_test.go b/internal/shortcut/install_test.go index 31e87e1..14ccd01 100644 --- a/internal/shortcut/install_test.go +++ b/internal/shortcut/install_test.go @@ -8,6 +8,7 @@ import ( "strings" "testing" + "lazyrun/internal/icon" "lazyrun/internal/store" ) @@ -22,6 +23,8 @@ func testEnv(t *testing.T) string { t.Setenv("XDG_DATA_HOME", "") t.Setenv("XDG_CONFIG_HOME", "") t.Setenv("PATH", "") + // tes tidak boleh menyentuh internet: arahkan pencarian ikon ke port mati + icon.SetSearchEndpoint("http://127.0.0.1:1") return home } diff --git a/internal/store/store.go b/internal/store/store.go index 2fbf7b8..f6517f9 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -30,6 +30,7 @@ type Shortcut struct { ExecArgs []string `json:"exec_args,omitempty"` BinaryPath string `json:"binary_path,omitempty"` IconPath string `json:"icon_path,omitempty"` + IconSource string `json:"icon_source,omitempty"` DesktopPath string `json:"desktop_path"` DesktopCopyPath string `json:"desktop_copy_path,omitempty"` Targets Targets `json:"targets"` diff --git a/internal/ui/dialog_executable.go b/internal/ui/dialog_executable.go index 3022937..b159a3f 100644 --- a/internal/ui/dialog_executable.go +++ b/internal/ui/dialog_executable.go @@ -11,6 +11,7 @@ import ( "fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/widget" + "lazyrun/internal/icon" "lazyrun/internal/shortcut" "lazyrun/internal/store" ) @@ -26,7 +27,8 @@ var ( ) func (ui *app) showExecutableDialog() { - var binPath, iconPath string + var binPath, iconPath, iconURL string + var iconData []byte binLabel := muted(T("none")) iconLabel := muted(T("icon_auto")) @@ -50,10 +52,16 @@ func (ui *app) showExecutableDialog() { }) browseIcon := widget.NewButtonWithIcon(T("browse"), theme.FolderOpenIcon(), func() { pickFile(ui.win, imageFilter, func(path string) { - iconPath = path + iconPath, iconURL, iconData = path, "", nil iconLabel.SetText(filepath.Base(path)) }) }) + onlineIcon := widget.NewButtonWithIcon(T("online"), theme.SearchIcon(), func() { + ui.showIconSearch(nameEntry.Text, func(result icon.Result, data []byte) { + iconURL, iconPath, iconData = result.URL, "", data + iconLabel.SetText(result.Name) + }) + }) var d *dialog.CustomDialog install := widget.NewButtonWithIcon(T("install"), theme.DownloadIcon(), func() { @@ -71,6 +79,8 @@ func (ui *app) showExecutableDialog() { opts := shortcut.ExecOptions{ BinaryPath: binPath, IconPath: iconPath, + IconURL: iconURL, + IconData: iconData, Name: nameEntry.Text, Category: category.Selected, Menu: menu.Checked, @@ -85,7 +95,8 @@ func (ui *app) showExecutableDialog() { form := widget.NewForm( widget.NewFormItem(T("field_binary"), container.NewBorder(nil, nil, nil, browseBin, binLabel)), - widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil, browseIcon, iconLabel)), + widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil, + container.NewHBox(onlineIcon, browseIcon), iconLabel)), widget.NewFormItem(T("field_name"), nameEntry), widget.NewFormItem(T("field_category"), category), ) diff --git a/internal/ui/dialog_icon.go b/internal/ui/dialog_icon.go new file mode 100644 index 0000000..f96e404 --- /dev/null +++ b/internal/ui/dialog_icon.go @@ -0,0 +1,96 @@ +package ui + +import ( + "fmt" + "strings" + + "fyne.io/fyne/v2" + "fyne.io/fyne/v2/container" + "fyne.io/fyne/v2/dialog" + "fyne.io/fyne/v2/theme" + "fyne.io/fyne/v2/widget" + + "lazyrun/internal/icon" +) + +// showIconSearch menampilkan pencarian ikon online (Iconify) dan mengirim hasil pilihan. +func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) { + entry := widget.NewEntry() + entry.SetText(icon.QueryHint(query)) + entry.SetPlaceHolder("whatsapp") + + status := muted(T("search_hint")) + slots := make(chan struct{}, 4) + grid := container.NewGridWithColumns(6) + results := container.NewVScroll(container.NewPadded(grid)) + + var d *dialog.CustomDialog + search := func() { + if strings.TrimSpace(entry.Text) == "" { + status.SetText(T("search_hint")) + return + } + status.SetText(T("searching")) + grid.RemoveAll() + go func() { + list, err := icon.SearchBest(entry.Text, 24) + fyne.Do(func() { + if err != nil { + status.SetText(T("search_failed")) + return + } + status.SetText(fmt.Sprintf(T("search_found"), len(list))) + for _, result := range list { + grid.Add(ui.iconChoice(result, onPick, d, slots)) + } + }) + }() + } + + entry.OnSubmitted = func(string) { search() } + content := container.NewBorder( + entry, + container.NewBorder(nil, nil, status, + container.NewHBox( + widget.NewButton(T("cancel"), func() { d.Hide() }), + widget.NewButtonWithIcon(T("search"), theme.SearchIcon(), search), + )), + nil, nil, + results, + ) + + d = dialog.NewCustom(T("icon_search_title"), "", content, ui.win) + d.Resize(fyne.NewSize(560, 460)) + d.Show() + search() +} + +// iconChoice adalah satu ikon hasil pencarian; gambarnya diunduh di latar belakang. +func (ui *app) iconChoice(result icon.Result, onPick func(icon.Result, []byte), d *dialog.CustomDialog, slot chan struct{}) fyne.CanvasObject { + var data []byte + + button := widget.NewButton("", func() { + onPick(result, data) + if d != nil { + d.Hide() + } + }) + button.Importance = widget.LowImportance + + go func() { + slot <- struct{}{} // batasi unduhan bersamaan supaya tidak kena rate limit + defer func() { <-slot }() + + downloaded, _, err := icon.FromURL(result.URL) + if err != nil { + return + } + data = downloaded + fyne.Do(func() { + button.Icon = fyne.NewStaticResource(result.Name, downloaded) + button.Refresh() + }) + }() + + return container.NewGridWrap(fyne.NewSize(52, 52), button) +} diff --git a/internal/ui/dialog_webapp.go b/internal/ui/dialog_webapp.go index 0c55629..b4b0ffe 100644 --- a/internal/ui/dialog_webapp.go +++ b/internal/ui/dialog_webapp.go @@ -12,6 +12,7 @@ import ( "fyne.io/fyne/v2/widget" "lazyrun/internal/browser" + "lazyrun/internal/icon" "lazyrun/internal/shortcut" "lazyrun/internal/store" ) @@ -22,7 +23,8 @@ func (ui *app) showWebAppDialog() { return } - var iconPath string + var iconPath, iconURL string + var iconData []byte var nameTouched, filling bool urlEntry := widget.NewEntry() @@ -55,10 +57,16 @@ func (ui *app) showWebAppDialog() { browseIcon := widget.NewButtonWithIcon(T("browse"), theme.FolderOpenIcon(), func() { pickFile(ui.win, imageFilter, func(path string) { - iconPath = path + iconPath, iconURL, iconData = path, "", nil iconLabel.SetText(filepath.Base(path)) }) }) + onlineIcon := widget.NewButtonWithIcon(T("online"), theme.SearchIcon(), func() { + ui.showIconSearch(nameEntry.Text, func(result icon.Result, data []byte) { + iconURL, iconPath, iconData = result.URL, "", data + iconLabel.SetText(result.Name) + }) + }) var d *dialog.CustomDialog install := widget.NewButtonWithIcon(T("install"), theme.DownloadIcon(), func() { @@ -79,6 +87,8 @@ func (ui *app) showWebAppDialog() { Name: nameEntry.Text, Browser: shortcut.BrowserArg{Name: b.Name, Exec: b.Exec, Firefox: b.Kind == browser.Firefox}, IconPath: iconPath, + IconURL: iconURL, + IconData: iconData, Menu: menu.Checked, Desktop: desktop.Checked, } @@ -93,7 +103,8 @@ func (ui *app) showWebAppDialog() { widget.NewFormItem(T("field_url"), urlEntry), widget.NewFormItem(T("field_name"), nameEntry), widget.NewFormItem(T("field_browser"), picker), - widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil, browseIcon, iconLabel)), + widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil, + container.NewHBox(onlineIcon, browseIcon), iconLabel)), ) content := container.NewVBox( form, diff --git a/internal/ui/main_window.go b/internal/ui/main_window.go index 3c17a2a..94596da 100644 --- a/internal/ui/main_window.go +++ b/internal/ui/main_window.go @@ -6,6 +6,7 @@ import ( "os/exec" "path/filepath" "strconv" + "strings" "time" "fyne.io/fyne/v2" @@ -138,6 +139,9 @@ func (ui *app) row(sc store.Shortcut) *hoverCard { actions := container.NewHBox(chips...) actions.Add(iconButton(theme.MediaPlayIcon(), func() { ui.launch(sc) })) + if syncable(sc) { + actions.Add(iconButton(theme.ViewRefreshIcon(), func() { ui.syncIcon(sc) })) + } actions.Add(iconButton(theme.FolderOpenIcon(), func() { if err := exec.Command("xdg-open", filepath.Dir(sc.DesktopPath)).Start(); err != nil { dialog.ShowError(err, ui.win) @@ -192,6 +196,40 @@ func (ui *app) flashCards() { } } +// syncable berarti ikon shortcut punya sumber online yang bisa diunduh ulang. +func syncable(sc store.Shortcut) bool { + source := sc.IconSource + return strings.HasPrefix(source, "http") || + strings.HasPrefix(source, "favicon:") || + strings.HasPrefix(source, "search:") +} + +// syncIcon mengunduh ulang ikon shortcut dari sumber online-nya. +func (ui *app) syncIcon(sc store.Shortcut) { + progress := dialog.NewProgressInfinite(T("sync_icon"), T("working"), ui.win) + progress.Show() + go func() { + path, err := shortcut.RefreshIcon(sc) + fyne.Do(func() { + progress.Hide() + if err != nil { + dialog.ShowError(err, ui.win) + return + } + if i, ok := ui.store.Get(sc.Slug); ok { + ui.store.Items[i].IconPath = path + if err := ui.store.Save(); err != nil { + dialog.ShowError(err, ui.win) + } + } + ui.reload() + if card, ok := ui.cards[sc.Slug]; ok { + card.flash(0) + } + }) + }() +} + func (ui *app) confirmDelete(sc store.Shortcut) { content := container.NewVBox(widget.NewLabel(fmt.Sprintf(T("confirm_delete_msg"), sc.Name))) diff --git a/internal/ui/strings.go b/internal/ui/strings.go index 9fbb561..9607661 100644 --- a/internal/ui/strings.go +++ b/internal/ui/strings.go @@ -7,24 +7,32 @@ import ( // tr menyimpan teks UI: index 0 = Inggris, 1 = Indonesia. var tr = map[string][2]string{ - "app_title": {"LazyRun — lazy shortcut build", "LazyRun — lazy shortcut build"}, - "tagline": {"lazy shortcut build", "lazy shortcut build"}, - "btn_executable": {"Executable", "Executable"}, - "btn_webapp": {"Web App", "Aplikasi Web"}, - "installed": {"Installed shortcuts", "Shortcut terinstall"}, - "empty_title": {"Nothing here yet", "Belum ada apa-apa"}, - "empty_hint": {"Create your first shortcut with the buttons in the corner.", "Buat shortcut pertamamu lewat tombol di pojok kanan atas."}, - "chip_menu": {"Menu", "Menu"}, - "chip_desktop": {"Desktop", "Desktop"}, - "chip_broken": {"file missing", "file hilang"}, - "none": {"not selected", "belum dipilih"}, - "icon_auto": {"automatic", "otomatis"}, - "browse": {"Browse...", "Pilih..."}, - "install": {"Install", "Install"}, - "cancel": {"Cancel", "Batal"}, - "delete": {"Delete", "Hapus"}, - "folder": {"Folder", "Folder"}, - "working": {"Working...", "Sedang diproses..."}, + "app_title": {"LazyRun — lazy shortcut build", "LazyRun — lazy shortcut build"}, + "tagline": {"lazy shortcut build", "lazy shortcut build"}, + "btn_executable": {"Executable", "Executable"}, + "btn_webapp": {"Web App", "Aplikasi Web"}, + "installed": {"Installed shortcuts", "Shortcut terinstall"}, + "empty_title": {"Nothing here yet", "Belum ada apa-apa"}, + "empty_hint": {"Create your first shortcut with the buttons in the corner.", "Buat shortcut pertamamu lewat tombol di pojok kanan atas."}, + "chip_menu": {"Menu", "Menu"}, + "chip_desktop": {"Desktop", "Desktop"}, + "chip_broken": {"file missing", "file hilang"}, + "none": {"not selected", "belum dipilih"}, + "icon_auto": {"automatic", "otomatis"}, + "online": {"Online", "Online"}, + "search": {"Search", "Cari"}, + "searching": {"Searching...", "Mencari..."}, + "search_failed": {"Search failed. Check your connection.", "Pencarian gagal. Cek koneksi kamu."}, + "search_found": {"%d icons found - click one to use it.", "%d ikon ditemukan - klik satu untuk dipakai."}, + "search_hint": {"Type a name and press Search.", "Tulis nama lalu tekan Cari."}, + "icon_search_title": {"Icon from the internet", "Ikon dari internet"}, + "sync_icon": {"Sync icon", "Sinkron ikon"}, + "browse": {"Browse...", "Pilih..."}, + "install": {"Install", "Install"}, + "cancel": {"Cancel", "Batal"}, + "delete": {"Delete", "Hapus"}, + "folder": {"Folder", "Folder"}, + "working": {"Working...", "Sedang diproses..."}, "new_exec_title": {"New Executable Shortcut", "Shortcut Executable Baru"}, "new_web_title": {"New Web App Shortcut", "Shortcut Web App Baru"}, @@ -39,7 +47,7 @@ var tr = map[string][2]string{ "check_menu": {"Application menu", "Menu aplikasi"}, "check_desktop": {"Desktop", "Desktop"}, "exec_hint": {"The binary is copied to ~/.local/bin and can be uninstalled from the list.", "Binary disalin ke ~/.local/bin dan bisa dihapus lagi dari daftar."}, - "web_hint": {"The icon is fetched from the site's favicon if you leave it empty.", "Ikon diambil otomatis dari favicon situs kalau dibiarkan kosong."}, + "web_hint": {"The icon comes from the site's favicon, or is looked up online if there is none.", "Ikon diambil dari favicon situs, atau dicari otomatis di internet kalau tidak ada."}, "confirm_delete_title": {"Uninstall shortcut", "Hapus shortcut"}, "confirm_delete_msg": {"Remove %s?", "Hapus %s?"},