9 Commits
Author SHA1 Message Date
oxpoe 20d15efc3b ci: rilis via API Gitea (ganti action-gh-release) + fix deps build
CI / test (push) Failing after 1m36s
Release / release (push) Successful in 2m35s
2026-10-14 21:45:30 +07:00
oxpoe dfdab88b43 rebrand: app-ID io.github.lazyrun.LazyRun -> io.oxpoe.lazyrun.LazyRun
CI / test (push) Successful in 1m34s
Release / release (push) Failing after 2m38s
2026-10-14 21:37:59 +07:00
oxpoe e771c00446 rebrand: ganti sisa teks 'GitHub' -> 'Gitea' (UI, komentar, dokumen)
CI / test (push) Successful in 1m0s
2026-10-14 21:09:03 +07:00
oxpoe 651fd0fc3d rebrand: arahkan tautan, badge, label UI & cek-update ke Gitea (bukan GitHub)
CI / test (push) Successful in 1m34s
2026-10-14 21:08:03 +07:00
oxpoe 2bfed9bea3 docs: banner mirror (repo asli di Gitea)
CI / test (push) Successful in 5m0s
2026-10-14 12:30:38 +07:00
oxpoe 63869e9158 Deteksi aplikasi terminal (CLI/TUI) + perbaikan shortcut lazydocker
- internal/shortcut/terminal.go: RunsInTerminal() menebak dari library dinamis
  (tanpa lib GUI = aplikasi terminal); script selalu true, .exe wine false
- checkbox 'Run in a terminal window' kini muncul untuk semua executable, bukan
  hanya script, dan terisi otomatis saat berkas dipilih
- dialog edit: kalau entri lama belum menyalakan Terminal padahal aplikasinya
  CLI/TUI, muncul catatan kecil sebagai petunjuk
- tes: script/.exe/binary CLI (test binary)/binary GUI (helium)/lazydocker nyata
- kasus nyata: lazydocker & docker-dash terpasang Terminal=false -> TUI gagal
  ('open /dev/tty: no such device or address'); dengan Terminal=true TUI jalan
2026-10-13 10:34:05 +07:00
oxpoe 35e83dba09 Tombol Run menghormati Terminal=true (gio launch) + wrapper pakai script asli
- launch lewat 'gio launch <file .desktop>' kalau tersedia: Terminal=true membuka
  terminal, Path= dihormati, sama seperti menu desktop (script yang butuh sudo
  atau input sekarang bisa jalan dari tombol Run)
- wrapper script memakai berkas script asli kalau masih ada, jadi dirname $0
  menunjuk folder aslinya (laporan/hasil tidak nyasar ke ~/.local/bin);
  salinan .real tetap jadi cadangan
- tes: pemilihan perintah launch, wrapper memakai script asli + PATH
2026-10-13 09:36:02 +07:00
oxpoe 581cd07dba 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 + <slug>.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
2026-10-13 09:25:49 +07:00
oxpoe fdb503e152 docs: screenshot fitur baru (tombol edit, export/import, progress bar) 2026-10-13 09:19:17 +07:00
30 changed files with 1131 additions and 121 deletions

No files matched your search

