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:
oxpoe committed 2026-10-13 08:00:12 +07:00
1 parent 0885d69015
commit 6e6f9afea9
9 files changed
+312 -72

No files matched your search

+9 -9
View File
@@ -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) {
`<link rel="icon" type="image/png" sizes="192x192" href="/big.png">`+
`</head></html>`)
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, `<html><head><title>no icon</title></head></html>`)
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(`<?xml version="1.0"?><svg xmlns="..."><path/></svg>`)); ext != ".svg" {
t.Errorf("sniffExt(svg) = %s", ext)
+11 -8
View File
@@ -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 }
+51
View File
@@ -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
}
+82
View File
@@ -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)
}
}