13 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
oxpoe deaa30689c Tooltip hint, loading bar, edit shortcut, export/import .lazy
- Keterangan tombol saat hover: baris hint di footer window dan di dalam dialog
  (bukan popup: overlay Fyne menelan klik pertama, tombol jadi perlu 2x klik)
- Dialog proses sekarang pakai progress bar berjalan (bukan spinner diam)
- Tombol edit per shortcut: ganti binary/URL, ikon (file/online), nama, kategori,
  dan lokasi; berkas .desktop + salinan Desktop ditulis ulang, berkas lama dibuang,
  jenis aplikasi dideteksi ulang, binary lama dihapus kalau nama berubah
- Export/Import daftar shortcut ke berkas SQLite .lazy (driver murni Go modernc),
  skema + meta format versi, impor tidak menimpa shortcut yang sudah ada
- Tes: detect kind, edit (rename/ganti binary/ikon/target), db roundtrip, merge impor,
  tooltip (hint muncul/hilang + klik tetap sampai ke tombol), semua paket hijau
- README: bagian backup + hint tombol
2026-10-13 09:12:39 +07:00
oxpoe c1aed1e286 Tema surreal + logo koala baru
- assets/logo.svg (dream sky koala) + assets/icon.png hasil render 512x512
  (skrip render: oksvg/rasterx, sparkle di-inline karena <use> tidak didukung)
