Tooltip hint, loading bar, edit shortcut, export/import .lazy
- Keterangan tombol saat hover: baris hint di footer window dan di dalam dialog (bukan popup: overlay Fyne menelan klik pertama, tombol jadi perlu 2x klik) - Dialog proses sekarang pakai progress bar berjalan (bukan spinner diam) - Tombol edit per shortcut: ganti binary/URL, ikon (file/online), nama, kategori, dan lokasi; berkas .desktop + salinan Desktop ditulis ulang, berkas lama dibuang, jenis aplikasi dideteksi ulang, binary lama dihapus kalau nama berubah - Export/Import daftar shortcut ke berkas SQLite .lazy (driver murni Go modernc), skema + meta format versi, impor tidak menimpa shortcut yang sudah ada - Tes: detect kind, edit (rename/ganti binary/ikon/target), db roundtrip, merge impor, tooltip (hint muncul/hilang + klik tetap sampai ke tombol), semua paket hijau - README: bagian backup + hint tombol
This commit is contained in:
1 parent
c1aed1e286
commit
deaa30689c
19 files changed
+1362
-61
No files matched your search
@@ -0,0 +1,206 @@
|
||||
package shortcut
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"lazyrun/internal/store"
|
||||
"lazyrun/internal/xdg"
|
||||
)
|
||||
|
||||
// EditExecutable memperbarui shortcut executable yang sudah ada:
|
||||
// binary bisa diganti (disalin ulang), begitu juga ikon, nama, dan lokasinya.
|
||||
func EditExecutable(old store.Shortcut, o ExecOptions) (store.Shortcut, error) {
|
||||
name := strings.TrimSpace(o.Name)
|
||||
if err := validate(name, o.Menu, o.Desktop); err != nil {
|
||||
return old, err
|
||||
}
|
||||
|
||||
src := o.BinaryPath
|
||||
if src == "" {
|
||||
src = old.BinaryPath
|
||||
}
|
||||
if err := checkRunnable(src); err != nil {
|
||||
return old, fmt.Errorf("%s: %w", filepath.Base(src), err)
|
||||
}
|
||||
|
||||
sl := slugify(name)
|
||||
binDst := filepath.Join(xdg.BinDir(), sl)
|
||||
if sl != old.Slug {
|
||||
if _, err := os.Stat(filepath.Join(xdg.ApplicationsDir(), sl+".desktop")); err == nil {
|
||||
return old, fmt.Errorf("shortcut %q already exists", name)
|
||||
}
|
||||
}
|
||||
|
||||
// salin kalau sumbernya bukan berkas yang sudah terpasang
|
||||
if !samePath(src, binDst) {
|
||||
if err := copyFile(src, binDst, 0o755); err != nil {
|
||||
return old, err
|
||||
}
|
||||
}
|
||||
|
||||
kind := DetectKind(binDst)
|
||||
execArgs := []string{binDst}
|
||||
if kind == KindWindows {
|
||||
wine, err := exec.LookPath("wine")
|
||||
if err != nil {
|
||||
return old, errors.New("this is a Windows .exe; install wine first so the shortcut can run it")
|
||||
}
|
||||
execArgs = []string{wine, binDst}
|
||||
}
|
||||
|
||||
iconPath, iconSource := old.IconPath, old.IconSource
|
||||
if o.IconPath != "" || o.IconURL != "" || len(o.IconData) > 0 {
|
||||
path, source, err := installIconFor(iconOptions{
|
||||
IconPath: o.IconPath, IconURL: o.IconURL, IconData: o.IconData,
|
||||
Query: name, Slug: sl,
|
||||
})
|
||||
if err != nil {
|
||||
return old, err
|
||||
}
|
||||
iconPath, iconSource = path, source
|
||||
}
|
||||
|
||||
category := category(o.Category, "Utility;")
|
||||
if o.Category == "" {
|
||||
category = old.Categories
|
||||
if category == "" {
|
||||
category = "Utility;"
|
||||
}
|
||||
}
|
||||
|
||||
updated := old
|
||||
updated.Slug = sl
|
||||
updated.Name = name
|
||||
updated.Kind = kind
|
||||
updated.ExecArgs = execArgs
|
||||
updated.Categories = category
|
||||
updated.BinaryPath = binDst
|
||||
updated.IconPath = iconPath
|
||||
updated.IconSource = iconSource
|
||||
updated.Targets = store.Targets{Menu: o.Menu, Desktop: o.Desktop}
|
||||
|
||||
if err := rewriteShortcutFiles(old, &updated, Entry{
|
||||
Name: name,
|
||||
ExecArgs: execArgs,
|
||||
Icon: iconPath,
|
||||
Categories: category,
|
||||
NoDisplay: !o.Menu,
|
||||
}); err != nil {
|
||||
return old, err
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// EditWebApp memperbarui shortcut web app (URL, browser, ikon, nama, lokasi).
|
||||
func EditWebApp(old store.Shortcut, o WebOptions) (store.Shortcut, error) {
|
||||
name := strings.TrimSpace(o.Name)
|
||||
if err := validate(name, o.Menu, o.Desktop); err != nil {
|
||||
return old, err
|
||||
}
|
||||
if len(o.Browser.Exec) == 0 {
|
||||
return old, errors.New("no browser selected")
|
||||
}
|
||||
pageURL, err := normalizeURL(o.URL)
|
||||
if err != nil {
|
||||
return old, err
|
||||
}
|
||||
|
||||
sl := slugify(name)
|
||||
if sl != old.Slug {
|
||||
if _, err := os.Stat(filepath.Join(xdg.ApplicationsDir(), sl+".desktop")); err == nil {
|
||||
return old, fmt.Errorf("shortcut %q already exists", name)
|
||||
}
|
||||
}
|
||||
|
||||
iconPath, iconSource := old.IconPath, old.IconSource
|
||||
if o.IconPath != "" || o.IconURL != "" || len(o.IconData) > 0 {
|
||||
path, source, err := installIconFor(iconOptions{
|
||||
IconPath: o.IconPath, IconURL: o.IconURL, IconData: o.IconData,
|
||||
PageURL: pageURL, Query: name, Slug: sl,
|
||||
})
|
||||
if err != nil {
|
||||
return old, err
|
||||
}
|
||||
iconPath, iconSource = path, source
|
||||
}
|
||||
|
||||
appArgs := o.Browser.Exec[1:]
|
||||
if o.Browser.Firefox {
|
||||
appArgs = append(appArgs, "--new-window", pageURL)
|
||||
} else {
|
||||
appArgs = append(appArgs, "--app="+pageURL)
|
||||
}
|
||||
execArgs := append([]string{o.Browser.Exec[0]}, appArgs...)
|
||||
|
||||
updated := old
|
||||
updated.Slug = sl
|
||||
updated.Name = name
|
||||
updated.Kind = KindPWA
|
||||
updated.URL = pageURL
|
||||
updated.Browser = strings.Join(o.Browser.Exec, " ")
|
||||
updated.ExecArgs = execArgs
|
||||
updated.IconPath = iconPath
|
||||
updated.IconSource = iconSource
|
||||
updated.Targets = store.Targets{Menu: o.Menu, Desktop: o.Desktop}
|
||||
|
||||
if err := rewriteShortcutFiles(old, &updated, Entry{
|
||||
Name: name,
|
||||
ExecArgs: execArgs,
|
||||
Icon: iconPath,
|
||||
Categories: old.Categories,
|
||||
NoDisplay: !o.Menu,
|
||||
}); err != nil {
|
||||
return old, err
|
||||
}
|
||||
return updated, nil
|
||||
}
|
||||
|
||||
// rewriteShortcutFiles menulis ulang .desktop (dan salinannya) untuk shortcut
|
||||
// yang diedit, termasuk membereskan berkas lama kalau nama/slug berubah.
|
||||
// updated diisi lewat pointer karena path-nya berubah di sini.
|
||||
func rewriteShortcutFiles(old store.Shortcut, updated *store.Shortcut, entry Entry) error {
|
||||
if entry.Categories == "" {
|
||||
entry.Categories = "Utility;"
|
||||
}
|
||||
|
||||
newDesk := filepath.Join(xdg.ApplicationsDir(), updated.Slug+".desktop")
|
||||
if err := WriteDesktop(newDesk, entry); err != nil {
|
||||
return err
|
||||
}
|
||||
updated.DesktopPath = newDesk
|
||||
|
||||
// salinan Desktop: dibuat kalau diminta, dihapus kalau tidak
|
||||
newCopy := ""
|
||||
if updated.Targets.Desktop {
|
||||
newCopy = filepath.Join(xdg.DesktopDir(), updated.Slug+".desktop")
|
||||
if err := copyFile(newDesk, newCopy, 0o755); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
updated.DesktopCopyPath = newCopy
|
||||
|
||||
// bersihkan berkas lama kalau lokasinya berubah
|
||||
if old.DesktopPath != "" && old.DesktopPath != newDesk {
|
||||
_ = os.Remove(old.DesktopPath)
|
||||
}
|
||||
if old.DesktopCopyPath != "" && old.DesktopCopyPath != newCopy {
|
||||
_ = os.Remove(old.DesktopCopyPath)
|
||||
}
|
||||
if old.IconPath != "" && old.IconPath != updated.IconPath {
|
||||
_ = os.Remove(old.IconPath)
|
||||
}
|
||||
// binary lama dibuang kalau nama berkasnya berubah
|
||||
if old.Type == store.TypeExecutable && old.BinaryPath != "" && old.BinaryPath != updated.BinaryPath {
|
||||
if !samePath(old.BinaryPath, updated.BinaryPath) {
|
||||
_ = os.Remove(old.BinaryPath)
|
||||
}
|
||||
}
|
||||
|
||||
go RefreshCache()
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,248 @@
|
||||
package shortcut
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestEditExecutableRenamesAndMovesFiles(t *testing.T) {
|
||||
home := testEnv(t)
|
||||
src := fakeBinary(t, home, "old-tool")
|
||||
|
||||
sc, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Old Tool", Category: "Utility", Menu: true, Desktop: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
updated, err := EditExecutable(sc, ExecOptions{
|
||||
BinaryPath: sc.BinaryPath, // binary tidak diganti
|
||||
Name: "New Tool",
|
||||
Category: "Network",
|
||||
Menu: true,
|
||||
Desktop: false, // salinan Desktop dimatikan
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updated.Slug != "new-tool" || updated.BinaryPath != filepath.Join(home, ".local", "bin", "new-tool") {
|
||||
t.Errorf("slug/binary baru salah: %+v", updated)
|
||||
}
|
||||
if _, err := os.Stat(updated.DesktopPath); err != nil {
|
||||
t.Errorf(".desktop baru tidak ada: %v", err)
|
||||
}
|
||||
if !strings.HasSuffix(updated.DesktopPath, "new-tool.desktop") {
|
||||
t.Errorf("nama .desktop = %s", updated.DesktopPath)
|
||||
}
|
||||
if updated.DesktopCopyPath != "" {
|
||||
t.Errorf("salinan desktop harus kosong, dapat %s", updated.DesktopCopyPath)
|
||||
}
|
||||
// berkas lama sudah dibersihkan
|
||||
if _, err := os.Stat(sc.DesktopPath); !os.IsNotExist(err) {
|
||||
t.Errorf(".desktop lama masih ada")
|
||||
}
|
||||
if _, err := os.Stat(sc.DesktopCopyPath); !os.IsNotExist(err) {
|
||||
t.Errorf("salinan desktop lama masih ada")
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(updated.DesktopPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(content), "Name=New Tool\n") || !strings.Contains(string(content), "Categories=Network;\n") {
|
||||
t.Errorf("isi .desktop salah:\n%s", content)
|
||||
}
|
||||
if !strings.Contains(string(content), "Exec="+updated.BinaryPath+"\n") {
|
||||
t.Errorf("Exec tidak menunjuk binary baru:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditExecutableReplacesBinaryAndKind(t *testing.T) {
|
||||
home := testEnv(t)
|
||||
first := fakeBinary(t, home, "first")
|
||||
second := filepath.Join(home, "second.sh")
|
||||
if err := os.WriteFile(second, []byte("#!/bin/sh\necho hai\n"), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sc, err := InstallExecutable(ExecOptions{BinaryPath: first, Name: "Tool", Menu: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if sc.Kind != KindBinary {
|
||||
t.Fatalf("kind awal = %q", sc.Kind)
|
||||
}
|
||||
|
||||
updated, err := EditExecutable(sc, ExecOptions{BinaryPath: second, Name: "Tool", Menu: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.Kind != KindScript {
|
||||
t.Errorf("kind setelah ganti binary = %q, mau %q", updated.Kind, KindScript)
|
||||
}
|
||||
if updated.IconPath == "" {
|
||||
t.Error("ikon harus tetap ada")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditExecutableKeepsIconUnlessChanged(t *testing.T) {
|
||||
home := testEnv(t)
|
||||
src := fakeBinary(t, home, "tool")
|
||||
iconFile := filepath.Join(home, "ikon.png")
|
||||
if err := os.WriteFile(iconFile, append(pngMagic, []byte("ikon")...), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sc, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Tool", Menu: true, IconPath: iconFile})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := sc.IconPath
|
||||
|
||||
updated, err := EditExecutable(sc, ExecOptions{BinaryPath: sc.BinaryPath, Name: "Tool", Menu: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.IconPath != before {
|
||||
t.Errorf("ikon berubah tanpa diminta: %s -> %s", before, updated.IconPath)
|
||||
}
|
||||
|
||||
// ganti ikon dengan berkas lain: nama berkasnya sama (slug sama),
|
||||
// jadi yang diperiksa isinya
|
||||
beforeBytes, err := os.ReadFile(before)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
other := filepath.Join(home, "lain.png")
|
||||
if err := os.WriteFile(other, append(pngMagic, []byte("ikon lain yang lebih panjang")...), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
updated, err = EditExecutable(sc, ExecOptions{BinaryPath: sc.BinaryPath, Name: "Tool", Menu: true, IconPath: other})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
afterBytes, err := os.ReadFile(updated.IconPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(beforeBytes) == string(afterBytes) {
|
||||
t.Error("isi ikon tidak ikut berubah saat diganti")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditWebAppChangesURLAndTargets(t *testing.T) {
|
||||
testEnv(t)
|
||||
sc, err := InstallWebApp(WebOptions{
|
||||
URL: "https://lama.example",
|
||||
Name: "Contoh",
|
||||
Browser: BrowserArg{Name: "Chromium", Exec: []string{"/usr/bin/chromium"}},
|
||||
Menu: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
updated, err := EditWebApp(sc, WebOptions{
|
||||
URL: "lama.example/baru",
|
||||
Name: "Contoh Baru",
|
||||
Browser: BrowserArg{Name: "Firefox", Exec: []string{"/usr/bin/firefox"}, Firefox: true},
|
||||
Menu: false,
|
||||
Desktop: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if updated.URL != "https://lama.example/baru" {
|
||||
t.Errorf("URL = %s", updated.URL)
|
||||
}
|
||||
if updated.DesktopCopyPath == "" {
|
||||
t.Error("salinan desktop harus dibuat")
|
||||
}
|
||||
content, err := os.ReadFile(updated.DesktopPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(content), "Exec=/usr/bin/firefox --new-window https://lama.example/baru\n") {
|
||||
t.Errorf("Exec salah:\n%s", content)
|
||||
}
|
||||
if !strings.Contains(string(content), "NoDisplay=true") {
|
||||
t.Errorf("menu dimatikan harus NoDisplay:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditExecutableRejectsDuplicateName(t *testing.T) {
|
||||
home := testEnv(t)
|
||||
a := fakeBinary(t, home, "app-a")
|
||||
b := fakeBinary(t, home, "app-b")
|
||||
|
||||
if _, err := InstallExecutable(ExecOptions{BinaryPath: a, Name: "App A", Menu: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
scB, err := InstallExecutable(ExecOptions{BinaryPath: b, Name: "App B", Menu: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if _, err := EditExecutable(scB, ExecOptions{BinaryPath: scB.BinaryPath, Name: "App A", Menu: true}); err == nil {
|
||||
t.Error("nama yang sudah dipakai harus ditolak")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEditWebAppRefreshesIconFromSite(t *testing.T) {
|
||||
testEnv(t)
|
||||
|
||||
page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/":
|
||||
_, _ = w.Write([]byte(`<html><head><link rel="icon" type="image/png" sizes="64x64" href="/baru.png"></head></html>`))
|
||||
case "/paksa.png":
|
||||
_, _ = w.Write(append(pngMagic, []byte("ikon paksa yang panjang")...))
|
||||
default:
|
||||
_, _ = w.Write(append(pngMagic, []byte("ikon lama")...))
|
||||
}
|
||||
}))
|
||||
defer page.Close()
|
||||
|
||||
sc, err := InstallWebApp(WebOptions{
|
||||
URL: page.URL,
|
||||
Name: "Situs",
|
||||
Browser: BrowserArg{Name: "Chromium", Exec: []string{"/usr/bin/chromium"}},
|
||||
Menu: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ikon sebelum diedit (harus dibaca sebelum berkasnya ditimpa)
|
||||
before, err := os.ReadFile(sc.IconPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// ubah URL ke halaman lain -> ikon lama harus diganti sumbernya
|
||||
updated, err := EditWebApp(sc, WebOptions{
|
||||
URL: page.URL + "/halaman",
|
||||
Name: "Situs",
|
||||
Browser: BrowserArg{Name: "Chromium", Exec: []string{"/usr/bin/chromium"}},
|
||||
IconURL: page.URL + "/paksa.png",
|
||||
Menu: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if updated.IconSource != page.URL+"/paksa.png" {
|
||||
t.Errorf("icon_source = %q", updated.IconSource)
|
||||
}
|
||||
after, err := os.ReadFile(updated.IconPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(before) == string(after) {
|
||||
t.Error("isi ikon harus diperbarui dari sumber baru")
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,7 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) {
|
||||
Type: store.TypeExecutable,
|
||||
Kind: kind,
|
||||
ExecArgs: entry.ExecArgs,
|
||||
Categories: entry.Categories,
|
||||
BinaryPath: binDst,
|
||||
IconPath: iconPath,
|
||||
IconSource: iconSource,
|
||||
@@ -189,6 +190,7 @@ func InstallWebApp(o WebOptions) (store.Shortcut, error) {
|
||||
Type: store.TypeWebApp,
|
||||
Kind: KindPWA,
|
||||
URL: pageURL,
|
||||
Categories: entry.Categories,
|
||||
Browser: strings.Join(o.Browser.Exec, " "),
|
||||
ExecArgs: entry.ExecArgs,
|
||||
IconPath: iconPath,
|
||||
|
||||
Reference in new issue
Block a user