From cad41caaee7633eb9109e4be20950dcb3c79aa4e Mon Sep 17 00:00:00 2001 From: oxpoe Date: Tue, 13 Oct 2026 07:50:43 +0700 Subject: [PATCH] Hapus tombol kosong bawaan Fyne di modal + semua format ikon dikonversi ke PNG - dialog.NewCustom selalu menambah tombol dismiss walau labelnya kosong; modal kita pakai baris tombol sendiri, jadi pindah ke NewCustomWithoutButtons (ditambah tes yang menolak pemakaian dialog.NewCustom lagi) - Ikon: SVG/webp/gif/bmp/ico/jpeg semuanya dikonversi ke PNG saat dipasang (desktop environment tidak konsisten membaca format lain), gambar besar diperkecil ke maksimal 512px - Filter file picker ikon menambah webp/gif/bmp - Perbaiki data race pada endpoint pencarian ikon (iconifyBase) yang ditemukan oleh go test -race; tes UI menunggu goroutine pencarian selesai - go test -race -count=1 ./... hijau untuk semua paket --- go.mod | 2 +- internal/icon/convert_test.go | 90 ++++++++++++++++++++++++ internal/icon/decoders.go | 11 +++ internal/icon/install.go | 114 +++++++++++++++++-------------- internal/icon/online.go | 25 +++++-- internal/shortcut/icons.go | 13 ++-- internal/ui/dialog_executable.go | 4 +- internal/ui/dialog_icon.go | 9 +-- internal/ui/dialog_test.go | 72 +++++++++++++++++++ internal/ui/dialog_webapp.go | 2 +- 10 files changed, 269 insertions(+), 73 deletions(-) create mode 100644 internal/icon/convert_test.go create mode 100644 internal/icon/decoders.go create mode 100644 internal/ui/dialog_test.go diff --git a/go.mod b/go.mod index 40e3d40..7f20705 100644 --- a/go.mod +++ b/go.mod @@ -6,6 +6,7 @@ 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 + golang.org/x/image v0.24.0 ) require ( @@ -36,7 +37,6 @@ require ( github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // 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 golang.org/x/net v0.35.0 // indirect golang.org/x/sys v0.30.0 // indirect golang.org/x/text v0.22.0 // indirect diff --git a/internal/icon/convert_test.go b/internal/icon/convert_test.go new file mode 100644 index 0000000..fe232a9 --- /dev/null +++ b/internal/icon/convert_test.go @@ -0,0 +1,90 @@ +package icon + +import ( + "bytes" + "image" + "image/color" + "image/gif" + "image/jpeg" + "image/png" + "testing" + + "golang.org/x/image/bmp" +) + +func solidImg(size int) image.Image { + img := image.NewNRGBA(image.Rect(0, 0, size, size)) + for x := 0; x < size; x++ { + for y := 0; y < size; y++ { + img.Set(x, y, color.NRGBA{R: 0x12, G: 0x9f, B: 0x55, A: 0xFF}) + } + } + return img +} + +func encode(t *testing.T, fn func(*bytes.Buffer) error) []byte { + t.Helper() + var buf bytes.Buffer + if err := fn(&buf); err != nil { + t.Fatal(err) + } + return buf.Bytes() +} + +func TestToPNGConvertsFormats(t *testing.T) { + src := solidImg(64) + + cases := map[string][]byte{ + "png": encode(t, func(b *bytes.Buffer) error { return png.Encode(b, src) }), + "gif": encode(t, func(b *bytes.Buffer) error { return gif.Encode(b, src, nil) }), + "bmp": encode(t, func(b *bytes.Buffer) error { return bmp.Encode(b, src) }), + "jpg": encode(t, func(b *bytes.Buffer) error { return jpeg.Encode(b, src, nil) }), + "svg": []byte(``), + "ico": icoWithPNG(encode(t, func(b *bytes.Buffer) error { return png.Encode(b, solidImg(16)) })), + } + + for name, data := range cases { + t.Run(name, func(t *testing.T) { + out, err := ToPNG(data) + if err != nil { + t.Fatalf("ToPNG(%s) gagal: %v", name, err) + } + if !bytes.HasPrefix(out, pngMagic) { + t.Fatalf("hasil %s bukan PNG", name) + } + if _, err := png.Decode(bytes.NewReader(out)); err != nil { + t.Fatalf("hasil %s tidak bisa di-decode: %v", name, err) + } + }) + } +} + +func TestToPNGRejectsUnknown(t *testing.T) { + if _, err := ToPNG([]byte("halo dunia")); err == nil { + t.Error("data bukan gambar harus error") + } +} + +func TestToPNGShrinksHugeImages(t *testing.T) { + out, err := ToPNG(encode(t, func(b *bytes.Buffer) error { return png.Encode(b, solidImg(1024)) })) + if err != nil { + t.Fatal(err) + } + if len(out) >= 1024*1024 { + t.Errorf("gambar besar tidak diperkecil: %d byte", len(out)) + } +} + +func TestSaveAlwaysWritesPNG(t *testing.T) { + t.Setenv("HOME", t.TempDir()) + t.Setenv("XDG_DATA_HOME", "") + + // ikon webp tak disupport ditest lewat gif: hasil harus selalu .png + path, err := Save(encode(t, func(b *bytes.Buffer) error { return gif.Encode(b, solidImg(32), nil) }), "contoh") + if err != nil { + t.Fatal(err) + } + if got := path[len(path)-4:]; got != ".png" { + t.Errorf("path ikon = %s", path) + } +} diff --git a/internal/icon/decoders.go b/internal/icon/decoders.go new file mode 100644 index 0000000..500b172 --- /dev/null +++ b/internal/icon/decoders.go @@ -0,0 +1,11 @@ +package icon + +// decoder gambar tambahan: webp & bmp tidak ada di stdlib, +// jpeg/gif perlu di-import supaya terdaftar di image.Decode. +import ( + _ "golang.org/x/image/bmp" + _ "golang.org/x/image/webp" + + _ "image/gif" + _ "image/jpeg" +) diff --git a/internal/icon/install.go b/internal/icon/install.go index 1b634be..09bf519 100644 --- a/internal/icon/install.go +++ b/internal/icon/install.go @@ -2,38 +2,30 @@ package icon import ( + "bytes" "errors" + "image" + "image/png" "os" "path/filepath" "strings" + xdraw "golang.org/x/image/draw" + "lazyrun/assets" "lazyrun/internal/xdg" ) -var exts = []string{".png", ".svg", ".jpg", ".jpeg", ".xpm"} -var nearbyNames = []string{"icon", "logo"} +var exts = []string{".png", ".svg", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".ico", ".xpm"} -// Save memasang ikon untuk shortcut dan mengembalikan path absolutnya. -// Urutan pilihan: ikon dari user, ikon di folder binary, lalu ikon default LazyRun. -func Save(userIcon, nearbyDir, nearbyBase, slug string) (string, error) { - if userIcon != "" { - return fromFile(userIcon, slug) - } - for _, name := range append([]string{nearbyBase}, nearbyNames...) { - if name == "" { - continue - } - if p := findNearby(nearbyDir, name); p != "" { - return fromFile(p, slug) - } - } - return Default(slug) -} +var pngMagic = []byte("\x89PNG\r\n\x1a\n") + +// maxSide adalah sisi terpanjang ikon yang disimpan. +const maxSide = 512 // Default memasang ikon bawaan LazyRun. func Default(slug string) (string, error) { - return SaveBytes(assets.Icon, ".png", slug) + return Save(assets.Icon, slug) } // Nearby mencari file ikon bernama base.* di folder dir (dipakai untuk auto-deteksi). @@ -55,48 +47,66 @@ func findNearby(dir, base string) string { return "" } -func fromFile(src, slug string) (string, error) { - data, err := os.ReadFile(src) +// Save menulis ikon ke direktori ikon XDG sebagai PNG. +// +// Semua format (SVG, webp, gif, bmp, ico, jpeg) dikonversi dulu ke PNG karena +// desktop environment tidak konsisten membacanya, dan sebagian SVG dari internet +// punya fitur yang bikin proses menggambar berhenti di UI. +func Save(data []byte, slug string) (string, error) { + converted, err := ToPNG(data) if err != nil { return "", err } - ext := strings.ToLower(filepath.Ext(src)) - // ICO tidak dipakai desktop theme; pakai PNG di dalamnya kalau ada. - if ext == ".ico" { - png := largestPNGFromICO(data) - if png == nil { - return "", errors.New("this .ico has no PNG inside it, pick another icon") - } - return SaveBytes(png, ".png", slug) - } - return SaveBytes(data, ext, slug) -} - -// 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 } - // shortcut: ikon disimpan apa adanya di hicolor/512x512 tanpa resize; - // desktop environment men-skalanya sendiri. Tambah resize kalau ikon kecil terlihat pecah. - dest := filepath.Join(xdg.IconsDir(), slug+ext) - return dest, os.WriteFile(dest, data, 0o644) + dest := filepath.Join(xdg.IconsDir(), slug+".png") + return dest, os.WriteFile(dest, converted, 0o644) } -// Ext menebak ekstensi file dari isi data (dipakai untuk ikon yang sudah diunduh UI). +// Ext menebak ekstensi file dari isi data. func Ext(data []byte) string { return sniffExt(data) } // IconBytes adalah ikon default LazyRun. func IconBytes() []byte { return assets.Icon } + +// ToPNG mengubah data gambar apa pun yang didukung menjadi PNG. +// Data yang sudah PNG dikembalikan apa adanya. +func ToPNG(data []byte) ([]byte, error) { + if bytes.HasPrefix(data, pngMagic) { + return data, nil + } + + if sniffExt(data) == ".svg" { + return Rasterize(data, 256) + } + + // jpeg, gif, bmp, webp dikenali dari isi file + if img, _, err := image.Decode(bytes.NewReader(data)); err == nil { + return encodePNG(img) + } + + // ico bisa berisi PNG langsung + if png := largestPNGFromICO(data); png != nil { + return png, nil + } + return nil, errors.New("format ikon tidak didukung") +} + +// encodePNG menulis gambar jadi PNG, diperkecil kalau lebih besar dari maxSide. +func encodePNG(img image.Image) ([]byte, error) { + bounds := img.Bounds() + if bounds.Dx() > maxSide || bounds.Dy() > maxSide { + scale := float64(maxSide) / float64(max(bounds.Dx(), bounds.Dy())) + size := image.Rect(0, 0, int(float64(bounds.Dx())*scale), int(float64(bounds.Dy())*scale)) + scaled := image.NewNRGBA(size) + xdraw.CatmullRom.Scale(scaled, size, img, bounds, xdraw.Over, nil) + img = scaled + } + + var out bytes.Buffer + if err := png.Encode(&out, img); err != nil { + return nil, err + } + return out.Bytes(), nil +} diff --git a/internal/icon/online.go b/internal/icon/online.go index 95e65a1..5ae8f66 100644 --- a/internal/icon/online.go +++ b/internal/icon/online.go @@ -7,14 +7,30 @@ import ( "net/url" "sort" "strings" + "sync" "time" "unicode" ) -var iconifyBase = "https://api.iconify.design" +var ( + iconifyMu sync.RWMutex + iconifyBase = "https://api.iconify.design" +) // SetSearchEndpoint mengarahkan pencarian ikon ke endpoint lain (dipakai tes). -func SetSearchEndpoint(base string) { iconifyBase = base } +func SetSearchEndpoint(base string) { + iconifyMu.Lock() + defer iconifyMu.Unlock() + iconifyBase = base +} + +// searchEndpoint dibaca setiap kali mencari, jadi harus lewat lock +// (tes bisa mengganti endpoint sementara goroutine lain mencari). +func searchEndpoint() string { + iconifyMu.RLock() + defer iconifyMu.RUnlock() + return iconifyBase +} // Result adalah satu ikon dari pencarian online. type Result struct { @@ -41,7 +57,8 @@ func Search(query string, limit int) ([]Result, error) { limit = 32 } - endpoint := fmt.Sprintf("%s/search?query=%s&limit=%d", iconifyBase, url.QueryEscape(query), limit) + base := searchEndpoint() + endpoint := fmt.Sprintf("%s/search?query=%s&limit=%d", base, url.QueryEscape(query), limit) data, err := get(endpoint) if err != nil { return nil, err @@ -63,7 +80,7 @@ func Search(query string, limit int) ([]Result, error) { // 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)), + URL: fmt.Sprintf("%s/%s/%s.svg?height=256", base, url.PathEscape(prefix), url.PathEscape(icon)), }) } if len(results) == 0 { diff --git a/internal/shortcut/icons.go b/internal/shortcut/icons.go index 50ddff2..2054aea 100644 --- a/internal/shortcut/icons.go +++ b/internal/shortcut/icons.go @@ -81,11 +81,11 @@ func installIconFor(o iconOptions) (path, source string, err error) { } for _, attempt := range attempts { - data, ext, err := attempt.load() + data, _, err := attempt.load() if err != nil || len(data) == 0 { continue } - path, err := icon.SaveBytes(data, ext, o.Slug) + path, err := icon.Save(data, o.Slug) if err != nil { continue } @@ -102,22 +102,21 @@ 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 - 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)) + data, _, 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) + data, _, err = icon.FromURL(result.URL) case strings.HasPrefix(sc.IconSource, "http"): - data, ext, err = icon.FromURL(sc.IconSource) + data, _, err = icon.FromURL(sc.IconSource) default: return "", errors.New("this icon comes from a local file, not the internet") } @@ -125,7 +124,7 @@ func RefreshIcon(sc store.Shortcut) (string, error) { return "", err } - path, err := icon.SaveBytes(data, ext, sc.Slug) + path, err := icon.Save(data, sc.Slug) if err != nil { return "", err } diff --git a/internal/ui/dialog_executable.go b/internal/ui/dialog_executable.go index 96af916..703f54c 100644 --- a/internal/ui/dialog_executable.go +++ b/internal/ui/dialog_executable.go @@ -22,7 +22,7 @@ var categories = []string{ } var ( - imageFilter = storage.NewExtensionFileFilter([]string{".png", ".svg", ".jpg", ".jpeg", ".xpm", ".ico"}) + imageFilter = storage.NewExtensionFileFilter([]string{".png", ".svg", ".jpg", ".jpeg", ".webp", ".gif", ".bmp", ".ico", ".xpm"}) stripExts = []string{".AppImage", ".appimage", ".sh", ".bin", ".run", ".desktop"} ) @@ -113,7 +113,7 @@ func (ui *app) showExecutableDialog() { install), )) - d = dialog.NewCustom(T("new_exec_title"), "", content, ui.win) + d = dialog.NewCustomWithoutButtons(T("new_exec_title"), content, ui.win) showDialog(d, ui.win, 560, 520) } diff --git a/internal/ui/dialog_icon.go b/internal/ui/dialog_icon.go index e2f25ab..803b734 100644 --- a/internal/ui/dialog_icon.go +++ b/internal/ui/dialog_icon.go @@ -97,7 +97,7 @@ func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) { results, ) - d = dialog.NewCustom(T("icon_search_title"), "", content, ui.win) + d = dialog.NewCustomWithoutButtons(T("icon_search_title"), content, ui.win) showDialog(d, ui.win, 560, 470) // sekaligus menampilkan dialog search() } @@ -129,12 +129,9 @@ func (ui *app) iconChoice(result icon.Result, onPick func(icon.Result, []byte), if err != nil { return } - thumb, err := icon.Rasterize(downloaded, 64) + thumb, err := icon.ToPNG(downloaded) if err != nil { - if icon.Ext(downloaded) == ".svg" { - return - } - thumb = downloaded // PNG/JPEG bisa langsung dipakai + return // placeholder tetap tampil } mu.Lock() diff --git a/internal/ui/dialog_test.go b/internal/ui/dialog_test.go new file mode 100644 index 0000000..9330baa --- /dev/null +++ b/internal/ui/dialog_test.go @@ -0,0 +1,72 @@ +package ui + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "fyne.io/fyne/v2/test" + + "lazyrun/internal/icon" +) + +// TestNoEmptyDismissButton memastikan modal tidak memakai dialog.NewCustom, +// yang selalu menambah tombol dismiss walau labelnya kosong (tombol hantu +// di bagian bawah modal). Kita punya baris tombol sendiri, jadi harus memakai +// NewCustomWithoutButtons. +func TestNoEmptyDismissButton(t *testing.T) { + files, err := filepath.Glob("*.go") + if err != nil { + t.Fatal(err) + } + for _, file := range files { + if strings.HasSuffix(file, "_test.go") { + continue + } + data, err := os.ReadFile(file) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(data), "dialog.NewCustom(") { + t.Errorf("%s memakai dialog.NewCustom (menambah tombol kosong); gunakan NewCustomWithoutButtons", file) + } + } +} + +// TestDialogsOpenWithoutPanic membuka semua modal di test driver. +func TestDialogsOpenWithoutPanic(t *testing.T) { + a := test.NewApp() + defer a.Quit() + + // pencarian ikon diarahkan ke port mati: tes tidak boleh menyentuh internet + // dan tidak boleh mengubah UI dari goroutine latar. + icon.SetSearchEndpoint("http://127.0.0.1:1") + defer icon.SetSearchEndpoint("https://api.iconify.design") + + ui, err := build(a, "test") + if err != nil { + t.Fatal(err) + } + ui.win.Show() + + ui.showExecutableDialog() + if top := ui.win.Canvas().Overlays().Top(); top == nil { + t.Fatal("modal executable tidak muncul") + } + ui.showWebAppDialog() + if len(ui.browsers) > 0 { + if top := ui.win.Canvas().Overlays().Top(); top == nil { + t.Fatal("modal web app tidak muncul") + } + } + ui.showIconSearch("whatsapp", func(icon.Result, []byte) {}) + if top := ui.win.Canvas().Overlays().Top(); top == nil { + t.Fatal("modal pencarian ikon tidak muncul") + } + + // tunggu goroutine pencarian selesai: test driver menjalankan fyne.Do inline, + // jadi sisa goroutine bisa bertabrakan dengan tes berikutnya kalau dibiarkan. + time.Sleep(300 * time.Millisecond) +} diff --git a/internal/ui/dialog_webapp.go b/internal/ui/dialog_webapp.go index fe78ab0..9438387 100644 --- a/internal/ui/dialog_webapp.go +++ b/internal/ui/dialog_webapp.go @@ -118,7 +118,7 @@ func (ui *app) showWebAppDialog() { install), )) - d = dialog.NewCustom(T("new_web_title"), "", content, ui.win) + d = dialog.NewCustomWithoutButtons(T("new_web_title"), content, ui.win) showDialog(d, ui.win, 560, 520) }