- tema: langit malam ungu (#140E24), kartu violet, primer dusk pink, aksen
  amber<->magenta yang berdenyut; varian terang jadi lavender lembut
- ikon monokrom dari Iconify ikut diwarnai dusk pink (#c25c9e), bukan indigo lama
- screenshot README diperbarui
2026-10-13 09:02:09 +07:00
oxpoe 25a979f24c docs: catat batasan yang diketahui di README 2026-10-13 08:46:45 +07:00
oxpoe ab6dafbe5b Update: catatan rilis dirender sebagai markdown (bukan teks mentah)
Catatan rilis GitHub memakai markdown; blok kode dan daftar sekarang tampil rapi
di dialog update (widget.NewRichTextFromMarkdown).
2026-10-13 08:38:29 +07:00
48 changed files with 2582 additions and 165 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
+42 -9
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)
@@ -10,6 +12,9 @@ Desktop app (Go + Fyne) that creates application shortcuts on Linux in a few cli
from an executable file, or from a web URL as a PWA. Ships as a single-file from an executable file, or from a web URL as a PWA. Ships as a single-file
AppImage, no terminal needed. AppImage, no terminal needed.
*Dream-sky theme: night violet, dusk pink and a moon-coloured accent that subtly
breathes while the app is open (the koala in `assets/logo.svg` is the mascot).*
![LazyRun main window](docs/main-dark.png) ![LazyRun main window](docs/main-dark.png)
<p align="center"> <p align="center">
@@ -17,14 +22,30 @@ AppImage, no terminal needed.
<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.
@@ -58,7 +79,7 @@ AppImage, no terminal needed.
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
@@ -107,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
@@ -142,9 +163,21 @@ GH_USER=oxpoe GH_REPO=lazyrun ./build.sh
## Roadmap ## Roadmap
- v1.1 — edit existing shortcuts, detect binaries in `$PATH`, optional symlink - Edit existing shortcuts, detect binaries in `$PATH`, optional symlink instead of a
instead of a Desktop copy, import/export, dark mode. Desktop copy, import/export, dark mode.
- v1.2 — 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://gitea.oxpoe.qzz.io/oxpoe/lazyrun/issues);
the main ones:
- the type label (`Go`, `Electron`, …) is detected when the shortcut is created, so a
binary replaced by hand keeps its old label until it is reinstalled;
- the update check runs while the app is open (startup + every 4 hours), there is no
background service, and updates are offered, never forced;
- online icon search uses Iconify plus the icons a website itself provides
(manifest / touch icon / favicon) — there is no image-search scraping;
- `Electron` detection uses the `resources/*.asar` marker, so Chromium-based apps
(Vivaldi, Helium, …) show up as `Binary`.
## Disclaimer / Catatan ## Disclaimer / Catatan
BIN
View File
Binary file not shown.

Before

Width:  |  Height:  |  Size: 27 KiB

After

Width:  |  Height:  |  Size: 102 KiB

+85
View File
@@ -0,0 +1,85 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 256 272" width="256" height="272" role="img" aria-label="lazyrun koala logo">
<title>lazyrun</title>
<defs>
<!-- dream sky -->
<linearGradient id="sky" x1="0" y1="0" x2="0.3" y2="1">
<stop offset="0" stop-color="#241247"/>
<stop offset="0.45" stop-color="#6a3d9a"/>
<stop offset="0.78" stop-color="#c25c9e"/>
<stop offset="1" stop-color="#ff9a76"/>
</linearGradient>
<!-- soft sun halo -->
<radialGradient id="sun" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#fff3c4" stop-opacity="0.95"/>
<stop offset="0.6" stop-color="#ffd166" stop-opacity="0.45"/>
<stop offset="1" stop-color="#ffd166" stop-opacity="0"/>
</radialGradient>
<!-- glow behind koala -->
<radialGradient id="glow" cx="0.5" cy="0.5" r="0.5">
<stop offset="0" stop-color="#ffffff" stop-opacity="0.28"/>
<stop offset="1" stop-color="#ffffff" stop-opacity="0"/>
</radialGradient>
</defs>
<!-- surreal background -->
<rect x="0" y="0" width="256" height="272" rx="46" fill="url(#sky)"/>
<circle cx="128" cy="148" r="96" fill="url(#sun)"/>
<circle cx="66" cy="196" r="52" fill="url(#glow)"/>
<circle cx="196" cy="96" r="44" fill="url(#glow)"/>
<!-- floating ringed planet -->
<g opacity="0.95">
<circle cx="212" cy="62" r="13" fill="#ffd166"/>
<ellipse cx="212" cy="62" rx="24" ry="7" fill="none" stroke="#fff3c4" stroke-width="2" opacity="0.8" transform="rotate(-20 212 62)"/>
</g>
<!-- floating orbs -->
<circle cx="34" cy="150" r="7" fill="#ffd166" opacity="0.85"/>
<circle cx="224" cy="150" r="4" fill="#ffffff" opacity="0.6"/>
<circle cx="46" cy="96" r="3.5" fill="#ffffff" opacity="0.55"/>
<circle cx="206" cy="188" r="5" fill="#c25c9e" opacity="0.7"/>
<!-- sparkles -->
<g fill="#ffffff" opacity="0.85">
<path d="M44 37 C44.9 43.3 46.7 45.1 53 46 C46.7 46.9 44.9 48.7 44 55 C43.1 48.7 41.3 46.9 35 46 C41.3 45.1 43.1 43.3 44 37 Z"/>
<path d="M208 23 C208.7 27.9 210.1 29.3 215 30 C210.1 30.7 208.7 32.1 208 37 C207.3 32.1 205.9 30.7 201 30 C205.9 29.3 207.3 27.9 208 23 Z"/>
<path d="M30 202 C30.6 206.2 31.8 207.4 36 208 C31.8 208.6 30.6 209.8 30 214 C29.4 209.8 28.2 208.6 24 208 C28.2 207.4 29.4 206.2 30 202 Z"/>
<path d="M226 206 C226.8 211.6 228.4 213.2 234 214 C228.4 214.8 226.8 216.4 226 222 C225.2 216.4 223.6 214.8 218 214 C223.6 213.2 225.2 211.6 226 206 Z"/>
<path d="M128 16.5 C128.55 20.35 129.65 21.45 133.5 22 C129.65 22.55 128.55 23.65 128 27.5 C127.45 23.65 126.35 22.55 122.5 22 C126.35 21.45 127.45 20.35 128 16.5 Z"/>
<path d="M96 189.5 C96.45 192.65 97.35 193.55 100.5 194 C97.35 194.45 96.45 195.35 96 198.5 C95.55 195.35 94.65 194.45 91.5 194 C94.65 193.55 95.55 192.65 96 189.5 Z"/>
</g>
<!-- koala -->
<g>
<!-- ears -->
<circle cx="74" cy="72" r="30" fill="#9aa5b1"/>
<circle cx="74" cy="72" r="16" fill="#d9dee3"/>
<circle cx="182" cy="72" r="30" fill="#9aa5b1"/>
<circle cx="182" cy="72" r="16" fill="#d9dee3"/>
<!-- head -->
<ellipse cx="128" cy="118" rx="68" ry="62" fill="#a7b2bd"/>
<!-- eyes -->
<circle cx="104" cy="110" r="8" fill="#2b2f36"/>
<circle cx="101.5" cy="107" r="2.6" fill="#ffffff"/>
<circle cx="152" cy="110" r="8" fill="#2b2f36"/>
<circle cx="149.5" cy="107" r="2.6" fill="#ffffff"/>
<!-- nose -->
<ellipse cx="128" cy="140" rx="16" ry="11" fill="#2b2f36"/>
<ellipse cx="123" cy="137" rx="4" ry="3" fill="#4b5058"/>
<!-- mouth -->
<path d="M128 151 v6 q0 6 -9 6" fill="none" stroke="#2b2f36" stroke-width="3" stroke-linecap="round"/>
<path d="M128 157 q0 6 9 6" fill="none" stroke="#2b2f36" stroke-width="3" stroke-linecap="round"/>
</g>
<!-- wordmark -->
<text x="128" y="240" text-anchor="middle"
font-family="'Segoe UI', Roboto, Helvetica, Arial, sans-serif"
font-size="42" font-weight="700" letter-spacing="-1"
fill="#ffffff">lazyrun</text>
</svg>

After

Width:  |  Height:  |  Size: 3.9 KiB

+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.

Before

Width:  |  Height:  |  Size: 82 KiB

After

Width:  |  Height:  |  Size: 92 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 67 KiB

After

Width:  |  Height:  |  Size: 77 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 80 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 59 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 54 KiB

+11 -2
View File
@@ -1,6 +1,6 @@
module lazyrun module lazyrun
go 1.24 go 1.26.0
require ( require (
fyne.io/fyne/v2 v2.8.2 fyne.io/fyne/v2 v2.8.2
@@ -16,6 +16,7 @@ require (
github.com/anthonynsimon/bild v0.14.0 // indirect github.com/anthonynsimon/bild v0.14.0 // indirect
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 // indirect github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 // indirect
github.com/fyne-io/glfw-js v0.4.0 // indirect github.com/fyne-io/glfw-js v0.4.0 // indirect
@@ -25,20 +26,28 @@ require (
github.com/go-text/render v0.2.1 // indirect github.com/go-text/render v0.2.1 // indirect
github.com/go-text/typesetting v0.3.5 // indirect github.com/go-text/typesetting v0.3.5 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hack-pad/go-indexeddb v0.3.2 // indirect github.com/hack-pad/go-indexeddb v0.3.2 // indirect
github.com/hack-pad/safejs v0.1.0 // indirect github.com/hack-pad/safejs v0.1.0 // indirect
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rymdport/portal v0.4.2 // indirect github.com/rymdport/portal v0.4.2 // indirect
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
github.com/stretchr/testify v1.11.1 // indirect github.com/stretchr/testify v1.11.1 // indirect
github.com/yuin/goldmark v1.8.2 // indirect github.com/yuin/goldmark v1.8.2 // indirect
golang.org/x/net v0.35.0 // indirect golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.22.0 // indirect golang.org/x/text v0.22.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.77.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
modernc.org/sqlite v1.60.1 // indirect
) )
+20
View File
@@ -12,6 +12,8 @@ github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdoh
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 h1:0kdPD/GEntpWmZEK5Zu/xE6Tr37jYCVDf9QP8lA/QK8= github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 h1:0kdPD/GEntpWmZEK5Zu/xE6Tr37jYCVDf9QP8lA/QK8=
@@ -34,6 +36,8 @@ github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc h1:8FGo2
github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o= github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8= github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
@@ -44,8 +48,12 @@ github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe9
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
@@ -54,6 +62,8 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWb
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU= github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
@@ -70,6 +80,8 @@ golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -77,3 +89,11 @@ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8X
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
+201
View File
@@ -0,0 +1,201 @@
// Package db menyimpan daftar shortcut LazyRun dalam satu berkas SQLite (.lazy)
// supaya bisa dipindah antar komputer.
package db
import (
"database/sql"
"errors"
"fmt"
"os"
"strings"
"time"
_ "modernc.org/sqlite"
"lazyrun/internal/store"
)
// FormatVersion dipakai untuk mengenali berkas .lazy buatan LazyRun.
// 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"
const schema = `
CREATE TABLE meta (
key TEXT PRIMARY KEY,
value TEXT NOT NULL
);
CREATE TABLE shortcuts (
slug TEXT PRIMARY KEY,
name TEXT NOT NULL,
type TEXT NOT NULL,
kind TEXT,
url TEXT,
browser TEXT,
exec_args TEXT,
categories TEXT,
binary_path TEXT,
icon_path TEXT,
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,
binary_data BLOB,
icon_data BLOB
);`
// Export menulis seluruh daftar shortcut ke berkas SQLite baru.
func Export(path string, appVersion string, items []store.Shortcut) error {
if path == "" {
return errors.New("lokasi berkas ekspor kosong")
}
_ = os.Remove(path) // mulai dari berkas baru
conn, err := sql.Open("sqlite", path)
if err != nil {
return err
}
defer conn.Close()
if _, err := conn.Exec(schema); err != nil {
return err
}
meta := map[string]string{
"format": FormatVersion,
"app_version": appVersion,
"exported_at": time.Now().UTC().Format(time.RFC3339),
}
for key, value := range meta {
if _, err := conn.Exec("INSERT INTO meta(key, value) VALUES(?, ?)", key, value); err != nil {
return err
}
}
tx, err := conn.Begin()
if err != nil {
return err
}
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,
terminal, menu, desktop, installed_at, binary_data, icon_data)
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
if err != nil {
tx.Rollback()
return err
}
defer insert.Close()
for _, item := range items {
_, err := insert.Exec(
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.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()
return err
}
}
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) ([]Backup, error) {
if _, err := os.Stat(path); err != nil {
return nil, err
}
conn, err := sql.Open("sqlite", path)
if err != nil {
return nil, err
}
defer conn.Close()
var format string
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)
}
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, 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 []Backup
for rows.Next() {
var item store.Shortcut
var args, installed string
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,
&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, Backup{Item: item, Binary: binary, Icon: iconBlob})
}
return items, rows.Err()
}
func boolToInt(v bool) int {
if v {
return 1
}
return 0
}
+170
View File
@@ -0,0 +1,170 @@
package db
import (
"os"
"path/filepath"
"testing"
"time"
"lazyrun/internal/store"
)
func sampleItems() []store.Shortcut {
return []store.Shortcut{
{
Slug: "winbox",
Name: "Winbox",
Type: store.TypeExecutable,
Kind: "windows",
ExecArgs: []string{"/usr/bin/wine", "/home/u/.local/bin/winbox"},
Categories: "Network;",
BinaryPath: "/home/u/.local/bin/winbox",
IconPath: "/home/u/.local/share/icons/winbox.png",
IconSource: "search:winbox",
DesktopPath: "/home/u/.local/share/applications/winbox.desktop",
Targets: store.Targets{Menu: true, Desktop: true},
InstalledAt: time.Date(2026, 10, 13, 10, 0, 0, 0, time.UTC),
},
{
Slug: "whatsapp-web",
Name: "WhatsApp Web",
Type: store.TypeWebApp,
Kind: "pwa",
URL: "https://web.whatsapp.com",
Browser: "flatpak run org.chromium.Chromium",
ExecArgs: []string{"flatpak", "run", "org.chromium.Chromium", "--app=https://web.whatsapp.com"},
Categories: "Network;WebBrowser;",
DesktopPath: "/home/u/.local/share/applications/whatsapp-web.desktop",
DesktopCopyPath: "/home/u/Desktop/whatsapp-web.desktop",
Targets: store.Targets{Menu: true},
InstalledAt: time.Date(2026, 10, 13, 10, 5, 0, 0, time.UTC),
},
}
}
func TestExportImportRoundtrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "db.lazy")
items := sampleItems()
if err := Export(path, "1.2.0", items); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(path); err != nil {
t.Fatalf("berkas ekspor tidak ada: %v", err)
}
got, err := Import(path)
if err != nil {
t.Fatal(err)
}
if len(got) != len(items) {
t.Fatalf("impor %d item, mau %d", len(got), len(items))
}
// urutan hasil impor berdasarkan nama: WhatsApp Web lalu Winbox
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)
}
if exe.Slug != "winbox" || exe.Kind != "windows" || len(exe.ExecArgs) != 2 || exe.ExecArgs[0] != "/usr/bin/wine" {
t.Errorf("data executable tidak utuh: %+v", exe)
}
if exe.Categories != "Network;" || !exe.Targets.Desktop {
t.Errorf("kategori/target hilang: %+v", exe)
}
if !exe.InstalledAt.Equal(items[0].InstalledAt) {
t.Errorf("installed_at berubah: %v", exe.InstalledAt)
}
}
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 {
t.Fatal(err)
}
if err := Export(path, "1.2.0", sampleItems()[:1]); err != nil {
t.Fatal(err)
}
items, err := Import(path)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 {
t.Errorf("isi setelah timpa: %d item", len(items))
}
}
func TestImportRejectsForeignFile(t *testing.T) {
dir := t.TempDir()
plain := filepath.Join(dir, "catatan.lazy")
if err := os.WriteFile(plain, []byte("halo dunia"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := Import(plain); err == nil {
t.Error("berkas bukan SQLite harus ditolak")
}
if _, err := Import(filepath.Join(dir, "tidak-ada.lazy")); err == nil {
t.Error("berkas tidak ada harus error")
}
}
+3 -2
View File
@@ -11,8 +11,9 @@ import (
"github.com/srwiley/rasterx" "github.com/srwiley/rasterx"
) )
// currentColor diisi ke SVG yang memakai fill="currentColor". // currentColor diisi ke SVG yang memakai fill="currentColor"
const currentColor = "#4f46e5" // (warna brand: dusk pink, terlihat di background terang maupun gelap).
const currentColor = "#c25c9e"
// Rasterize mengubah SVG menjadi PNG persegi `size`x`size`. // Rasterize mengubah SVG menjadi PNG persegi `size`x`size`.
// //
+3 -3
View File
@@ -76,9 +76,9 @@ func TestRasterizeColorsCurrentColorIcons(t *testing.T) {
if a == 0 { if a == 0 {
t.Fatal("ikon currentColor tidak terwarnai (transparan)") t.Fatal("ikon currentColor tidak terwarnai (transparan)")
} }
// brand indigo #4F46E5: biru > merah > hijau // brand dusk pink #C25C9E: merah > biru > hijau
if !(b > r && r > g) { if !(r > b && b > g) {
t.Errorf("warna pengganti bukan brand indigo: r=%d g=%d b=%d", r>>8, g>>8, b>>8) t.Errorf("warna pengganti bukan warna brand: r=%d g=%d b=%d", r>>8, g>>8, b>>8)
} }
} }
+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
+212
View File
@@ -0,0 +1,212 @@
package shortcut
import (
"errors"
"fmt"
"os"
"os/exec"
"path/filepath"
"strings"
"lazyrun/internal/store"
"lazyrun/internal/xdg"
)
// EditExecutable memperbarui shortcut executable yang sudah ada:
// binary bisa diganti (disalin ulang), begitu juga ikon, nama, dan lokasinya.
func EditExecutable(old store.Shortcut, o ExecOptions) (store.Shortcut, error) {
name := strings.TrimSpace(o.Name)
if err := validate(name, o.Menu, o.Desktop); err != nil {
return old, err
}
src := o.BinaryPath
if src == "" {
src = old.BinaryPath
}
if err := checkRunnable(src); err != nil {
return old, fmt.Errorf("%s: %w", filepath.Base(src), err)
}
sl := slugify(name)
binDst := filepath.Join(xdg.BinDir(), sl)
if sl != old.Slug {
if _, err := os.Stat(filepath.Join(xdg.ApplicationsDir(), sl+".desktop")); err == nil {
return old, fmt.Errorf("shortcut %q already exists", name)
}
}
kind := DetectKind(src)
if err := installBinary(kind, src, binDst); err != nil {
return old, err
}
execArgs := []string{binDst}
if kind == KindWindows {
wine, err := exec.LookPath("wine")
if err != nil {
return old, errors.New("this is a Windows .exe; install wine first so the shortcut can run it")
}
execArgs = []string{wine, binDst}
}
iconPath, iconSource := old.IconPath, old.IconSource
if o.IconPath != "" || o.IconURL != "" || len(o.IconData) > 0 {
path, source, err := installIconFor(iconOptions{
IconPath: o.IconPath, IconURL: o.IconURL, IconData: o.IconData,
Query: name, Slug: sl,
})
if err != nil {
return old, err
}
iconPath, iconSource = path, source
}
category := category(o.Category, "Utility;")
if o.Category == "" {
category = old.Categories
if category == "" {
category = "Utility;"
}
}
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); err != nil {
return old, err
}
return updated, nil
}
// EditWebApp memperbarui shortcut web app (URL, browser, ikon, nama, lokasi).
func EditWebApp(old store.Shortcut, o WebOptions) (store.Shortcut, error) {
name := strings.TrimSpace(o.Name)
if err := validate(name, o.Menu, o.Desktop); err != nil {
return old, err
}
if len(o.Browser.Exec) == 0 {
return old, errors.New("no browser selected")
}
pageURL, err := normalizeURL(o.URL)
if err != nil {
return old, err
}
sl := slugify(name)
if sl != old.Slug {
if _, err := os.Stat(filepath.Join(xdg.ApplicationsDir(), sl+".desktop")); err == nil {
return old, fmt.Errorf("shortcut %q already exists", name)
}
}
iconPath, iconSource := old.IconPath, old.IconSource
if o.IconPath != "" || o.IconURL != "" || len(o.IconData) > 0 {
path, source, err := installIconFor(iconOptions{
IconPath: o.IconPath, IconURL: o.IconURL, IconData: o.IconData,
PageURL: pageURL, Query: name, Slug: sl,
})
if err != nil {
return old, err
}
iconPath, iconSource = path, source
}
appArgs := o.Browser.Exec[1:]
if o.Browser.Firefox {
appArgs = append(appArgs, "--new-window", pageURL)
} else {
appArgs = append(appArgs, "--app="+pageURL)
}
execArgs := append([]string{o.Browser.Exec[0]}, appArgs...)
updated := old
updated.Slug = sl
updated.Name = name
updated.Kind = KindPWA
updated.URL = pageURL
updated.Browser = strings.Join(o.Browser.Exec, " ")
updated.ExecArgs = execArgs
updated.IconPath = iconPath
updated.IconSource = iconSource
updated.Targets = store.Targets{Menu: o.Menu, Desktop: o.Desktop}
if err := rewriteShortcutFiles(old, &updated, Entry{
Name: name,
ExecArgs: execArgs,
Icon: iconPath,
Categories: old.Categories,
NoDisplay: !o.Menu,
}); err != nil {
return old, err
}
return updated, nil
}
// rewriteShortcutFiles menulis ulang .desktop (dan salinannya) untuk shortcut
// yang diedit, termasuk membereskan berkas lama kalau nama/slug berubah.
// updated diisi lewat pointer karena path-nya berubah di sini.
func rewriteShortcutFiles(old store.Shortcut, updated *store.Shortcut, entry Entry) error {
if entry.Categories == "" {
entry.Categories = "Utility;"
}
newDesk := filepath.Join(xdg.ApplicationsDir(), updated.Slug+".desktop")
if err := WriteDesktop(newDesk, entry); err != nil {
return err
}
updated.DesktopPath = newDesk
// salinan Desktop: dibuat kalau diminta, dihapus kalau tidak
newCopy := ""
if updated.Targets.Desktop {
newCopy = filepath.Join(xdg.DesktopDir(), updated.Slug+".desktop")
if err := copyFile(newDesk, newCopy, 0o755); err != nil {
return err
}
}
updated.DesktopCopyPath = newCopy
// bersihkan berkas lama kalau lokasinya berubah
if old.DesktopPath != "" && old.DesktopPath != newDesk {
_ = os.Remove(old.DesktopPath)
}
if old.DesktopCopyPath != "" && old.DesktopCopyPath != newCopy {
_ = os.Remove(old.DesktopCopyPath)
}
if old.IconPath != "" && old.IconPath != updated.IconPath {
_ = os.Remove(old.IconPath)
}
// binary lama dibuang kalau nama berkasnya berubah
if old.Type == store.TypeExecutable && old.BinaryPath != "" && old.BinaryPath != updated.BinaryPath {
if !samePath(old.BinaryPath, updated.BinaryPath) {
_ = os.Remove(old.BinaryPath)
}
}
go RefreshCache()
return nil
}
+248
View File
@@ -0,0 +1,248 @@
package shortcut
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
)
func TestEditExecutableRenamesAndMovesFiles(t *testing.T) {
home := testEnv(t)
src := fakeBinary(t, home, "old-tool")
sc, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Old Tool", Category: "Utility", Menu: true, Desktop: true})
if err != nil {
t.Fatal(err)
}
updated, err := EditExecutable(sc, ExecOptions{
BinaryPath: sc.BinaryPath, // binary tidak diganti
Name: "New Tool",
Category: "Network",
Menu: true,
Desktop: false, // salinan Desktop dimatikan
})
if err != nil {
t.Fatal(err)
}
if updated.Slug != "new-tool" || updated.BinaryPath != filepath.Join(home, ".local", "bin", "new-tool") {
t.Errorf("slug/binary baru salah: %+v", updated)
}
if _, err := os.Stat(updated.DesktopPath); err != nil {
t.Errorf(".desktop baru tidak ada: %v", err)
}
if !strings.HasSuffix(updated.DesktopPath, "new-tool.desktop") {
t.Errorf("nama .desktop = %s", updated.DesktopPath)
}
if updated.DesktopCopyPath != "" {
t.Errorf("salinan desktop harus kosong, dapat %s", updated.DesktopCopyPath)
}
// berkas lama sudah dibersihkan
if _, err := os.Stat(sc.DesktopPath); !os.IsNotExist(err) {
t.Errorf(".desktop lama masih ada")
}
if _, err := os.Stat(sc.DesktopCopyPath); !os.IsNotExist(err) {
t.Errorf("salinan desktop lama masih ada")
}
content, err := os.ReadFile(updated.DesktopPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "Name=New Tool\n") || !strings.Contains(string(content), "Categories=Network;\n") {
t.Errorf("isi .desktop salah:\n%s", content)
}
if !strings.Contains(string(content), "Exec="+updated.BinaryPath+"\n") {
t.Errorf("Exec tidak menunjuk binary baru:\n%s", content)
}
}
func TestEditExecutableReplacesBinaryAndKind(t *testing.T) {
home := testEnv(t)
first := fakeBinary(t, home, "first")
second := filepath.Join(home, "second.sh")
if err := os.WriteFile(second, []byte("#!/bin/sh\necho hai\n"), 0o755); err != nil {
t.Fatal(err)
}
sc, err := InstallExecutable(ExecOptions{BinaryPath: first, Name: "Tool", Menu: true})
if err != nil {
t.Fatal(err)
}
if sc.Kind != KindBinary {
t.Fatalf("kind awal = %q", sc.Kind)
}
updated, err := EditExecutable(sc, ExecOptions{BinaryPath: second, Name: "Tool", Menu: true})
if err != nil {
t.Fatal(err)
}
if updated.Kind != KindScript {
t.Errorf("kind setelah ganti binary = %q, mau %q", updated.Kind, KindScript)
}
if updated.IconPath == "" {
t.Error("ikon harus tetap ada")
}
}
func TestEditExecutableKeepsIconUnlessChanged(t *testing.T) {
home := testEnv(t)
src := fakeBinary(t, home, "tool")
iconFile := filepath.Join(home, "ikon.png")
if err := os.WriteFile(iconFile, append(pngMagic, []byte("ikon")...), 0o644); err != nil {
t.Fatal(err)
}
sc, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Tool", Menu: true, IconPath: iconFile})
if err != nil {
t.Fatal(err)
}
before := sc.IconPath
updated, err := EditExecutable(sc, ExecOptions{BinaryPath: sc.BinaryPath, Name: "Tool", Menu: true})
if err != nil {
t.Fatal(err)
}
if updated.IconPath != before {
t.Errorf("ikon berubah tanpa diminta: %s -> %s", before, updated.IconPath)
}
// ganti ikon dengan berkas lain: nama berkasnya sama (slug sama),
// jadi yang diperiksa isinya
beforeBytes, err := os.ReadFile(before)
if err != nil {
t.Fatal(err)
}
other := filepath.Join(home, "lain.png")
if err := os.WriteFile(other, append(pngMagic, []byte("ikon lain yang lebih panjang")...), 0o644); err != nil {
t.Fatal(err)
}
updated, err = EditExecutable(sc, ExecOptions{BinaryPath: sc.BinaryPath, Name: "Tool", Menu: true, IconPath: other})
if err != nil {
t.Fatal(err)
}
afterBytes, err := os.ReadFile(updated.IconPath)
if err != nil {
t.Fatal(err)
}
if string(beforeBytes) == string(afterBytes) {
t.Error("isi ikon tidak ikut berubah saat diganti")
}
}
func TestEditWebAppChangesURLAndTargets(t *testing.T) {
testEnv(t)
sc, err := InstallWebApp(WebOptions{
URL: "https://lama.example",
Name: "Contoh",
Browser: BrowserArg{Name: "Chromium", Exec: []string{"/usr/bin/chromium"}},
Menu: true,
})
if err != nil {
t.Fatal(err)
}
updated, err := EditWebApp(sc, WebOptions{
URL: "lama.example/baru",
Name: "Contoh Baru",
Browser: BrowserArg{Name: "Firefox", Exec: []string{"/usr/bin/firefox"}, Firefox: true},
Menu: false,
Desktop: true,
})
if err != nil {
t.Fatal(err)
}
if updated.URL != "https://lama.example/baru" {
t.Errorf("URL = %s", updated.URL)
}
if updated.DesktopCopyPath == "" {
t.Error("salinan desktop harus dibuat")
}
content, err := os.ReadFile(updated.DesktopPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "Exec=/usr/bin/firefox --new-window https://lama.example/baru\n") {
t.Errorf("Exec salah:\n%s", content)
}
if !strings.Contains(string(content), "NoDisplay=true") {
t.Errorf("menu dimatikan harus NoDisplay:\n%s", content)
}
}
func TestEditExecutableRejectsDuplicateName(t *testing.T) {
home := testEnv(t)
a := fakeBinary(t, home, "app-a")
b := fakeBinary(t, home, "app-b")
if _, err := InstallExecutable(ExecOptions{BinaryPath: a, Name: "App A", Menu: true}); err != nil {
t.Fatal(err)
}
scB, err := InstallExecutable(ExecOptions{BinaryPath: b, Name: "App B", Menu: true})
if err != nil {
t.Fatal(err)
}
if _, err := EditExecutable(scB, ExecOptions{BinaryPath: scB.BinaryPath, Name: "App A", Menu: true}); err == nil {
t.Error("nama yang sudah dipakai harus ditolak")
}
}
func TestEditWebAppRefreshesIconFromSite(t *testing.T) {
testEnv(t)
page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/":
_, _ = w.Write([]byte(`<html><head><link rel="icon" type="image/png" sizes="64x64" href="/baru.png"></head></html>`))
case "/paksa.png":
_, _ = w.Write(append(pngMagic, []byte("ikon paksa yang panjang")...))
default:
_, _ = w.Write(append(pngMagic, []byte("ikon lama")...))
}
}))
defer page.Close()
sc, err := InstallWebApp(WebOptions{
URL: page.URL,
Name: "Situs",
Browser: BrowserArg{Name: "Chromium", Exec: []string{"/usr/bin/chromium"}},
Menu: true,
})
if err != nil {
t.Fatal(err)
}
// ikon sebelum diedit (harus dibaca sebelum berkasnya ditimpa)
before, err := os.ReadFile(sc.IconPath)
if err != nil {
t.Fatal(err)
}
// ubah URL ke halaman lain -> ikon lama harus diganti sumbernya
updated, err := EditWebApp(sc, WebOptions{
URL: page.URL + "/halaman",
Name: "Situs",
Browser: BrowserArg{Name: "Chromium", Exec: []string{"/usr/bin/chromium"}},
IconURL: page.URL + "/paksa.png",
Menu: true,
})
if err != nil {
t.Fatal(err)
}
if updated.IconSource != page.URL+"/paksa.png" {
t.Errorf("icon_source = %q", updated.IconSource)
}
after, err := os.ReadFile(updated.IconPath)
if err != nil {
t.Fatal(err)
}
if string(before) == string(after) {
t.Error("isi ikon harus diperbarui dari sumber baru")
}
}
+16 -6
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,10 +49,9 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) {
return sc, fmt.Errorf("%s already exists, rename the app or remove that file first", binDst) 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,
@@ -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
} }
@@ -94,6 +101,8 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) {
Type: store.TypeExecutable, Type: store.TypeExecutable,
Kind: kind, Kind: kind,
ExecArgs: entry.ExecArgs, ExecArgs: entry.ExecArgs,
Categories: entry.Categories,
Terminal: entry.Terminal,
BinaryPath: binDst, BinaryPath: binDst,
IconPath: iconPath, IconPath: iconPath,
IconSource: iconSource, IconSource: iconSource,
@@ -189,6 +198,7 @@ func InstallWebApp(o WebOptions) (store.Shortcut, error) {
Type: store.TypeWebApp, Type: store.TypeWebApp,
Kind: KindPWA, Kind: KindPWA,
URL: pageURL, URL: pageURL,
Categories: entry.Categories,
Browser: strings.Join(o.Browser.Exec, " "), Browser: strings.Join(o.Browser.Exec, " "),
ExecArgs: entry.ExecArgs, ExecArgs: entry.ExecArgs,
IconPath: iconPath, IconPath: iconPath,
+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
+5 -2
View File
@@ -31,8 +31,11 @@ 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
errs = append(errs, err) for _, path := range []string{sc.BinaryPath, sc.BinaryPath + scriptSuffix} {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
errs = append(errs, err)
}
} }
} }
+2
View File
@@ -29,6 +29,8 @@ type Shortcut struct {
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
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"`
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"`
+1 -1
View File
@@ -17,7 +17,7 @@ import (
// pulseGradient membuat garis aksen bergerak bolak-balik antara indigo dan cyan. // pulseGradient membuat garis aksen bergerak bolak-balik antara indigo dan cyan.
func pulseGradient(bar *canvas.LinearGradient) { func pulseGradient(bar *canvas.LinearGradient) {
anim := canvas.NewColorRGBAAnimation(brandIndigo, brandCyan, 3*time.Second, func(c color.Color) { anim := canvas.NewColorRGBAAnimation(brandAmber, brandMagenta, 3*time.Second, func(c color.Color) {
bar.EndColor = c bar.EndColor = c
canvas.Refresh(bar) canvas.Refresh(bar)
}) })
+298
View File
@@ -0,0 +1,298 @@
package ui
import (
"fmt"
"path/filepath"
"strings"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/storage"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
"lazyrun/internal/browser"
"lazyrun/internal/db"
"lazyrun/internal/icon"
"lazyrun/internal/shortcut"
"lazyrun/internal/store"
)
var lazyFilter = storage.NewExtensionFileFilter([]string{".lazy"})
// exportShortcuts menyimpan daftar shortcut ke satu berkas SQLite (.lazy),
// mis. "db.lazy", supaya bisa dipindah ke komputer lain.
func (ui *app) exportShortcuts() {
save := dialog.NewFileSave(func(w fyne.URIWriteCloser, err error) {
if err != nil {
dialog.ShowError(err, ui.win)
return
}
if w == nil {
return
}
path := w.URI().Path()
w.Close() // berkas kosong dari dialog akan diganti basis data
if !strings.HasSuffix(strings.ToLower(path), ".lazy") {
path += ".lazy"
}
if err := db.Export(path, ui.version, ui.store.Items); err != nil {
dialog.ShowError(err, ui.win)
return
}
dialog.ShowInformation(T("export"), fmt.Sprintf(T("export_done"), len(ui.store.Items), path), ui.win)
}, ui.win)
save.SetFileName(db.DefaultName)
save.SetFilter(lazyFilter)
save.Show()
}
// importShortcuts menambahkan shortcut dari berkas .lazy tanpa menimpa yang sudah ada.
func (ui *app) importShortcuts() {
open := dialog.NewFileOpen(func(r fyne.URIReadCloser, err error) {
if err != nil {
dialog.ShowError(err, ui.win)
return
}
if r == nil {
return
}
path := r.URI().Path()
r.Close()
items, err := db.Import(path)
if err != nil {
dialog.ShowError(err, ui.win)
return
}
if len(items) == 0 {
dialog.ShowInformation(T("import"), T("import_empty"), ui.win)
return
}
added, skipped := ui.restoreImported(items)
if err := ui.store.Save(); err != nil {
dialog.ShowError(err, ui.win)
return
}
ui.reload()
dialog.ShowInformation(T("import"), fmt.Sprintf(T("import_done"), added, skipped), ui.win)
}, ui.win)
open.SetFilter(lazyFilter)
open.Show()
}
// showEditDialog mengubah shortcut yang sudah ada: binary/URL, ikon, nama, lokasi.
func (ui *app) showEditDialog(sc store.Shortcut) {
isWeb := sc.Type == store.TypeWebApp
var iconPath, iconURL, binPath string
var iconData []byte
nameEntry := widget.NewEntry()
nameEntry.SetText(sc.Name)
iconLabel := muted(iconLabelOf(sc))
menu := widget.NewCheck(T("check_menu"), nil)
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) {
iconPath, iconURL, iconData = path, "", nil
iconLabel.SetText(filepath.Base(path))
})
})
onlineIcon := newButton(T("online"), theme.SearchIcon(), T("tip_online"), widget.MediumImportance, func() {
ui.showIconSearch(nameEntry.Text, func(result icon.Result, data []byte) {
iconURL, iconPath, iconData = result.URL, "", data
iconLabel.SetText(result.Name)
})
})
// widget khusus executable
binLabel := muted(sc.BinaryPath)
browseBin := newButton(T("browse"), theme.FolderOpenIcon(), T("tip_browse"), widget.MediumImportance, func() {
pickFile(ui.win, nil, func(path string) {
binPath = path
binLabel.SetText(filepath.Base(path) + " · " + detectedKind(path))
terminal.SetChecked(shortcut.RunsInTerminal(path))
})
})
category := widget.NewSelect(categories, nil)
category.SetSelected(categoryOf(sc))
// widget khusus web app
urlEntry := widget.NewEntry()
urlEntry.SetText(sc.URL)
browserSel := widget.NewSelect(browserNames(ui.browsers), nil)
browserSel.SetSelected(currentBrowser(ui.browsers, sc.Browser))
iconItem := widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil,
container.NewHBox(onlineIcon, browseIcon), iconLabel))
items := []*widget.FormItem{}
if isWeb {
items = append(items,
widget.NewFormItem(T("field_url"), urlEntry),
widget.NewFormItem(T("field_name"), nameEntry),
iconItem,
widget.NewFormItem(T("field_browser"), browserSel),
)
} else {
items = append(items,
widget.NewFormItem(T("field_binary"), container.NewBorder(nil, nil, nil, browseBin, binLabel)),
widget.NewFormItem(T("field_name"), nameEntry),
iconItem,
widget.NewFormItem(T("field_category"), category),
)
}
var d *dialog.CustomDialog
save := newButton(T("save"), theme.DocumentSaveIcon(), T("tip_save"), widget.HighImportance, func() {
switch {
case strings.TrimSpace(nameEntry.Text) == "":
dialog.ShowInformation(T("err_title"), T("need_name"), ui.win)
return
case !menu.Checked && !desktopChk.Checked:
dialog.ShowInformation(T("err_title"), T("need_target"), ui.win)
return
}
ui.install(func() (store.Shortcut, error) {
if isWeb {
b := ui.browsers[indexOf(ui.browsers, browserSel.Selected)]
return shortcut.EditWebApp(sc, shortcut.WebOptions{
URL: urlEntry.Text,
Name: nameEntry.Text,
Browser: shortcut.BrowserArg{Name: b.Name, Exec: b.Exec, Firefox: b.Kind == browser.Firefox},
IconPath: iconPath,
IconURL: iconURL,
IconData: iconData,
Menu: menu.Checked,
Desktop: desktopChk.Checked,
})
}
path := binPath
if path == "" {
path = sc.BinaryPath
}
return shortcut.EditExecutable(sc, shortcut.ExecOptions{
BinaryPath: path,
IconPath: iconPath,
IconURL: iconURL,
IconData: iconData,
Name: nameEntry.Text,
Category: category.Selected,
Terminal: terminal.Checked,
Menu: menu.Checked,
Desktop: desktopChk.Checked,
})
}, func(updated store.Shortcut, err error) {
if err != nil {
dialog.ShowError(err, ui.win)
return
}
ui.store.Add(updated)
if err := ui.store.Save(); err != nil {
dialog.ShowError(err, ui.win)
return
}
d.Hide()
ui.reload()
if card, ok := ui.cards[updated.Slug]; ok {
card.flash(0)
}
dialog.ShowInformation(T("edit_title"), fmt.Sprintf(T("edit_saved"), updated.Name), ui.win)
})
})
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...),
widget.NewSeparator(),
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() }),
save),
))
d = dialog.NewCustomWithoutButtons(T("edit_title"), content, ui.win)
d.SetOnClosed(pushHintTarget(hint))
showDialog(d, ui.win, 560, 520)
}
// 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 == "" {
skipped++
continue
}
if _, exists := ui.store.Get(shortcut.Slug(item.Name)); exists {
skipped++
continue
}
restored, err := shortcut.Restore(item, backup.Binary, backup.Icon)
if err != nil {
skipped++
continue
}
ui.store.Add(restored)
added++
}
return added, skipped
}
// iconLabelOf menampilkan ikon yang sedang dipakai shortcut.
func iconLabelOf(sc store.Shortcut) string {
if sc.IconPath == "" {
return T("icon_auto")
}
return filepath.Base(sc.IconPath)
}
// currentBrowser mengembalikan nama browser yang cocok dengan yang tersimpan.
func currentBrowser(list []browser.Browser, saved string) string {
for _, b := range list {
if strings.Join(b.Exec, " ") == saved {
return b.Name
}
}
if len(list) > 0 {
return list[0].Name
}
return ""
}
// categoryOf mengambil kategori pertama yang tersimpan.
func categoryOf(sc store.Shortcut) string {
first, _, _ := strings.Cut(strings.TrimSuffix(sc.Categories, ";"), ";")
for _, c := range categories {
if c == first {
return c
}
}
return "Utility"
}
+14 -8
View File
@@ -40,23 +40,27 @@ 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 := widget.NewButtonWithIcon(T("browse"), theme.FolderOpenIcon(), 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))
} }
}) })
}) })
browseIcon := widget.NewButtonWithIcon(T("browse"), theme.FolderOpenIcon(), 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) {
iconPath, iconURL, iconData = path, "", nil iconPath, iconURL, iconData = path, "", nil
iconLabel.SetText(filepath.Base(path)) iconLabel.SetText(filepath.Base(path))
}) })
}) })
onlineIcon := widget.NewButtonWithIcon(T("online"), theme.SearchIcon(), func() { onlineIcon := newButton(T("online"), theme.SearchIcon(), T("tip_online"), widget.MediumImportance, func() {
ui.showIconSearch(nameEntry.Text, func(result icon.Result, data []byte) { ui.showIconSearch(nameEntry.Text, func(result icon.Result, data []byte) {
iconURL, iconPath, iconData = result.URL, "", data iconURL, iconPath, iconData = result.URL, "", data
iconLabel.SetText(result.Name) iconLabel.SetText(result.Name)
@@ -64,7 +68,7 @@ func (ui *app) showExecutableDialog() {
}) })
var d *dialog.CustomDialog var d *dialog.CustomDialog
install := widget.NewButtonWithIcon(T("install"), theme.DownloadIcon(), func() { install := newButton(T("install"), theme.DownloadIcon(), T("tip_install"), widget.HighImportance, func() {
switch { switch {
case binPath == "": case binPath == "":
dialog.ShowInformation(T("err_title"), T("need_binary"), ui.win) dialog.ShowInformation(T("err_title"), T("need_binary"), ui.win)
@@ -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,
} }
@@ -91,8 +96,6 @@ func (ui *app) showExecutableDialog() {
func(sc store.Shortcut, err error) { ui.added(sc, err, d.Hide) }, func(sc store.Shortcut, err error) { ui.added(sc, err, d.Hide) },
) )
}) })
install.Importance = widget.HighImportance
form := widget.NewForm( form := widget.NewForm(
widget.NewFormItem(T("field_binary"), container.NewBorder(nil, nil, nil, browseBin, binLabel)), widget.NewFormItem(T("field_binary"), container.NewBorder(nil, nil, nil, browseBin, binLabel)),
widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil, widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil,
@@ -100,6 +103,7 @@ func (ui *app) showExecutableDialog() {
widget.NewFormItem(T("field_name"), nameEntry), widget.NewFormItem(T("field_name"), nameEntry),
widget.NewFormItem(T("field_category"), category), widget.NewFormItem(T("field_category"), category),
) )
hint := textLine(T("exec_hint"), 12, false, colorNameMuted)
content := container.NewVScroll(container.NewVBox( content := container.NewVScroll(container.NewVBox(
closeRow(func() { d.Hide() }), closeRow(func() { d.Hide() }),
form, form,
@@ -107,13 +111,15 @@ func (ui *app) showExecutableDialog() {
sectionTitle(T("section_location")), sectionTitle(T("section_location")),
menu, menu,
desktop, desktop,
muted(T("exec_hint")), terminal,
hint,
container.NewHBox(logoBox(newLogo(), 20), spacer(), container.NewHBox(logoBox(newLogo(), 20), spacer(),
widget.NewButton(T("cancel"), func() { d.Hide() }), newButton(T("cancel"), nil, T("tip_cancel"), widget.MediumImportance, func() { d.Hide() }),
install), install),
)) ))
d = dialog.NewCustomWithoutButtons(T("new_exec_title"), content, ui.win) d = dialog.NewCustomWithoutButtons(T("new_exec_title"), content, ui.win)
d.SetOnClosed(pushHintTarget(hint)) // keterangan tombol muncul di baris hint dialog ini
showDialog(d, ui.win, 560, 520) showDialog(d, ui.win, 560, 520)
} }
+7 -5
View File
@@ -87,10 +87,11 @@ func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) {
entry.OnSubmitted = func(string) { search() } entry.OnSubmitted = func(string) { search() }
// status di barisnya sendiri: supaya tidak bertabrakan dengan tombol // status di barisnya sendiri: supaya tidak bertabrakan dengan tombol
// di layar sempit atau ber-penskalaan besar. // di layar sempit atau ber-penskalaan besar.
bottom := container.NewVBox(status, container.NewHBox( hint := textLine(T("icon_search_hint"), 12, false, colorNameMuted)
bottom := container.NewVBox(hint, status, container.NewHBox(
spacer(), spacer(),
widget.NewButtonWithIcon(T("back"), theme.NavigateBackIcon(), func() { d.Hide() }), newButton(T("back"), theme.NavigateBackIcon(), T("tip_back"), widget.MediumImportance, func() { d.Hide() }),
widget.NewButtonWithIcon(T("search"), theme.SearchIcon(), search), newButton(T("search"), theme.SearchIcon(), T("tip_search"), widget.HighImportance, search),
)) ))
content := container.NewBorder( content := container.NewBorder(
container.NewVBox(closeRow(func() { d.Hide() }), entry), container.NewVBox(closeRow(func() { d.Hide() }), entry),
@@ -99,6 +100,7 @@ func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) {
) )
d = dialog.NewCustomWithoutButtons(T("icon_search_title"), content, ui.win) d = dialog.NewCustomWithoutButtons(T("icon_search_title"), content, ui.win)
d.SetOnClosed(pushHintTarget(hint))
showDialog(d, ui.win, 560, 470) // sekaligus menampilkan dialog showDialog(d, ui.win, 560, 470) // sekaligus menampilkan dialog
search() search()
} }
@@ -126,7 +128,7 @@ func (ui *app) iconChoice(result icon.Result, onPick func(icon.Result, []byte),
var mu sync.Mutex var mu sync.Mutex
var raw []byte var raw []byte
button := widget.NewButton("", func() { button := newButton("", nil, result.Name, widget.LowImportance, func() {
mu.Lock() mu.Lock()
picked := raw picked := raw
mu.Unlock() mu.Unlock()
@@ -135,8 +137,8 @@ func (ui *app) iconChoice(result icon.Result, onPick func(icon.Result, []byte),
d.Hide() d.Hide()
} }
}) })
button.Importance = widget.LowImportance
button.Icon = theme.NewThemedResource(theme.FileImageIcon()) // placeholder selama diunduh button.Icon = theme.NewThemedResource(theme.FileImageIcon()) // placeholder selama diunduh
button.Refresh()
go func() { go func() {
slot <- struct{}{} slot <- struct{}{}
+66
View File
@@ -10,7 +10,9 @@ import (
"fyne.io/fyne/v2/test" "fyne.io/fyne/v2/test"
"lazyrun/internal/db"
"lazyrun/internal/icon" "lazyrun/internal/icon"
"lazyrun/internal/store"
) )
// TestNoEmptyDismissButton memastikan modal tidak memakai dialog.NewCustom, // TestNoEmptyDismissButton memastikan modal tidak memakai dialog.NewCustom,
@@ -62,6 +64,17 @@ func TestDialogsOpenWithoutPanic(t *testing.T) {
t.Fatal("modal web app tidak muncul") t.Fatal("modal web app tidak muncul")
} }
} }
ui.showEditDialog(store.Shortcut{Slug: "winbox", Name: "Winbox", Type: store.TypeExecutable, Kind: "windows",
BinaryPath: "/tmp/winbox", IconPath: "", Categories: "Network;", Targets: store.Targets{Menu: true, Desktop: true}})
if top := ui.win.Canvas().Overlays().Top(); top == nil {
t.Fatal("modal edit executable tidak muncul")
}
ui.showEditDialog(store.Shortcut{Slug: "wa", Name: "WhatsApp Web", Type: store.TypeWebApp, Kind: "pwa",
URL: "https://web.whatsapp.com", Browser: "/usr/bin/vivaldi", Targets: store.Targets{Menu: true}})
if top := ui.win.Canvas().Overlays().Top(); top == nil {
t.Fatal("modal edit webapp tidak muncul")
}
ui.showIconSearch("whatsapp", func(icon.Result, []byte) {}) ui.showIconSearch("whatsapp", func(icon.Result, []byte) {})
if top := ui.win.Canvas().Overlays().Top(); top == nil { if top := ui.win.Canvas().Overlays().Top(); top == nil {
t.Fatal("modal pencarian ikon tidak muncul") t.Fatal("modal pencarian ikon tidak muncul")
@@ -108,3 +121,56 @@ func TestStringsCoverUsedKeys(t *testing.T) {
} }
} }
} }
// 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", "")
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}}},
}
a := test.NewApp()
defer a.Quit()
ui, err := build(a, "test")
if err != nil {
t.Fatal(err)
}
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)
}
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)
}
}
+7 -7
View File
@@ -54,13 +54,13 @@ func (ui *app) showWebAppDialog() {
} }
} }
browseIcon := widget.NewButtonWithIcon(T("browse"), theme.FolderOpenIcon(), 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) {
iconPath, iconURL, iconData = path, "", nil iconPath, iconURL, iconData = path, "", nil
iconLabel.SetText(filepath.Base(path)) iconLabel.SetText(filepath.Base(path))
}) })
}) })
onlineIcon := widget.NewButtonWithIcon(T("online"), theme.SearchIcon(), func() { onlineIcon := newButton(T("online"), theme.SearchIcon(), T("tip_online"), widget.MediumImportance, func() {
ui.showIconSearch(nameEntry.Text, func(result icon.Result, data []byte) { ui.showIconSearch(nameEntry.Text, func(result icon.Result, data []byte) {
iconURL, iconPath, iconData = result.URL, "", data iconURL, iconPath, iconData = result.URL, "", data
iconLabel.SetText(result.Name) iconLabel.SetText(result.Name)
@@ -68,7 +68,7 @@ func (ui *app) showWebAppDialog() {
}) })
var d *dialog.CustomDialog var d *dialog.CustomDialog
install := widget.NewButtonWithIcon(T("install"), theme.DownloadIcon(), func() { install := newButton(T("install"), theme.DownloadIcon(), T("tip_install"), widget.HighImportance, func() {
switch { switch {
case strings.TrimSpace(urlEntry.Text) == "": case strings.TrimSpace(urlEntry.Text) == "":
dialog.ShowInformation(T("err_title"), T("need_url"), ui.win) dialog.ShowInformation(T("err_title"), T("need_url"), ui.win)
@@ -96,8 +96,6 @@ func (ui *app) showWebAppDialog() {
func(sc store.Shortcut, err error) { ui.added(sc, err, d.Hide) }, func(sc store.Shortcut, err error) { ui.added(sc, err, d.Hide) },
) )
}) })
install.Importance = widget.HighImportance
form := widget.NewForm( form := widget.NewForm(
widget.NewFormItem(T("field_url"), urlEntry), widget.NewFormItem(T("field_url"), urlEntry),
widget.NewFormItem(T("field_name"), nameEntry), widget.NewFormItem(T("field_name"), nameEntry),
@@ -105,6 +103,7 @@ func (ui *app) showWebAppDialog() {
widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil, widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil,
container.NewHBox(onlineIcon, browseIcon), iconLabel)), container.NewHBox(onlineIcon, browseIcon), iconLabel)),
) )
hint := textLine(T("web_hint"), 12, false, colorNameMuted)
content := container.NewVScroll(container.NewVBox( content := container.NewVScroll(container.NewVBox(
closeRow(func() { d.Hide() }), closeRow(func() { d.Hide() }),
form, form,
@@ -112,13 +111,14 @@ func (ui *app) showWebAppDialog() {
sectionTitle(T("section_location")), sectionTitle(T("section_location")),
menu, menu,
desktop, desktop,
muted(T("web_hint")), hint,
container.NewHBox(logoBox(newLogo(), 20), spacer(), container.NewHBox(logoBox(newLogo(), 20), spacer(),
widget.NewButton(T("cancel"), func() { d.Hide() }), newButton(T("cancel"), nil, T("tip_cancel"), widget.MediumImportance, func() { d.Hide() }),
install), install),
)) ))
d = dialog.NewCustomWithoutButtons(T("new_web_title"), content, ui.win) d = dialog.NewCustomWithoutButtons(T("new_web_title"), content, ui.win)
d.SetOnClosed(pushHintTarget(hint))
showDialog(d, ui.win, 560, 520) showDialog(d, ui.win, 560, 520)
} }
+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")
}
+27 -22
View File
@@ -32,6 +32,8 @@ type app struct {
icons *iconCache icons *iconCache
count *canvas.Text // angka jumlah shortcut di header daftar count *canvas.Text // angka jumlah shortcut di header daftar
versionText *canvas.Text // versi di footer; berubah kalau ada update versionText *canvas.Text // versi di footer; berubah kalau ada update
hint *canvas.Text // keterangan tombol yang sedang disorot
accent *canvas.LinearGradient
version string version string
} }
@@ -39,6 +41,7 @@ type app struct {
func Run(a fyne.App, version string) { func Run(a fyne.App, version string) {
ui, loadErr := build(a, version) ui, loadErr := build(a, version)
ui.win.Show() ui.win.Show()
pushHintTarget(ui.hint) // target default: baris hint di footer
ui.flashCards() ui.flashCards()
ui.scheduleUpdateCheck() ui.scheduleUpdateCheck()
if loadErr != nil { if loadErr != nil {
@@ -66,9 +69,8 @@ func build(a fyne.App, version string) (*app, error) {
st, err := store.Load(xdg.MetadataPath()) st, err := store.Load(xdg.MetadataPath())
ui.store = st ui.store = st
addExec := widget.NewButtonWithIcon(T("btn_executable"), theme.ContentAddIcon(), ui.showExecutableDialog) addExec := newButton(T("btn_executable"), theme.ContentAddIcon(), T("tip_executable"), widget.HighImportance, ui.showExecutableDialog)
addExec.Importance = widget.HighImportance addWeb := newButton(T("btn_webapp"), theme.ContentAddIcon(), T("tip_webapp"), widget.MediumImportance, ui.showWebAppDialog)
addWeb := widget.NewButtonWithIcon(T("btn_webapp"), theme.ContentAddIcon(), ui.showWebAppDialog)
leftHeader := container.NewHBox(logoBox(newLogo(), 40), container.NewVBox( leftHeader := container.NewHBox(logoBox(newLogo(), 40), container.NewVBox(
heading("LazyRun"), heading("LazyRun"),
@@ -77,19 +79,22 @@ func build(a fyne.App, version string) (*app, error) {
rightHeader := container.NewHBox(addExec, addWeb) rightHeader := container.NewHBox(addExec, addWeb)
header := container.NewBorder(nil, nil, leftHeader, rightHeader) header := container.NewBorder(nil, nil, leftHeader, rightHeader)
update := widget.NewButtonWithIcon(T("check_updates"), theme.DownloadIcon(), ui.checkUpdates) update := newButton(T("check_updates"), theme.DownloadIcon(), T("tip_check_updates"), widget.MediumImportance, ui.checkUpdates)
github := widget.NewButton(T("github"), func() { ui.openURL(repoURL) }) github := newButton(T("github"), nil, T("tip_github"), widget.MediumImportance, func() { ui.openURL(repoURL) })
donate := widget.NewButtonWithIcon(T("donate"), donate := newButton(T("donate"), fyne.NewStaticResource("heart.png", assets.Heart), T("tip_donate"), widget.MediumImportance, func() { ui.openURL(donateURL) })
fyne.NewStaticResource("heart.png", assets.Heart), func() { ui.openURL(donateURL) }) export := newIconButton(theme.DocumentSaveIcon(), T("tip_export"), ui.exportShortcuts)
importBtn := newIconButton(theme.StorageIcon(), T("tip_import"), ui.importShortcuts)
ui.hint = caption("")
footer := container.NewBorder( footer := container.NewBorder(
widget.NewSeparator(), nil, widget.NewSeparator(), nil,
container.NewHBox(update, github, donate), container.NewHBox(update, export, importBtn, github, donate),
ui.versionText, ui.versionText,
ui.hint,
) )
accent := accentBar() ui.accent = accentBar()
pulseGradient(accent) pulseGradient(ui.accent)
top := container.NewVBox(header, accent, top := container.NewVBox(header, ui.accent,
container.NewHBox(sectionLabel(T("installed")), countChip)) container.NewHBox(sectionLabel(T("installed")), countChip))
body := container.NewBorder( body := container.NewBorder(
top, top,
@@ -144,16 +149,17 @@ func (ui *app) row(sc store.Shortcut) *hoverCard {
} }
actions := container.NewHBox(chips...) actions := container.NewHBox(chips...)
actions.Add(iconButton(theme.MediaPlayIcon(), func() { ui.launch(sc) })) actions.Add(newIconButton(theme.MediaPlayIcon(), T("tip_run"), func() { ui.launch(sc) }))
if syncable(sc) { if syncable(sc) {
actions.Add(iconButton(theme.ViewRefreshIcon(), func() { ui.syncIcon(sc) })) actions.Add(newIconButton(theme.ViewRefreshIcon(), T("tip_sync"), func() { ui.syncIcon(sc) }))
} }
actions.Add(iconButton(theme.FolderOpenIcon(), func() { actions.Add(newIconButton(theme.DocumentCreateIcon(), T("tip_edit"), func() { ui.showEditDialog(sc) }))
actions.Add(newIconButton(theme.FolderOpenIcon(), T("tip_folder"), func() {
if err := exec.Command("xdg-open", filepath.Dir(sc.DesktopPath)).Start(); err != nil { if err := exec.Command("xdg-open", filepath.Dir(sc.DesktopPath)).Start(); err != nil {
dialog.ShowError(err, ui.win) dialog.ShowError(err, ui.win)
} }
})) }))
actions.Add(iconButton(theme.DeleteIcon(), func() { ui.confirmDelete(sc) })) actions.Add(newIconButton(theme.DeleteIcon(), T("tip_delete"), func() { ui.confirmDelete(sc) }))
return newHoverCard(container.NewBorder(nil, nil, return newHoverCard(container.NewBorder(nil, nil,
container.NewHBox(thumb(sc.IconPath, fallbackIcon(sc)), info), container.NewHBox(thumb(sc.IconPath, fallbackIcon(sc)), info),
@@ -238,12 +244,11 @@ func syncable(sc store.Shortcut) bool {
// syncIcon mengunduh ulang ikon shortcut dari sumber online-nya. // syncIcon mengunduh ulang ikon shortcut dari sumber online-nya.
func (ui *app) syncIcon(sc store.Shortcut) { func (ui *app) syncIcon(sc store.Shortcut) {
progress := dialog.NewProgressInfinite(T("sync_icon"), T("working"), ui.win) hide := ui.showLoading(T("sync_icon"), T("working"))
progress.Show()
go func() { go func() {
path, err := shortcut.RefreshIcon(sc) path, err := shortcut.RefreshIcon(sc)
fyne.Do(func() { fyne.Do(func() {
progress.Hide() hide()
if err != nil { if err != nil {
dialog.ShowError(err, ui.win) dialog.ShowError(err, ui.win)
return return
@@ -291,14 +296,14 @@ func (ui *app) confirmDelete(sc store.Shortcut) {
d.Show() d.Show()
} }
// install menjalankan pekerjaan berat (salin file besar / fetch favicon) di luar UI thread. // install menjalankan pekerjaan berat (salin file besar / unduh ikon) di luar UI thread,
// dengan dialog proses berisi progress bar.
func (ui *app) install(work func() (store.Shortcut, error), done func(store.Shortcut, error)) { func (ui *app) install(work func() (store.Shortcut, error), done func(store.Shortcut, error)) {
progress := dialog.NewProgressInfinite(T("install"), T("working"), ui.win) hide := ui.showLoading(T("install"), T("working"))
progress.Show()
go func() { go func() {
sc, err := work() sc, err := work()
fyne.Do(func() { fyne.Do(func() {
progress.Hide() hide()
done(sc, err) done(sc, err)
}) })
}() }()
+39 -1
View File
@@ -25,12 +25,14 @@ var tr = map[string][2]string{
"searching": {"Searching...", "Mencari..."}, "searching": {"Searching...", "Mencari..."},
"search_failed": {"Search failed. Check your connection.", "Pencarian gagal. Cek koneksi kamu."}, "search_failed": {"Search failed. Check your connection.", "Pencarian gagal. Cek koneksi kamu."},
"search_found": {"%d icons found - click one to use it.", "%d ikon ditemukan - klik satu untuk dipakai."}, "search_found": {"%d icons found - click one to use it.", "%d ikon ditemukan - klik satu untuk dipakai."},
"icon_search_hint": {"Hover a button to see what it does.", "Sorot tombol untuk melihat fungsinya."},
"search_hint": {"Type a name and press Search.", "Tulis nama lalu tekan Cari."}, "search_hint": {"Type a name and press Search.", "Tulis nama lalu tekan Cari."},
"icon_search_title": {"Icon from the internet", "Ikon dari internet"}, "icon_search_title": {"Icon from the internet", "Ikon dari internet"},
"sync_icon": {"Sync icon", "Sinkron ikon"}, "sync_icon": {"Sync icon", "Sinkron ikon"},
"browse": {"Browse...", "Pilih..."}, "browse": {"Browse...", "Pilih..."},
"install": {"Install", "Install"}, "install": {"Install", "Install"},
"cancel": {"Cancel", "Batal"}, "cancel": {"Cancel", "Batal"},
"save": {"Save", "Simpan"},
"back": {"Back", "Kembali"}, "back": {"Back", "Kembali"},
"delete": {"Delete", "Hapus"}, "delete": {"Delete", "Hapus"},
"working": {"Working...", "Sedang diproses..."}, "working": {"Working...", "Sedang diproses..."},
@@ -47,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."},
@@ -64,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"},
@@ -78,6 +82,40 @@ var tr = map[string][2]string{
"update_restart": {"Restart now", "Restart sekarang"}, "update_restart": {"Restart now", "Restart sekarang"},
"up_to_date": {"LazyRun %s is the latest version.", "LazyRun %s adalah versi terbaru."}, "up_to_date": {"LazyRun %s is the latest version.", "LazyRun %s adalah versi terbaru."},
"dev_build": {"This is a development build (%s), update check is skipped.", "Ini build development (%s), cek update dilewati."}, "dev_build": {"This is a development build (%s), update check is skipped.", "Ini build development (%s), cek update dilewati."},
"export": {"Export", "Ekspor"},
"import": {"Import", "Impor"},
"edit_title": {"Edit shortcut", "Edit shortcut"},
"edit_saved": {"%s updated.", "%s diperbarui."},
"edit_hint": {"Change the binary, icon, name, or where the shortcut appears.", "Ubah binary, ikon, nama, atau lokasi shortcutnya."},
"import_done": {"%d shortcuts imported (%d skipped).", "%d shortcut diimpor (%d dilewati)."},
"import_empty": {"No shortcuts found in this file.", "Tidak ada shortcut di berkas ini."},
"export_done": {"Exported %d shortcuts to\n%s", "%d shortcut diekspor ke\n%s"},
"tip_executable": {"Create a shortcut from a binary", "Buat shortcut dari berkas binary"},
"tip_webapp": {"Create a shortcut from a web address", "Buat shortcut dari alamat web"},
"tip_run": {"Run this shortcut", "Jalankan shortcut ini"},
"tip_sync": {"Download the icon again from its source", "Unduh ulang ikonnya dari sumber"},
"tip_edit": {"Edit: binary, icon, name, location", "Edit: binary, ikon, nama, lokasi"},
"tip_folder": {"Open the .desktop folder", "Buka folder .desktop"},
"tip_delete": {"Remove this shortcut", "Hapus shortcut ini"},
"tip_check_updates": {"Check for a newer release", "Periksa rilis terbaru"},
"tip_github": {"Open the LazyRun repository", "Buka repo LazyRun"},
"tip_donate": {"Support LazyRun development", "Dukung pengembangan LazyRun"},
"tip_export": {"Export the shortcut list to a .lazy database", "Ekspor daftar shortcut ke basis data .lazy"},
"tip_import": {"Import shortcuts from a .lazy database", "Impor shortcut dari basis data .lazy"},
"tip_close": {"Close this dialog", "Tutup dialog ini"},
"tip_back": {"Back to the previous dialog", "Kembali ke dialog sebelumnya"},
"tip_search": {"Search icons online", "Cari ikon di internet"},
"tip_browse": {"Pick a file", "Pilih berkas"},
"tip_online": {"Pick an icon from the internet", "Pilih ikon dari internet"},
"tip_install": {"Install the shortcut", "Pasang shortcutnya"},
"tip_cancel": {"Cancel without saving", "Batal tanpa menyimpan"},
"tip_save": {"Save the changes", "Simpan perubahan"},
"tip_update_now": {"Download and install the update", "Unduh dan pasang update"},
"tip_update_page": {"Open the release page in the browser", "Buka halaman rilis di browser"},
"tip_later": {"Ask me later", "Ingatkan nanti"},
"tip_restart": {"Restart LazyRun with the new version", "Jalankan ulang LazyRun dengan versi baru"},
} }
var lang = detectLang() var lang = detectLang()
+2 -9
View File
@@ -78,18 +78,11 @@ func chipLabel(text string, bg fyne.ThemeColorName) (fyne.CanvasObject, *canvas.
// accentBar adalah garis gradien brand di bawah header. // accentBar adalah garis gradien brand di bawah header.
func accentBar() *canvas.LinearGradient { func accentBar() *canvas.LinearGradient {
bar := canvas.NewLinearGradient(brandIndigo, brandCyan, 90) bar := canvas.NewLinearGradient(brandAmber, brandMagenta, 90)
bar.SetMinSize(fyne.NewSize(0, 3)) bar.SetMinSize(fyne.NewSize(0, 3))
return bar return bar
} }
// iconButton adalah tombol ikon tanpa label, gaya datar.
func iconButton(icon fyne.Resource, tapped func()) *widget.Button {
button := widget.NewButtonWithIcon("", icon, tapped)
button.Importance = widget.LowImportance
return button
}
// thumb menampilkan ikon shortcut, atau fallback kalau file ikonnya hilang. // thumb menampilkan ikon shortcut, atau fallback kalau file ikonnya hilang.
func thumb(path string, fallback fyne.Resource) fyne.CanvasObject { func thumb(path string, fallback fyne.Resource) fyne.CanvasObject {
var image *canvas.Image var image *canvas.Image
@@ -122,7 +115,7 @@ func logoBox(image *canvas.Image, size float32) fyne.CanvasObject {
// closeRow menempatkan tombol tutup di kanan atas isi dialog, supaya // closeRow menempatkan tombol tutup di kanan atas isi dialog, supaya
// selalu ada jalan keluar yang terlihat walau tombol bawah terpotong. // selalu ada jalan keluar yang terlihat walau tombol bawah terpotong.
func closeRow(hide func()) fyne.CanvasObject { func closeRow(hide func()) fyne.CanvasObject {
return container.NewHBox(spacer(), iconButton(theme.CancelIcon(), hide)) return container.NewHBox(spacer(), newIconButton(theme.CancelIcon(), T("tip_close"), hide))
} }
// showDialog menampilkan dialog dengan ukuran yang disesuaikan ke window. // showDialog menampilkan dialog dengan ukuran yang disesuaikan ke window.
+20 -16
View File
@@ -7,10 +7,11 @@ import (
"fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/theme"
) )
// Palet brand LazyRun, diambil dari ikon aplikasi. // Palet brand LazyRun, diambil dari logo (dream sky: ungu malam, senja pink, bulan amber).
var ( var (
brandIndigo = color.NRGBA{R: 0x4F, G: 0x46, B: 0xE5, A: 0xFF} brandPurple = color.NRGBA{R: 0x6A, G: 0x3D, B: 0x9A, A: 0xFF}
brandCyan = color.NRGBA{R: 0x06, G: 0xB6, B: 0xD4, A: 0xFF} brandMagenta = color.NRGBA{R: 0xC2, G: 0x5C, B: 0x9E, A: 0xFF}
brandAmber = color.NRGBA{R: 0xFF, G: 0xD1, B: 0x66, A: 0xFF}
) )
// Nama warna tambahan di luar tema standar Fyne. // Nama warna tambahan di luar tema standar Fyne.
@@ -31,31 +32,34 @@ func (b brand) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.
dark := variant == theme.VariantDark dark := variant == theme.VariantDark
switch name { switch name {
case theme.ColorNamePrimary: case theme.ColorNamePrimary:
return pick(dark, color.NRGBA{R: 0x7C, G: 0x7A, B: 0xFF, A: 0xFF}, brandIndigo) // senja pink di malam hari, ungu tua di tema terang
return pick(dark, brandMagenta, color.NRGBA{R: 0x8E, G: 0x3F, B: 0xA8, A: 0xFF})
case theme.ColorNameBackground: case theme.ColorNameBackground:
return pick(dark, color.NRGBA{R: 0x13, G: 0x13, B: 0x1B, A: 0xFF}, color.NRGBA{R: 0xF5, G: 0xF5, B: 0xFA, A: 0xFF}) // langit malam surreal
return pick(dark, color.NRGBA{R: 0x14, G: 0x0E, B: 0x24, A: 0xFF}, color.NRGBA{R: 0xF5, G: 0xEF, B: 0xF9, A: 0xFF})
case colorNameCard: case colorNameCard:
return pick(dark, color.NRGBA{R: 0x1D, G: 0x1D, B: 0x28, A: 0xFF}, color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF}) return pick(dark, color.NRGBA{R: 0x1E, G: 0x16, B: 0x36, A: 0xFF}, color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF})
case colorNameCardHover: case colorNameCardHover:
return withAlpha(brandIndigo, pick[uint8](dark, 0x2E, 0x14)) return withAlpha(brandAmber, pick[uint8](dark, 0x1F, 0x18))
case theme.ColorNameInputBackground: case theme.ColorNameInputBackground:
return pick(dark, color.NRGBA{R: 0x1A, G: 0x1A, B: 0x24, A: 0xFF}, color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF}) return pick(dark, color.NRGBA{R: 0x19, G: 0x11, B: 0x35, A: 0xFF}, color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF})
case theme.ColorNameButton, theme.ColorNameMenuBackground: case theme.ColorNameButton, theme.ColorNameMenuBackground:
return pick(dark, color.NRGBA{R: 0x22, G: 0x22, B: 0x2E, A: 0xFF}, color.NRGBA{R: 0xEC, G: 0xEC, B: 0xF4, A: 0xFF}) return pick(dark, color.NRGBA{R: 0x26, G: 0x1B, B: 0x45, A: 0xFF}, color.NRGBA{R: 0xED, G: 0xE3, B: 0xF5, A: 0xFF})
case theme.ColorNameOverlayBackground: case theme.ColorNameOverlayBackground:
return pick(dark, color.NRGBA{R: 0x1B, G: 0x1B, B: 0x25, A: 0xFF}, color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF}) return pick(dark, color.NRGBA{R: 0x1B, G: 0x13, B: 0x33, A: 0xFF}, color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF})
case theme.ColorNameInputBorder, theme.ColorNameSeparator: case theme.ColorNameInputBorder, theme.ColorNameSeparator:
return pick(dark, color.NRGBA{R: 0x32, G: 0x32, B: 0x44, A: 0xFF}, color.NRGBA{R: 0xDC, G: 0xDC, B: 0xE8, A: 0xFF}) return pick(dark, color.NRGBA{R: 0x3A, G: 0x2A, B: 0x63, A: 0xFF}, color.NRGBA{R: 0xDC, G: 0xCF, B: 0xE9, A: 0xFF})
case colorNameMuted: case colorNameMuted:
return pick(dark, color.NRGBA{R: 0x9C, G: 0x9C, B: 0xB4, A: 0xFF}, color.NRGBA{R: 0x6B, G: 0x6B, B: 0x80, A: 0xFF}) return pick(dark, color.NRGBA{R: 0xA7, G: 0x9A, B: 0xC8, A: 0xFF}, color.NRGBA{R: 0x6E, G: 0x61, B: 0x83, A: 0xFF})
case colorNameChipMenu: case colorNameChipMenu:
return withAlpha(brandIndigo, pick[uint8](dark, 0x44, 0x26)) return withAlpha(brandPurple, pick[uint8](dark, 0x66, 0x2E))
case colorNameChipDesk: case colorNameChipDesk:
return withAlpha(brandCyan, pick[uint8](dark, 0x3D, 0x2C)) return withAlpha(brandMagenta, pick[uint8](dark, 0x4E, 0x30))
case colorNameChipKind: case colorNameChipKind:
return pick(dark, color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0x1C}, color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x12}) return withAlpha(color.NRGBA{R: 0xFF, G: 0xD1, B: 0x66, A: 0xFF}, pick[uint8](dark, 0x24, 0x30))
case colorNameChipBroken: case colorNameChipBroken:
return withAlpha(color.NRGBA{R: 0xE0, G: 0x4F, B: 0x4F, A: 0xFF}, pick[uint8](dark, 0x4A, 0x2E)) // merah senja: peringatan yang tetap terasa "seram" tapi terbaca
return withAlpha(color.NRGBA{R: 0xD9, G: 0x46, B: 0x3C, A: 0xFF}, pick[uint8](dark, 0x50, 0x30))
} }
return b.Theme.Color(name, variant) return b.Theme.Color(name, variant)
} }
+107
View File
@@ -0,0 +1,107 @@
package ui
import (
"sync"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/driver/desktop"
"fyne.io/fyne/v2/widget"
)
// Keterangan tombol muncul di satu baris hint (bawah window atau dalam dialog),
// bukan sebagai popup: popup Fyne selalu jadi overlay, dan selama overlay ada
// semua klik ke konten di bawahnya tidak diteruskan (tombol jadi perlu 2x klik).
var (
hintMu sync.Mutex
hintTarget *canvas.Text
)
// pushHintTarget mengarahkan keterangan hover ke label tertentu (dipakai dialog).
// Fungsi yang dikembalikan mengembalikan target sebelumnya.
func pushHintTarget(label *canvas.Text) func() {
hintMu.Lock()
previous := hintTarget
hintTarget = label
hintMu.Unlock()
return func() {
hintMu.Lock()
hintTarget = previous
hintMu.Unlock()
}
}
func showHint(text string) {
hintMu.Lock()
target := hintTarget
hintMu.Unlock()
if target == nil || text == "" {
return
}
target.Text = text
target.Refresh()
}
// clearHint menghapus hint hanya kalau isinya memang milik tombol ini.
func clearHint(text string) {
hintMu.Lock()
target := hintTarget
hintMu.Unlock()
if target == nil || target.Text != text {
return
}
target.Text = ""
target.Refresh()
}
// tipButton adalah tombol yang menampilkan fungsinya saat disorot mouse.
type tipButton struct {
widget.Button
tip string
}
// newButton membuat tombol (dengan ikon kalau diberikan) plus keterangan hover.
func newButton(text string, icon fyne.Resource, tip string, importance widget.Importance, tapped func()) *tipButton {
button := &tipButton{tip: tip}
button.Text = text
button.Icon = icon
button.Importance = importance
button.OnTapped = tapped
button.ExtendBaseWidget(button)
return button
}
// newIconButton adalah tombol ikon saja (tanpa teks), gaya datar.
func newIconButton(icon fyne.Resource, tip string, tapped func()) *tipButton {
return newButton("", icon, tip, widget.LowImportance, tapped)
}
func (b *tipButton) MouseIn(event *desktop.MouseEvent) {
b.Button.MouseIn(event)
showHint(b.tip)
}
func (b *tipButton) MouseOut() {
b.Button.MouseOut()
clearHint(b.tip)
}
// showLoading menampilkan dialog proses dengan progress bar yang berjalan,
// dan mengembalikan fungsi untuk menutupnya.
func (ui *app) showLoading(title, message string) func() {
bar := widget.NewProgressBarInfinite()
var d *dialog.CustomDialog
d = dialog.NewCustomWithoutButtons(title, container.NewBorder(
container.NewVBox(logoBox(newLogo(), 22), muted(message)),
nil, nil, nil,
bar,
), ui.win)
showDialog(d, ui.win, 420, 170)
return d.Hide
}
+89
View File
@@ -0,0 +1,89 @@
package ui
import (
"testing"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/test"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
func TestTipButtonShowsAndHidesHint(t *testing.T) {
a := test.NewApp()
a.Settings().SetTheme(brand{theme.DefaultTheme()})
defer a.Quit()
hint := textLine("", 12, false, colorNameMuted)
button := newIconButton(theme.MediaPlayIcon(), "jalankan shortcut", func() {})
w := a.NewWindow("tip")
w.SetContent(container.NewBorder(nil, hint, nil, nil, button))
w.Resize(fyne.NewSize(300, 200))
w.Show()
restore := pushHintTarget(hint)
defer restore()
if hint.Text != "" {
t.Fatal("hint muncul tanpa hover")
}
test.MoveMouse(w.Canvas(), fyne.NewPos(20, 20))
if hint.Text != "jalankan shortcut" {
t.Errorf("hint saat hover = %q", hint.Text)
}
// keluar dari tombol: hint dibersihkan
test.MoveMouse(w.Canvas(), fyne.NewPos(280, 180))
if hint.Text != "" {
t.Errorf("hint masih %q setelah kursor keluar", hint.Text)
}
}
// klik saat tooltip terbuka harus tetap kena tombolnya (dulu PopUp menelan klik).
// klik saat hint tampil harus tetap kena tombolnya (dulu pakai popup yang
// jadi overlay, sehingga klik pertama selalu tertelan).
func TestTipButtonClickGoesThrough(t *testing.T) {
a := test.NewApp()
a.Settings().SetTheme(brand{theme.DefaultTheme()})
defer a.Quit()
tapped := false
hint := textLine("", 12, false, colorNameMuted)
button := newIconButton(theme.MediaPlayIcon(), "jalankan shortcut", func() { tapped = true })
w := a.NewWindow("tip")
w.SetContent(container.NewBorder(nil, hint, nil, nil, button))
w.Resize(fyne.NewSize(300, 200))
w.Show()
restore := pushHintTarget(hint)
defer restore()
test.MoveMouse(w.Canvas(), fyne.NewPos(20, 20))
test.TapCanvas(w.Canvas(), fyne.NewPos(20, 20))
if !tapped {
t.Error("klik pertama tidak sampai ke tombol")
}
if top := w.Canvas().Overlays().Top(); top != nil {
t.Error("tidak boleh ada overlay untuk keterangan tombol")
}
}
func TestTipButtonWithoutTipDoesNothing(t *testing.T) {
a := test.NewApp()
a.Settings().SetTheme(brand{theme.DefaultTheme()})
defer a.Quit()
hint := textLine("", 12, false, colorNameMuted)
button := newButton("Tanpa tip", nil, "", widget.MediumImportance, func() {})
w := a.NewWindow("tip")
w.SetContent(container.NewBorder(nil, hint, nil, nil, button))
w.Resize(fyne.NewSize(300, 200))
w.Show()
restore := pushHintTarget(hint)
defer restore()
test.MoveMouse(w.Canvas(), fyne.NewPos(20, 20))
if hint.Text != "" {
t.Error("tombol tanpa tip tidak boleh menulis hint")
}
}
+14 -16
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,9 +97,9 @@ 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) {
notes := widget.NewLabel(rel.Notes) // catatan rilis Gitea berformat markdown; render supaya blok kode/daftar
notes.Wrapping = fyne.TextWrapWord // tidak tampil sebagai teks mentah
notes.Importance = widget.LowImportance notes := widget.NewRichTextFromMarkdown(rel.Notes)
// catatan rilis di tengah (bukan top): VScroll di Fyne punya MinSize kecil, // catatan rilis di tengah (bukan top): VScroll di Fyne punya MinSize kecil,
// jadi kalau dipasang sebagai top dari Border isinya terpotong. // jadi kalau dipasang sebagai top dari Border isinya terpotong.
@@ -110,18 +110,18 @@ func (ui *app) showUpdateDialog(rel update.Release) {
buttons := container.NewHBox( buttons := container.NewHBox(
logoBox(newLogo(), 20), logoBox(newLogo(), 20),
spacer(), spacer(),
widget.NewButton(T("update_later"), func() { d.Hide() }), newButton(T("update_later"), nil, T("tip_later"), widget.MediumImportance, func() { d.Hide() }),
widget.NewButton(T("update_page"), func() { ui.openURL(rel.PageURL) }), newButton(T("update_page"), nil, T("tip_update_page"), widget.MediumImportance, func() { ui.openURL(rel.PageURL) }),
) )
if updates := ui.updaterBinary(); updates != "" { if updates := ui.updaterBinary(); updates != "" {
// AppImageUpdate yang memasang (dan memverifikasi) sendiri // AppImageUpdate yang memasang (dan memverifikasi) sendiri
buttons.Add(widget.NewButtonWithIcon(T("update_now"), theme.DownloadIcon(), func() { buttons.Add(newButton(T("update_now"), theme.DownloadIcon(), T("tip_update_now"), widget.HighImportance, func() {
d.Hide() d.Hide()
ui.runUpdater(updates, rel) ui.runUpdater(updates, rel)
})) }))
} else { } else {
buttons.Add(widget.NewButtonWithIcon(T("update_now"), theme.DownloadIcon(), func() { buttons.Add(newButton(T("update_now"), theme.DownloadIcon(), T("tip_update_now"), widget.HighImportance, func() {
d.Hide() d.Hide()
ui.downloadUpdate(rel) ui.downloadUpdate(rel)
})) }))
@@ -151,12 +151,11 @@ func (ui *app) downloadUpdate(rel update.Release) {
return return
} }
progress := dialog.NewProgressInfinite(T("check_updates"), T("update_downloading"), ui.win) hide := ui.showLoading(T("check_updates"), T("update_downloading"))
progress.Show()
go func() { go func() {
err := update.Download(rel, appImage) err := update.Download(rel, appImage)
fyne.Do(func() { fyne.Do(func() {
progress.Hide() hide()
if err != nil { if err != nil {
dialog.ShowError(err, ui.win) dialog.ShowError(err, ui.win)
return return
@@ -174,12 +173,11 @@ func (ui *app) runUpdater(updater string, rel update.Release) {
return return
} }
progress := dialog.NewProgressInfinite(T("check_updates"), T("update_downloading"), ui.win) hide := ui.showLoading(T("check_updates"), T("update_downloading"))
progress.Show()
go func() { go func() {
err := exec.Command(updater, appImage).Run() err := exec.Command(updater, appImage).Run()
fyne.Do(func() { fyne.Do(func() {
progress.Hide() hide()
if err != nil { if err != nil {
dialog.ShowError(err, ui.win) dialog.ShowError(err, ui.win)
return return
@@ -195,8 +193,8 @@ func (ui *app) offerRestart() {
content := container.NewVBox( content := container.NewVBox(
widget.NewLabel(T("update_installed")), widget.NewLabel(T("update_installed")),
container.NewHBox(spacer(), container.NewHBox(spacer(),
widget.NewButton(T("update_later"), func() { d.Hide() }), newButton(T("update_later"), nil, T("tip_later"), widget.MediumImportance, func() { d.Hide() }),
widget.NewButtonWithIcon(T("update_restart"), theme.ViewRefreshIcon(), ui.restart), newButton(T("update_restart"), theme.ViewRefreshIcon(), T("tip_restart"), widget.HighImportance, ui.restart),
), ),
) )
d = dialog.NewCustomWithoutButtons(T("check_updates"), content, ui.win) d = dialog.NewCustomWithoutButtons(T("check_updates"), content, ui.win)
+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
``` ```