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)
This commit is contained in:
1 parent
0885d69015
commit
6e6f9afea9
9 files changed
+308
-68
No files matched your search
@@ -2,7 +2,11 @@ module lazyrun
|
|||||||
|
|
||||||
go 1.24
|
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 (
|
require (
|
||||||
fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4 // indirect
|
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/gl-js v0.2.1-0.20260315212741-029c47fd27e8 // indirect
|
||||||
github.com/fyne-io/glfw-js v0.4.0 // indirect
|
github.com/fyne-io/glfw-js v0.4.0 // indirect
|
||||||
github.com/fyne-io/image v0.1.1 // 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/gl v0.0.0-20260331235117-4566fea9a276 // indirect
|
||||||
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.2 // indirect
|
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.2 // indirect
|
||||||
github.com/go-text/render v0.2.1 // 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/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/rymdport/portal v0.4.2 // indirect
|
github.com/rymdport/portal v0.4.2 // indirect
|
||||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // 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/stretchr/testify v1.11.1 // indirect
|
||||||
github.com/yuin/goldmark v1.8.2 // indirect
|
github.com/yuin/goldmark v1.8.2 // indirect
|
||||||
golang.org/x/image v0.24.0 // indirect
|
golang.org/x/image v0.24.0 // indirect
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ func icoWithPNG(png []byte) []byte {
|
|||||||
return ico
|
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) {
|
func TestFetchUsesLinkedIcon(t *testing.T) {
|
||||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -34,9 +34,9 @@ func TestFetchUsesLinkedIcon(t *testing.T) {
|
|||||||
`<link rel="icon" type="image/png" sizes="192x192" href="/big.png">`+
|
`<link rel="icon" type="image/png" sizes="192x192" href="/big.png">`+
|
||||||
`</head></html>`)
|
`</head></html>`)
|
||||||
case "/big.png":
|
case "/big.png":
|
||||||
w.Write(png)
|
w.Write(pngBytes)
|
||||||
default:
|
default:
|
||||||
w.Write(icoWithPNG(png))
|
w.Write(icoWithPNG(pngBytes))
|
||||||
}
|
}
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
@@ -45,7 +45,7 @@ func TestFetchUsesLinkedIcon(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
t.Errorf("ikon terpilih salah: ext=%s data=%q", ext, data)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -56,7 +56,7 @@ func TestFetchFallsBackToFaviconICO(t *testing.T) {
|
|||||||
io.WriteString(w, `<html><head><title>no icon</title></head></html>`)
|
io.WriteString(w, `<html><head><title>no icon</title></head></html>`)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
w.Write(icoWithPNG(png))
|
w.Write(icoWithPNG(pngBytes))
|
||||||
}))
|
}))
|
||||||
defer srv.Close()
|
defer srv.Close()
|
||||||
|
|
||||||
@@ -64,7 +64,7 @@ func TestFetchFallsBackToFaviconICO(t *testing.T) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatal(err)
|
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)
|
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) {
|
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)
|
t.Errorf("PNG di dalam .ico tidak terbaca: %q", got)
|
||||||
}
|
}
|
||||||
if got := largestPNGFromICO([]byte("bukan ico")); got != nil {
|
if got := largestPNGFromICO([]byte("bukan ico")); got != nil {
|
||||||
@@ -97,8 +97,8 @@ func TestLargestPNGFromICO(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func TestSniffExt(t *testing.T) {
|
func TestSniffExt(t *testing.T) {
|
||||||
if ext := sniffExt(png); ext != ".png" {
|
if ext := sniffExt(pngBytes); ext != ".png" {
|
||||||
t.Errorf("sniffExt(png) = %s", ext)
|
t.Errorf("sniffExt(pngBytes) = %s", ext)
|
||||||
}
|
}
|
||||||
if ext := sniffExt([]byte(`<?xml version="1.0"?><svg xmlns="..."><path/></svg>`)); ext != ".svg" {
|
if ext := sniffExt([]byte(`<?xml version="1.0"?><svg xmlns="..."><path/></svg>`)); ext != ".svg" {
|
||||||
t.Errorf("sniffExt(svg) = %s", ext)
|
t.Errorf("sniffExt(svg) = %s", ext)
|
||||||
|
|||||||
@@ -73,10 +73,19 @@ func fromFile(src, slug string) (string, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// SaveBytes menulis data ikon ke direktori ikon XDG.
|
// 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) {
|
func SaveBytes(data []byte, ext, slug string) (string, error) {
|
||||||
if ext == "" {
|
if ext == "" {
|
||||||
ext = ".png"
|
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 {
|
if err := os.MkdirAll(xdg.IconsDir(), 0o755); err != nil {
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
@@ -86,14 +95,8 @@ func SaveBytes(data []byte, ext, slug string) (string, error) {
|
|||||||
return dest, os.WriteFile(dest, data, 0o644)
|
return dest, os.WriteFile(dest, data, 0o644)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SaveData menyimpan ikon yang sudah ada di memori (mis. hasil pencarian online).
|
// Ext menebak ekstensi file dari isi data (dipakai untuk ikon yang sudah diunduh UI).
|
||||||
func SaveData(data []byte, slug string) (string, error) {
|
func Ext(data []byte) string { return sniffExt(data) }
|
||||||
ext := sniffExt(data)
|
|
||||||
if ext == "" {
|
|
||||||
return "", errors.New("unsupported icon format")
|
|
||||||
}
|
|
||||||
return SaveBytes(data, ext, slug)
|
|
||||||
}
|
|
||||||
|
|
||||||
// IconBytes adalah ikon default LazyRun.
|
// IconBytes adalah ikon default LazyRun.
|
||||||
func IconBytes() []byte { return assets.Icon }
|
func IconBytes() []byte { return assets.Icon }
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
package icon
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"image/png"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRasterizeSimpleSVG(t *testing.T) {
|
||||||
|
svg := []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">` +
|
||||||
|
`<circle cx="12" cy="12" r="10" fill="#f0f"/></svg>`)
|
||||||
|
|
||||||
|
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(`<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256"><rect width="256" height="256"/></svg>`)
|
||||||
|
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("<html><body>404</body></html>"),
|
||||||
|
"svg kosong": []byte(`<svg xmlns="http://www.w3.org/2000/svg"></svg>`),
|
||||||
|
"path ngawur": []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><path d="M NaN NaN L x y"/></svg>`),
|
||||||
|
"data terpotong": []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><path d="M0 0 L5 5"`),
|
||||||
|
"koordinat tak hingga": []byte(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 10 10"><path d="M0 0 L1e400 1e400"/></svg>`),
|
||||||
|
}
|
||||||
|
|
||||||
|
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(`<?xml version="1.0"?><svg xmlns="http://www.w3.org/2000/svg"/>`)); 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(`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">` +
|
||||||
|
`<path fill="currentColor" d="M3 3h18v18H3z"/></svg>`)
|
||||||
|
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
+59
-27
@@ -3,6 +3,7 @@ package shortcut
|
|||||||
import (
|
import (
|
||||||
"errors"
|
"errors"
|
||||||
"os"
|
"os"
|
||||||
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"lazyrun/internal/icon"
|
"lazyrun/internal/icon"
|
||||||
@@ -29,44 +30,75 @@ type iconOptions struct {
|
|||||||
|
|
||||||
// installIconFor menentukan ikon shortcut dan sumbernya.
|
// installIconFor menentukan ikon shortcut dan sumbernya.
|
||||||
// Urutan: file user → URL user → ikon di folder binary → favicon situs → cari online → ikon default.
|
// 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) {
|
func installIconFor(o iconOptions) (path, source string, err error) {
|
||||||
if o.IconPath != "" {
|
attempts := []struct {
|
||||||
path, err = icon.Save(o.IconPath, "", "", o.Slug)
|
source string
|
||||||
return path, iconSourceFile + o.IconPath, err
|
load func() ([]byte, string, error)
|
||||||
|
}{
|
||||||
|
{iconSourceFile + o.IconPath, func() ([]byte, string, error) {
|
||||||
|
if o.IconPath == "" {
|
||||||
|
return nil, "", errSkipIcon
|
||||||
}
|
}
|
||||||
if len(o.IconData) > 0 && o.IconURL != "" {
|
data, err := os.ReadFile(o.IconPath)
|
||||||
if path, err := icon.SaveData(o.IconData, o.Slug); err == nil {
|
return data, strings.ToLower(filepath.Ext(o.IconPath)), err
|
||||||
return path, o.IconURL, nil
|
}},
|
||||||
|
{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
|
||||||
}
|
}
|
||||||
if o.IconURL != "" {
|
|
||||||
if data, ext, err := icon.FromURL(o.IconURL); err == nil {
|
|
||||||
path, err := icon.SaveBytes(data, ext, o.Slug)
|
path, err := icon.SaveBytes(data, ext, o.Slug)
|
||||||
return path, o.IconURL, err
|
if err != nil {
|
||||||
}
|
continue
|
||||||
}
|
|
||||||
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
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
return path, attempt.source, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
path, err = icon.Default(o.Slug)
|
path, err = icon.Default(o.Slug)
|
||||||
return path, "", err
|
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).
|
// RefreshIcon mengunduh ulang ikon shortcut dari sumber online-nya (sync manual).
|
||||||
func RefreshIcon(sc store.Shortcut) (string, error) {
|
func RefreshIcon(sc store.Shortcut) (string, error) {
|
||||||
var data []byte
|
var data []byte
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package shortcut
|
package shortcut
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"bytes"
|
||||||
"net/http"
|
"net/http"
|
||||||
"net/http/httptest"
|
"net/http/httptest"
|
||||||
"os"
|
"os"
|
||||||
@@ -129,9 +130,10 @@ func TestInstallExecutableSearchesIconOnline(t *testing.T) {
|
|||||||
if sc.IconSource != "search:Winbox" {
|
if sc.IconSource != "search:Winbox" {
|
||||||
t.Errorf("icon_source = %q", sc.IconSource)
|
t.Errorf("icon_source = %q", sc.IconSource)
|
||||||
}
|
}
|
||||||
if filepath.Ext(sc.IconPath) != ".svg" {
|
if filepath.Ext(sc.IconPath) != ".png" {
|
||||||
t.Errorf("ikon online harus tersimpan sebagai .svg, dapat %s", sc.IconPath)
|
t.Errorf("SVG online harus diraster jadi .png, dapat %s", sc.IconPath)
|
||||||
}
|
}
|
||||||
|
assertPNG(t, sc.IconPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInstallWebAppUsesIconURL(t *testing.T) {
|
func TestInstallWebAppUsesIconURL(t *testing.T) {
|
||||||
@@ -164,9 +166,10 @@ func TestInstallWebAppUsesIconURL(t *testing.T) {
|
|||||||
if sc.IconSource != srv.URL+"/pilih.svg" {
|
if sc.IconSource != srv.URL+"/pilih.svg" {
|
||||||
t.Errorf("ikon pilihan user harus dipakai, icon_source = %q", sc.IconSource)
|
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)
|
t.Errorf("ikon = %s", sc.IconPath)
|
||||||
}
|
}
|
||||||
|
assertPNG(t, sc.IconPath)
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestInstallWebAppUsesIconDataFromUI(t *testing.T) {
|
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" {
|
if sc.IconSource != "https://api.iconify.design/mdi/example.svg?height=256" {
|
||||||
t.Errorf("icon_source = %q", sc.IconSource)
|
t.Errorf("icon_source = %q", sc.IconSource)
|
||||||
}
|
}
|
||||||
data, err := os.ReadFile(sc.IconPath)
|
assertPNG(t, sc.IconPath)
|
||||||
if err != nil || string(data) != string(svg) {
|
}
|
||||||
t.Errorf("ikon tersimpan salah: %v %d byte", err, len(data))
|
|
||||||
|
// 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))])
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+71
-15
@@ -3,6 +3,7 @@ package ui
|
|||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
"fyne.io/fyne/v2/container"
|
"fyne.io/fyne/v2/container"
|
||||||
@@ -13,6 +14,41 @@ import (
|
|||||||
"lazyrun/internal/icon"
|
"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.
|
// showIconSearch menampilkan pencarian ikon online (Iconify) dan mengirim hasil pilihan.
|
||||||
func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) {
|
func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) {
|
||||||
entry := widget.NewEntry()
|
entry := widget.NewEntry()
|
||||||
@@ -20,9 +56,9 @@ func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) {
|
|||||||
entry.SetPlaceHolder("whatsapp")
|
entry.SetPlaceHolder("whatsapp")
|
||||||
|
|
||||||
status := muted(T("search_hint"))
|
status := muted(T("search_hint"))
|
||||||
slots := make(chan struct{}, 4)
|
|
||||||
grid := container.NewGridWithColumns(6)
|
grid := container.NewGridWithColumns(6)
|
||||||
results := container.NewVScroll(container.NewPadded(grid))
|
results := container.NewVScroll(container.NewPadded(grid))
|
||||||
|
slots := make(chan struct{}, 3)
|
||||||
|
|
||||||
var d *dialog.CustomDialog
|
var d *dialog.CustomDialog
|
||||||
search := func() {
|
search := func() {
|
||||||
@@ -48,45 +84,65 @@ func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
entry.OnSubmitted = func(string) { search() }
|
entry.OnSubmitted = func(string) { search() }
|
||||||
content := container.NewBorder(
|
// status di barisnya sendiri: supaya tidak bertabrakan dengan tombol
|
||||||
container.NewVBox(closeRow(func() { d.Hide() }), entry),
|
// di layar sempit atau ber-penskalaan besar.
|
||||||
container.NewBorder(nil, nil, status,
|
bottom := container.NewVBox(status, container.NewHBox(
|
||||||
container.NewHBox(
|
spacer(),
|
||||||
widget.NewButtonWithIcon(T("back"), theme.NavigateBackIcon(), func() { d.Hide() }),
|
widget.NewButtonWithIcon(T("back"), theme.NavigateBackIcon(), func() { d.Hide() }),
|
||||||
widget.NewButtonWithIcon(T("search"), theme.SearchIcon(), search),
|
widget.NewButtonWithIcon(T("search"), theme.SearchIcon(), search),
|
||||||
)),
|
))
|
||||||
nil, nil,
|
content := container.NewBorder(
|
||||||
|
container.NewVBox(closeRow(func() { d.Hide() }), entry),
|
||||||
|
bottom, nil, nil,
|
||||||
results,
|
results,
|
||||||
)
|
)
|
||||||
|
|
||||||
d = dialog.NewCustom(T("icon_search_title"), "", content, ui.win)
|
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()
|
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 {
|
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() {
|
button := widget.NewButton("", func() {
|
||||||
onPick(result, data)
|
mu.Lock()
|
||||||
|
picked := raw
|
||||||
|
mu.Unlock()
|
||||||
|
onPick(result, picked)
|
||||||
if d != nil {
|
if d != nil {
|
||||||
d.Hide()
|
d.Hide()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
button.Importance = widget.LowImportance
|
button.Importance = widget.LowImportance
|
||||||
|
button.Icon = theme.NewThemedResource(theme.FileImageIcon()) // placeholder selama diunduh
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
slot <- struct{}{} // batasi unduhan bersamaan supaya tidak kena rate limit
|
slot <- struct{}{}
|
||||||
defer func() { <-slot }()
|
defer func() { <-slot }()
|
||||||
|
|
||||||
downloaded, _, err := icon.FromURL(result.URL)
|
downloaded, err := ui.fetchIcon(result.URL)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
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() {
|
fyne.Do(func() {
|
||||||
button.Icon = fyne.NewStaticResource(result.Name, downloaded)
|
button.Icon = fyne.NewStaticResource(result.Name+".png", thumb)
|
||||||
button.Refresh()
|
button.Refresh()
|
||||||
})
|
})
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ type app struct {
|
|||||||
browsers []browser.Browser
|
browsers []browser.Browser
|
||||||
rows *fyne.Container
|
rows *fyne.Container
|
||||||
cards map[string]*hoverCard
|
cards map[string]*hoverCard
|
||||||
|
icons *iconCache
|
||||||
count *canvas.Text // angka jumlah shortcut di header daftar
|
count *canvas.Text // angka jumlah shortcut di header daftar
|
||||||
version string
|
version string
|
||||||
}
|
}
|
||||||
@@ -52,6 +53,7 @@ func build(a fyne.App, version string) (*app, error) {
|
|||||||
a.SetIcon(fyne.NewStaticResource("lazyrun.png", assets.Icon))
|
a.SetIcon(fyne.NewStaticResource("lazyrun.png", assets.Icon))
|
||||||
|
|
||||||
ui.browsers = browser.Detect()
|
ui.browsers = browser.Detect()
|
||||||
|
ui.icons = newIconCache()
|
||||||
ui.rows = container.NewVBox()
|
ui.rows = container.NewVBox()
|
||||||
countChip, count := chipLabel("0", colorNameChipMenu)
|
countChip, count := chipLabel("0", colorNameChipMenu)
|
||||||
ui.count = count
|
ui.count = count
|
||||||
|
|||||||
Reference in new issue
Block a user