From 581cd07dba137bb0bac675406c6afc7c5d33ae79 Mon Sep 17 00:00:00 2001 From: oxpoe Date: Tue, 13 Oct 2026 09:25:49 +0700 Subject: [PATCH] Perbaiki backup/restore dan script yang tidak jalan dari shortcut Backup/restore: - berkas .lazy (format versi 2) kini menyimpan ISI berkas: binary/script dan ikon (maks 512 MB per berkas); format versi 1 tetap bisa diimpor - impor memakai shortcut.Restore: binary ditulis ke ~/.local/bin lokal, ikon dari cadangan (atau diunduh ulang dari icon_source), .desktop ditulis dengan path lokal, salinan Desktop dibuat sesuai target - path dari komputer lama tidak dipakai mentah -> tidak ada lagi 'banyak file missing'; entri yang tidak bisa dipulihkan ditandai dan bisa dibetulkan via edit - tes end-to-end: pasang 3 shortcut -> ekspor -> pulihkan di HOME lain -> semua berkas lokal, script hasil restore benar-benar jalan Script: - script dipasang sebagai wrapper + .real: wrapper menambahkan ~/.local/bin ke PATH dan cd ke folder asli script (desktop environment menjalankan dengan cwd = home, PATH sesi) - .desktop dapat Path= sesuai folder asli script - opsi 'Run in a terminal window (scripts)' di dialog install & edit - uninstall membuang wrapper + script aslinya --- README.md | 4 + internal/db/db.go | 63 ++++++++-- internal/db/db_test.go | 61 +++++++++- internal/shortcut/backup_e2e_test.go | 138 +++++++++++++++++++++ internal/shortcut/desktop.go | 10 +- internal/shortcut/edit.go | 32 +++-- internal/shortcut/executable.go | 20 ++- internal/shortcut/install.go | 50 ++++++++ internal/shortcut/restore.go | 174 +++++++++++++++++++++++++++ internal/shortcut/restore_test.go | 166 +++++++++++++++++++++++++ internal/shortcut/uninstall.go | 7 +- internal/store/store.go | 1 + internal/ui/dialog_edit.go | 36 ++++-- internal/ui/dialog_executable.go | 11 ++ internal/ui/dialog_test.go | 64 +++++++--- internal/ui/strings.go | 1 + 16 files changed, 779 insertions(+), 59 deletions(-) create mode 100644 internal/shortcut/backup_e2e_test.go create mode 100644 internal/shortcut/restore.go create mode 100644 internal/shortcut/restore_test.go diff --git a/README.md b/README.md index bc621ea..71d8ddf 100644 --- a/README.md +++ b/README.md @@ -33,6 +33,10 @@ breathes while the app is open (the koala in `assets/logo.svg` is the mascot).* - **Automatic type label** — every shortcut is tagged with what it actually is, detected from the file: `Go`, `AppImage`, `Electron`, `Script`, `Windows`, `Binary` (`.exe` files are launched through `wine`), or `PWA` for web apps. +- **Scripts run properly** — the desktop environment starts programs with your home as + the working directory and the session `PATH`, which breaks scripts that use relative + paths or tools from `~/.local/bin`. LazyRun therefore installs a tiny wrapper that + fixes both (and can open the script in a terminal window when you tick that option). - **Web app shortcut** — opens the URL as an app: `--app=` for Chromium-based browsers (Chrome, Chromium, Brave, Edge, Vivaldi, Helium, including Flatpak builds), `--new-window` for Firefox. diff --git a/internal/db/db.go b/internal/db/db.go index 7aec706..620c97d 100644 --- a/internal/db/db.go +++ b/internal/db/db.go @@ -16,7 +16,8 @@ import ( ) // FormatVersion dipakai untuk mengenali berkas .lazy buatan LazyRun. -const FormatVersion = "1" +// Versi 1 hanya metadata; versi 2 menyertakan isi binary dan ikon. +const FormatVersion = "2" // DefaultName adalah nama berkas cadangan yang disarankan. const DefaultName = "lazyrun-shortcuts.lazy" @@ -40,9 +41,12 @@ CREATE TABLE shortcuts ( icon_source TEXT, desktop_path TEXT, desktop_copy_path TEXT, + terminal INTEGER NOT NULL DEFAULT 0, menu INTEGER NOT NULL, desktop INTEGER NOT NULL, - installed_at TEXT + installed_at TEXT, + binary_data BLOB, + icon_data BLOB );` // Export menulis seluruh daftar shortcut ke berkas SQLite baru. @@ -80,7 +84,8 @@ func Export(path string, appVersion string, items []store.Shortcut) error { insert, err := tx.Prepare(`INSERT INTO shortcuts( slug, name, type, kind, url, browser, exec_args, categories, binary_path, icon_path, icon_source, desktop_path, desktop_copy_path, - menu, desktop, installed_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`) + terminal, menu, desktop, installed_at, binary_data, icon_data) + VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`) if err != nil { tx.Rollback() return err @@ -92,8 +97,8 @@ func Export(path string, appVersion string, items []store.Shortcut) error { item.Slug, item.Name, item.Type, item.Kind, item.URL, item.Browser, strings.Join(item.ExecArgs, "\x00"), item.Categories, item.BinaryPath, item.IconPath, item.IconSource, item.DesktopPath, - item.DesktopCopyPath, boolToInt(item.Targets.Menu), boolToInt(item.Targets.Desktop), - item.InstalledAt.Format(time.RFC3339), + item.DesktopCopyPath, boolToInt(item.Terminal), boolToInt(item.Targets.Menu), boolToInt(item.Targets.Desktop), + item.InstalledAt.Format(time.RFC3339), readBlob(item.BinaryPath), readBlob(item.IconPath), ) if err != nil { tx.Rollback() @@ -103,8 +108,34 @@ func Export(path string, appVersion string, items []store.Shortcut) error { return tx.Commit() } +// Backup adalah satu shortcut beserta isi berkasnya dari .lazy. +type Backup struct { + Item store.Shortcut + Binary []byte // isi binary/script (kalau ada) + Icon []byte // isi berkas ikon (kalau ada) +} + +// maxBlobFile adalah batas ukuran berkas yang ikut disimpan ke cadangan. +const maxBlobFile = 512 << 20 + +// readBlob membaca isi berkas untuk disimpan ke cadangan; gagal/kebesaran = kosong. +func readBlob(path string) []byte { + if path == "" { + return nil + } + info, err := os.Stat(path) + if err != nil || info.IsDir() || info.Size() > maxBlobFile { + return nil + } + data, err := os.ReadFile(path) + if err != nil { + return nil + } + return data +} + // Import membaca daftar shortcut dari berkas .lazy. -func Import(path string) ([]store.Shortcut, error) { +func Import(path string) ([]Backup, error) { if _, err := os.Stat(path); err != nil { return nil, err } @@ -119,37 +150,45 @@ func Import(path string) ([]store.Shortcut, error) { if err := conn.QueryRow("SELECT value FROM meta WHERE key = 'format'").Scan(&format); err != nil { return nil, fmt.Errorf("bukan basis data LazyRun: %w", err) } - if format != FormatVersion { + switch format { + case FormatVersion: + // versi sekarang: ada blobs + case "1": + // cadangan lama: hanya metadata, berkas dipulihkan dari sumber lain + default: return nil, fmt.Errorf("versi basis data %s tidak didukung (mendukung %s)", format, FormatVersion) } rows, err := conn.Query(`SELECT slug, name, type, kind, url, browser, exec_args, categories, binary_path, icon_path, icon_source, desktop_path, - desktop_copy_path, menu, desktop, installed_at FROM shortcuts ORDER BY name`) + desktop_copy_path, terminal, menu, desktop, installed_at, + binary_data, icon_data FROM shortcuts ORDER BY name`) if err != nil { return nil, err } defer rows.Close() - var items []store.Shortcut + var items []Backup for rows.Next() { var item store.Shortcut var args, installed string - var menu, desktop int + var terminal, menu, desktop int + var binary, iconBlob []byte if err := rows.Scan(&item.Slug, &item.Name, &item.Type, &item.Kind, &item.URL, &item.Browser, &args, &item.Categories, &item.BinaryPath, &item.IconPath, &item.IconSource, &item.DesktopPath, &item.DesktopCopyPath, - &menu, &desktop, &installed); err != nil { + &terminal, &menu, &desktop, &installed, &binary, &iconBlob); err != nil { return nil, err } if args != "" { item.ExecArgs = strings.Split(args, "\x00") } + item.Terminal = terminal != 0 item.Targets = store.Targets{Menu: menu != 0, Desktop: desktop != 0} if t, err := time.Parse(time.RFC3339, installed); err == nil { item.InstalledAt = t } - items = append(items, item) + items = append(items, Backup{Item: item, Binary: binary, Icon: iconBlob}) } return items, rows.Err() } diff --git a/internal/db/db_test.go b/internal/db/db_test.go index 74bd887..93cde51 100644 --- a/internal/db/db_test.go +++ b/internal/db/db_test.go @@ -62,7 +62,7 @@ func TestExportImportRoundtrip(t *testing.T) { } // urutan hasil impor berdasarkan nama: WhatsApp Web lalu Winbox - web, exe := got[0], got[1] + web, exe := got[0].Item, got[1].Item if web.Slug != "whatsapp-web" || web.URL != "https://web.whatsapp.com" || !web.Targets.Menu || web.Targets.Desktop { t.Errorf("data webapp tidak utuh: %+v", web) } @@ -77,6 +77,65 @@ func TestExportImportRoundtrip(t *testing.T) { } } +func TestExportStoresFileContents(t *testing.T) { + home := t.TempDir() + binary := filepath.Join(home, "app") + if err := os.WriteFile(binary, []byte("isi-binary-penting"), 0o755); err != nil { + t.Fatal(err) + } + iconFile := filepath.Join(home, "app.png") + iconBytes := append([]byte("\x89PNG\r\n\x1a\n"), []byte("ikon")...) + if err := os.WriteFile(iconFile, iconBytes, 0o644); err != nil { + t.Fatal(err) + } + + items := []store.Shortcut{{ + Slug: "app", Name: "App", Type: store.TypeExecutable, Kind: "binary", + BinaryPath: binary, IconPath: iconFile, DesktopPath: filepath.Join(home, "app.desktop"), + Terminal: true, Targets: store.Targets{Menu: true}, + }} + + path := filepath.Join(home, "db.lazy") + if err := Export(path, "1.2.0", items); err != nil { + t.Fatal(err) + } + + got, err := Import(path) + if err != nil { + t.Fatal(err) + } + if string(got[0].Binary) != "isi-binary-penting" { + t.Errorf("isi binary tidak ikut tercadang: %q", got[0].Binary) + } + if string(got[0].Icon) != string(iconBytes) { + t.Errorf("isi ikon tidak ikut tercadang: %q", got[0].Icon) + } + if !got[0].Item.Terminal { + t.Error("flag terminal hilang") + } +} + +func TestExportSkipsMissingFiles(t *testing.T) { + home := t.TempDir() + items := []store.Shortcut{{ + Slug: "hilang", Name: "Hilang", Type: store.TypeExecutable, + BinaryPath: filepath.Join(home, "tidak-ada"), DesktopPath: filepath.Join(home, "x.desktop"), + Targets: store.Targets{Menu: true}, + }} + + path := filepath.Join(home, "db.lazy") + if err := Export(path, "1.2.0", items); err != nil { + t.Fatal(err) + } + got, err := Import(path) + if err != nil { + t.Fatal(err) + } + if len(got) != 1 || len(got[0].Binary) != 0 { + t.Errorf("berkas hilang tidak boleh bikin ekspor gagal: %+v", got) + } +} + func TestExportOverwritesOldFile(t *testing.T) { path := filepath.Join(t.TempDir(), "db.lazy") if err := os.WriteFile(path, []byte("bukan sqlite"), 0o644); err != nil { diff --git a/internal/shortcut/backup_e2e_test.go b/internal/shortcut/backup_e2e_test.go new file mode 100644 index 0000000..96743af --- /dev/null +++ b/internal/shortcut/backup_e2e_test.go @@ -0,0 +1,138 @@ +package shortcut + +import ( + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "lazyrun/internal/db" + "lazyrun/internal/store" +) + +// TestBackupRestoreEndToEnd meniru alur backup: pasang beberapa shortcut, +// ekspor ke berkas .lazy, lalu pulihkan di "komputer lain" (HOME berbeda) +// dan pastikan hasilnya benar-benar jalan. +func TestBackupRestoreEndToEnd(t *testing.T) { + codeDir := t.TempDir() + homeA := t.TempDir() + + // ---- komputer A: pasang shortcut ---- + t.Setenv("HOME", homeA) + t.Setenv("XDG_DATA_HOME", "") + t.Setenv("PATH", "") + + site := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/" { + _, _ = w.Write([]byte(``)) + return + } + _, _ = w.Write(append(pngMagic, []byte("ikon web")...)) + })) + defer site.Close() + + // script yang butuh folder kerjanya + tool di ~/.local/bin + scriptSrc := filepath.Join(codeDir, "deploy.sh") + if err := os.WriteFile(scriptSrc, []byte(`#!/bin/sh +[ -f ./data.txt ] && echo "data ok" || echo "data hilang" +case "$PATH" in *".local/bin"*) echo "path ok";; *) echo "path hilang";; esac +`), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(codeDir, "data.txt"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + script, err := InstallExecutable(ExecOptions{BinaryPath: scriptSrc, Name: "Deploy", Category: "Utility", Menu: true, Desktop: true}) + if err != nil { + t.Fatal(err) + } + binarySrc := fakeBinary(t, codeDir, "tool") + binaryShortcut, err := InstallExecutable(ExecOptions{BinaryPath: binarySrc, Name: "Tool", Menu: true}) + if err != nil { + t.Fatal(err) + } + web, err := InstallWebApp(WebOptions{ + URL: site.URL, Name: "Situs Web", + Browser: BrowserArg{Name: "Chromium", Exec: []string{"/usr/bin/chromium"}}, + Menu: true, + }) + if err != nil { + t.Fatal(err) + } + + // ---- ekspor ke .lazy ---- + backupPath := filepath.Join(t.TempDir(), "db.lazy") + if err := db.Export(backupPath, "1.2.0", []store.Shortcut{script, binaryShortcut, web}); err != nil { + t.Fatal(err) + } + + // ---- komputer B: HOME baru, pulihkan ---- + homeB := t.TempDir() + t.Setenv("HOME", homeB) + + backups, err := db.Import(backupPath) + if err != nil { + t.Fatal(err) + } + if len(backups) != 3 { + t.Fatalf("cadangan berisi %d entri, mau 3", len(backups)) + } + + restored := map[string]store.Shortcut{} + for _, backup := range backups { + item, err := Restore(backup.Item, backup.Binary, backup.Icon) + if err != nil { + t.Fatalf("restore %s: %v", backup.Item.Name, err) + } + restored[backup.Item.Name] = item + } + + // ---- semua berkas harus di home B, bukan home A ---- + for name, item := range restored { + if item.BinaryPath != "" && !strings.HasPrefix(item.BinaryPath, homeB) { + t.Errorf("%s: binary masih menunjuk home lama: %s", name, item.BinaryPath) + } + if !strings.HasPrefix(item.DesktopPath, homeB) { + t.Errorf("%s: .desktop masih menunjuk home lama: %s", name, item.DesktopPath) + } + if _, err := os.Stat(item.DesktopPath); err != nil { + t.Errorf("%s: .desktop tidak ada: %v", name, err) + } + if _, err := os.Stat(item.IconPath); err != nil { + t.Errorf("%s: ikon tidak ada: %v", name, err) + } + } + + // script hasil restore harus jalan seperti dijalankan desktop environment + deploy := restored["Deploy"] + if !strings.HasPrefix(deploy.DesktopPath, homeB) { + t.Fatal("script tidak terpulihkan") + } + cmd := exec.Command(deploy.ExecArgs[0], deploy.ExecArgs[1:]...) + cmd.Dir = homeB + cmd.Env = []string{"HOME=" + homeB, "PATH=/usr/bin:/bin"} + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("script hasil restore gagal jalan: %v\n%s", err, out) + } + if !strings.Contains(string(out), "path ok") { + t.Errorf("PATH tidak disiapkan wrapper: %s", out) + } + + // binary biasa hasil restore juga harus executable + tool := restored["Tool"] + if st, err := os.Stat(tool.BinaryPath); err != nil || st.Mode().Perm()&0o111 == 0 { + t.Errorf("binary hasil restore tidak executable: %v %v", st, err) + } + if !strings.HasPrefix(tool.DesktopCopyPath, homeB) && tool.Targets.Desktop { + t.Errorf("salinan desktop salah: %s", tool.DesktopCopyPath) + } + + // web app: exec args tetap menunjuk browser + URL + if !strings.Contains(strings.Join(restored["Situs Web"].ExecArgs, " "), "--app="+site.URL) { + t.Errorf("exec web app salah: %v", restored["Situs Web"].ExecArgs) + } +} diff --git a/internal/shortcut/desktop.go b/internal/shortcut/desktop.go index be5f074..367f287 100644 --- a/internal/shortcut/desktop.go +++ b/internal/shortcut/desktop.go @@ -14,6 +14,8 @@ type Entry struct { ExecArgs []string // argv lengkap; tiap arg di-quote sesuai spec Exec Icon string // path absolut atau nama ikon Categories string // sudah diakhiri ";" atau kosong + Terminal bool // true = jalankan di terminal (untuk script interaktif) + WorkingDir string // folder kerja saat dijalankan (Path=), opsional // NoDisplay menyembunyikan entry dari menu aplikasi. Dipakai kalau user // hanya memilih Desktop: file tetap ada di applications/ sebagai sumber kebenaran. NoDisplay bool @@ -29,7 +31,10 @@ func (e Entry) Render() string { if e.Icon != "" { fmt.Fprintf(&b, "Icon=%s\n", escapeValue(e.Icon)) } - b.WriteString("Terminal=false\n") + fmt.Fprintf(&b, "Terminal=%t\n", e.Terminal) + if e.WorkingDir != "" { + fmt.Fprintf(&b, "Path=%s\n", escapeValue(e.WorkingDir)) + } if e.Categories != "" { fmt.Fprintf(&b, "Categories=%s\n", escapeValue(e.Categories)) } @@ -71,6 +76,9 @@ func escapeValue(s string) string { return r.Replace(s) } +// Slug mengembalikan nama berkas yang aman dari nama aplikasi (dipakai UI). +func Slug(name string) string { return slugify(name) } + // slugify membuat nama file yang aman dari nama aplikasi. func slugify(name string) string { var b strings.Builder diff --git a/internal/shortcut/edit.go b/internal/shortcut/edit.go index 8d3ee97..1c30f53 100644 --- a/internal/shortcut/edit.go +++ b/internal/shortcut/edit.go @@ -36,14 +36,11 @@ func EditExecutable(old store.Shortcut, o ExecOptions) (store.Shortcut, error) { } } - // 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(src) + if err := installBinary(kind, src, binDst); err != nil { + return old, err } - kind := DetectKind(binDst) execArgs := []string{binDst} if kind == KindWindows { wine, err := exec.LookPath("wine") @@ -73,24 +70,33 @@ func EditExecutable(old store.Shortcut, o ExecOptions) (store.Shortcut, error) { } } + entry := Entry{ + Name: name, + ExecArgs: execArgs, + Icon: iconPath, + Categories: category, + Terminal: o.Terminal && kind != KindWindows, + NoDisplay: !o.Menu, + } + if kind == KindScript { + if _, err := os.Stat(filepath.Dir(src)); err == nil { + entry.WorkingDir = filepath.Dir(src) + } + } + updated := old updated.Slug = sl updated.Name = name updated.Kind = kind updated.ExecArgs = execArgs updated.Categories = category + updated.Terminal = entry.Terminal 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 { + if err := rewriteShortcutFiles(old, &updated, entry); err != nil { return old, err } return updated, nil diff --git a/internal/shortcut/executable.go b/internal/shortcut/executable.go index 524fb33..f3704f6 100644 --- a/internal/shortcut/executable.go +++ b/internal/shortcut/executable.go @@ -21,6 +21,7 @@ type ExecOptions struct { IconURL string // opsional: URL ikon IconData []byte // opsional: ikon yang sudah diunduh UI Name string + Terminal bool // jalankan di terminal (script interaktif) Category string Menu bool Desktop bool @@ -48,10 +49,9 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) { return sc, fmt.Errorf("%s already exists, rename the app or remove that file first", binDst) } - if !samePath(o.BinaryPath, binDst) { - if err := copyFile(o.BinaryPath, binDst, 0o755); err != nil { - return sc, err - } + kind := DetectKind(o.BinaryPath) + if err := installBinary(kind, o.BinaryPath, binDst); err != nil { + return sc, err } iconPath, iconSource, err := installIconFor(iconOptions{ IconPath: o.IconPath, @@ -66,8 +66,7 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) { return sc, err } - // jenis aplikasi dideteksi otomatis; .exe Windows dijalankan lewat wine - kind := DetectKind(o.BinaryPath) + // .exe Windows dijalankan lewat wine; script lewat wrapper (PATH & cwd) execArgs := []string{binDst} if kind == KindWindows { wine, err := exec.LookPath("wine") @@ -82,8 +81,16 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) { ExecArgs: execArgs, Icon: iconPath, Categories: category(o.Category, "Utility;"), + Terminal: o.Terminal && kind != KindWindows, NoDisplay: !o.Menu, } + if kind == KindScript { + // desktop environment menjalankan dengan cwd = home, jadi folder asli + // script dipakai sebagai Path= kalau masih ada + if _, err := os.Stat(filepath.Dir(o.BinaryPath)); err == nil { + entry.WorkingDir = filepath.Dir(o.BinaryPath) + } + } if err := WriteDesktop(deskDst, entry); err != nil { return sc, err } @@ -95,6 +102,7 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) { Kind: kind, ExecArgs: entry.ExecArgs, Categories: entry.Categories, + Terminal: entry.Terminal, BinaryPath: binDst, IconPath: iconPath, IconSource: iconSource, diff --git a/internal/shortcut/install.go b/internal/shortcut/install.go index 2a7b26f..75988cb 100644 --- a/internal/shortcut/install.go +++ b/internal/shortcut/install.go @@ -105,3 +105,53 @@ func desktopCopy(desktopPath, slug string) (string, error) { } return dst, copyFile(desktopPath, dst, 0o755) } + +// installBinary menyalin berkas target ke binDst. +// +// Script dapat perlakuan khusus: yang disalin adalah script aslinya (dengan +// akhiran .real) plus wrapper kecil. Desktop environment menjalankan shortcut +// dengan cwd = home dan PATH sesi, sehingga script yang memakai path relatif +// atau tool di ~/.local/bin gagal; wrapper yang membereskan keduanya. +func installBinary(kind, src, binDst string) error { + if kind == KindScript { + if err := copyFile(src, binDst+scriptSuffix, 0o755); err != nil { + return err + } + return writeScriptWrapper(binDst, binDst+scriptSuffix, filepath.Dir(src)) + } + if samePath(src, binDst) { + return nil + } + return copyFile(src, binDst, 0o755) +} + +// writeFileMode menulis berkas dengan mode tertentu (dipakai saat restore). +func writeFileMode(path string, data []byte, mode os.FileMode) error { + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, data, mode) +} + +// scriptSuffix adalah akhiran berkas script asli di samping wrapper. +const scriptSuffix = ".real" + +// writeScriptWrapper menulis wrapper yang menyiapkan PATH dan folder kerja +// sebelum menjalankan script aslinya. +func writeScriptWrapper(path, real, originDir string) error { + body := "#!/bin/sh\n" + + "# dibuat oleh LazyRun: siapkan PATH & folder kerja lalu jalankan script aslinya\n" + + `PATH="$HOME/.local/bin:$PATH"` + "\n" + + "export PATH\n" + if originDir != "" && originDir != "." { + quoted := shellQuote(originDir) + body += "if [ -d " + quoted + " ]; then cd " + quoted + " || exit 1; fi\n" + } + body += "exec " + shellQuote(real) + ` "$@"` + "\n" + return os.WriteFile(path, []byte(body), 0o755) +} + +// shellQuote mengutip nilai untuk dipakai di dalam script shell. +func shellQuote(value string) string { + return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'" +} diff --git a/internal/shortcut/restore.go b/internal/shortcut/restore.go new file mode 100644 index 0000000..ec448a7 --- /dev/null +++ b/internal/shortcut/restore.go @@ -0,0 +1,174 @@ +package shortcut + +import ( + "errors" + "os" + "os/exec" + "path/filepath" + "strings" + + "lazyrun/internal/icon" + "lazyrun/internal/store" + "lazyrun/internal/xdg" +) + +// Restore memasang ulang shortcut dari data cadangan ke mesin ini: +// binary dan ikon ditulis dari isi cadangan (kalau ada), lalu .desktop dibuat +// dengan path lokal. Metadata yang menunjuk ke komputer lain tidak dipakai +// mentah-mentah, jadi hasil restore langsung bisa dipakai. +func Restore(item store.Shortcut, binary, iconData []byte) (store.Shortcut, error) { + name := strings.TrimSpace(item.Name) + if name == "" { + return item, errors.New("nama shortcut kosong") + } + sl := slugify(name) + if sl == "" { + return item, errors.New("nama shortcut tidak valid") + } + + restored := item + restored.Slug = sl + restored.Name = name + restored.DesktopCopyPath = "" + restored.IconPath = "" + + if restored.Type == store.TypeExecutable { + if err := restoreBinary(&restored, sl, binary); err != nil { + return restored, err + } + } + if restored.Type == store.TypeWebApp && len(restored.ExecArgs) == 0 && restored.Browser != "" { + restored.ExecArgs = webAppArgs(restored.Browser, restored.URL) + } + + iconPath, err := restoreIcon(restored, sl, iconData) + if err != nil { + return restored, err + } + restored.IconPath = iconPath + + entry := Entry{ + Name: name, + ExecArgs: restored.ExecArgs, + Icon: iconPath, + Categories: restored.Categories, + Terminal: restored.Terminal && restored.Kind != KindWindows, + NoDisplay: !restored.Targets.Menu, + } + if entry.Categories == "" { + entry.Categories = "Utility;" + } + if restored.Kind == KindScript { + if dir := scriptOriginDir(restored); dir != "" { + entry.WorkingDir = dir + } + } + + deskDst := filepath.Join(xdg.ApplicationsDir(), sl+".desktop") + if err := WriteDesktop(deskDst, entry); err != nil { + return restored, err + } + restored.DesktopPath = deskDst + + if restored.Targets.Desktop { + copyPath := filepath.Join(xdg.DesktopDir(), sl+".desktop") + if err := copyFile(deskDst, copyPath, 0o755); err != nil { + return restored, err + } + restored.DesktopCopyPath = copyPath + } + + go RefreshCache() + return restored, nil +} + +// restoreBinary menulis binary/script dari cadangan ke ~/.local/bin. +// Kalau cadangan tidak memuat isinya, berkas lokal yang masih ada dipakai. +func restoreBinary(restored *store.Shortcut, slug string, binary []byte) error { + binDst := filepath.Join(xdg.BinDir(), slug) + originDir := scriptOriginDir(*restored) + + if len(binary) == 0 { + // tidak ada isi cadangan: pakai berkas lama kalau masih ada di mesin ini + if _, err := os.Stat(restored.BinaryPath); err != nil { + restored.BinaryPath = "" + restored.ExecArgs = nil + return nil + } + binary, _ = os.ReadFile(restored.BinaryPath) + } + + if err := writeFileMode(binDst, binary, 0o755); err != nil { + return err + } + + kind := DetectKind(binDst) + if kind == KindScript { + // script dijalankan lewat wrapper: script aslinya disimpan sebagai .real + if err := os.Rename(binDst, binDst+scriptSuffix); err != nil { + return err + } + if err := writeScriptWrapper(binDst, binDst+scriptSuffix, originDir); err != nil { + return err + } + } + + restored.Kind = kind + restored.BinaryPath = binDst + restored.ExecArgs = []string{binDst} + if kind == KindWindows { + if wine, err := exec.LookPath("wine"); err == nil { + restored.ExecArgs = []string{wine, binDst} + } + } + return nil +} + +// restoreIcon memakai ikon dari cadangan; kalau tidak ada, coba unduh ulang +// dari sumber yang tercatat, lalu jatuh ke ikon default. +func restoreIcon(item store.Shortcut, slug string, iconData []byte) (string, error) { + if len(iconData) > 0 { + if path, err := icon.Save(iconData, slug); err == nil { + return path, nil + } + } + + source := item.IconSource + if source != "" { + options := iconOptions{Slug: slug} + switch { + case strings.HasPrefix(source, iconSourceFavicon): + options.PageURL = strings.TrimPrefix(source, iconSourceFavicon) + case strings.HasPrefix(source, iconSourceSearch): + options.Query = strings.TrimPrefix(source, iconSourceSearch) + case strings.HasPrefix(source, "http"): + options.IconURL = source + } + if path, _, err := installIconFor(options); err == nil && path != "" { + return path, nil + } + } + return icon.Default(slug) +} + +// scriptOriginDir menebak folder asal script dari metadata (dipakai sebagai +// Path= dan folder kerja wrapper). +func scriptOriginDir(item store.Shortcut) string { + dir := filepath.Dir(item.BinaryPath) + if dir == "" || dir == "." || dir == "/" { + return "" + } + return dir +} + +// webAppArgs menyusun argv PWA dari perintah browser yang tersimpan. +func webAppArgs(browser, pageURL string) []string { + args := strings.Fields(browser) + if len(args) == 0 { + return nil + } + if strings.Contains(strings.ToLower(args[0]), "firefox") { + return append(args, "--new-window", pageURL) + } + return append(args, "--app="+pageURL) +} diff --git a/internal/shortcut/restore_test.go b/internal/shortcut/restore_test.go new file mode 100644 index 0000000..b4e95c2 --- /dev/null +++ b/internal/shortcut/restore_test.go @@ -0,0 +1,166 @@ +package shortcut + +import ( + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "testing" + + "lazyrun/internal/store" +) + +func TestRestoreExecutableFromBackup(t *testing.T) { + home := testEnv(t) + + backup := store.Shortcut{ + Slug: "winbox", + Name: "Winbox", + Type: store.TypeExecutable, + Kind: "binary", + BinaryPath: "/komputer-lama/.local/bin/winbox", // path dari mesin lain + IconPath: "/komputer-lama/.local/share/icons/winbox.png", + DesktopPath: "/komputer-lama/.local/share/applications/winbox.desktop", + Categories: "Network;", + Targets: store.Targets{Menu: true, Desktop: true}, + } + restored, err := Restore(backup, append([]byte{0x7f, 'E', 'L', 'F', 0x02, 0x01, 0x01, 0x00}, []byte("isi")...), nil) + if err != nil { + t.Fatal(err) + } + + wantBin := filepath.Join(home, ".local", "bin", "winbox") + if restored.BinaryPath != wantBin { + t.Errorf("binary = %s, mau %s", restored.BinaryPath, wantBin) + } + if st, err := os.Stat(wantBin); err != nil || st.Mode().Perm()&0o111 == 0 { + t.Fatalf("binary hasil restore tidak executable: %v %v", st, err) + } + // path dari mesin lama tidak boleh dipakai + if strings.Contains(restored.DesktopPath, "komputer-lama") { + t.Errorf("desktop path masih menunjuk mesin lama: %s", restored.DesktopPath) + } + if restored.DesktopCopyPath != filepath.Join(home, "Desktop", "winbox.desktop") { + t.Errorf("salinan desktop = %s", restored.DesktopCopyPath) + } + content, err := os.ReadFile(restored.DesktopPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "Exec="+wantBin+"\n") || !strings.Contains(string(content), "Categories=Network;\n") { + t.Errorf("isi .desktop hasil restore salah:\n%s", content) + } + if _, err := os.Stat(restored.IconPath); err != nil { + t.Errorf("ikon hasil restore tidak ada: %v", err) + } +} + +func TestRestoreScriptGetsWrapper(t *testing.T) { + home := testEnv(t) + + backup := store.Shortcut{ + Name: "Deploy", + Type: store.TypeExecutable, + Kind: "script", + BinaryPath: "/komputer-lama/proyek/deploy.sh", + Terminal: true, + Targets: store.Targets{Menu: true}, + } + restored, err := Restore(backup, []byte("#!/bin/sh\necho halo\n"), nil) + if err != nil { + t.Fatal(err) + } + + if restored.Kind != KindScript { + t.Errorf("kind = %q", restored.Kind) + } + if _, err := os.Stat(filepath.Join(home, ".local", "bin", "deploy")); err != nil { + t.Errorf("wrapper tidak dibuat: %v", err) + } + if _, err := os.Stat(filepath.Join(home, ".local", "bin", "deploy"+scriptSuffix)); err != nil { + t.Errorf("script asli tidak dibuat: %v", err) + } + + content, err := os.ReadFile(restored.DesktopPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "Terminal=true") { + t.Errorf("flag terminal hilang:\n%s", content) + } +} + +func TestRestoreWithoutBinaryBlobMarksMissing(t *testing.T) { + testEnv(t) + + backup := store.Shortcut{ + Name: "Hilang", + Type: store.TypeExecutable, + BinaryPath: "/komputer-lama/.local/bin/hilang", + Targets: store.Targets{Menu: true}, + } + restored, err := Restore(backup, nil, nil) + if err != nil { + t.Fatal(err) + } + if restored.BinaryPath != "" { + t.Errorf("binary mestinya kosong (tidak bisa dipulihkan), dapat %s", restored.BinaryPath) + } + if restored.ExecArgs != nil { + t.Errorf("exec args mestinya kosong: %v", restored.ExecArgs) + } + // .desktop tetap dibuat supaya entri bisa dibetulkan lewat edit + if _, err := os.Stat(restored.DesktopPath); err != nil { + t.Errorf(".desktop tidak dibuat: %v", err) + } +} + +func TestRestoreWebAppRebuildsExec(t *testing.T) { + testEnv(t) + + backup := store.Shortcut{ + Name: "WhatsApp Web", + Type: store.TypeWebApp, + Kind: KindPWA, + URL: "https://web.whatsapp.com", + Browser: "flatpak run org.chromium.Chromium", + Targets: store.Targets{Menu: true}, + } + restored, err := Restore(backup, nil, nil) + if err != nil { + t.Fatal(err) + } + if len(restored.ExecArgs) != 4 || restored.ExecArgs[3] != "--app=https://web.whatsapp.com" { + t.Errorf("exec args PWA salah: %v", restored.ExecArgs) + } +} + +func TestRestoreIconFromSource(t *testing.T) { + testEnv(t) + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _, _ = w.Write(append(pngMagic, []byte("ikon dari internet")...)) + })) + defer srv.Close() + + backup := store.Shortcut{ + Name: "Icon", + Type: store.TypeExecutable, + BinaryPath: "", + IconPath: "/komputer-lama/ikon.png", + IconSource: srv.URL + "/ikon.png", + Targets: store.Targets{Menu: true}, + } + restored, err := Restore(backup, nil, nil) + if err != nil { + t.Fatal(err) + } + data, err := os.ReadFile(restored.IconPath) + if err != nil { + t.Fatalf("ikon tidak dipasang: %v", err) + } + if !strings.Contains(string(data), "ikon dari internet") { + t.Errorf("ikon tidak diunduh ulang dari sumber: %q", data) + } +} diff --git a/internal/shortcut/uninstall.go b/internal/shortcut/uninstall.go index e865236..03984a0 100644 --- a/internal/shortcut/uninstall.go +++ b/internal/shortcut/uninstall.go @@ -31,8 +31,11 @@ func Uninstall(sc store.Shortcut, o RemoveOptions) error { } } if o.Binary && sc.Type == store.TypeExecutable && sc.BinaryPath != "" { - if err := os.Remove(sc.BinaryPath); err != nil && !os.IsNotExist(err) { - errs = append(errs, err) + // script punya dua berkas: wrapper + script aslinya + for _, path := range []string{sc.BinaryPath, sc.BinaryPath + scriptSuffix} { + if err := os.Remove(path); err != nil && !os.IsNotExist(err) { + errs = append(errs, err) + } } } diff --git a/internal/store/store.go b/internal/store/store.go index de0c9ee..4abe914 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -30,6 +30,7 @@ type Shortcut struct { Browser string `json:"browser,omitempty"` ExecArgs []string `json:"exec_args,omitempty"` Categories string `json:"categories,omitempty"` + Terminal bool `json:"terminal,omitempty"` BinaryPath string `json:"binary_path,omitempty"` IconPath string `json:"icon_path,omitempty"` IconSource string `json:"icon_source,omitempty"` diff --git a/internal/ui/dialog_edit.go b/internal/ui/dialog_edit.go index ab3c62b..6891ce4 100644 --- a/internal/ui/dialog_edit.go +++ b/internal/ui/dialog_edit.go @@ -72,7 +72,7 @@ func (ui *app) importShortcuts() { return } - added, skipped := mergeImported(ui.store, items) + added, skipped := ui.restoreImported(items) if err := ui.store.Save(); err != nil { dialog.ShowError(err, ui.win) return @@ -98,6 +98,11 @@ func (ui *app) showEditDialog(sc store.Shortcut) { menu.SetChecked(sc.Targets.Menu) desktopChk := widget.NewCheck(T("check_desktop"), nil) desktopChk.SetChecked(sc.Targets.Desktop) + terminal := widget.NewCheck(T("check_terminal"), nil) + terminal.SetChecked(sc.Terminal) + if sc.Kind != shortcut.KindScript { + terminal.Hide() + } browseIcon := newButton(T("browse"), theme.FolderOpenIcon(), T("tip_browse"), widget.MediumImportance, func() { pickFile(ui.win, imageFilter, func(path string) { @@ -118,6 +123,12 @@ func (ui *app) showEditDialog(sc store.Shortcut) { pickFile(ui.win, nil, func(path string) { binPath = path binLabel.SetText(filepath.Base(path) + " · " + detectedKind(path)) + if shortcut.DetectKind(path) == shortcut.KindScript { + terminal.Show() + } else { + terminal.SetChecked(false) + terminal.Hide() + } }) }) category := widget.NewSelect(categories, nil) @@ -185,6 +196,7 @@ func (ui *app) showEditDialog(sc store.Shortcut) { IconData: iconData, Name: nameEntry.Text, Category: category.Selected, + Terminal: terminal.Checked, Menu: menu.Checked, Desktop: desktopChk.Checked, }) @@ -215,6 +227,7 @@ func (ui *app) showEditDialog(sc store.Shortcut) { sectionTitle(T("section_location")), menu, desktopChk, + terminal, hint, container.NewHBox(logoBox(newLogo(), 20), spacer(), newButton(T("cancel"), nil, T("tip_cancel"), widget.MediumImportance, func() { d.Hide() }), @@ -226,19 +239,28 @@ func (ui *app) showEditDialog(sc store.Shortcut) { showDialog(d, ui.win, 560, 520) } -// mergeImported menambahkan shortcut hasil impor tanpa menimpa yang sudah ada. -func mergeImported(target *store.Store, items []store.Shortcut) (added, skipped int) { - for _, item := range items { +// restoreImported memasang ulang setiap entri cadangan ke mesin ini +// (binary, ikon, .desktop) lalu menambahkannya ke daftar, tanpa menimpa +// shortcut yang sudah ada. +func (ui *app) restoreImported(items []db.Backup) (added, skipped int) { + for _, backup := range items { + item := backup.Item item.Name = strings.TrimSpace(item.Name) - if item.Name == "" || item.Slug == "" { + if item.Name == "" { skipped++ continue } - if _, exists := target.Get(item.Slug); exists { + if _, exists := ui.store.Get(shortcut.Slug(item.Name)); exists { skipped++ continue } - target.Add(item) + + restored, err := shortcut.Restore(item, backup.Binary, backup.Icon) + if err != nil { + skipped++ + continue + } + ui.store.Add(restored) added++ } return added, skipped diff --git a/internal/ui/dialog_executable.go b/internal/ui/dialog_executable.go index e964009..cdabe90 100644 --- a/internal/ui/dialog_executable.go +++ b/internal/ui/dialog_executable.go @@ -40,11 +40,20 @@ func (ui *app) showExecutableDialog() { menu.SetChecked(true) desktop := widget.NewCheck(T("check_desktop"), nil) desktop.SetChecked(true) + terminal := widget.NewCheck(T("check_terminal"), nil) + terminal.Hide() // hanya relevan untuk script browseBin := newButton(T("browse"), theme.FolderOpenIcon(), T("tip_browse"), widget.MediumImportance, func() { pickFile(ui.win, nil, func(path string) { binPath = path binLabel.SetText(filepath.Base(path) + " · " + detectedKind(path)) + // terminal hanya perlu untuk script; tampilkan kalau terdeteksi + if shortcut.DetectKind(path) == shortcut.KindScript { + terminal.Show() + } else { + terminal.SetChecked(false) + terminal.Hide() + } if strings.TrimSpace(nameEntry.Text) == "" { nameEntry.SetText(appNameFromFile(path)) } @@ -83,6 +92,7 @@ func (ui *app) showExecutableDialog() { IconData: iconData, Name: nameEntry.Text, Category: category.Selected, + Terminal: terminal.Checked, Menu: menu.Checked, Desktop: desktop.Checked, } @@ -106,6 +116,7 @@ func (ui *app) showExecutableDialog() { sectionTitle(T("section_location")), menu, desktop, + terminal, hint, container.NewHBox(logoBox(newLogo(), 20), spacer(), newButton(T("cancel"), nil, T("tip_cancel"), widget.MediumImportance, func() { d.Hide() }), diff --git a/internal/ui/dialog_test.go b/internal/ui/dialog_test.go index b374711..8928368 100644 --- a/internal/ui/dialog_test.go +++ b/internal/ui/dialog_test.go @@ -10,6 +10,7 @@ import ( "fyne.io/fyne/v2/test" + "lazyrun/internal/db" "lazyrun/internal/icon" "lazyrun/internal/store" ) @@ -121,26 +122,55 @@ func TestStringsCoverUsedKeys(t *testing.T) { } } -func TestMergeImported(t *testing.T) { - target := &store.Store{} - target.Add(store.Shortcut{Slug: "winbox", Name: "Winbox"}) +// TestRestoreImported memasang ulang entri cadangan: berkas dibuat di HOME saat ini +// dan entri yang sudah ada tidak ditimpa. +func TestRestoreImported(t *testing.T) { + home := t.TempDir() + t.Setenv("HOME", home) + t.Setenv("XDG_DATA_HOME", "") + t.Setenv("PATH", "") - added, skipped := mergeImported(target, []store.Shortcut{ - {Slug: "winbox", Name: "Winbox lama"}, // sudah ada -> dilewati - {Slug: "figma", Name: "Figma"}, // baru - {Slug: "", Name: "Tanpa slug"}, // rusak -> dilewati - {Slug: "kosong", Name: " "}, // tanpa nama -> dilewati - {Slug: "deploy", Name: " deploy.sh "}, // nama dirapikan - }) + script := []byte("#!/bin/sh\necho halo\n") + items := []db.Backup{ + {Item: store.Shortcut{Slug: "lama", Name: "Deploy", Type: store.TypeExecutable, Kind: "script", + BinaryPath: "/komputer-lama/.local/bin/deploy", Targets: store.Targets{Menu: true}}, Binary: script}, + {Item: store.Shortcut{Name: "Figma", Type: store.TypeWebApp, Kind: "pwa", + URL: "https://figma.com", Browser: "/usr/bin/vivaldi", Targets: store.Targets{Menu: true}}}, + } - if added != 2 || skipped != 3 { - t.Errorf("added=%d skipped=%d, mau 2 dan 3", added, skipped) + a := test.NewApp() + defer a.Quit() + ui, err := build(a, "test") + if err != nil { + t.Fatal(err) } - if len(target.Items) != 3 { - t.Errorf("jumlah item = %d", len(target.Items)) + ui.store.Items = nil + ui.store.Add(store.Shortcut{Slug: "figma", Name: "Figma"}) // sudah ada -> dilewati + + added, skipped := ui.restoreImported(items) + if added != 1 || skipped != 1 { + t.Fatalf("added=%d skipped=%d, mau 1 dan 1", added, skipped) } - dep, ok := target.Get("deploy") - if !ok || target.Items[dep].Name != "deploy.sh" { - t.Errorf("nama item tidak dirapikan: %+v", target.Items) + + deploy, ok := ui.store.Get("deploy") + if !ok { + t.Fatal("shortcut hasil restore tidak masuk daftar") + } + sc := ui.store.Items[deploy] + want := filepath.Join(home, ".local", "bin", "deploy") + if sc.BinaryPath != want { + t.Errorf("binary = %s, mau %s", sc.BinaryPath, want) + } + if _, err := os.Stat(want + ".real"); err != nil { + t.Errorf("script asli (.real) tidak ditulis: %v", err) + } + if _, err := os.Stat(sc.DesktopPath); err != nil { + t.Errorf(".desktop tidak dibuat: %v", err) + } + if sc.IconPath == "" { + t.Error("ikon tidak dipasang") + } + if sc.Kind != "script" { + t.Errorf("kind hasil restore = %q", sc.Kind) } } diff --git a/internal/ui/strings.go b/internal/ui/strings.go index 2483af6..de3d206 100644 --- a/internal/ui/strings.go +++ b/internal/ui/strings.go @@ -49,6 +49,7 @@ var tr = map[string][2]string{ "section_location": {"Shortcut location", "Lokasi shortcut"}, "check_menu": {"Application menu", "Menu aplikasi"}, "check_desktop": {"Desktop", "Desktop"}, + "check_terminal": {"Run in a terminal window (scripts)", "Jalankan di jendela terminal (script)"}, "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 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."},