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
env:
# GH_USER/GH_REPO dipakai build.sh untuk menyisipkan info update zsync
# (gh-releases-zsync|<user>|<repo>|latest|lazyrun-*.AppImage.zsync)
GH_USER: ${{ github.repository_owner }}
GH_REPO: ${{ github.event.repository.name }}
# build.sh menyisipkan info update zsync yang mengarah ke rilis di Gitea.
GITEA_BASE: ${{ github.server_url }}/${{ github.repository }}
run: |
VERSION="${GITHUB_REF_NAME#v}" ./build.sh
ls -lh dist/lazyrun-*.AppImage*
- name: Create release
env:
GH_TOKEN: ${{ github.token }}
TOKEN: ${{ github.token }}
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
`~/.local/` and `~/.config/lazyrun/`.
```bash
chmod +x lazyrun-*-x86_64.AppImage
./lazyrun-*-x86_64.AppImage
```
Already using LazyRun? It checks this release page on startup and offers to
update itself (delta updates with `AppImageUpdate` when installed).
---
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
Already using LazyRun? It upgrades itself from this release page.
EOF
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"
for f in dist/lazyrun-*.AppImage dist/lazyrun-*.AppImage.zsync; do
[ -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
+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
[![CI](https://github.com/oxpoe/lazyrun/actions/workflows/ci.yml/badge.svg)](https://github.com/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)
[![License](https://img.shields.io/github/license/oxpoe/lazyrun)](LICENSE)
[![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/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/badge/license-MIT-blue)](LICENSE)
![Platform](https://img.shields.io/badge/platform-Linux-blue)
![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">
</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/>
- **Executable shortcut** — picks an ELF/AppImage/script/Windows `.exe`, copies it to
`~/.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,
detected from the file: `Go`, `AppImage`, `Electron`, `Script`, `Windows`, `Binary`
(`.exe` files are launched through `wine`), or `PWA` for web apps.
- **Scripts run properly** — the desktop environment starts programs with your home as
the working directory and the session `PATH`, which breaks scripts that use relative
paths or tools from `~/.local/bin`. LazyRun therefore installs a tiny wrapper that
fixes both, 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
Chromium-based browsers (Chrome, Chromium, Brave, Edge, Vivaldi, Helium,
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
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
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.
**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`):
```bash
@@ -149,7 +167,7 @@ GH_USER=oxpoe GH_REPO=lazyrun ./build.sh
Desktop copy, import/export, dark mode.
- 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 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_VERSION="$VERSION"
export LDAI_OUTPUT="$PWD/$OUT/lazyrun-$VERSION-$ARCH.AppImage"
if [[ -n "${GH_USER:-}" && -n "${GH_REPO:-}" ]]; then
export LDAI_UPDATE_INFORMATION="gh-releases-zsync|$GH_USER|$GH_REPO|latest|lazyrun-*.AppImage.zsync"
else
echo "==> catatan: GH_USER/GH_REPO kosong, AppImage tanpa update info zsync"
export LDAI_NO_APPSTREAM=1
fi
GITEA_BASE="${GITEA_BASE:-https://gitea.oxpoe.qzz.io/oxpoe/lazyrun}"
export LDAI_UPDATE_INFORMATION="zsync|$GITEA_BASE/releases/download/v$VERSION/lazyrun-$VERSION-$ARCH.AppImage.zsync"
# 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).
+1 -1
View File
@@ -12,5 +12,5 @@ var version = "dev"
func main() {
cursor.Apply() // sebelum app dibuat: GLFW membaca env cursor saat init
ui.Run(app.NewWithID("io.github.lazyrun.LazyRun"), version)
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.
const FormatVersion = "1"
// Versi 1 hanya metadata; versi 2 menyertakan isi binary dan ikon.
const FormatVersion = "2"
// DefaultName adalah nama berkas cadangan yang disarankan.
const DefaultName = "lazyrun-shortcuts.lazy"
@@ -40,9 +41,12 @@ CREATE TABLE shortcuts (
icon_source TEXT,
desktop_path TEXT,
desktop_copy_path TEXT,
terminal INTEGER NOT NULL DEFAULT 0,
menu INTEGER NOT NULL,
desktop INTEGER NOT NULL,
installed_at TEXT
installed_at TEXT,
binary_data BLOB,
icon_data BLOB
);`
// Export menulis seluruh daftar shortcut ke berkas SQLite baru.
@@ -80,7 +84,8 @@ func Export(path string, appVersion string, items []store.Shortcut) error {
insert, err := tx.Prepare(`INSERT INTO shortcuts(
slug, name, type, kind, url, browser, exec_args, categories,
binary_path, icon_path, icon_source, desktop_path, desktop_copy_path,
menu, desktop, installed_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
terminal, menu, desktop, installed_at, binary_data, icon_data)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
if err != nil {
tx.Rollback()
return err
@@ -92,8 +97,8 @@ func Export(path string, appVersion string, items []store.Shortcut) error {
item.Slug, item.Name, item.Type, item.Kind, item.URL, item.Browser,
strings.Join(item.ExecArgs, "\x00"), item.Categories,
item.BinaryPath, item.IconPath, item.IconSource, item.DesktopPath,
item.DesktopCopyPath, boolToInt(item.Targets.Menu), boolToInt(item.Targets.Desktop),
item.InstalledAt.Format(time.RFC3339),
item.DesktopCopyPath, boolToInt(item.Terminal), boolToInt(item.Targets.Menu), boolToInt(item.Targets.Desktop),
item.InstalledAt.Format(time.RFC3339), readBlob(item.BinaryPath), readBlob(item.IconPath),
)
if err != nil {
tx.Rollback()
@@ -103,8 +108,34 @@ func Export(path string, appVersion string, items []store.Shortcut) error {
return tx.Commit()
}
// Backup adalah satu shortcut beserta isi berkasnya dari .lazy.
type Backup struct {
Item store.Shortcut
Binary []byte // isi binary/script (kalau ada)
Icon []byte // isi berkas ikon (kalau ada)
}
// maxBlobFile adalah batas ukuran berkas yang ikut disimpan ke cadangan.
const maxBlobFile = 512 << 20
// readBlob membaca isi berkas untuk disimpan ke cadangan; gagal/kebesaran = kosong.
func readBlob(path string) []byte {
if path == "" {
return nil
}
info, err := os.Stat(path)
if err != nil || info.IsDir() || info.Size() > maxBlobFile {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return nil
}
return data
}
// Import membaca daftar shortcut dari berkas .lazy.
func Import(path string) ([]store.Shortcut, error) {
func Import(path string) ([]Backup, error) {
if _, err := os.Stat(path); err != nil {
return nil, err
}
@@ -119,37 +150,45 @@ func Import(path string) ([]store.Shortcut, error) {
if err := conn.QueryRow("SELECT value FROM meta WHERE key = 'format'").Scan(&format); err != nil {
return nil, fmt.Errorf("bukan basis data LazyRun: %w", err)
}
if format != FormatVersion {
switch format {
case FormatVersion:
// versi sekarang: ada blobs
case "1":
// cadangan lama: hanya metadata, berkas dipulihkan dari sumber lain
default:
return nil, fmt.Errorf("versi basis data %s tidak didukung (mendukung %s)", format, FormatVersion)
}
rows, err := conn.Query(`SELECT slug, name, type, kind, url, browser, exec_args,
categories, binary_path, icon_path, icon_source, desktop_path,
desktop_copy_path, menu, desktop, installed_at FROM shortcuts ORDER BY name`)
desktop_copy_path, terminal, menu, desktop, installed_at,
binary_data, icon_data FROM shortcuts ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var items []store.Shortcut
var items []Backup
for rows.Next() {
var item store.Shortcut
var args, installed string
var menu, desktop int
var terminal, menu, desktop int
var binary, iconBlob []byte
if err := rows.Scan(&item.Slug, &item.Name, &item.Type, &item.Kind, &item.URL,
&item.Browser, &args, &item.Categories, &item.BinaryPath, &item.IconPath,
&item.IconSource, &item.DesktopPath, &item.DesktopCopyPath,
&menu, &desktop, &installed); err != nil {
&terminal, &menu, &desktop, &installed, &binary, &iconBlob); err != nil {
return nil, err
}
if args != "" {
item.ExecArgs = strings.Split(args, "\x00")
}
item.Terminal = terminal != 0
item.Targets = store.Targets{Menu: menu != 0, Desktop: desktop != 0}
if t, err := time.Parse(time.RFC3339, installed); err == nil {
item.InstalledAt = t
}
items = append(items, item)
items = append(items, Backup{Item: item, Binary: binary, Icon: iconBlob})
}
return items, rows.Err()
}
+60 -1
View File
@@ -62,7 +62,7 @@ func TestExportImportRoundtrip(t *testing.T) {
}
// urutan hasil impor berdasarkan nama: WhatsApp Web lalu Winbox
web, exe := got[0], got[1]
web, exe := got[0].Item, got[1].Item
if web.Slug != "whatsapp-web" || web.URL != "https://web.whatsapp.com" || !web.Targets.Menu || web.Targets.Desktop {
t.Errorf("data webapp tidak utuh: %+v", web)
}
@@ -77,6 +77,65 @@ func TestExportImportRoundtrip(t *testing.T) {
}
}
func TestExportStoresFileContents(t *testing.T) {
home := t.TempDir()
binary := filepath.Join(home, "app")
if err := os.WriteFile(binary, []byte("isi-binary-penting"), 0o755); err != nil {
t.Fatal(err)
}
iconFile := filepath.Join(home, "app.png")
iconBytes := append([]byte("\x89PNG\r\n\x1a\n"), []byte("ikon")...)
if err := os.WriteFile(iconFile, iconBytes, 0o644); err != nil {
t.Fatal(err)
}
items := []store.Shortcut{{
Slug: "app", Name: "App", Type: store.TypeExecutable, Kind: "binary",
BinaryPath: binary, IconPath: iconFile, DesktopPath: filepath.Join(home, "app.desktop"),
Terminal: true, Targets: store.Targets{Menu: true},
}}
path := filepath.Join(home, "db.lazy")
if err := Export(path, "1.2.0", items); err != nil {
t.Fatal(err)
}
got, err := Import(path)
if err != nil {
t.Fatal(err)
}
if string(got[0].Binary) != "isi-binary-penting" {
t.Errorf("isi binary tidak ikut tercadang: %q", got[0].Binary)
}
if string(got[0].Icon) != string(iconBytes) {
t.Errorf("isi ikon tidak ikut tercadang: %q", got[0].Icon)
}
if !got[0].Item.Terminal {
t.Error("flag terminal hilang")
}
}
func TestExportSkipsMissingFiles(t *testing.T) {
home := t.TempDir()
items := []store.Shortcut{{
Slug: "hilang", Name: "Hilang", Type: store.TypeExecutable,
BinaryPath: filepath.Join(home, "tidak-ada"), DesktopPath: filepath.Join(home, "x.desktop"),
Targets: store.Targets{Menu: true},
}}
path := filepath.Join(home, "db.lazy")
if err := Export(path, "1.2.0", items); err != nil {
t.Fatal(err)
}
got, err := Import(path)
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || len(got[0].Binary) != 0 {
t.Errorf("berkas hilang tidak boleh bikin ekspor gagal: %+v", got)
}
}
func TestExportOverwritesOldFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "db.lazy")
if err := os.WriteFile(path, []byte("bukan sqlite"), 0o644); err != nil {
+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
Icon string // path absolut atau nama ikon
Categories string // sudah diakhiri ";" atau kosong
Terminal bool // true = jalankan di terminal (untuk script interaktif)
WorkingDir string // folder kerja saat dijalankan (Path=), opsional
// NoDisplay menyembunyikan entry dari menu aplikasi. Dipakai kalau user
// hanya memilih Desktop: file tetap ada di applications/ sebagai sumber kebenaran.
NoDisplay bool
@@ -29,7 +31,10 @@ func (e Entry) Render() string {
if e.Icon != "" {
fmt.Fprintf(&b, "Icon=%s\n", escapeValue(e.Icon))
}
b.WriteString("Terminal=false\n")
fmt.Fprintf(&b, "Terminal=%t\n", e.Terminal)
if e.WorkingDir != "" {
fmt.Fprintf(&b, "Path=%s\n", escapeValue(e.WorkingDir))
}
if e.Categories != "" {
fmt.Fprintf(&b, "Categories=%s\n", escapeValue(e.Categories))
}
@@ -71,6 +76,9 @@ func escapeValue(s string) string {
return r.Replace(s)
}
// Slug mengembalikan nama berkas yang aman dari nama aplikasi (dipakai UI).
func Slug(name string) string { return slugify(name) }
// slugify membuat nama file yang aman dari nama aplikasi.
func slugify(name string) string {
var b strings.Builder
+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
if !samePath(src, binDst) {
if err := copyFile(src, binDst, 0o755); err != nil {
kind := DetectKind(src)
if err := installBinary(kind, src, binDst); err != nil {
return old, err
}
}
kind := DetectKind(binDst)
execArgs := []string{binDst}
if kind == KindWindows {
wine, err := exec.LookPath("wine")
@@ -73,24 +70,33 @@ func EditExecutable(old store.Shortcut, o ExecOptions) (store.Shortcut, error) {
}
}
entry := Entry{
Name: name,
ExecArgs: execArgs,
Icon: iconPath,
Categories: category,
Terminal: o.Terminal && kind != KindWindows,
NoDisplay: !o.Menu,
}
if kind == KindScript {
if _, err := os.Stat(filepath.Dir(src)); err == nil {
entry.WorkingDir = filepath.Dir(src)
}
}
updated := old
updated.Slug = sl
updated.Name = name
updated.Kind = kind
updated.ExecArgs = execArgs
updated.Categories = category
updated.Terminal = entry.Terminal
updated.BinaryPath = binDst
updated.IconPath = iconPath
updated.IconSource = iconSource
updated.Targets = store.Targets{Menu: o.Menu, Desktop: o.Desktop}
if err := rewriteShortcutFiles(old, &updated, Entry{
Name: name,
ExecArgs: execArgs,
Icon: iconPath,
Categories: category,
NoDisplay: !o.Menu,
}); err != nil {
if err := rewriteShortcutFiles(old, &updated, entry); err != nil {
return old, err
}
return updated, nil
+13 -5
View File
@@ -21,6 +21,7 @@ type ExecOptions struct {
IconURL string // opsional: URL ikon
IconData []byte // opsional: ikon yang sudah diunduh UI
Name string
Terminal bool // jalankan di terminal (script interaktif)
Category string
Menu bool
Desktop bool
@@ -48,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)
}
if !samePath(o.BinaryPath, binDst) {
if err := copyFile(o.BinaryPath, binDst, 0o755); err != nil {
kind := DetectKind(o.BinaryPath)
if err := installBinary(kind, o.BinaryPath, binDst); err != nil {
return sc, err
}
}
iconPath, iconSource, err := installIconFor(iconOptions{
IconPath: o.IconPath,
IconURL: o.IconURL,
@@ -66,8 +66,7 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) {
return sc, err
}
// jenis aplikasi dideteksi otomatis; .exe Windows dijalankan lewat wine
kind := DetectKind(o.BinaryPath)
// .exe Windows dijalankan lewat wine; script lewat wrapper (PATH & cwd)
execArgs := []string{binDst}
if kind == KindWindows {
wine, err := exec.LookPath("wine")
@@ -82,8 +81,16 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) {
ExecArgs: execArgs,
Icon: iconPath,
Categories: category(o.Category, "Utility;"),
Terminal: o.Terminal && kind != KindWindows,
NoDisplay: !o.Menu,
}
if kind == KindScript {
// desktop environment menjalankan dengan cwd = home, jadi folder asli
// script dipakai sebagai Path= kalau masih ada
if _, err := os.Stat(filepath.Dir(o.BinaryPath)); err == nil {
entry.WorkingDir = filepath.Dir(o.BinaryPath)
}
}
if err := WriteDesktop(deskDst, entry); err != nil {
return sc, err
}
@@ -95,6 +102,7 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) {
Kind: kind,
ExecArgs: entry.ExecArgs,
Categories: entry.Categories,
Terminal: entry.Terminal,
BinaryPath: binDst,
IconPath: iconPath,
IconSource: iconSource,
+60
View File
@@ -105,3 +105,63 @@ func desktopCopy(desktopPath, slug string) (string, error) {
}
return dst, copyFile(desktopPath, dst, 0o755)
}
// installBinary menyalin berkas target ke binDst.
//
// Script dapat perlakuan khusus: yang disalin adalah script aslinya (dengan
// akhiran .real) plus wrapper kecil. Desktop environment menjalankan shortcut
// dengan cwd = home dan PATH sesi, sehingga script yang memakai path relatif
// atau tool di ~/.local/bin gagal; wrapper yang membereskan keduanya.
func installBinary(kind, src, binDst string) error {
if kind == KindScript {
if err := copyFile(src, binDst+scriptSuffix, 0o755); err != nil {
return err
}
return writeScriptWrapper(binDst, binDst+scriptSuffix, 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)
}
}
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 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)
}
}
}
go RefreshCache()
return errors.Join(errs...)
+1
View File
@@ -30,6 +30,7 @@ type Shortcut struct {
Browser string `json:"browser,omitempty"`
ExecArgs []string `json:"exec_args,omitempty"`
Categories string `json:"categories,omitempty"`
Terminal bool `json:"terminal,omitempty"`
BinaryPath string `json:"binary_path,omitempty"`
IconPath string `json:"icon_path,omitempty"`
IconSource string `json:"icon_source,omitempty"`
+28 -7
View File
@@ -72,7 +72,7 @@ func (ui *app) importShortcuts() {
return
}
added, skipped := mergeImported(ui.store, items)
added, skipped := ui.restoreImported(items)
if err := ui.store.Save(); err != nil {
dialog.ShowError(err, ui.win)
return
@@ -98,6 +98,8 @@ func (ui *app) showEditDialog(sc store.Shortcut) {
menu.SetChecked(sc.Targets.Menu)
desktopChk := widget.NewCheck(T("check_desktop"), nil)
desktopChk.SetChecked(sc.Targets.Desktop)
terminal := widget.NewCheck(T("check_terminal"), nil)
terminal.SetChecked(sc.Terminal)
browseIcon := newButton(T("browse"), theme.FolderOpenIcon(), T("tip_browse"), widget.MediumImportance, func() {
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) {
binPath = path
binLabel.SetText(filepath.Base(path) + " · " + detectedKind(path))
terminal.SetChecked(shortcut.RunsInTerminal(path))
})
})
category := widget.NewSelect(categories, nil)
@@ -185,6 +188,7 @@ func (ui *app) showEditDialog(sc store.Shortcut) {
IconData: iconData,
Name: nameEntry.Text,
Category: category.Selected,
Terminal: terminal.Checked,
Menu: menu.Checked,
Desktop: desktopChk.Checked,
})
@@ -208,6 +212,12 @@ func (ui *app) showEditDialog(sc store.Shortcut) {
})
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(
closeRow(func() { d.Hide() }),
widget.NewForm(items...),
@@ -215,6 +225,8 @@ func (ui *app) showEditDialog(sc store.Shortcut) {
sectionTitle(T("section_location")),
menu,
desktopChk,
terminal,
terminalSuggest,
hint,
container.NewHBox(logoBox(newLogo(), 20), spacer(),
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)
}
// mergeImported menambahkan shortcut hasil impor tanpa menimpa yang sudah ada.
func mergeImported(target *store.Store, items []store.Shortcut) (added, skipped int) {
for _, item := range items {
// restoreImported memasang ulang setiap entri cadangan ke mesin ini
// (binary, ikon, .desktop) lalu menambahkannya ke daftar, tanpa menimpa
// shortcut yang sudah ada.
func (ui *app) restoreImported(items []db.Backup) (added, skipped int) {
for _, backup := range items {
item := backup.Item
item.Name = strings.TrimSpace(item.Name)
if item.Name == "" || item.Slug == "" {
if item.Name == "" {
skipped++
continue
}
if _, exists := target.Get(item.Slug); exists {
if _, exists := ui.store.Get(shortcut.Slug(item.Name)); exists {
skipped++
continue
}
target.Add(item)
restored, err := shortcut.Restore(item, backup.Binary, backup.Icon)
if err != nil {
skipped++
continue
}
ui.store.Add(restored)
added++
}
return added, skipped
+6
View File
@@ -40,11 +40,15 @@ func (ui *app) showExecutableDialog() {
menu.SetChecked(true)
desktop := widget.NewCheck(T("check_desktop"), nil)
desktop.SetChecked(true)
terminal := widget.NewCheck(T("check_terminal"), nil)
browseBin := newButton(T("browse"), theme.FolderOpenIcon(), T("tip_browse"), widget.MediumImportance, func() {
pickFile(ui.win, nil, func(path string) {
binPath = path
binLabel.SetText(filepath.Base(path) + " · " + detectedKind(path))
// aplikasi terminal (TUI/CLI/script) perlu Terminal=true;
// tebakannya dari library yang dipakai binary
terminal.SetChecked(shortcut.RunsInTerminal(path))
if strings.TrimSpace(nameEntry.Text) == "" {
nameEntry.SetText(appNameFromFile(path))
}
@@ -83,6 +87,7 @@ func (ui *app) showExecutableDialog() {
IconData: iconData,
Name: nameEntry.Text,
Category: category.Selected,
Terminal: terminal.Checked,
Menu: menu.Checked,
Desktop: desktop.Checked,
}
@@ -106,6 +111,7 @@ func (ui *app) showExecutableDialog() {
sectionTitle(T("section_location")),
menu,
desktop,
terminal,
hint,
container.NewHBox(logoBox(newLogo(), 20), spacer(),
newButton(T("cancel"), nil, T("tip_cancel"), widget.MediumImportance, func() { d.Hide() }),
+47 -17
View File
@@ -10,6 +10,7 @@ import (
"fyne.io/fyne/v2/test"
"lazyrun/internal/db"
"lazyrun/internal/icon"
"lazyrun/internal/store"
)
@@ -121,26 +122,55 @@ func TestStringsCoverUsedKeys(t *testing.T) {
}
}
func TestMergeImported(t *testing.T) {
target := &store.Store{}
target.Add(store.Shortcut{Slug: "winbox", Name: "Winbox"})
// TestRestoreImported memasang ulang entri cadangan: berkas dibuat di HOME saat ini
// dan entri yang sudah ada tidak ditimpa.
func TestRestoreImported(t *testing.T) {
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_DATA_HOME", "")
t.Setenv("PATH", "")
added, skipped := mergeImported(target, []store.Shortcut{
{Slug: "winbox", Name: "Winbox lama"}, // sudah ada -> dilewati
{Slug: "figma", Name: "Figma"}, // baru
{Slug: "", Name: "Tanpa slug"}, // rusak -> dilewati
{Slug: "kosong", Name: " "}, // tanpa nama -> dilewati
{Slug: "deploy", Name: " deploy.sh "}, // nama dirapikan
})
script := []byte("#!/bin/sh\necho halo\n")
items := []db.Backup{
{Item: store.Shortcut{Slug: "lama", Name: "Deploy", Type: store.TypeExecutable, Kind: "script",
BinaryPath: "/komputer-lama/.local/bin/deploy", Targets: store.Targets{Menu: true}}, Binary: script},
{Item: store.Shortcut{Name: "Figma", Type: store.TypeWebApp, Kind: "pwa",
URL: "https://figma.com", Browser: "/usr/bin/vivaldi", Targets: store.Targets{Menu: true}}},
}
if added != 2 || skipped != 3 {
t.Errorf("added=%d skipped=%d, mau 2 dan 3", added, skipped)
a := test.NewApp()
defer a.Quit()
ui, err := build(a, "test")
if err != nil {
t.Fatal(err)
}
if len(target.Items) != 3 {
t.Errorf("jumlah item = %d", len(target.Items))
ui.store.Items = nil
ui.store.Add(store.Shortcut{Slug: "figma", Name: "Figma"}) // sudah ada -> dilewati
added, skipped := ui.restoreImported(items)
if added != 1 || skipped != 1 {
t.Fatalf("added=%d skipped=%d, mau 1 dan 1", added, skipped)
}
dep, ok := target.Get("deploy")
if !ok || target.Items[dep].Name != "deploy.sh" {
t.Errorf("nama item tidak dirapikan: %+v", target.Items)
deploy, ok := ui.store.Get("deploy")
if !ok {
t.Fatal("shortcut hasil restore tidak masuk daftar")
}
sc := ui.store.Items[deploy]
want := filepath.Join(home, ".local", "bin", "deploy")
if sc.BinaryPath != want {
t.Errorf("binary = %s, mau %s", sc.BinaryPath, want)
}
if _, err := os.Stat(want + ".real"); err != nil {
t.Errorf("script asli (.real) tidak ditulis: %v", err)
}
if _, err := os.Stat(sc.DesktopPath); err != nil {
t.Errorf(".desktop tidak dibuat: %v", err)
}
if sc.IconPath == "" {
t.Error("ikon tidak dipasang")
}
if sc.Kind != "script" {
t.Errorf("kind hasil restore = %q", sc.Kind)
}
}
+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
import (
"errors"
"net/url"
"os"
"os/exec"
"fyne.io/fyne/v2"
@@ -10,9 +12,9 @@ import (
"lazyrun/internal/store"
)
// Link aplikasi: repo GitHub LazyRun dan halaman donasi.
// Link aplikasi: repo Gitea LazyRun dan halaman donasi.
const (
repoURL = "https://github.com/oxpoe/lazyrun"
repoURL = "https://gitea.oxpoe.qzz.io/oxpoe/lazyrun"
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) {
args := sc.ExecArgs
if len(args) == 0 {
switch {
case sc.Type == store.TypeWebApp && sc.URL != "":
args = []string{"xdg-open", sc.URL}
case sc.BinaryPath != "":
args = []string{sc.BinaryPath}
}
}
if len(args) == 0 {
args, err := launchCommand(sc)
if err != nil {
dialog.ShowError(err, ui.win)
return
}
@@ -49,3 +48,23 @@ func (ui *app) launch(sc store.Shortcut) {
}
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"},
"check_menu": {"Application menu", "Menu aplikasi"},
"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."},
"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."},
"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"},
"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.
func (ui *app) checkForUpdate(manual bool) {
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.
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
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
import (
@@ -14,8 +14,8 @@ import (
)
var (
apiBase = "https://api.github.com/repos/oxpoe/lazyrun/releases"
repoURL = "https://github.com/oxpoe/lazyrun"
apiBase = "https://gitea.oxpoe.qzz.io/api/v1/repos/oxpoe/lazyrun/releases"
repoURL = "https://gitea.oxpoe.qzz.io/oxpoe/lazyrun"
)
// LAZYRUN_UPDATE_API bisa dipakai untuk mengarahkan cek update ke server lain
@@ -38,22 +38,22 @@ func RepoURL() string { return repoURL }
type Release struct {
Tag string // tag asli, mis. "v1.1.0"
Version string // versi tanpa awalan v, mis. "1.1.0"
Notes string // catatan rilis (markdown GitHub)
PageURL string // halaman rilis di GitHub
Notes string // catatan rilis (markdown Gitea)
PageURL string // halaman rilis di Gitea
AppImage string // URL unduhan AppImage (kosong kalau tidak ada)
Zsync string // URL berkas .zsync (opsional)
}
var client = &http.Client{Timeout: 20 * time.Second}
// Latest mengambil rilis terbaru dari GitHub.
// Latest mengambil rilis terbaru dari Gitea.
func Latest() (Release, error) {
var rel Release
req, err := http.NewRequest(http.MethodGet, apiBase+"/latest", nil)
if err != nil {
return rel, err
}
req.Header.Set("Accept", "application/vnd.github+json")
req.Header.Set("Accept", "application/json")
req.Header.Set("User-Agent", "LazyRun")
resp, err := client.Do(req)
@@ -62,7 +62,7 @@ func Latest() (Release, error) {
}
defer resp.Body.Close()
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 {
+3 -3
View File
@@ -46,12 +46,12 @@ func TestNewerIgnoresSameAndOlder(t *testing.T) {
func TestLatestParsesRelease(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("User-Agent") == "" {
t.Error("GitHub butuh header User-Agent")
t.Error("Gitea butuh header User-Agent")
}
_, _ = w.Write([]byte(`{
"tag_name": "v1.2.0",
"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": [
{"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"}
@@ -61,7 +61,7 @@ func TestLatestParsesRelease(t *testing.T) {
defer srv.Close()
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()
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**.
- 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
./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
```