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
This commit is contained in:
oxpoe committed 2026-10-13 08:00:12 +07:00
1 parent 70534bf6e2
commit 7f7cb97a65
19 files changed
+1117 -40

No files matched your search

+25 -15
View File
@@ -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 == "" {
+134
View File
@@ -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())
}
+194
View File
@@ -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(`<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256"></svg>`))
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(`<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256"></svg>`))
case "/":
_, _ = w.Write([]byte(`<html><head><link rel="icon" href="/situs.png"></head></html>`))
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(`<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256"></svg>`)
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))
}
}
+3
View File
@@ -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
}