+15 -22
View File
@@ -32,35 +32,28 @@ jobs:
- name: Build AppImage - name: Build AppImage
env: env:
# GH_USER/GH_REPO dipakai build.sh untuk menyisipkan info update zsync # build.sh menyisipkan info update zsync yang mengarah ke rilis di Gitea.
# (gh-releases-zsync|<user>|<repo>|latest|lazyrun-*.AppImage.zsync) GITEA_BASE: ${{ github.server_url }}/${{ github.repository }}
GH_USER: ${{ github.repository_owner }}
GH_REPO: ${{ github.event.repository.name }}
run: | run: |
VERSION="${GITHUB_REF_NAME#v}" ./build.sh VERSION="${GITHUB_REF_NAME#v}" ./build.sh
ls -lh dist/lazyrun-*.AppImage* ls -lh dist/lazyrun-*.AppImage*
- name: Create release - name: Create release
env: env:
GH_TOKEN: ${{ github.token }} TOKEN: ${{ github.token }}
run: | run: |
cat > /tmp/notes.md <<'NOTES' API="${GITHUB_API_URL:-${GITHUB_SERVER_URL}/api/v1}"
TAG="${GITHUB_REF_NAME}"
cat > /tmp/body.md <<'EOF'
Single-file AppImage for Linux x86_64. No root needed, everything stays in Single-file AppImage for Linux x86_64. No root needed, everything stays in
`~/.local/` and `~/.config/lazyrun/`. `~/.local/` and `~/.config/lazyrun/`.
```bash Already using LazyRun? It upgrades itself from this release page.
chmod +x lazyrun-*-x86_64.AppImage EOF
./lazyrun-*-x86_64.AppImage python3 -c 'import json,sys;print(json.dumps({"tag_name":sys.argv[1],"name":"LazyRun "+sys.argv[1],"body":open(sys.argv[2]).read()}))' "$TAG" /tmp/body.md > /tmp/rel.json
``` RID=$(curl -sf -X POST -H "Authorization: token $TOKEN" -H "Content-Type: application/json" "$API/repos/$GITHUB_REPOSITORY/releases" --data @/tmp/rel.json | python3 -c 'import json,sys;print(json.load(sys.stdin)["id"])')
echo "release id=$RID"
Already using LazyRun? It checks this release page on startup and offers to for f in dist/lazyrun-*.AppImage dist/lazyrun-*.AppImage.zsync; do
update itself (delta updates with `AppImageUpdate` when installed). [ -f "$f" ] || continue
curl -sf -X POST -H "Authorization: token $TOKEN" -F "attachment=@$f" "$API/repos/$GITHUB_REPOSITORY/releases/$RID/assets?name=$(basename "$f")" -o /dev/null
--- done
NOTES
gh release create "$GITHUB_REF_NAME" \
dist/lazyrun-*.AppImage \
dist/lazyrun-*.AppImage.zsync \
--title "LazyRun ${GITHUB_REF_NAME}" \
--notes-file /tmp/notes.md \
--generate-notes
+25 -7
View File
@@ -1,8 +1,10 @@
> 🪞 **Mirror.** Repo asli & pengembangan ada di **Gitea**: https://gitea.oxpoe.qzz.io/oxpoe/lazyrun
# LazyRun — lazy shortcut build # LazyRun — lazy shortcut build
[![CI](https://github.com/oxpoe/lazyrun/actions/workflows/ci.yml/badge.svg)](https://github.com/oxpoe/lazyrun/actions/workflows/ci.yml) [![CI](https://gitea.oxpoe.qzz.io/oxpoe/lazyrun/actions/workflows/ci.yml/badge.svg)](https://gitea.oxpoe.qzz.io/oxpoe/lazyrun/actions/workflows/ci.yml)
[![Release](https://img.shields.io/github/v/release/oxpoe/lazyrun?label=release)](https://github.com/oxpoe/lazyrun/releases/latest) [![Release](https://img.shields.io/badge/dynamic/json?url=https%3A%2F%2Fgitea.oxpoe.qzz.io%2Fapi%2Fv1%2Frepos%2Foxpoe%2Flazyrun%2Freleases%2Flatest&query=%24.tag_name&label=release&color=green&logo=gitea)](https://gitea.oxpoe.qzz.io/oxpoe/lazyrun/releases)
[![License](https://img.shields.io/github/license/oxpoe/lazyrun)](LICENSE) [![License](https://img.shields.io/badge/license-MIT-blue)](LICENSE)
![Platform](https://img.shields.io/badge/platform-Linux-blue) ![Platform](https://img.shields.io/badge/platform-Linux-blue)
![UI](https://img.shields.io/badge/UI-Fyne%20%2F%20Go-00ADD8) ![UI](https://img.shields.io/badge/UI-Fyne%20%2F%20Go-00ADD8)
@@ -20,14 +22,30 @@ 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>
- **Repo:** <https://github.com/oxpoe/lazyrun> <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://gitea.oxpoe.qzz.io/oxpoe/lazyrun>
- **Donate:** <https://donasi.oxpoe.qzz.io/> - **Donate:** <https://donasi.oxpoe.qzz.io/>
- **Executable shortcut** — picks an ELF/AppImage/script/Windows `.exe`, copies it to - **Executable shortcut** — picks an ELF/AppImage/script/Windows `.exe`, copies it to
`~/.local/bin/`, installs the icon, writes the `.desktop` entry. `~/.local/bin/`, installs the icon, writes the `.desktop` entry.
- **Terminal apps just work** — CLI/TUI programs (lazydocker, htop, a script) need
`Terminal=true`, while GUI apps must not open a terminal. LazyRun guesses this from
the libraries the binary links (no GUI library → terminal app), pre-fills the
*Run in a terminal window* checkbox, and keeps it editable.
- **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.
@@ -61,7 +79,7 @@ breathes while the app is open (the koala in `assets/logo.svg` is the mascot).*
Needs a desktop Linux with the usual GL/X11 or Wayland libraries (any KDE Plasma or Needs a desktop Linux with the usual GL/X11 or Wayland libraries (any KDE Plasma or
GNOME install has them). No root, nothing outside your home directory. GNOME install has them). No root, nothing outside your home directory.
Download the AppImage from [Releases](https://github.com/oxpoe/lazyrun/releases), then: Download the AppImage from [Releases](https://gitea.oxpoe.qzz.io/oxpoe/lazyrun/releases), then:
```bash ```bash
chmod +x lazyrun-*-x86_64.AppImage chmod +x lazyrun-*-x86_64.AppImage
@@ -110,7 +128,7 @@ start (only when running as an AppImage) and offers to install it:
*Check for Updates* in the footer runs the same flow manually. *Check for Updates* in the footer runs the same flow manually.
**Publishing a release** is just a tag — GitHub Actions builds and uploads **Publishing a release** is just a tag — Gitea Actions builds and uploads
everything (`.github/workflows/release.yml`): everything (`.github/workflows/release.yml`):
```bash ```bash
@@ -149,7 +167,7 @@ GH_USER=oxpoe GH_REPO=lazyrun ./build.sh
Desktop copy, import/export, dark mode. Desktop copy, import/export, dark mode.
- Firefox PWA via `--kiosk`, Flatpak/Snap targets, icon preview. - Firefox PWA via `--kiosk`, Flatpak/Snap targets, icon preview.
Known limitations are tracked as [issues](https://github.com/oxpoe/lazyrun/issues); Known limitations are tracked as [issues](https://gitea.oxpoe.qzz.io/oxpoe/lazyrun/issues);
the main ones: the main ones:
- the type label (`Go`, `Electron`, …) is detected when the shortcut is created, so a - the type label (`Go`, `Electron`, …) is detected when the shortcut is created, so a
+2 -6
View File
@@ -39,12 +39,8 @@ export ARCH
export LINUXDEPLOY_OUTPUT_APP_NAME=lazyrun export LINUXDEPLOY_OUTPUT_APP_NAME=lazyrun
export LINUXDEPLOY_OUTPUT_VERSION="$VERSION" export LINUXDEPLOY_OUTPUT_VERSION="$VERSION"
export LDAI_OUTPUT="$PWD/$OUT/lazyrun-$VERSION-$ARCH.AppImage" export LDAI_OUTPUT="$PWD/$OUT/lazyrun-$VERSION-$ARCH.AppImage"
if [[ -n "${GH_USER:-}" && -n "${GH_REPO:-}" ]]; then GITEA_BASE="${GITEA_BASE:-https://gitea.oxpoe.qzz.io/oxpoe/lazyrun}"
export LDAI_UPDATE_INFORMATION="gh-releases-zsync|$GH_USER|$GH_REPO|latest|lazyrun-*.AppImage.zsync" export LDAI_UPDATE_INFORMATION="zsync|$GITEA_BASE/releases/download/v$VERSION/lazyrun-$VERSION-$ARCH.AppImage.zsync"
else
echo "==> catatan: GH_USER/GH_REPO kosong, AppImage tanpa update info zsync"
export LDAI_NO_APPSTREAM=1
fi
# Library X11/Wayland/xkb/cursor tidak boleh ikut dibundel: kalau versinya beda # Library X11/Wayland/xkb/cursor tidak boleh ikut dibundel: kalau versinya beda
# dengan host, cursor & rendering jadi tidak native (mis. cursor theme desktop tidak terpakai). # dengan host, cursor & rendering jadi tidak native (mis. cursor theme desktop tidak terpakai).
+1 -1
View File
@@ -12,5 +12,5 @@ var version = "dev"
func main() { func main() {
cursor.Apply() // sebelum app dibuat: GLFW membaca env cursor saat init cursor.Apply() // sebelum app dibuat: GLFW membaca env cursor saat init
ui.Run(app.NewWithID("io.github.lazyrun.LazyRun"), version) ui.Run(app.NewWithID("io.oxpoe.lazyrun.LazyRun"), version)
} }
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
View File
@@ -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
View File
@@ -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 {
+140
View File
@@ -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)
}
}
+9 -1
View File
@@ -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
View File
@@ -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
+13 -5
View File
@@ -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,
+60
View File
@@ -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, "'", `'\''`) + "'"
}
+78
View File
@@ -267,3 +267,81 @@ 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)
}
}
func TestInstallCLIAppGetsTerminalEntry(t *testing.T) {
testEnv(t)
// pakai binary tes ini sendiri: ELF asli tanpa library GUI
self, err := os.Executable()
if err != nil {
t.Fatal(err)
}
src := self
// binary tanpa library GUI -> dianggap aplikasi terminal
terminal := RunsInTerminal(src)
if !terminal {
t.Fatal("binary CLI harus terdeteksi butuh terminal")
}
sc, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "TUI Tool", Menu: true, Terminal: terminal})
if err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(sc.DesktopPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "Terminal=true\n") {
t.Errorf("aplikasi CLI harus Terminal=true:\n%s", content)
}
}
func TestInstallGUIAppKeepsTerminalFalse(t *testing.T) {
home := testEnv(t)
src := fakeBinary(t, home, "gui-tool")
// simulasi aplikasi GUI: tidak butuh terminal
sc, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "GUI Tool", Menu: true, Terminal: false})
if err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(sc.DesktopPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "Terminal=false\n") {
t.Errorf("aplikasi GUI tidak boleh membuka terminal:\n%s", content)
}
}
+178
View File
@@ -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)
}
+166
View File
@@ -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)
}
}
+48
View File
@@ -0,0 +1,48 @@
package shortcut
import (
"debug/elf"
"strings"
)
// guiLibs adalah library yang menandakan aplikasi berjendela sendiri.
// Kalau binary tidak memakai satu pun dari ini, biasanya ia program terminal.
var guiLibs = []string{
"libX11", "libxcb", "libwayland-client", "libgtk", "libgdk",
"libQt5", "libQt6", "libSDL", "libGL", "libEGL", "libglfw", "libglib",
}
// RunsInTerminal menebak apakah program perlu jendela terminal.
//
// Script dan aplikasi baris perintah butuh Terminal=true di .desktop,
// sedangkan aplikasi GUI tidak (kalau dipaksa, jendela terminal ikut terbuka).
// Tebakannya dari daftar library dinamis: tanpa library GUI dianggap CLI.
func RunsInTerminal(path string) bool {
kind := DetectKind(path)
if kind == KindScript {
return true
}
if kind == KindWindows {
return false // .exe Windows (lewat wine) umumnya GUI
}
f, err := elf.Open(path)
if err != nil {
return false
}
defer f.Close()
libs, err := f.DynString(elf.DT_NEEDED)
if err != nil {
// binary statis (Go murni) tanpa dependency dinamis: kemungkinan CLI
return true
}
for _, lib := range libs {
for _, gui := range guiLibs {
if strings.Contains(lib, gui) {
return false
}
}
}
return true
}
+69
View File
@@ -0,0 +1,69 @@
package shortcut
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestRunsInTerminal(t *testing.T) {
dir := t.TempDir()
script := filepath.Join(dir, "tool.sh")
if err := os.WriteFile(script, []byte("#!/bin/sh\necho hai\n"), 0o755); err != nil {
t.Fatal(err)
}
if !RunsInTerminal(script) {
t.Error("script harus dianggap butuh terminal")
}
exe := filepath.Join(dir, "win.exe")
if err := os.WriteFile(exe, append([]byte("MZ\x90\x00"), make([]byte, 32)...), 0o755); err != nil {
t.Fatal(err)
}
if RunsInTerminal(exe) {
t.Error("aplikasi Windows (wine) tidak perlu terminal")
}
// binary CLI tanpa library GUI: pakai binary tes ini sendiri
self, err := os.Executable()
if err != nil {
t.Fatal(err)
}
if !RunsInTerminal(self) {
t.Error("binary CLI (tanpa lib GUI) harus dianggap butuh terminal")
}
// berkas tidak ada / bukan ELF
if RunsInTerminal(filepath.Join(dir, "tidak-ada")) {
t.Error("berkas tidak ada tidak boleh dianggap butuh terminal")
}
}
func TestRunsInTerminalOnGUIBinary(t *testing.T) {
// pakai aplikasi GUI nyata kalau ada di mesin ini
candidates := []string{"/opt/helium-browser-bin/helium", "/usr/bin/vivaldi"}
for _, path := range candidates {
if _, err := os.Stat(path); err != nil {
continue
}
if RunsInTerminal(path) {
t.Errorf("%s memakai lib GUI, tidak boleh dianggap butuh terminal", path)
}
return
}
t.Skip("tidak ada binary GUI untuk diuji")
}
func TestRunsInTerminalOnRealCLITool(t *testing.T) {
// lazydocker (TUI Go) adalah kasus nyata yang dilaporkan user
if _, err := os.Stat("/home/oxpoe/.local/bin/lazydocker"); err != nil {
t.Skip("lazydocker tidak ada di mesin ini")
}
if !RunsInTerminal("/home/oxpoe/.local/bin/lazydocker") {
t.Error("lazydocker adalah TUI, harus butuh terminal")
}
}
var _ = exec.LookPath
+4 -1
View File
@@ -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...)
+1
View File
@@ -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"`
+28 -7
View File
@@ -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,8 @@ 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)
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 +120,7 @@ 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))
terminal.SetChecked(shortcut.RunsInTerminal(path))
}) })
}) })
category := widget.NewSelect(categories, nil) category := widget.NewSelect(categories, nil)
@@ -185,6 +188,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,
}) })
@@ -208,6 +212,12 @@ func (ui *app) showEditDialog(sc store.Shortcut) {
}) })
hint := textLine(T("edit_hint"), 12, false, colorNameMuted) hint := textLine(T("edit_hint"), 12, false, colorNameMuted)
// beberapa entri lama belum menyalakan Terminal walau aplikasinya CLI/TUI
terminalSuggest := container.NewVBox()
if !sc.Terminal && shortcut.RunsInTerminal(sc.BinaryPath) {
terminalSuggest.Add(textLine(T("terminal_suggest"), 12, false, colorNameChipDesk))
}
content := container.NewVScroll(container.NewVBox( content := container.NewVScroll(container.NewVBox(
closeRow(func() { d.Hide() }), closeRow(func() { d.Hide() }),
widget.NewForm(items...), widget.NewForm(items...),
@@ -215,6 +225,8 @@ func (ui *app) showEditDialog(sc store.Shortcut) {
sectionTitle(T("section_location")), sectionTitle(T("section_location")),
menu, menu,
desktopChk, desktopChk,
terminal,
terminalSuggest,
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 +238,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
+6
View File
@@ -40,11 +40,15 @@ 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)
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))
// aplikasi terminal (TUI/CLI/script) perlu Terminal=true;
// tebakannya dari library yang dipakai binary
terminal.SetChecked(shortcut.RunsInTerminal(path))
if strings.TrimSpace(nameEntry.Text) == "" { if strings.TrimSpace(nameEntry.Text) == "" {
nameEntry.SetText(appNameFromFile(path)) nameEntry.SetText(appNameFromFile(path))
} }
@@ -83,6 +87,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 +111,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
View File
@@ -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)
} }
} }
+62
View File
@@ -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")
}
}
+32 -13
View File
@@ -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"
@@ -10,9 +12,9 @@ import (
"lazyrun/internal/store" "lazyrun/internal/store"
) )
// Link aplikasi: repo GitHub LazyRun dan halaman donasi. // Link aplikasi: repo Gitea LazyRun dan halaman donasi.
const ( const (
repoURL = "https://github.com/oxpoe/lazyrun" repoURL = "https://gitea.oxpoe.qzz.io/oxpoe/lazyrun"
donateURL = "https://donasi.oxpoe.qzz.io/" donateURL = "https://donasi.oxpoe.qzz.io/"
) )
@@ -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")
}
+3 -1
View File
@@ -49,6 +49,8 @@ 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 (CLI/TUI apps)", "Jalankan di jendela terminal (aplikasi CLI/TUI)"},
"terminal_suggest": {"This looks like a terminal app - tick the box above if it closes right away.", "Ini kelihatan aplikasi terminal - centang kotak di atas kalau menutup begitu saja."},
"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."},
@@ -66,7 +68,7 @@ var tr = map[string][2]string{
"need_target": {"Pick at least one location: menu or desktop.", "Pilih minimal satu lokasi: menu atau desktop."}, "need_target": {"Pick at least one location: menu or desktop.", "Pilih minimal satu lokasi: menu atau desktop."},
"no_browser": {"No supported browser found. Install Chrome, Chromium, Brave, Edge, Vivaldi, or Firefox first.", "Browser yang didukung tidak ditemukan. Install Chrome, Chromium, Brave, Edge, Vivaldi, atau Firefox dulu."}, "no_browser": {"No supported browser found. Install Chrome, Chromium, Brave, Edge, Vivaldi, or Firefox first.", "Browser yang didukung tidak ditemukan. Install Chrome, Chromium, Brave, Edge, Vivaldi, atau Firefox dulu."},
"github": {"GitHub", "GitHub"}, "github": {"Gitea", "Gitea"},
"donate": {"Donate", "Donasi"}, "donate": {"Donate", "Donasi"},
"check_updates": {"Check for Updates", "Cek Update"}, "check_updates": {"Check for Updates", "Cek Update"},
+2 -2
View File
@@ -57,7 +57,7 @@ func (ui *app) scheduleUpdateCheck() {
}() }()
} }
// checkForUpdate mengambil rilis terbaru dari GitHub. manual=false hanya // checkForUpdate mengambil rilis terbaru dari Gitea. manual=false hanya
// memberitahu kalau memang ada versi baru. // memberitahu kalau memang ada versi baru.
func (ui *app) checkForUpdate(manual bool) { func (ui *app) checkForUpdate(manual bool) {
if ui.version == "" || ui.version == "dev" { if ui.version == "" || ui.version == "dev" {
@@ -97,7 +97,7 @@ func (ui *app) markUpdatePending(rel update.Release) {
// showUpdateDialog menawarkan update: unduh sekarang, buka halaman rilis, atau nanti. // showUpdateDialog menawarkan update: unduh sekarang, buka halaman rilis, atau nanti.
func (ui *app) showUpdateDialog(rel update.Release) { func (ui *app) showUpdateDialog(rel update.Release) {
// catatan rilis GitHub berformat markdown; render supaya blok kode/daftar // catatan rilis Gitea berformat markdown; render supaya blok kode/daftar
// tidak tampil sebagai teks mentah // tidak tampil sebagai teks mentah
notes := widget.NewRichTextFromMarkdown(rel.Notes) notes := widget.NewRichTextFromMarkdown(rel.Notes)
+8 -8
View File
@@ -1,4 +1,4 @@
// Package update memeriksa rilis terbaru LazyRun di GitHub dan memasangnya. // Package update memeriksa rilis terbaru LazyRun di Gitea dan memasangnya.
package update package update
import ( import (
@@ -14,8 +14,8 @@ import (
) )
var ( var (
apiBase = "https://api.github.com/repos/oxpoe/lazyrun/releases" apiBase = "https://gitea.oxpoe.qzz.io/api/v1/repos/oxpoe/lazyrun/releases"
repoURL = "https://github.com/oxpoe/lazyrun" repoURL = "https://gitea.oxpoe.qzz.io/oxpoe/lazyrun"
) )
// LAZYRUN_UPDATE_API bisa dipakai untuk mengarahkan cek update ke server lain // LAZYRUN_UPDATE_API bisa dipakai untuk mengarahkan cek update ke server lain
@@ -38,22 +38,22 @@ func RepoURL() string { return repoURL }
type Release struct { type Release struct {
Tag string // tag asli, mis. "v1.1.0" Tag string // tag asli, mis. "v1.1.0"
Version string // versi tanpa awalan v, mis. "1.1.0" Version string // versi tanpa awalan v, mis. "1.1.0"
Notes string // catatan rilis (markdown GitHub) Notes string // catatan rilis (markdown Gitea)
PageURL string // halaman rilis di GitHub PageURL string // halaman rilis di Gitea
AppImage string // URL unduhan AppImage (kosong kalau tidak ada) AppImage string // URL unduhan AppImage (kosong kalau tidak ada)
Zsync string // URL berkas .zsync (opsional) Zsync string // URL berkas .zsync (opsional)
} }
var client = &http.Client{Timeout: 20 * time.Second} var client = &http.Client{Timeout: 20 * time.Second}
// Latest mengambil rilis terbaru dari GitHub. // Latest mengambil rilis terbaru dari Gitea.
func Latest() (Release, error) { func Latest() (Release, error) {
var rel Release var rel Release
req, err := http.NewRequest(http.MethodGet, apiBase+"/latest", nil) req, err := http.NewRequest(http.MethodGet, apiBase+"/latest", nil)
if err != nil { if err != nil {
return rel, err return rel, err
} }
req.Header.Set("Accept", "application/vnd.github+json") req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "LazyRun") req.Header.Set("User-Agent", "LazyRun")
resp, err := client.Do(req) resp, err := client.Do(req)
@@ -62,7 +62,7 @@ func Latest() (Release, error) {
} }
defer resp.Body.Close() defer resp.Body.Close()
if resp.StatusCode != http.StatusOK { if resp.StatusCode != http.StatusOK {
return rel, fmt.Errorf("GitHub menjawab %s", resp.Status) return rel, fmt.Errorf("Gitea menjawab %s", resp.Status)
} }
var payload struct { var payload struct {
+3 -3
View File
@@ -46,12 +46,12 @@ func TestNewerIgnoresSameAndOlder(t *testing.T) {
func TestLatestParsesRelease(t *testing.T) { func TestLatestParsesRelease(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("User-Agent") == "" { if r.Header.Get("User-Agent") == "" {
t.Error("GitHub butuh header User-Agent") t.Error("Gitea butuh header User-Agent")
} }
_, _ = w.Write([]byte(`{ _, _ = w.Write([]byte(`{
"tag_name": "v1.2.0", "tag_name": "v1.2.0",
"body": " - ikon online\n - animasi ", "body": " - ikon online\n - animasi ",
"html_url": "https://github.com/oxpoe/lazyrun/releases/tag/v1.2.0", "html_url": "https://gitea.oxpoe.qzz.io/oxpoe/lazyrun/releases/tag/v1.2.0",
"assets": [ "assets": [
{"name": "lazyrun-1.2.0-x86_64.AppImage.zsync", "browser_download_url": "https://contoh/zsync"}, {"name": "lazyrun-1.2.0-x86_64.AppImage.zsync", "browser_download_url": "https://contoh/zsync"},
{"name": "lazyrun-1.2.0-x86_64.AppImage", "browser_download_url": "https://contoh/appimage"} {"name": "lazyrun-1.2.0-x86_64.AppImage", "browser_download_url": "https://contoh/appimage"}
@@ -61,7 +61,7 @@ func TestLatestParsesRelease(t *testing.T) {
defer srv.Close() defer srv.Close()
SetEndpoint(srv.URL) SetEndpoint(srv.URL)
t.Cleanup(func() { SetEndpoint("https://api.github.com/repos/oxpoe/lazyrun/releases") }) t.Cleanup(func() { SetEndpoint("https://gitea.oxpoe.qzz.io/api/v1/repos/oxpoe/lazyrun/releases") })
rel, err := Latest() rel, err := Latest()
if err != nil { if err != nil {
+2 -2
View File
@@ -127,7 +127,7 @@ Setiap kali membuat shortcut, pengguna memilih lokasi via **checkbox** yang flek
- Bundle AppImage dengan dukungan **zsync**. - Bundle AppImage dengan dukungan **zsync**.
- Menu **"Check for Updates"** memanggil `AppImageUpdate`. - Menu **"Check for Updates"** memanggil `AppImageUpdate`.
- Update otomatis dari GitHub Releases. - Update otomatis dari Gitea Releases.
--- ---
@@ -336,7 +336,7 @@ ln -s usr/bin/lazyrun lazyrun.AppDir/AppRun
# 3. Build AppImage dengan dukungan update # 3. Build AppImage dengan dukungan update
./appimagetool-x86_64.AppImage \ ./appimagetool-x86_64.AppImage \
-u "gh-releases-zsync|user|lazyrun|latest|lazyrun-*.AppImage.zsync" \ -u "zsync|https://gitea.oxpoe.qzz.io/oxpoe/lazyrun/releases/download/<tag>/lazyrun-<ver>-x86_64.AppImage.zsync" \
lazyrun.AppDir lazyrun.AppDir
``` ```