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:
1 parent
70534bf6e2
commit
7f7cb97a65
19 files changed
+1117
-40
No files matched your search
@@ -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 == "" {
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
Reference in new issue
Block a user