Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
35e83dba09 | ||
|
|
581cd07dba | ||
|
|
fdb503e152 |
No files matched your search
@@ -20,6 +20,11 @@ breathes while the app is open (the koala in `assets/logo.svg` is the mascot).*
|
|||||||
<img src="docs/dialog-icons.png" width="49%" alt="Icon search dialog">
|
<img src="docs/dialog-icons.png" width="49%" alt="Icon search dialog">
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/dialog-loading.png" width="49%" alt="Progress bar while installing">
|
||||||
|
<img src="docs/main-light.png" width="49%" alt="LazyRun in light theme">
|
||||||
|
</p>
|
||||||
|
|
||||||
- **Repo:** <https://github.com/oxpoe/lazyrun>
|
- **Repo:** <https://github.com/oxpoe/lazyrun>
|
||||||
- **Donate:** <https://donasi.oxpoe.qzz.io/>
|
- **Donate:** <https://donasi.oxpoe.qzz.io/>
|
||||||
|
|
||||||
@@ -28,6 +33,13 @@ 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,
|
- **Automatic type label** — every shortcut is tagged with what it actually is,
|
||||||
detected from the file: `Go`, `AppImage`, `Electron`, `Script`, `Windows`, `Binary`
|
detected from the file: `Go`, `AppImage`, `Electron`, `Script`, `Windows`, `Binary`
|
||||||
(`.exe` files are launched through `wine`), or `PWA` for web apps.
|
(`.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, running the original script file when it is still there (so `dirname $0`
|
||||||
|
keeps pointing at its own folder). *Run* in the list launches through
|
||||||
|
`gio launch <desktop file>`, so `Terminal=true` and `Path=` behave exactly like the
|
||||||
|
desktop menu — handy for scripts that ask for input or need `sudo`.
|
||||||
- **Web app shortcut** — opens the URL as an app: `--app=<url>` for
|
- **Web app shortcut** — opens the URL as an app: `--app=<url>` for
|
||||||
Chromium-based browsers (Chrome, Chromium, Brave, Edge, Vivaldi, Helium,
|
Chromium-based browsers (Chrome, Chromium, Brave, Edge, Vivaldi, Helium,
|
||||||
including Flatpak builds), `--new-window` for Firefox.
|
including Flatpak builds), `--new-window` for Firefox.
|
||||||
|
|||||||
Binary file not shown.
|
After Width: | Height: | Size: 80 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 59 KiB |
+51
-12
@@ -16,7 +16,8 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// FormatVersion dipakai untuk mengenali berkas .lazy buatan LazyRun.
|
// 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.
|
// DefaultName adalah nama berkas cadangan yang disarankan.
|
||||||
const DefaultName = "lazyrun-shortcuts.lazy"
|
const DefaultName = "lazyrun-shortcuts.lazy"
|
||||||
@@ -40,9 +41,12 @@ CREATE TABLE shortcuts (
|
|||||||
icon_source TEXT,
|
icon_source TEXT,
|
||||||
desktop_path TEXT,
|
desktop_path TEXT,
|
||||||
desktop_copy_path TEXT,
|
desktop_copy_path TEXT,
|
||||||
|
terminal INTEGER NOT NULL DEFAULT 0,
|
||||||
menu INTEGER NOT NULL,
|
menu INTEGER NOT NULL,
|
||||||
desktop 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.
|
// 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(
|
insert, err := tx.Prepare(`INSERT INTO shortcuts(
|
||||||
slug, name, type, kind, url, browser, exec_args, categories,
|
slug, name, type, kind, url, browser, exec_args, categories,
|
||||||
binary_path, icon_path, icon_source, desktop_path, desktop_copy_path,
|
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 {
|
if err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
return err
|
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,
|
item.Slug, item.Name, item.Type, item.Kind, item.URL, item.Browser,
|
||||||
strings.Join(item.ExecArgs, "\x00"), item.Categories,
|
strings.Join(item.ExecArgs, "\x00"), item.Categories,
|
||||||
item.BinaryPath, item.IconPath, item.IconSource, item.DesktopPath,
|
item.BinaryPath, item.IconPath, item.IconSource, item.DesktopPath,
|
||||||
item.DesktopCopyPath, boolToInt(item.Targets.Menu), boolToInt(item.Targets.Desktop),
|
item.DesktopCopyPath, boolToInt(item.Terminal), boolToInt(item.Targets.Menu), boolToInt(item.Targets.Desktop),
|
||||||
item.InstalledAt.Format(time.RFC3339),
|
item.InstalledAt.Format(time.RFC3339), readBlob(item.BinaryPath), readBlob(item.IconPath),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
tx.Rollback()
|
tx.Rollback()
|
||||||
@@ -103,8 +108,34 @@ func Export(path string, appVersion string, items []store.Shortcut) error {
|
|||||||
return tx.Commit()
|
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.
|
// 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 {
|
if _, err := os.Stat(path); err != nil {
|
||||||
return nil, err
|
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 {
|
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)
|
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)
|
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,
|
rows, err := conn.Query(`SELECT slug, name, type, kind, url, browser, exec_args,
|
||||||
categories, binary_path, icon_path, icon_source, desktop_path,
|
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 {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
defer rows.Close()
|
defer rows.Close()
|
||||||
|
|
||||||
var items []store.Shortcut
|
var items []Backup
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var item store.Shortcut
|
var item store.Shortcut
|
||||||
var args, installed string
|
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,
|
if err := rows.Scan(&item.Slug, &item.Name, &item.Type, &item.Kind, &item.URL,
|
||||||
&item.Browser, &args, &item.Categories, &item.BinaryPath, &item.IconPath,
|
&item.Browser, &args, &item.Categories, &item.BinaryPath, &item.IconPath,
|
||||||
&item.IconSource, &item.DesktopPath, &item.DesktopCopyPath,
|
&item.IconSource, &item.DesktopPath, &item.DesktopCopyPath,
|
||||||
&menu, &desktop, &installed); err != nil {
|
&terminal, &menu, &desktop, &installed, &binary, &iconBlob); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
if args != "" {
|
if args != "" {
|
||||||
item.ExecArgs = strings.Split(args, "\x00")
|
item.ExecArgs = strings.Split(args, "\x00")
|
||||||
}
|
}
|
||||||
|
item.Terminal = terminal != 0
|
||||||
item.Targets = store.Targets{Menu: menu != 0, Desktop: desktop != 0}
|
item.Targets = store.Targets{Menu: menu != 0, Desktop: desktop != 0}
|
||||||
if t, err := time.Parse(time.RFC3339, installed); err == nil {
|
if t, err := time.Parse(time.RFC3339, installed); err == nil {
|
||||||
item.InstalledAt = t
|
item.InstalledAt = t
|
||||||
}
|
}
|
||||||
items = append(items, item)
|
items = append(items, Backup{Item: item, Binary: binary, Icon: iconBlob})
|
||||||
}
|
}
|
||||||
return items, rows.Err()
|
return items, rows.Err()
|
||||||
}
|
}
|
||||||
|
|||||||
+60
-1
@@ -62,7 +62,7 @@ func TestExportImportRoundtrip(t *testing.T) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// urutan hasil impor berdasarkan nama: WhatsApp Web lalu Winbox
|
// 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 {
|
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)
|
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) {
|
func TestExportOverwritesOldFile(t *testing.T) {
|
||||||
path := filepath.Join(t.TempDir(), "db.lazy")
|
path := filepath.Join(t.TempDir(), "db.lazy")
|
||||||
if err := os.WriteFile(path, []byte("bukan sqlite"), 0o644); err != nil {
|
if err := os.WriteFile(path, []byte("bukan sqlite"), 0o644); err != nil {
|
||||||
|
|||||||
@@ -0,0 +1,140 @@
|
|||||||
|
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(`<html><head><link rel="icon" type="image/png" sizes="128x128" href="/web.png"></head></html>`))
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
// script asli di folder asalnya masih ada di mesin ini? bukan di sini,
|
||||||
|
// jadi wrapper memakai salinannya (sudah diuji lewat 'data ok' di atas)
|
||||||
|
|
||||||
|
// 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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ type Entry struct {
|
|||||||
ExecArgs []string // argv lengkap; tiap arg di-quote sesuai spec Exec
|
ExecArgs []string // argv lengkap; tiap arg di-quote sesuai spec Exec
|
||||||
Icon string // path absolut atau nama ikon
|
Icon string // path absolut atau nama ikon
|
||||||
Categories string // sudah diakhiri ";" atau kosong
|
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
|
// NoDisplay menyembunyikan entry dari menu aplikasi. Dipakai kalau user
|
||||||
// hanya memilih Desktop: file tetap ada di applications/ sebagai sumber kebenaran.
|
// hanya memilih Desktop: file tetap ada di applications/ sebagai sumber kebenaran.
|
||||||
NoDisplay bool
|
NoDisplay bool
|
||||||
@@ -29,7 +31,10 @@ func (e Entry) Render() string {
|
|||||||
if e.Icon != "" {
|
if e.Icon != "" {
|
||||||
fmt.Fprintf(&b, "Icon=%s\n", escapeValue(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 != "" {
|
if e.Categories != "" {
|
||||||
fmt.Fprintf(&b, "Categories=%s\n", escapeValue(e.Categories))
|
fmt.Fprintf(&b, "Categories=%s\n", escapeValue(e.Categories))
|
||||||
}
|
}
|
||||||
@@ -71,6 +76,9 @@ func escapeValue(s string) string {
|
|||||||
return r.Replace(s)
|
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.
|
// slugify membuat nama file yang aman dari nama aplikasi.
|
||||||
func slugify(name string) string {
|
func slugify(name string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|||||||
+18
-12
@@ -36,14 +36,11 @@ func EditExecutable(old store.Shortcut, o ExecOptions) (store.Shortcut, error) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// salin kalau sumbernya bukan berkas yang sudah terpasang
|
kind := DetectKind(src)
|
||||||
if !samePath(src, binDst) {
|
if err := installBinary(kind, src, binDst); err != nil {
|
||||||
if err := copyFile(src, binDst, 0o755); err != nil {
|
|
||||||
return old, err
|
return old, err
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
kind := DetectKind(binDst)
|
|
||||||
execArgs := []string{binDst}
|
execArgs := []string{binDst}
|
||||||
if kind == KindWindows {
|
if kind == KindWindows {
|
||||||
wine, err := exec.LookPath("wine")
|
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 := old
|
||||||
updated.Slug = sl
|
updated.Slug = sl
|
||||||
updated.Name = name
|
updated.Name = name
|
||||||
updated.Kind = kind
|
updated.Kind = kind
|
||||||
updated.ExecArgs = execArgs
|
updated.ExecArgs = execArgs
|
||||||
updated.Categories = category
|
updated.Categories = category
|
||||||
|
updated.Terminal = entry.Terminal
|
||||||
updated.BinaryPath = binDst
|
updated.BinaryPath = binDst
|
||||||
updated.IconPath = iconPath
|
updated.IconPath = iconPath
|
||||||
updated.IconSource = iconSource
|
updated.IconSource = iconSource
|
||||||
updated.Targets = store.Targets{Menu: o.Menu, Desktop: o.Desktop}
|
updated.Targets = store.Targets{Menu: o.Menu, Desktop: o.Desktop}
|
||||||
|
|
||||||
if err := rewriteShortcutFiles(old, &updated, Entry{
|
if err := rewriteShortcutFiles(old, &updated, entry); err != nil {
|
||||||
Name: name,
|
|
||||||
ExecArgs: execArgs,
|
|
||||||
Icon: iconPath,
|
|
||||||
Categories: category,
|
|
||||||
NoDisplay: !o.Menu,
|
|
||||||
}); err != nil {
|
|
||||||
return old, err
|
return old, err
|
||||||
}
|
}
|
||||||
return updated, nil
|
return updated, nil
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ type ExecOptions struct {
|
|||||||
IconURL string // opsional: URL ikon
|
IconURL string // opsional: URL ikon
|
||||||
IconData []byte // opsional: ikon yang sudah diunduh UI
|
IconData []byte // opsional: ikon yang sudah diunduh UI
|
||||||
Name string
|
Name string
|
||||||
|
Terminal bool // jalankan di terminal (script interaktif)
|
||||||
Category string
|
Category string
|
||||||
Menu bool
|
Menu bool
|
||||||
Desktop bool
|
Desktop bool
|
||||||
@@ -48,11 +49,10 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) {
|
|||||||
return sc, fmt.Errorf("%s already exists, rename the app or remove that file first", binDst)
|
return sc, fmt.Errorf("%s already exists, rename the app or remove that file first", binDst)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !samePath(o.BinaryPath, binDst) {
|
kind := DetectKind(o.BinaryPath)
|
||||||
if err := copyFile(o.BinaryPath, binDst, 0o755); err != nil {
|
if err := installBinary(kind, o.BinaryPath, binDst); err != nil {
|
||||||
return sc, err
|
return sc, err
|
||||||
}
|
}
|
||||||
}
|
|
||||||
iconPath, iconSource, err := installIconFor(iconOptions{
|
iconPath, iconSource, err := installIconFor(iconOptions{
|
||||||
IconPath: o.IconPath,
|
IconPath: o.IconPath,
|
||||||
IconURL: o.IconURL,
|
IconURL: o.IconURL,
|
||||||
@@ -66,8 +66,7 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) {
|
|||||||
return sc, err
|
return sc, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// jenis aplikasi dideteksi otomatis; .exe Windows dijalankan lewat wine
|
// .exe Windows dijalankan lewat wine; script lewat wrapper (PATH & cwd)
|
||||||
kind := DetectKind(o.BinaryPath)
|
|
||||||
execArgs := []string{binDst}
|
execArgs := []string{binDst}
|
||||||
if kind == KindWindows {
|
if kind == KindWindows {
|
||||||
wine, err := exec.LookPath("wine")
|
wine, err := exec.LookPath("wine")
|
||||||
@@ -82,8 +81,16 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) {
|
|||||||
ExecArgs: execArgs,
|
ExecArgs: execArgs,
|
||||||
Icon: iconPath,
|
Icon: iconPath,
|
||||||
Categories: category(o.Category, "Utility;"),
|
Categories: category(o.Category, "Utility;"),
|
||||||
|
Terminal: o.Terminal && kind != KindWindows,
|
||||||
NoDisplay: !o.Menu,
|
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 {
|
if err := WriteDesktop(deskDst, entry); err != nil {
|
||||||
return sc, err
|
return sc, err
|
||||||
}
|
}
|
||||||
@@ -95,6 +102,7 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) {
|
|||||||
Kind: kind,
|
Kind: kind,
|
||||||
ExecArgs: entry.ExecArgs,
|
ExecArgs: entry.ExecArgs,
|
||||||
Categories: entry.Categories,
|
Categories: entry.Categories,
|
||||||
|
Terminal: entry.Terminal,
|
||||||
BinaryPath: binDst,
|
BinaryPath: binDst,
|
||||||
IconPath: iconPath,
|
IconPath: iconPath,
|
||||||
IconSource: iconSource,
|
IconSource: iconSource,
|
||||||
|
|||||||
@@ -105,3 +105,63 @@ func desktopCopy(desktopPath, slug string) (string, error) {
|
|||||||
}
|
}
|
||||||
return dst, copyFile(desktopPath, dst, 0o755)
|
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, 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.
|
||||||
|
//
|
||||||
|
// Script asli di folder asalnya dipakai lebih dulu kalau masih ada (supaya
|
||||||
|
// `dirname $0` dan berkas di sebelahnya tetap mengarah ke sana); salinan di
|
||||||
|
// ~/.local/bin jadi cadangan kalau script aslinya sudah pindah/dihapus.
|
||||||
|
func writeScriptWrapper(path, real, originFile string) error {
|
||||||
|
body := "#!/bin/sh\n" +
|
||||||
|
"# dibuat oleh LazyRun: siapkan PATH & folder kerja lalu jalankan script\n" +
|
||||||
|
`PATH="$HOME/.local/bin:$PATH"` + "\n" +
|
||||||
|
"export PATH\n"
|
||||||
|
|
||||||
|
originDir := filepath.Dir(originFile)
|
||||||
|
if originDir != "" && originDir != "." && originDir != "/" {
|
||||||
|
quoted := shellQuote(originDir)
|
||||||
|
body += "if [ -d " + quoted + " ]; then cd " + quoted + " || exit 1; fi\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
if originFile != "" && originFile != real {
|
||||||
|
body += "if [ -f " + shellQuote(originFile) + " ]; then exec " + shellQuote(originFile) + ` "$@"; 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, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
@@ -267,3 +267,35 @@ func TestUninstallKeepsBinaryByDefault(t *testing.T) {
|
|||||||
t.Errorf("binary harus tetap ada: %v", err)
|
t.Errorf("binary harus tetap ada: %v", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestScriptWrapperPrefersOriginalFile(t *testing.T) {
|
||||||
|
home := testEnv(t)
|
||||||
|
dir := filepath.Join(home, "proyek")
|
||||||
|
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
src := filepath.Join(dir, "bench.sh")
|
||||||
|
if err := os.WriteFile(src, []byte("#!/bin/sh\necho asli \"$(pwd)\"\n"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sc, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Bench", Menu: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wrapper, err := os.ReadFile(sc.BinaryPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
text := string(wrapper)
|
||||||
|
if !strings.Contains(text, shellQuote(src)) {
|
||||||
|
t.Errorf("wrapper tidak memakai script asli:\n%s", text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, shellQuote(dir)) {
|
||||||
|
t.Errorf("wrapper tidak cd ke folder asli:\n%s", text)
|
||||||
|
}
|
||||||
|
if !strings.Contains(text, "PATH=\"$HOME/.local/bin:$PATH\"") {
|
||||||
|
t.Errorf("wrapper tidak menyiapkan PATH:\n%s", text)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
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
|
||||||
|
}
|
||||||
|
originFile := ""
|
||||||
|
if originDir != "" {
|
||||||
|
originFile = filepath.Join(originDir, filepath.Base(restored.BinaryPath))
|
||||||
|
}
|
||||||
|
if err := writeScriptWrapper(binDst, binDst+scriptSuffix, originFile); 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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,10 +31,13 @@ func Uninstall(sc store.Shortcut, o RemoveOptions) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if o.Binary && sc.Type == store.TypeExecutable && sc.BinaryPath != "" {
|
if o.Binary && sc.Type == store.TypeExecutable && sc.BinaryPath != "" {
|
||||||
if err := os.Remove(sc.BinaryPath); err != nil && !os.IsNotExist(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)
|
errs = append(errs, err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
go RefreshCache()
|
go RefreshCache()
|
||||||
return errors.Join(errs...)
|
return errors.Join(errs...)
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ type Shortcut struct {
|
|||||||
Browser string `json:"browser,omitempty"`
|
Browser string `json:"browser,omitempty"`
|
||||||
ExecArgs []string `json:"exec_args,omitempty"`
|
ExecArgs []string `json:"exec_args,omitempty"`
|
||||||
Categories string `json:"categories,omitempty"`
|
Categories string `json:"categories,omitempty"`
|
||||||
|
Terminal bool `json:"terminal,omitempty"`
|
||||||
BinaryPath string `json:"binary_path,omitempty"`
|
BinaryPath string `json:"binary_path,omitempty"`
|
||||||
IconPath string `json:"icon_path,omitempty"`
|
IconPath string `json:"icon_path,omitempty"`
|
||||||
IconSource string `json:"icon_source,omitempty"`
|
IconSource string `json:"icon_source,omitempty"`
|
||||||
|
|||||||
@@ -72,7 +72,7 @@ func (ui *app) importShortcuts() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
added, skipped := mergeImported(ui.store, items)
|
added, skipped := ui.restoreImported(items)
|
||||||
if err := ui.store.Save(); err != nil {
|
if err := ui.store.Save(); err != nil {
|
||||||
dialog.ShowError(err, ui.win)
|
dialog.ShowError(err, ui.win)
|
||||||
return
|
return
|
||||||
@@ -98,6 +98,11 @@ func (ui *app) showEditDialog(sc store.Shortcut) {
|
|||||||
menu.SetChecked(sc.Targets.Menu)
|
menu.SetChecked(sc.Targets.Menu)
|
||||||
desktopChk := widget.NewCheck(T("check_desktop"), nil)
|
desktopChk := widget.NewCheck(T("check_desktop"), nil)
|
||||||
desktopChk.SetChecked(sc.Targets.Desktop)
|
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() {
|
browseIcon := newButton(T("browse"), theme.FolderOpenIcon(), T("tip_browse"), widget.MediumImportance, func() {
|
||||||
pickFile(ui.win, imageFilter, func(path string) {
|
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) {
|
pickFile(ui.win, nil, func(path string) {
|
||||||
binPath = path
|
binPath = path
|
||||||
binLabel.SetText(filepath.Base(path) + " · " + detectedKind(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)
|
category := widget.NewSelect(categories, nil)
|
||||||
@@ -185,6 +196,7 @@ func (ui *app) showEditDialog(sc store.Shortcut) {
|
|||||||
IconData: iconData,
|
IconData: iconData,
|
||||||
Name: nameEntry.Text,
|
Name: nameEntry.Text,
|
||||||
Category: category.Selected,
|
Category: category.Selected,
|
||||||
|
Terminal: terminal.Checked,
|
||||||
Menu: menu.Checked,
|
Menu: menu.Checked,
|
||||||
Desktop: desktopChk.Checked,
|
Desktop: desktopChk.Checked,
|
||||||
})
|
})
|
||||||
@@ -215,6 +227,7 @@ func (ui *app) showEditDialog(sc store.Shortcut) {
|
|||||||
sectionTitle(T("section_location")),
|
sectionTitle(T("section_location")),
|
||||||
menu,
|
menu,
|
||||||
desktopChk,
|
desktopChk,
|
||||||
|
terminal,
|
||||||
hint,
|
hint,
|
||||||
container.NewHBox(logoBox(newLogo(), 20), spacer(),
|
container.NewHBox(logoBox(newLogo(), 20), spacer(),
|
||||||
newButton(T("cancel"), nil, T("tip_cancel"), widget.MediumImportance, func() { d.Hide() }),
|
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)
|
showDialog(d, ui.win, 560, 520)
|
||||||
}
|
}
|
||||||
|
|
||||||
// mergeImported menambahkan shortcut hasil impor tanpa menimpa yang sudah ada.
|
// restoreImported memasang ulang setiap entri cadangan ke mesin ini
|
||||||
func mergeImported(target *store.Store, items []store.Shortcut) (added, skipped int) {
|
// (binary, ikon, .desktop) lalu menambahkannya ke daftar, tanpa menimpa
|
||||||
for _, item := range items {
|
// 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)
|
item.Name = strings.TrimSpace(item.Name)
|
||||||
if item.Name == "" || item.Slug == "" {
|
if item.Name == "" {
|
||||||
skipped++
|
skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if _, exists := target.Get(item.Slug); exists {
|
if _, exists := ui.store.Get(shortcut.Slug(item.Name)); exists {
|
||||||
skipped++
|
skipped++
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
target.Add(item)
|
|
||||||
|
restored, err := shortcut.Restore(item, backup.Binary, backup.Icon)
|
||||||
|
if err != nil {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ui.store.Add(restored)
|
||||||
added++
|
added++
|
||||||
}
|
}
|
||||||
return added, skipped
|
return added, skipped
|
||||||
|
|||||||
@@ -40,11 +40,20 @@ func (ui *app) showExecutableDialog() {
|
|||||||
menu.SetChecked(true)
|
menu.SetChecked(true)
|
||||||
desktop := widget.NewCheck(T("check_desktop"), nil)
|
desktop := widget.NewCheck(T("check_desktop"), nil)
|
||||||
desktop.SetChecked(true)
|
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() {
|
browseBin := newButton(T("browse"), theme.FolderOpenIcon(), T("tip_browse"), widget.MediumImportance, func() {
|
||||||
pickFile(ui.win, nil, func(path string) {
|
pickFile(ui.win, nil, func(path string) {
|
||||||
binPath = path
|
binPath = path
|
||||||
binLabel.SetText(filepath.Base(path) + " · " + detectedKind(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) == "" {
|
if strings.TrimSpace(nameEntry.Text) == "" {
|
||||||
nameEntry.SetText(appNameFromFile(path))
|
nameEntry.SetText(appNameFromFile(path))
|
||||||
}
|
}
|
||||||
@@ -83,6 +92,7 @@ func (ui *app) showExecutableDialog() {
|
|||||||
IconData: iconData,
|
IconData: iconData,
|
||||||
Name: nameEntry.Text,
|
Name: nameEntry.Text,
|
||||||
Category: category.Selected,
|
Category: category.Selected,
|
||||||
|
Terminal: terminal.Checked,
|
||||||
Menu: menu.Checked,
|
Menu: menu.Checked,
|
||||||
Desktop: desktop.Checked,
|
Desktop: desktop.Checked,
|
||||||
}
|
}
|
||||||
@@ -106,6 +116,7 @@ func (ui *app) showExecutableDialog() {
|
|||||||
sectionTitle(T("section_location")),
|
sectionTitle(T("section_location")),
|
||||||
menu,
|
menu,
|
||||||
desktop,
|
desktop,
|
||||||
|
terminal,
|
||||||
hint,
|
hint,
|
||||||
container.NewHBox(logoBox(newLogo(), 20), spacer(),
|
container.NewHBox(logoBox(newLogo(), 20), spacer(),
|
||||||
newButton(T("cancel"), nil, T("tip_cancel"), widget.MediumImportance, func() { d.Hide() }),
|
newButton(T("cancel"), nil, T("tip_cancel"), widget.MediumImportance, func() { d.Hide() }),
|
||||||
|
|||||||
+47
-17
@@ -10,6 +10,7 @@ import (
|
|||||||
|
|
||||||
"fyne.io/fyne/v2/test"
|
"fyne.io/fyne/v2/test"
|
||||||
|
|
||||||
|
"lazyrun/internal/db"
|
||||||
"lazyrun/internal/icon"
|
"lazyrun/internal/icon"
|
||||||
"lazyrun/internal/store"
|
"lazyrun/internal/store"
|
||||||
)
|
)
|
||||||
@@ -121,26 +122,55 @@ func TestStringsCoverUsedKeys(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestMergeImported(t *testing.T) {
|
// TestRestoreImported memasang ulang entri cadangan: berkas dibuat di HOME saat ini
|
||||||
target := &store.Store{}
|
// dan entri yang sudah ada tidak ditimpa.
|
||||||
target.Add(store.Shortcut{Slug: "winbox", Name: "Winbox"})
|
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{
|
script := []byte("#!/bin/sh\necho halo\n")
|
||||||
{Slug: "winbox", Name: "Winbox lama"}, // sudah ada -> dilewati
|
items := []db.Backup{
|
||||||
{Slug: "figma", Name: "Figma"}, // baru
|
{Item: store.Shortcut{Slug: "lama", Name: "Deploy", Type: store.TypeExecutable, Kind: "script",
|
||||||
{Slug: "", Name: "Tanpa slug"}, // rusak -> dilewati
|
BinaryPath: "/komputer-lama/.local/bin/deploy", Targets: store.Targets{Menu: true}}, Binary: script},
|
||||||
{Slug: "kosong", Name: " "}, // tanpa nama -> dilewati
|
{Item: store.Shortcut{Name: "Figma", Type: store.TypeWebApp, Kind: "pwa",
|
||||||
{Slug: "deploy", Name: " deploy.sh "}, // nama dirapikan
|
URL: "https://figma.com", Browser: "/usr/bin/vivaldi", Targets: store.Targets{Menu: true}}},
|
||||||
})
|
}
|
||||||
|
|
||||||
if added != 2 || skipped != 3 {
|
a := test.NewApp()
|
||||||
t.Errorf("added=%d skipped=%d, mau 2 dan 3", added, skipped)
|
defer a.Quit()
|
||||||
|
ui, err := build(a, "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
}
|
}
|
||||||
if len(target.Items) != 3 {
|
ui.store.Items = nil
|
||||||
t.Errorf("jumlah item = %d", len(target.Items))
|
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" {
|
deploy, ok := ui.store.Get("deploy")
|
||||||
t.Errorf("nama item tidak dirapikan: %+v", target.Items)
|
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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"lazyrun/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestLaunchCommandPrefersDesktopFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
desk := filepath.Join(dir, "tool.desktop")
|
||||||
|
if err := os.WriteFile(desk, []byte("[Desktop Entry]\n"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sc := store.Shortcut{DesktopPath: desk, BinaryPath: "/bin/true", ExecArgs: []string{"/bin/true"}}
|
||||||
|
args, err := launchCommand(sc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := exec.LookPath("gio"); err != nil {
|
||||||
|
// tanpa gio: langsung jalankan perintahnya
|
||||||
|
if len(args) != 1 || args[0] != "/bin/true" {
|
||||||
|
t.Errorf("tanpa gio harus jalankan binary, dapat %v", args)
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(args) != 3 || args[1] != "launch" || args[2] != desk {
|
||||||
|
t.Errorf("harus lewat gio launch supaya Terminal=true dihormati, dapat %v", args)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLaunchCommandFallsBack(t *testing.T) {
|
||||||
|
// .desktop tidak ada -> pakai exec args
|
||||||
|
sc := store.Shortcut{DesktopPath: "/tidak/ada/x.desktop", ExecArgs: []string{"/usr/bin/wine", "/x/winbox"}}
|
||||||
|
args, err := launchCommand(sc)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(args) != 2 || args[0] != "/usr/bin/wine" {
|
||||||
|
t.Errorf("fallback exec args salah: %v", args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// web app tanpa exec args
|
||||||
|
web := store.Shortcut{Type: store.TypeWebApp, URL: "https://example.com"}
|
||||||
|
args, err = launchCommand(web)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(args) != 2 || args[0] != "xdg-open" {
|
||||||
|
t.Errorf("fallback web app salah: %v", args)
|
||||||
|
}
|
||||||
|
|
||||||
|
// tanpa apa-apa
|
||||||
|
if _, err := launchCommand(store.Shortcut{}); err == nil {
|
||||||
|
t.Error("shortcut kosong harus error, bukan jalan tanpa perintah")
|
||||||
|
}
|
||||||
|
}
|
||||||
+30
-11
@@ -1,7 +1,9 @@
|
|||||||
package ui
|
package ui
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"errors"
|
||||||
"net/url"
|
"net/url"
|
||||||
|
"os"
|
||||||
"os/exec"
|
"os/exec"
|
||||||
|
|
||||||
"fyne.io/fyne/v2"
|
"fyne.io/fyne/v2"
|
||||||
@@ -27,18 +29,15 @@ func (ui *app) openURL(raw string) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// launch menjalankan shortcut: pakai argv hasil install, fallback ke binary/URL.
|
// launch menjalankan shortcut.
|
||||||
|
//
|
||||||
|
// Kalau ada, `gio launch <file .desktop>` dipakai supaya aturannya sama dengan
|
||||||
|
// menu desktop: Terminal=true membuka jendela terminal (script yang butuh sudo
|
||||||
|
// atau input bisa jalan) dan Path= ikut dihormati.
|
||||||
func (ui *app) launch(sc store.Shortcut) {
|
func (ui *app) launch(sc store.Shortcut) {
|
||||||
args := sc.ExecArgs
|
args, err := launchCommand(sc)
|
||||||
if len(args) == 0 {
|
if err != nil {
|
||||||
switch {
|
dialog.ShowError(err, ui.win)
|
||||||
case sc.Type == store.TypeWebApp && sc.URL != "":
|
|
||||||
args = []string{"xdg-open", sc.URL}
|
|
||||||
case sc.BinaryPath != "":
|
|
||||||
args = []string{sc.BinaryPath}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if len(args) == 0 {
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -49,3 +48,23 @@ func (ui *app) launch(sc store.Shortcut) {
|
|||||||
}
|
}
|
||||||
go cmd.Wait() // supaya proses anak tidak jadi zombie
|
go cmd.Wait() // supaya proses anak tidak jadi zombie
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// launchCommand menyusun perintah untuk menjalankan shortcut.
|
||||||
|
func launchCommand(sc store.Shortcut) ([]string, error) {
|
||||||
|
if sc.DesktopPath != "" {
|
||||||
|
if gio, err := exec.LookPath("gio"); err == nil {
|
||||||
|
if _, err := os.Stat(sc.DesktopPath); err == nil {
|
||||||
|
return []string{gio, "launch", sc.DesktopPath}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
switch {
|
||||||
|
case len(sc.ExecArgs) > 0:
|
||||||
|
return sc.ExecArgs, nil
|
||||||
|
case sc.Type == store.TypeWebApp && sc.URL != "":
|
||||||
|
return []string{"xdg-open", sc.URL}, nil
|
||||||
|
case sc.BinaryPath != "":
|
||||||
|
return []string{sc.BinaryPath}, nil
|
||||||
|
}
|
||||||
|
return nil, errors.New("shortcut ini tidak punya perintah yang bisa dijalankan")
|
||||||
|
}
|
||||||
@@ -49,6 +49,7 @@ var tr = map[string][2]string{
|
|||||||
"section_location": {"Shortcut location", "Lokasi shortcut"},
|
"section_location": {"Shortcut location", "Lokasi shortcut"},
|
||||||
"check_menu": {"Application menu", "Menu aplikasi"},
|
"check_menu": {"Application menu", "Menu aplikasi"},
|
||||||
"check_desktop": {"Desktop", "Desktop"},
|
"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."},
|
"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."},
|
"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."},
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user