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
@@ -12,6 +12,12 @@ AppImage, no terminal needed.
|
||||
- **Web app shortcut** — opens the URL as an app: `--app=<url>` for
|
||||
Chromium-based browsers (Chrome, Chromium, Brave, Edge, Vivaldi, Helium,
|
||||
including Flatpak builds), `--new-window` for Firefox.
|
||||
- **Icons** — three ways: pick a file, search an online icon library
|
||||
([Iconify](https://iconify.design), with the app name as the query), or let LazyRun
|
||||
do it automatically: a nearby image file, the site's favicon (including high
|
||||
resolution icons from the web app manifest), or a brand icon matched by name.
|
||||
Every downloaded icon remembers its source, so the *sync icon* button in the list
|
||||
can fetch it again later.
|
||||
- **Shortcut location** — checkbox for *Application menu* and/or *Desktop*,
|
||||
at least one is required. Desktop-only entries are written with
|
||||
`NoDisplay=true` so they do not show up in the menu.
|
||||
@@ -23,6 +29,9 @@ AppImage, no terminal needed.
|
||||
- **i18n** — UI in English or Indonesian, chosen from `$LANG`.
|
||||
- **Self update** — AppImage with embedded zsync update information, optional
|
||||
`AppImageUpdate` integration via *Check for Updates*.
|
||||
- **Desktop integration** — uses the host cursor theme (KDE/GNOME setting is
|
||||
forwarded to the toolkit when the session does not export it), live desktop
|
||||
menu refresh via `update-desktop-database` and `kbuildsycoca`.
|
||||
|
||||
## Install
|
||||
|
||||
|
||||
@@ -46,6 +46,8 @@ else
|
||||
export LDAI_NO_APPSTREAM=1
|
||||
fi
|
||||
|
||||
# Library X11/Wayland/xkb/cursor tidak boleh ikut dibundel: kalau versinya beda
|
||||
# dengan host, cursor & rendering jadi tidak native (mis. cursor theme desktop tidak terpakai).
|
||||
echo "==> bundle + package AppImage"
|
||||
# hapus output lama dulu: file yang sedang dijalankan tidak bisa ditimpa langsung
|
||||
rm -f "$OUT"/lazyrun-*.AppImage "$OUT"/lazyrun-*.AppImage.zsync
|
||||
@@ -53,6 +55,10 @@ rm -f "$OUT"/lazyrun-*.AppImage "$OUT"/lazyrun-*.AppImage.zsync
|
||||
--appdir "$APPDIR" \
|
||||
--desktop-file "$APPDIR/lazyrun.desktop" \
|
||||
--icon-file "$APPDIR/lazyrun.png" \
|
||||
--exclude-library "libwayland-*.so*" \
|
||||
--exclude-library "libX*.so*" \
|
||||
--exclude-library "libxkbcommon.so*" \
|
||||
--exclude-library "libffi.so*" \
|
||||
--output appimage
|
||||
|
||||
ls -lh "$OUT"/lazyrun-*.AppImage* 2>/dev/null || true
|
||||
|
||||
@@ -3,6 +3,7 @@ package main
|
||||
import (
|
||||
"fyne.io/fyne/v2/app"
|
||||
|
||||
"lazyrun/internal/cursor"
|
||||
"lazyrun/internal/ui"
|
||||
)
|
||||
|
||||
@@ -10,5 +11,6 @@ import (
|
||||
var version = "dev"
|
||||
|
||||
func main() {
|
||||
cursor.Apply() // sebelum app dibuat: GLFW membaca env cursor saat init
|
||||
ui.Run(app.NewWithID("io.github.lazyrun.LazyRun"), version)
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
// Package cursor meneruskan cursor theme desktop (KDE/GNOME) ke Fyne/GLFW.
|
||||
package cursor
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Apply men-set XCURSOR_THEME dan XCURSOR_SIZE kalau sesi belum men-set-nya.
|
||||
// Fyne (GLFW) membaca env ini waktu memuat cursor, jadi panggil sebelum app dibuat.
|
||||
func Apply() {
|
||||
if os.Getenv("XCURSOR_SIZE") == "" {
|
||||
if _, size := desktopCursor(); size != "" {
|
||||
os.Setenv("XCURSOR_SIZE", size)
|
||||
}
|
||||
}
|
||||
if os.Getenv("XCURSOR_THEME") != "" {
|
||||
return
|
||||
}
|
||||
if theme, _ := desktopCursor(); theme != "" {
|
||||
os.Setenv("XCURSOR_THEME", theme)
|
||||
}
|
||||
}
|
||||
|
||||
func home() string {
|
||||
h, _ := os.UserHomeDir()
|
||||
return h
|
||||
}
|
||||
|
||||
func configHome() string {
|
||||
if v := os.Getenv("XDG_CONFIG_HOME"); v != "" {
|
||||
return v
|
||||
}
|
||||
return filepath.Join(home(), ".config")
|
||||
}
|
||||
|
||||
// desktopCursor mengambil nama & ukuran cursor dari config KDE, GTK, atau ~/.icons/default.
|
||||
func desktopCursor() (theme, size string) {
|
||||
// KDE Plasma: ~/.config/kcminputrc
|
||||
kde := ini(filepath.Join(configHome(), "kcminputrc"))
|
||||
if t := kde["mouse"]["cursortheme"]; t != "" {
|
||||
return t, kde["mouse"]["cursorsize"]
|
||||
}
|
||||
|
||||
for _, gtk := range []string{"gtk-4.0", "gtk-3.0"} {
|
||||
cfg := ini(filepath.Join(configHome(), gtk, "settings.ini"))
|
||||
if t := cfg["settings"]["gtk-cursor-theme-name"]; t != "" {
|
||||
return t, cfg["settings"]["gtk-cursor-theme-size"]
|
||||
}
|
||||
}
|
||||
|
||||
// fallback: theme default di ~/.icons/default/index.theme (format "Inherits=")
|
||||
fallback := filepath.Join(home(), ".icons", "default", "index.theme")
|
||||
if t := ini(fallback)["icon theme"]["inherits"]; t != "" {
|
||||
return t, ""
|
||||
}
|
||||
return "", ""
|
||||
}
|
||||
|
||||
// ini membaca file config berformat INI sederhana; key disimpan huruf kecil.
|
||||
func ini(path string) map[string]map[string]string {
|
||||
out := map[string]map[string]string{}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
|
||||
section := ""
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
line = strings.TrimSpace(line)
|
||||
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
|
||||
section = strings.ToLower(strings.Trim(line, "[]"))
|
||||
continue
|
||||
}
|
||||
key, value, ok := strings.Cut(line, "=")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if out[section] == nil {
|
||||
out[section] = map[string]string{}
|
||||
}
|
||||
out[section][strings.ToLower(strings.TrimSpace(key))] = strings.TrimSpace(value)
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package cursor
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIniParsesSections(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "index.theme")
|
||||
body := "# komentar\n[Icon Theme]\nName=default\nInherits=Breeze_Cursors\n\n[Other]\nX=1\n"
|
||||
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
got := ini(path)
|
||||
if got["icon theme"]["inherits"] != "Breeze_Cursors" {
|
||||
t.Errorf("Inherits salah: %q", got["icon theme"]["inherits"])
|
||||
}
|
||||
if got["other"]["x"] != "1" {
|
||||
t.Errorf("section lain salah: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDesktopCursorFromKDE(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
|
||||
dir := filepath.Join(home, ".config")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "kcminputrc"), []byte("[Mouse]\ncursorTheme=material_cursors\ncursorSize=36\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
theme, size := desktopCursor()
|
||||
if theme != "material_cursors" || size != "36" {
|
||||
t.Errorf("desktopCursor() = %q, %q", theme, size)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplySetsEnvWhenEmpty(t *testing.T) {
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
||||
t.Setenv("XCURSOR_THEME", "")
|
||||
t.Setenv("XCURSOR_SIZE", "")
|
||||
|
||||
dir := filepath.Join(home, ".config")
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(dir, "kcminputrc"), []byte("[Mouse]\ncursorTheme=material_cursors\ncursorSize=36\n"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
Apply()
|
||||
if got := os.Getenv("XCURSOR_THEME"); got != "material_cursors" {
|
||||
t.Errorf("XCURSOR_THEME = %q", got)
|
||||
}
|
||||
if got := os.Getenv("XCURSOR_SIZE"); got != "36" {
|
||||
t.Errorf("XCURSOR_SIZE = %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyKeepsExistingEnv(t *testing.T) {
|
||||
t.Setenv("XCURSOR_THEME", "Breeze_Cursors")
|
||||
t.Setenv("XCURSOR_SIZE", "24")
|
||||
|
||||
Apply()
|
||||
if got := os.Getenv("XCURSOR_THEME"); got != "Breeze_Cursors" {
|
||||
t.Errorf("env yang sudah ada tidak boleh ditimpa, dapat %q", got)
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ package icon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
@@ -26,6 +27,7 @@ var (
|
||||
)
|
||||
|
||||
// Fetch mengambil favicon dari halaman. Return data + ekstensi file (".png", ".jpg", ".svg").
|
||||
// Urutan: ikon dari web app manifest (paling besar) → <link rel=icon> → /favicon.ico.
|
||||
func Fetch(pageURL string) ([]byte, string, error) {
|
||||
base, err := url.Parse(pageURL)
|
||||
if err != nil {
|
||||
@@ -53,6 +55,66 @@ func Fetch(pageURL string) ([]byte, string, error) {
|
||||
return nil, "", lastErr
|
||||
}
|
||||
|
||||
// manifestIcons membaca <link rel=manifest> lalu daftar ikonnya,
|
||||
// diurutkan dari ukuran terbesar. Gagal parse bukan error: cukup dilewati.
|
||||
func manifestIcons(page *url.URL) []iconCandidate {
|
||||
html, err := get(page.String())
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var manifestURL string
|
||||
for _, tag := range linkTag.FindAllString(string(html), -1) {
|
||||
if !strings.Contains(strings.ToLower(attr(relAttr, tag)), "manifest") {
|
||||
continue
|
||||
}
|
||||
if href := attr(hrefAttr, tag); href != "" {
|
||||
if ref, err := url.Parse(href); err == nil {
|
||||
manifestURL = page.ResolveReference(ref).String()
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
if manifestURL == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, err := get(manifestURL)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var manifest struct {
|
||||
Icons []struct {
|
||||
Src string `json:"src"`
|
||||
Sizes string `json:"sizes"`
|
||||
} `json:"icons"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &manifest); err != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
base, err := url.Parse(manifestURL)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var candidates []iconCandidate
|
||||
for _, entry := range manifest.Icons {
|
||||
if entry.Src == "" {
|
||||
continue
|
||||
}
|
||||
ref, err := url.Parse(entry.Src)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
score := 0
|
||||
if size := attr(sizeAttr, `sizes="`+entry.Sizes+`"`); size != "" {
|
||||
fmt.Sscanf(size, "%d", &score)
|
||||
}
|
||||
candidates = append(candidates, iconCandidate{base.ResolveReference(ref).String(), score})
|
||||
}
|
||||
sort.SliceStable(candidates, func(i, j int) bool { return candidates[i].score > candidates[j].score })
|
||||
return candidates
|
||||
}
|
||||
|
||||
// iconCandidate adalah kandidat URL ikon dengan skor prioritas.
|
||||
type iconCandidate struct {
|
||||
url string
|
||||
@@ -90,6 +152,7 @@ func iconCandidates(base *url.URL) []string {
|
||||
}
|
||||
|
||||
sort.SliceStable(found, func(i, j int) bool { return found[i].score > found[j].score })
|
||||
found = append(manifestIcons(base), found...)
|
||||
|
||||
out := make([]string, 0, len(found)+1)
|
||||
seen := map[string]bool{}
|
||||
|
||||
@@ -36,6 +36,14 @@ func Default(slug string) (string, error) {
|
||||
return SaveBytes(assets.Icon, ".png", slug)
|
||||
}
|
||||
|
||||
// Nearby mencari file ikon bernama base.* di folder dir (dipakai untuk auto-deteksi).
|
||||
func Nearby(dir, base string) string {
|
||||
if dir == "" || base == "" {
|
||||
return ""
|
||||
}
|
||||
return findNearby(dir, base)
|
||||
}
|
||||
|
||||
func findNearby(dir, base string) string {
|
||||
base = strings.ToLower(base)
|
||||
for _, ext := range exts {
|
||||
@@ -78,5 +86,14 @@ 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)
|
||||
}
|
||||
|
||||
// IconBytes adalah ikon default LazyRun.
|
||||
func IconBytes() []byte { return assets.Icon }
|
||||
@@ -0,0 +1,174 @@
|
||||
package icon
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode"
|
||||
)
|
||||
|
||||
var iconifyBase = "https://api.iconify.design"
|
||||
|
||||
// SetSearchEndpoint mengarahkan pencarian ikon ke endpoint lain (dipakai tes).
|
||||
func SetSearchEndpoint(base string) { iconifyBase = base }
|
||||
|
||||
// Result adalah satu ikon dari pencarian online.
|
||||
type Result struct {
|
||||
Name string // contoh: "logos:whatsapp-icon"
|
||||
URL string // URL SVG siap diunduh
|
||||
}
|
||||
|
||||
// ShortName mengembalikan nama ikon tanpa prefix koleksi.
|
||||
func (r Result) ShortName() string {
|
||||
_, name, ok := strings.Cut(r.Name, ":")
|
||||
if !ok {
|
||||
return r.Name
|
||||
}
|
||||
return name
|
||||
}
|
||||
|
||||
// Search mencari ikon di Iconify berdasarkan kata kunci.
|
||||
func Search(query string, limit int) ([]Result, error) {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return nil, errors.New("search query is empty")
|
||||
}
|
||||
if limit <= 0 {
|
||||
limit = 32
|
||||
}
|
||||
|
||||
endpoint := fmt.Sprintf("%s/search?query=%s&limit=%d", iconifyBase, url.QueryEscape(query), limit)
|
||||
data, err := get(endpoint)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var payload struct {
|
||||
Icons []string `json:"icons"`
|
||||
}
|
||||
if err := json.Unmarshal(data, &payload); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
results := make([]Result, 0, len(payload.Icons))
|
||||
for _, name := range payload.Icons {
|
||||
prefix, icon, ok := strings.Cut(name, ":")
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
// 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)),
|
||||
})
|
||||
}
|
||||
if len(results) == 0 {
|
||||
return nil, errors.New("no icon found for " + query)
|
||||
}
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// SearchBest mencoba beberapa variasi kata kunci supaya nama seperti
|
||||
// "web.whatsapp.com" atau "WhatsApp Web" tetap menemukan ikon.
|
||||
func SearchBest(query string, limit int) ([]Result, error) {
|
||||
for _, candidate := range queryVariants(query) {
|
||||
if results, err := Search(candidate, limit); err == nil {
|
||||
return results, nil
|
||||
}
|
||||
}
|
||||
return nil, errors.New("no icon found for " + strings.TrimSpace(query))
|
||||
}
|
||||
|
||||
// Find mengambil hasil pertama yang namanya cocok dengan kata kunci,
|
||||
// supaya ikon acak tidak dipasang ke aplikasi yang namanya tidak dikenal.
|
||||
func Find(query string) (Result, bool) {
|
||||
for _, candidate := range queryVariants(query) {
|
||||
results, err := Search(candidate, 32)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
want := slug(candidate)
|
||||
for _, r := range results {
|
||||
name := slug(r.ShortName())
|
||||
if name == want || strings.Contains(name, want) || strings.Contains(want, name) {
|
||||
return r, true
|
||||
}
|
||||
}
|
||||
}
|
||||
return Result{}, false
|
||||
}
|
||||
|
||||
// QueryHint mengembalikan kata kunci paling bermakna dari sebuah nama,
|
||||
// dipakai untuk mengisi kolom pencarian ikon.
|
||||
func QueryHint(query string) string {
|
||||
variants := queryVariants(query)
|
||||
if len(variants) > 1 {
|
||||
return variants[1]
|
||||
}
|
||||
return strings.TrimSpace(query)
|
||||
}
|
||||
|
||||
// queryVariants menyusun kandidat kata kunci: teks asli dulu, lalu kata-kata
|
||||
// yang lebih sederhana (kata terpanjang didahulukan). Kata < 4 huruf dibuang
|
||||
// supaya pencarian tidak jatuh ke ikon acak.
|
||||
func queryVariants(query string) []string {
|
||||
query = strings.TrimSpace(query)
|
||||
if query == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
out := []string{query}
|
||||
base := query
|
||||
// host seperti "web.whatsapp.com" → buang TLD-nya
|
||||
if !strings.Contains(base, " ") && strings.Contains(base, ".") {
|
||||
parts := strings.Split(base, ".")
|
||||
if len(parts) > 1 {
|
||||
base = strings.Join(parts[:len(parts)-1], " ")
|
||||
}
|
||||
}
|
||||
|
||||
words := strings.FieldsFunc(base, func(r rune) bool {
|
||||
return !unicode.IsLetter(r) && !unicode.IsDigit(r)
|
||||
})
|
||||
sort.SliceStable(words, func(i, j int) bool { return len(words[i]) > len(words[j]) })
|
||||
for _, word := range words {
|
||||
if len(word) < 4 || strings.EqualFold(word, query) {
|
||||
continue
|
||||
}
|
||||
out = append(out, word)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
// FromURL mengunduh ikon dari URL dan menebak ekstensinya dari isi file.
|
||||
func FromURL(rawURL string) ([]byte, string, error) {
|
||||
data, err := get(rawURL)
|
||||
if err != nil {
|
||||
// sekali coba lagi: API ikon publik kadang membatasi permintaan beruntun
|
||||
time.Sleep(600 * time.Millisecond)
|
||||
data, err = get(rawURL)
|
||||
}
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
ext := sniffExt(data)
|
||||
if ext == "" {
|
||||
return nil, "", fmt.Errorf("unsupported icon format at %s", rawURL)
|
||||
}
|
||||
return data, ext, nil
|
||||
}
|
||||
|
||||
// slug menyamakan nama untuk perbandingan (huruf kecil, tanpa pemisah).
|
||||
func slug(s string) string {
|
||||
var b strings.Builder
|
||||
for _, r := range strings.ToLower(s) {
|
||||
if r >= 'a' && r <= 'z' || r >= '0' && r <= '9' {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
package icon
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func fakeIconify(t *testing.T) *httptest.Server {
|
||||
t.Helper()
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/search":
|
||||
got := r.URL.Query().Get("query")
|
||||
if got == "" {
|
||||
http.Error(w, "no query", http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
// ikon whatsapp hanya untuk query "whatsapp" polos, supaya fallback ikut teruji
|
||||
if !strings.Contains(strings.ToLower(got), "whatsapp") || strings.Contains(got, ".") {
|
||||
_, _ = w.Write([]byte(`{"icons":[],"total":0}`))
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte(`{"icons":["ic:baseline-whatsapp","mdi:whatsapp","logos:whatsapp-icon"],"total":3}`))
|
||||
case "/logos/whatsapp-icon.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>`))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
t.Cleanup(srv.Close)
|
||||
return srv
|
||||
}
|
||||
|
||||
func TestSearchBuildsDownloadURLs(t *testing.T) {
|
||||
srv := fakeIconify(t)
|
||||
SetSearchEndpoint(srv.URL)
|
||||
t.Cleanup(func() { SetSearchEndpoint("https://api.iconify.design") })
|
||||
|
||||
results, err := Search("whatsapp", 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(results) != 3 {
|
||||
t.Fatalf("hasil pencarian = %d, mau 3", len(results))
|
||||
}
|
||||
if results[0].Name != "ic:baseline-whatsapp" || results[0].ShortName() != "baseline-whatsapp" {
|
||||
t.Errorf("hasil pertama salah: %+v", results[0])
|
||||
}
|
||||
if results[2].URL != srv.URL+"/logos/whatsapp-icon.svg?height=256" {
|
||||
t.Errorf("URL unduhan salah: %s", results[2].URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestQueryHintAndSearchBest(t *testing.T) {
|
||||
srv := fakeIconify(t)
|
||||
SetSearchEndpoint(srv.URL)
|
||||
t.Cleanup(func() { SetSearchEndpoint("https://api.iconify.design") })
|
||||
|
||||
// nama seperti host: TLD dibuang, kata terpanjang dipakai
|
||||
if got := QueryHint("web.whatsapp.com"); got != "whatsapp" {
|
||||
t.Errorf("QueryHint(host) = %q", got)
|
||||
}
|
||||
if got := QueryHint("WhatsApp Web"); got != "WhatsApp" {
|
||||
t.Errorf("QueryHint(nama) = %q", got)
|
||||
}
|
||||
if got := QueryHint(""); got != "" {
|
||||
t.Errorf("QueryHint(kosong) = %q", got)
|
||||
}
|
||||
|
||||
// query mentah gagal, tapi varian "whatsapp" berhasil
|
||||
results, err := SearchBest("web.whatsapp.com", 5)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(results) != 3 {
|
||||
t.Errorf("SearchBest() = %d hasil", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindIgnoresGenericWords(t *testing.T) {
|
||||
srv := fakeIconify(t)
|
||||
SetSearchEndpoint(srv.URL)
|
||||
t.Cleanup(func() { SetSearchEndpoint("https://api.iconify.design") })
|
||||
|
||||
// kata <= 3 huruf ("my", "go", "app") tidak boleh dipakai untuk mencari
|
||||
if _, ok := Find("my-go-app"); ok {
|
||||
t.Error("nama tanpa ikon resmi harus gagal, bukan dapat ikon acak")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearchEmptyQuery(t *testing.T) {
|
||||
if _, err := Search(" ", 5); err == nil {
|
||||
t.Error("query kosong harus error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindPicksMatchingName(t *testing.T) {
|
||||
srv := fakeIconify(t)
|
||||
SetSearchEndpoint(srv.URL)
|
||||
t.Cleanup(func() { SetSearchEndpoint("https://api.iconify.design") })
|
||||
|
||||
// "WhatsApp Web" harus cocok dengan ikon bernama whatsapp, bukan hasil acak.
|
||||
// (hasil pertama "ic:baseline-whatsapp" tidak cocok karena namanya lebih panjang,
|
||||
// jadi yang dipakai "mdi:whatsapp")
|
||||
got, ok := Find("WhatsApp Web")
|
||||
if !ok {
|
||||
t.Fatal("Find() tidak menemukan kandidat")
|
||||
}
|
||||
if got.Name != "mdi:whatsapp" {
|
||||
t.Errorf("ikon terpilih = %s", got.Name)
|
||||
}
|
||||
|
||||
// Nama yang tidak berhubungan tidak boleh dipaksa memakai ikon apa pun.
|
||||
if _, ok := Find("zzz-tidak-ada-xyz"); ok {
|
||||
t.Error("nama asing tidak boleh dapat ikon acak")
|
||||
}
|
||||
}
|
||||
|
||||
func TestFromURLSniffsFormat(t *testing.T) {
|
||||
srv := fakeIconify(t)
|
||||
data, ext, err := FromURL(srv.URL + "/logos/whatsapp-icon.svg")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ext != ".svg" || len(data) == 0 {
|
||||
t.Errorf("FromURL = ext %q, %d byte", ext, len(data))
|
||||
}
|
||||
|
||||
if _, _, err := FromURL(srv.URL + "/tidak-ada"); err == nil {
|
||||
t.Error("URL 404 harus error")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -30,6 +30,7 @@ type Shortcut struct {
|
||||
ExecArgs []string `json:"exec_args,omitempty"`
|
||||
BinaryPath string `json:"binary_path,omitempty"`
|
||||
IconPath string `json:"icon_path,omitempty"`
|
||||
IconSource string `json:"icon_source,omitempty"`
|
||||
DesktopPath string `json:"desktop_path"`
|
||||
DesktopCopyPath string `json:"desktop_copy_path,omitempty"`
|
||||
Targets Targets `json:"targets"`
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
|
||||
"lazyrun/internal/icon"
|
||||
"lazyrun/internal/shortcut"
|
||||
"lazyrun/internal/store"
|
||||
)
|
||||
@@ -26,7 +27,8 @@ var (
|
||||
)
|
||||
|
||||
func (ui *app) showExecutableDialog() {
|
||||
var binPath, iconPath string
|
||||
var binPath, iconPath, iconURL string
|
||||
var iconData []byte
|
||||
|
||||
binLabel := muted(T("none"))
|
||||
iconLabel := muted(T("icon_auto"))
|
||||
@@ -50,10 +52,16 @@ func (ui *app) showExecutableDialog() {
|
||||
})
|
||||
browseIcon := widget.NewButtonWithIcon(T("browse"), theme.FolderOpenIcon(), func() {
|
||||
pickFile(ui.win, imageFilter, func(path string) {
|
||||
iconPath = path
|
||||
iconPath, iconURL, iconData = path, "", nil
|
||||
iconLabel.SetText(filepath.Base(path))
|
||||
})
|
||||
})
|
||||
onlineIcon := widget.NewButtonWithIcon(T("online"), theme.SearchIcon(), func() {
|
||||
ui.showIconSearch(nameEntry.Text, func(result icon.Result, data []byte) {
|
||||
iconURL, iconPath, iconData = result.URL, "", data
|
||||
iconLabel.SetText(result.Name)
|
||||
})
|
||||
})
|
||||
|
||||
var d *dialog.CustomDialog
|
||||
install := widget.NewButtonWithIcon(T("install"), theme.DownloadIcon(), func() {
|
||||
@@ -71,6 +79,8 @@ func (ui *app) showExecutableDialog() {
|
||||
opts := shortcut.ExecOptions{
|
||||
BinaryPath: binPath,
|
||||
IconPath: iconPath,
|
||||
IconURL: iconURL,
|
||||
IconData: iconData,
|
||||
Name: nameEntry.Text,
|
||||
Category: category.Selected,
|
||||
Menu: menu.Checked,
|
||||
@@ -85,7 +95,8 @@ func (ui *app) showExecutableDialog() {
|
||||
|
||||
form := widget.NewForm(
|
||||
widget.NewFormItem(T("field_binary"), container.NewBorder(nil, nil, nil, browseBin, binLabel)),
|
||||
widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil, browseIcon, iconLabel)),
|
||||
widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil,
|
||||
container.NewHBox(onlineIcon, browseIcon), iconLabel)),
|
||||
widget.NewFormItem(T("field_name"), nameEntry),
|
||||
widget.NewFormItem(T("field_category"), category),
|
||||
)
|
||||
|
||||
@@ -0,0 +1,96 @@
|
||||
package ui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
"fyne.io/fyne/v2/container"
|
||||
"fyne.io/fyne/v2/dialog"
|
||||
"fyne.io/fyne/v2/theme"
|
||||
"fyne.io/fyne/v2/widget"
|
||||
|
||||
"lazyrun/internal/icon"
|
||||
)
|
||||
|
||||
// showIconSearch menampilkan pencarian ikon online (Iconify) dan mengirim hasil pilihan.
|
||||
func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) {
|
||||
entry := widget.NewEntry()
|
||||
entry.SetText(icon.QueryHint(query))
|
||||
entry.SetPlaceHolder("whatsapp")
|
||||
|
||||
status := muted(T("search_hint"))
|
||||
slots := make(chan struct{}, 4)
|
||||
grid := container.NewGridWithColumns(6)
|
||||
results := container.NewVScroll(container.NewPadded(grid))
|
||||
|
||||
var d *dialog.CustomDialog
|
||||
search := func() {
|
||||
if strings.TrimSpace(entry.Text) == "" {
|
||||
status.SetText(T("search_hint"))
|
||||
return
|
||||
}
|
||||
status.SetText(T("searching"))
|
||||
grid.RemoveAll()
|
||||
go func() {
|
||||
list, err := icon.SearchBest(entry.Text, 24)
|
||||
fyne.Do(func() {
|
||||
if err != nil {
|
||||
status.SetText(T("search_failed"))
|
||||
return
|
||||
}
|
||||
status.SetText(fmt.Sprintf(T("search_found"), len(list)))
|
||||
for _, result := range list {
|
||||
grid.Add(ui.iconChoice(result, onPick, d, slots))
|
||||
}
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
entry.OnSubmitted = func(string) { search() }
|
||||
content := container.NewBorder(
|
||||
entry,
|
||||
container.NewBorder(nil, nil, status,
|
||||
container.NewHBox(
|
||||
widget.NewButton(T("cancel"), func() { d.Hide() }),
|
||||
widget.NewButtonWithIcon(T("search"), theme.SearchIcon(), search),
|
||||
)),
|
||||
nil, nil,
|
||||
results,
|
||||
)
|
||||
|
||||
d = dialog.NewCustom(T("icon_search_title"), "", content, ui.win)
|
||||
d.Resize(fyne.NewSize(560, 460))
|
||||
d.Show()
|
||||
search()
|
||||
}
|
||||
|
||||
// iconChoice adalah satu ikon hasil pencarian; gambarnya diunduh di latar belakang.
|
||||
func (ui *app) iconChoice(result icon.Result, onPick func(icon.Result, []byte), d *dialog.CustomDialog, slot chan struct{}) fyne.CanvasObject {
|
||||
var data []byte
|
||||
|
||||
button := widget.NewButton("", func() {
|
||||
onPick(result, data)
|
||||
if d != nil {
|
||||
d.Hide()
|
||||
}
|
||||
})
|
||||
button.Importance = widget.LowImportance
|
||||
|
||||
go func() {
|
||||
slot <- struct{}{} // batasi unduhan bersamaan supaya tidak kena rate limit
|
||||
defer func() { <-slot }()
|
||||
|
||||
downloaded, _, err := icon.FromURL(result.URL)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
data = downloaded
|
||||
fyne.Do(func() {
|
||||
button.Icon = fyne.NewStaticResource(result.Name, downloaded)
|
||||
button.Refresh()
|
||||
})
|
||||
}()
|
||||
|
||||
return container.NewGridWrap(fyne.NewSize(52, 52), button)
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import (
|
||||
"fyne.io/fyne/v2/widget"
|
||||
|
||||
"lazyrun/internal/browser"
|
||||
"lazyrun/internal/icon"
|
||||
"lazyrun/internal/shortcut"
|
||||
"lazyrun/internal/store"
|
||||
)
|
||||
@@ -22,7 +23,8 @@ func (ui *app) showWebAppDialog() {
|
||||
return
|
||||
}
|
||||
|
||||
var iconPath string
|
||||
var iconPath, iconURL string
|
||||
var iconData []byte
|
||||
var nameTouched, filling bool
|
||||
|
||||
urlEntry := widget.NewEntry()
|
||||
@@ -55,10 +57,16 @@ func (ui *app) showWebAppDialog() {
|
||||
|
||||
browseIcon := widget.NewButtonWithIcon(T("browse"), theme.FolderOpenIcon(), func() {
|
||||
pickFile(ui.win, imageFilter, func(path string) {
|
||||
iconPath = path
|
||||
iconPath, iconURL, iconData = path, "", nil
|
||||
iconLabel.SetText(filepath.Base(path))
|
||||
})
|
||||
})
|
||||
onlineIcon := widget.NewButtonWithIcon(T("online"), theme.SearchIcon(), func() {
|
||||
ui.showIconSearch(nameEntry.Text, func(result icon.Result, data []byte) {
|
||||
iconURL, iconPath, iconData = result.URL, "", data
|
||||
iconLabel.SetText(result.Name)
|
||||
})
|
||||
})
|
||||
|
||||
var d *dialog.CustomDialog
|
||||
install := widget.NewButtonWithIcon(T("install"), theme.DownloadIcon(), func() {
|
||||
@@ -79,6 +87,8 @@ func (ui *app) showWebAppDialog() {
|
||||
Name: nameEntry.Text,
|
||||
Browser: shortcut.BrowserArg{Name: b.Name, Exec: b.Exec, Firefox: b.Kind == browser.Firefox},
|
||||
IconPath: iconPath,
|
||||
IconURL: iconURL,
|
||||
IconData: iconData,
|
||||
Menu: menu.Checked,
|
||||
Desktop: desktop.Checked,
|
||||
}
|
||||
@@ -93,7 +103,8 @@ func (ui *app) showWebAppDialog() {
|
||||
widget.NewFormItem(T("field_url"), urlEntry),
|
||||
widget.NewFormItem(T("field_name"), nameEntry),
|
||||
widget.NewFormItem(T("field_browser"), picker),
|
||||
widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil, browseIcon, iconLabel)),
|
||||
widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil,
|
||||
container.NewHBox(onlineIcon, browseIcon), iconLabel)),
|
||||
)
|
||||
content := container.NewVBox(
|
||||
form,
|
||||
|
||||
@@ -6,6 +6,7 @@ import (
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"fyne.io/fyne/v2"
|
||||
@@ -138,6 +139,9 @@ func (ui *app) row(sc store.Shortcut) *hoverCard {
|
||||
|
||||
actions := container.NewHBox(chips...)
|
||||
actions.Add(iconButton(theme.MediaPlayIcon(), func() { ui.launch(sc) }))
|
||||
if syncable(sc) {
|
||||
actions.Add(iconButton(theme.ViewRefreshIcon(), func() { ui.syncIcon(sc) }))
|
||||
}
|
||||
actions.Add(iconButton(theme.FolderOpenIcon(), func() {
|
||||
if err := exec.Command("xdg-open", filepath.Dir(sc.DesktopPath)).Start(); err != nil {
|
||||
dialog.ShowError(err, ui.win)
|
||||
@@ -192,6 +196,40 @@ func (ui *app) flashCards() {
|
||||
}
|
||||
}
|
||||
|
||||
// syncable berarti ikon shortcut punya sumber online yang bisa diunduh ulang.
|
||||
func syncable(sc store.Shortcut) bool {
|
||||
source := sc.IconSource
|
||||
return strings.HasPrefix(source, "http") ||
|
||||
strings.HasPrefix(source, "favicon:") ||
|
||||
strings.HasPrefix(source, "search:")
|
||||
}
|
||||
|
||||
// syncIcon mengunduh ulang ikon shortcut dari sumber online-nya.
|
||||
func (ui *app) syncIcon(sc store.Shortcut) {
|
||||
progress := dialog.NewProgressInfinite(T("sync_icon"), T("working"), ui.win)
|
||||
progress.Show()
|
||||
go func() {
|
||||
path, err := shortcut.RefreshIcon(sc)
|
||||
fyne.Do(func() {
|
||||
progress.Hide()
|
||||
if err != nil {
|
||||
dialog.ShowError(err, ui.win)
|
||||
return
|
||||
}
|
||||
if i, ok := ui.store.Get(sc.Slug); ok {
|
||||
ui.store.Items[i].IconPath = path
|
||||
if err := ui.store.Save(); err != nil {
|
||||
dialog.ShowError(err, ui.win)
|
||||
}
|
||||
}
|
||||
ui.reload()
|
||||
if card, ok := ui.cards[sc.Slug]; ok {
|
||||
card.flash(0)
|
||||
}
|
||||
})
|
||||
}()
|
||||
}
|
||||
|
||||
func (ui *app) confirmDelete(sc store.Shortcut) {
|
||||
content := container.NewVBox(widget.NewLabel(fmt.Sprintf(T("confirm_delete_msg"), sc.Name)))
|
||||
|
||||
|
||||
+27
-19
@@ -7,24 +7,32 @@ import (
|
||||
|
||||
// tr menyimpan teks UI: index 0 = Inggris, 1 = Indonesia.
|
||||
var tr = map[string][2]string{
|
||||
"app_title": {"LazyRun — lazy shortcut build", "LazyRun — lazy shortcut build"},
|
||||
"tagline": {"lazy shortcut build", "lazy shortcut build"},
|
||||
"btn_executable": {"Executable", "Executable"},
|
||||
"btn_webapp": {"Web App", "Aplikasi Web"},
|
||||
"installed": {"Installed shortcuts", "Shortcut terinstall"},
|
||||
"empty_title": {"Nothing here yet", "Belum ada apa-apa"},
|
||||
"empty_hint": {"Create your first shortcut with the buttons in the corner.", "Buat shortcut pertamamu lewat tombol di pojok kanan atas."},
|
||||
"chip_menu": {"Menu", "Menu"},
|
||||
"chip_desktop": {"Desktop", "Desktop"},
|
||||
"chip_broken": {"file missing", "file hilang"},
|
||||
"none": {"not selected", "belum dipilih"},
|
||||
"icon_auto": {"automatic", "otomatis"},
|
||||
"browse": {"Browse...", "Pilih..."},
|
||||
"install": {"Install", "Install"},
|
||||
"cancel": {"Cancel", "Batal"},
|
||||
"delete": {"Delete", "Hapus"},
|
||||
"folder": {"Folder", "Folder"},
|
||||
"working": {"Working...", "Sedang diproses..."},
|
||||
"app_title": {"LazyRun — lazy shortcut build", "LazyRun — lazy shortcut build"},
|
||||
"tagline": {"lazy shortcut build", "lazy shortcut build"},
|
||||
"btn_executable": {"Executable", "Executable"},
|
||||
"btn_webapp": {"Web App", "Aplikasi Web"},
|
||||
"installed": {"Installed shortcuts", "Shortcut terinstall"},
|
||||
"empty_title": {"Nothing here yet", "Belum ada apa-apa"},
|
||||
"empty_hint": {"Create your first shortcut with the buttons in the corner.", "Buat shortcut pertamamu lewat tombol di pojok kanan atas."},
|
||||
"chip_menu": {"Menu", "Menu"},
|
||||
"chip_desktop": {"Desktop", "Desktop"},
|
||||
"chip_broken": {"file missing", "file hilang"},
|
||||
"none": {"not selected", "belum dipilih"},
|
||||
"icon_auto": {"automatic", "otomatis"},
|
||||
"online": {"Online", "Online"},
|
||||
"search": {"Search", "Cari"},
|
||||
"searching": {"Searching...", "Mencari..."},
|
||||
"search_failed": {"Search failed. Check your connection.", "Pencarian gagal. Cek koneksi kamu."},
|
||||
"search_found": {"%d icons found - click one to use it.", "%d ikon ditemukan - klik satu untuk dipakai."},
|
||||
"search_hint": {"Type a name and press Search.", "Tulis nama lalu tekan Cari."},
|
||||
"icon_search_title": {"Icon from the internet", "Ikon dari internet"},
|
||||
"sync_icon": {"Sync icon", "Sinkron ikon"},
|
||||
"browse": {"Browse...", "Pilih..."},
|
||||
"install": {"Install", "Install"},
|
||||
"cancel": {"Cancel", "Batal"},
|
||||
"delete": {"Delete", "Hapus"},
|
||||
"folder": {"Folder", "Folder"},
|
||||
"working": {"Working...", "Sedang diproses..."},
|
||||
|
||||
"new_exec_title": {"New Executable Shortcut", "Shortcut Executable Baru"},
|
||||
"new_web_title": {"New Web App Shortcut", "Shortcut Web App Baru"},
|
||||
@@ -39,7 +47,7 @@ var tr = map[string][2]string{
|
||||
"check_menu": {"Application menu", "Menu aplikasi"},
|
||||
"check_desktop": {"Desktop", "Desktop"},
|
||||
"exec_hint": {"The binary is copied to ~/.local/bin and can be uninstalled from the list.", "Binary disalin ke ~/.local/bin dan bisa dihapus lagi dari daftar."},
|
||||
"web_hint": {"The icon is fetched from the site's favicon if you leave it empty.", "Ikon diambil otomatis dari favicon situs kalau dibiarkan kosong."},
|
||||
"web_hint": {"The icon comes from the site's favicon, or is looked up online if there is none.", "Ikon diambil dari favicon situs, atau dicari otomatis di internet kalau tidak ada."},
|
||||
|
||||
"confirm_delete_title": {"Uninstall shortcut", "Hapus shortcut"},
|
||||
"confirm_delete_msg": {"Remove %s?", "Hapus %s?"},
|
||||
|
||||
Reference in new issue
Block a user