From 6e6f9afea95cc572ceb100b94e877bf20b2b495e Mon Sep 17 00:00:00 2001 From: oxpoe Date: Tue, 13 Oct 2026 07:43:09 +0700 Subject: [PATCH] Perbaiki ikon internet yang kadang hilang / bikin crash - Semua SVG diraster ke PNG lebih dulu (install, sync, thumbnail). SVG dari internet sebagian memakai fitur yang tidak didukung renderer sehingga ikon tidak muncul atau proses menggambar berhenti. - fill="currentColor" diganti warna brand (penyebab tersering ikon Material/ Remix gagal diraster), rasterisasi dibungkus recover - Thumbnail pencarian: placeholder + cache unduhan + batas 3 unduhan bersamaan - Kandidat ikon yang gagal dilewati, install tetap lanjut ke ikon default - Race pada data ikon pilihan diperbaiki (mutex); tes -race hijau - Baris status modal pencarian dipindah ke barisnya sendiri (tidak bertabrakan dengan tombol Back/Search di layar berskala besar) --- go.mod | 8 +-- internal/icon/favicon_test.go | 18 +++---- internal/icon/install.go | 19 ++++--- internal/icon/raster.go | 51 ++++++++++++++++++ internal/icon/raster_test.go | 82 ++++++++++++++++++++++++++++ internal/shortcut/icons.go | 94 ++++++++++++++++++++++----------- internal/shortcut/icons_test.go | 24 ++++++--- internal/ui/dialog_icon.go | 86 ++++++++++++++++++++++++------ internal/ui/main_window.go | 2 + 9 files changed, 312 insertions(+), 72 deletions(-) create mode 100644 internal/icon/raster.go create mode 100644 internal/icon/raster_test.go diff --git a/go.mod b/go.mod index f3db88b..40e3d40 100644 --- a/go.mod +++ b/go.mod @@ -2,7 +2,11 @@ module lazyrun go 1.24 -require fyne.io/fyne/v2 v2.8.2 +require ( + fyne.io/fyne/v2 v2.8.2 + github.com/fyne-io/oksvg v0.2.1-0.20260918172519-a9af55fa95d9 + github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef +) require ( fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4 // indirect @@ -15,7 +19,6 @@ require ( github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 // indirect github.com/fyne-io/glfw-js v0.4.0 // indirect github.com/fyne-io/image v0.1.1 // indirect - github.com/fyne-io/oksvg v0.2.1-0.20260918172519-a9af55fa95d9 // indirect github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 // indirect github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.2 // indirect github.com/go-text/render v0.2.1 // indirect @@ -31,7 +34,6 @@ require ( github.com/pmezard/go-difflib v1.0.0 // indirect github.com/rymdport/portal v0.4.2 // indirect github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect - github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect github.com/stretchr/testify v1.11.1 // indirect github.com/yuin/goldmark v1.8.2 // indirect golang.org/x/image v0.24.0 // indirect diff --git a/internal/icon/favicon_test.go b/internal/icon/favicon_test.go index 9fcec1e..79f90ae 100644 --- a/internal/icon/favicon_test.go +++ b/internal/icon/favicon_test.go @@ -23,7 +23,7 @@ func icoWithPNG(png []byte) []byte { return ico } -var png = append([]byte("\x89PNG\r\n\x1a\n"), []byte("payload")...) +var pngBytes = append([]byte("\x89PNG\r\n\x1a\n"), []byte("payload")...) func TestFetchUsesLinkedIcon(t *testing.T) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { @@ -34,9 +34,9 @@ func TestFetchUsesLinkedIcon(t *testing.T) { ``+ ``) case "/big.png": - w.Write(png) + w.Write(pngBytes) default: - w.Write(icoWithPNG(png)) + w.Write(icoWithPNG(pngBytes)) } })) defer srv.Close() @@ -45,7 +45,7 @@ func TestFetchUsesLinkedIcon(t *testing.T) { if err != nil { t.Fatal(err) } - if ext != ".png" || string(data) != string(png) { + if ext != ".png" || string(data) != string(pngBytes) { t.Errorf("ikon terpilih salah: ext=%s data=%q", ext, data) } } @@ -56,7 +56,7 @@ func TestFetchFallsBackToFaviconICO(t *testing.T) { io.WriteString(w, `no icon`) return } - w.Write(icoWithPNG(png)) + w.Write(icoWithPNG(pngBytes)) })) defer srv.Close() @@ -64,7 +64,7 @@ func TestFetchFallsBackToFaviconICO(t *testing.T) { if err != nil { t.Fatal(err) } - if ext != ".png" || string(data) != string(png) { + if ext != ".png" || string(data) != string(pngBytes) { t.Errorf("PNG di dalam .ico harus dipakai: ext=%s data=%q", ext, data) } } @@ -81,7 +81,7 @@ func TestFetchFailsWithoutIcon(t *testing.T) { } func TestLargestPNGFromICO(t *testing.T) { - if got := largestPNGFromICO(icoWithPNG(png)); string(got) != string(png) { + if got := largestPNGFromICO(icoWithPNG(pngBytes)); string(got) != string(pngBytes) { t.Errorf("PNG di dalam .ico tidak terbaca: %q", got) } if got := largestPNGFromICO([]byte("bukan ico")); got != nil { @@ -97,8 +97,8 @@ func TestLargestPNGFromICO(t *testing.T) { } func TestSniffExt(t *testing.T) { - if ext := sniffExt(png); ext != ".png" { - t.Errorf("sniffExt(png) = %s", ext) + if ext := sniffExt(pngBytes); ext != ".png" { + t.Errorf("sniffExt(pngBytes) = %s", ext) } if ext := sniffExt([]byte(``)); ext != ".svg" { t.Errorf("sniffExt(svg) = %s", ext) diff --git a/internal/icon/install.go b/internal/icon/install.go index 9001443..1b634be 100644 --- a/internal/icon/install.go +++ b/internal/icon/install.go @@ -73,10 +73,19 @@ func fromFile(src, slug string) (string, error) { } // SaveBytes menulis data ikon ke direktori ikon XDG. +// SVG diraster dulu ke PNG: sebagian ikon internet memakai fitur SVG yang membuat +// proses menggambar berhenti (panic) atau tidak tampil di UI/desktop. func SaveBytes(data []byte, ext, slug string) (string, error) { if ext == "" { ext = ".png" } + if ext == ".svg" { + raster, err := Rasterize(data, 256) + if err != nil { + return "", err + } + data, ext = raster, ".png" + } if err := os.MkdirAll(xdg.IconsDir(), 0o755); err != nil { return "", err } @@ -86,14 +95,8 @@ 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) -} +// Ext menebak ekstensi file dari isi data (dipakai untuk ikon yang sudah diunduh UI). +func Ext(data []byte) string { return sniffExt(data) } // IconBytes adalah ikon default LazyRun. func IconBytes() []byte { return assets.Icon } diff --git a/internal/icon/raster.go b/internal/icon/raster.go new file mode 100644 index 0000000..2ec0657 --- /dev/null +++ b/internal/icon/raster.go @@ -0,0 +1,51 @@ +package icon + +import ( + "bytes" + "errors" + "fmt" + "image" + "image/png" + + "github.com/fyne-io/oksvg" + "github.com/srwiley/rasterx" +) + +// currentColor diisi ke SVG yang memakai fill="currentColor". +const currentColor = "#4f46e5" + +// Rasterize mengubah SVG menjadi PNG persegi `size`x`size`. +// +// Ikon dari internet kadang berupa SVG dengan fitur yang tidak didukung renderer; +// SVG seperti itu bisa membuat proses menggambar di UI berhenti (panic) atau tidak +// tampil sama sekali. Karena itu SVG selalu diraster lebih dulu, dan kalau gagal +// pemanggil memakai ikon default. +func Rasterize(svg []byte, size int) (pngBytes []byte, err error) { + defer func() { + if r := recover(); r != nil { + err = fmt.Errorf("svg tidak bisa diraster: %v", r) + } + }() + + // banyak ikon internet memakai fill="currentColor" (mis. Material, Remix); + // warnanya diganti warna brand supaya ikon monokrom tetap terlihat + // di background terang maupun gelap. + parsed, err := oksvg.ReadReplacingCurrentColor(bytes.NewReader(svg), currentColor) + if err != nil { + return nil, err + } + if parsed.ViewBox.W <= 0 || parsed.ViewBox.H <= 0 { + return nil, errors.New("svg tanpa viewBox") + } + + parsed.SetTarget(0, 0, float64(size), float64(size)) + img := image.NewRGBA(image.Rect(0, 0, size, size)) + scanner := rasterx.NewScannerGV(size, size, img, img.Bounds()) + parsed.Draw(rasterx.NewDasher(size, size, scanner), 1.0) + + var out bytes.Buffer + if err := png.Encode(&out, img); err != nil { + return nil, err + } + return out.Bytes(), nil +} diff --git a/internal/icon/raster_test.go b/internal/icon/raster_test.go new file mode 100644 index 0000000..2978420 --- /dev/null +++ b/internal/icon/raster_test.go @@ -0,0 +1,82 @@ +package icon + +import ( + "bytes" + "image/png" + "testing" +) + +func TestRasterizeSimpleSVG(t *testing.T) { + svg := []byte(`` + + ``) + + out, err := Rasterize(svg, 64) + if err != nil { + t.Fatal(err) + } + img, err := png.Decode(bytes.NewReader(out)) + if err != nil { + t.Fatalf("hasil raster bukan PNG: %v", err) + } + if b := img.Bounds(); b.Dx() != 64 || b.Dy() != 64 { + t.Errorf("ukuran hasil = %v", b) + } +} + +func TestRasterizeSVGWithoutViewBox(t *testing.T) { + // banyak ikon internet hanya punya width/height + svg := []byte(``) + if _, err := Rasterize(svg, 32); err != nil { + t.Errorf("svg tanpa viewBox harus tetap bisa: %v", err) + } +} + +func TestRasterizeRejectsGarbageWithoutPanic(t *testing.T) { + cases := map[string][]byte{ + "bukan svg": []byte("404"), + "svg kosong": []byte(``), + "path ngawur": []byte(``), + "data terpotong": []byte(``), + } + + for name, svg := range cases { + t.Run(name, func(t *testing.T) { + // inti tes: tidak panic. Error boleh. + _, _ = Rasterize(svg, 32) + }) + } +} + +func TestExtSniffsSVG(t *testing.T) { + if got := Ext([]byte(``)); got != ".svg" { + t.Errorf("Ext(svg) = %q", got) + } + if got := Ext(pngBytes); got != ".png" { + t.Errorf("Ext(png) = %q", got) + } +} + +func TestRasterizeColorsCurrentColorIcons(t *testing.T) { + // ikon Material/Remix memakai fill="currentColor" tanpa warna eksplisit; + // tanpa penggantian warna, rasterisasi gagal dan thumbnail tidak muncul. + svg := []byte(`` + + ``) + + out, err := Rasterize(svg, 32) + if err != nil { + t.Fatalf("ikon currentColor harus bisa diraster: %v", err) + } + img, err := png.Decode(bytes.NewReader(out)) + if err != nil { + t.Fatal(err) + } + r, g, b, a := img.At(16, 16).RGBA() + if a == 0 { + t.Fatal("ikon currentColor tidak terwarnai (transparan)") + } + // brand indigo #4F46E5: biru > merah > hijau + if !(b > r && r > g) { + t.Errorf("warna pengganti bukan brand indigo: r=%d g=%d b=%d", r>>8, g>>8, b>>8) + } +} diff --git a/internal/shortcut/icons.go b/internal/shortcut/icons.go index 89e86d6..50ddff2 100644 --- a/internal/shortcut/icons.go +++ b/internal/shortcut/icons.go @@ -3,6 +3,7 @@ package shortcut import ( "errors" "os" + "path/filepath" "strings" "lazyrun/internal/icon" @@ -29,44 +30,75 @@ type iconOptions struct { // installIconFor menentukan ikon shortcut dan sumbernya. // Urutan: file user → URL user → ikon di folder binary → favicon situs → cari online → ikon default. +// Kandidat yang gagal (file rusak, unduhan gagal, SVG tidak bisa diraster) dilewati, +// jadi install tidak pernah batal hanya karena ikon. 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 + attempts := []struct { + source string + load func() ([]byte, string, error) + }{ + {iconSourceFile + o.IconPath, func() ([]byte, string, error) { + if o.IconPath == "" { + return nil, "", errSkipIcon } - } + data, err := os.ReadFile(o.IconPath) + return data, strings.ToLower(filepath.Ext(o.IconPath)), err + }}, + {o.IconURL, func() ([]byte, string, error) { + // ikon pilihan dari pencarian online: datanya sudah diunduh UI + if len(o.IconData) > 0 { + return o.IconData, icon.Ext(o.IconData), nil + } + if o.IconURL == "" { + return nil, "", errSkipIcon + } + return icon.FromURL(o.IconURL) + }}, + {iconSourceFile + icon.Nearby(o.NearbyDir, o.NearbyBase), func() ([]byte, string, error) { + nearby := icon.Nearby(o.NearbyDir, o.NearbyBase) + if nearby == "" { + return nil, "", errSkipIcon + } + data, err := os.ReadFile(nearby) + return data, strings.ToLower(filepath.Ext(nearby)), err + }}, + {iconSourceFavicon + o.PageURL, func() ([]byte, string, error) { + if o.PageURL == "" { + return nil, "", errSkipIcon + } + return icon.Fetch(o.PageURL) + }}, + {iconSourceSearch + o.Query, func() ([]byte, string, error) { + if o.Query == "" { + return nil, "", errSkipIcon + } + result, ok := icon.Find(o.Query) + if !ok { + return nil, "", errSkipIcon + } + return icon.FromURL(result.URL) + }}, } + + for _, attempt := range attempts { + data, ext, err := attempt.load() + if err != nil || len(data) == 0 { + continue + } + path, err := icon.SaveBytes(data, ext, o.Slug) + if err != nil { + continue + } + return path, attempt.source, nil + } + path, err = icon.Default(o.Slug) return path, "", err } +// errSkipIcon menandai kandidat ikon yang tidak tersedia. +var errSkipIcon = errors.New("icon source not available") + // RefreshIcon mengunduh ulang ikon shortcut dari sumber online-nya (sync manual). func RefreshIcon(sc store.Shortcut) (string, error) { var data []byte diff --git a/internal/shortcut/icons_test.go b/internal/shortcut/icons_test.go index add894d..70cf0ca 100644 --- a/internal/shortcut/icons_test.go +++ b/internal/shortcut/icons_test.go @@ -1,6 +1,7 @@ package shortcut import ( + "bytes" "net/http" "net/http/httptest" "os" @@ -129,9 +130,10 @@ func TestInstallExecutableSearchesIconOnline(t *testing.T) { 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) + if filepath.Ext(sc.IconPath) != ".png" { + t.Errorf("SVG online harus diraster jadi .png, dapat %s", sc.IconPath) } + assertPNG(t, sc.IconPath) } func TestInstallWebAppUsesIconURL(t *testing.T) { @@ -164,9 +166,10 @@ func TestInstallWebAppUsesIconURL(t *testing.T) { 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" { + if filepath.Ext(sc.IconPath) != ".png" { t.Errorf("ikon = %s", sc.IconPath) } + assertPNG(t, sc.IconPath) } func TestInstallWebAppUsesIconDataFromUI(t *testing.T) { @@ -187,8 +190,17 @@ func TestInstallWebAppUsesIconDataFromUI(t *testing.T) { 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)) + assertPNG(t, sc.IconPath) +} + +// assertPNG memastikan file ikon berisi PNG (bukan SVG mentah). +func assertPNG(t *testing.T, path string) { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("ikon tidak terbaca: %v", err) + } + if !bytes.HasPrefix(data, []byte("\x89PNG\r\n\x1a\n")) { + t.Errorf("%s bukan PNG: %q", path, data[:min(12, len(data))]) } } diff --git a/internal/ui/dialog_icon.go b/internal/ui/dialog_icon.go index bee8a94..e2f25ab 100644 --- a/internal/ui/dialog_icon.go +++ b/internal/ui/dialog_icon.go @@ -3,6 +3,7 @@ package ui import ( "fmt" "strings" + "sync" "fyne.io/fyne/v2" "fyne.io/fyne/v2/container" @@ -13,6 +14,41 @@ import ( "lazyrun/internal/icon" ) +// iconCache menyimpan ikon yang sudah diunduh supaya pencarian ulang tidak +// mengunduh berkali-kali: API ikon publik membatasi jumlah permintaan beruntun. +type iconCache struct { + mu sync.Mutex + items map[string][]byte +} + +func newIconCache() *iconCache { return &iconCache{items: map[string][]byte{}} } + +func (c *iconCache) load(url string) ([]byte, bool) { + c.mu.Lock() + defer c.mu.Unlock() + data, ok := c.items[url] + return data, ok +} + +func (c *iconCache) store(url string, data []byte) { + c.mu.Lock() + defer c.mu.Unlock() + c.items[url] = data +} + +// fetchIcon mengunduh ikon (atau mengambil dari cache). +func (ui *app) fetchIcon(url string) ([]byte, error) { + if data, ok := ui.icons.load(url); ok { + return data, nil + } + data, _, err := icon.FromURL(url) + if err != nil { + return nil, err + } + ui.icons.store(url, data) + return data, nil +} + // showIconSearch menampilkan pencarian ikon online (Iconify) dan mengirim hasil pilihan. func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) { entry := widget.NewEntry() @@ -20,9 +56,9 @@ func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) { entry.SetPlaceHolder("whatsapp") status := muted(T("search_hint")) - slots := make(chan struct{}, 4) grid := container.NewGridWithColumns(6) results := container.NewVScroll(container.NewPadded(grid)) + slots := make(chan struct{}, 3) var d *dialog.CustomDialog search := func() { @@ -48,45 +84,65 @@ func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) { } entry.OnSubmitted = func(string) { search() } + // status di barisnya sendiri: supaya tidak bertabrakan dengan tombol + // di layar sempit atau ber-penskalaan besar. + bottom := container.NewVBox(status, container.NewHBox( + spacer(), + widget.NewButtonWithIcon(T("back"), theme.NavigateBackIcon(), func() { d.Hide() }), + widget.NewButtonWithIcon(T("search"), theme.SearchIcon(), search), + )) content := container.NewBorder( container.NewVBox(closeRow(func() { d.Hide() }), entry), - container.NewBorder(nil, nil, status, - container.NewHBox( - widget.NewButtonWithIcon(T("back"), theme.NavigateBackIcon(), func() { d.Hide() }), - widget.NewButtonWithIcon(T("search"), theme.SearchIcon(), search), - )), - nil, nil, + bottom, nil, nil, results, ) d = dialog.NewCustom(T("icon_search_title"), "", content, ui.win) - showDialog(d, ui.win, 560, 470) + showDialog(d, ui.win, 560, 470) // sekaligus menampilkan dialog search() } -// iconChoice adalah satu ikon hasil pencarian; gambarnya diunduh di latar belakang. +// 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. func (ui *app) iconChoice(result icon.Result, onPick func(icon.Result, []byte), d *dialog.CustomDialog, slot chan struct{}) fyne.CanvasObject { - var data []byte + var mu sync.Mutex + var raw []byte button := widget.NewButton("", func() { - onPick(result, data) + mu.Lock() + picked := raw + mu.Unlock() + onPick(result, picked) if d != nil { d.Hide() } }) button.Importance = widget.LowImportance + button.Icon = theme.NewThemedResource(theme.FileImageIcon()) // placeholder selama diunduh go func() { - slot <- struct{}{} // batasi unduhan bersamaan supaya tidak kena rate limit + slot <- struct{}{} defer func() { <-slot }() - downloaded, _, err := icon.FromURL(result.URL) + downloaded, err := ui.fetchIcon(result.URL) if err != nil { return } - data = downloaded + thumb, err := icon.Rasterize(downloaded, 64) + if err != nil { + if icon.Ext(downloaded) == ".svg" { + return + } + thumb = downloaded // PNG/JPEG bisa langsung dipakai + } + + mu.Lock() + raw = downloaded + mu.Unlock() + fyne.Do(func() { - button.Icon = fyne.NewStaticResource(result.Name, downloaded) + button.Icon = fyne.NewStaticResource(result.Name+".png", thumb) button.Refresh() }) }() diff --git a/internal/ui/main_window.go b/internal/ui/main_window.go index 94596da..6fe669c 100644 --- a/internal/ui/main_window.go +++ b/internal/ui/main_window.go @@ -29,6 +29,7 @@ type app struct { browsers []browser.Browser rows *fyne.Container cards map[string]*hoverCard + icons *iconCache count *canvas.Text // angka jumlah shortcut di header daftar version string } @@ -52,6 +53,7 @@ func build(a fyne.App, version string) (*app, error) { a.SetIcon(fyne.NewStaticResource("lazyrun.png", assets.Icon)) ui.browsers = browser.Detect() + ui.icons = newIconCache() ui.rows = container.NewVBox() countChip, count := chipLabel("0", colorNameChipMenu) ui.count = count