8 Commits
Author SHA1 Message Date
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
42 changed files with 2539 additions and 113 deletions

No files matched your search

+34 -3
View File
@@ -10,6 +10,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
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)
<p align="center">
@@ -17,14 +20,30 @@ AppImage, no terminal needed.
<img src="docs/dialog-icons.png" width="49%" alt="Icon search dialog">
</p>
<p align="center">
<img src="docs/dialog-loading.png" width="49%" alt="Progress bar while installing">
<img src="docs/main-light.png" width="49%" alt="LazyRun in light theme">
</p>
- **Repo:** <https://github.com/oxpoe/lazyrun>
- **Donate:** <https://donasi.oxpoe.qzz.io/>
- **Executable shortcut** — picks an ELF/AppImage/script/Windows `.exe`, copies it to
`~/.local/bin/`, installs the icon, writes the `.desktop` entry.
- **Terminal apps just work** — CLI/TUI programs (lazydocker, htop, a script) need
`Terminal=true`, while GUI apps must not open a terminal. LazyRun guesses this from
the libraries the binary links (no GUI library → terminal app), pre-fills the
*Run in a terminal window* checkbox, and keeps it editable.
- **Automatic type label** — every shortcut is tagged with what it actually is,
detected from the file: `Go`, `AppImage`, `Electron`, `Script`, `Windows`, `Binary`
(`.exe` files are launched through `wine`), or `PWA` for web apps.
- **Scripts run properly** — the desktop environment starts programs with your home as
the working directory and the session `PATH`, which breaks scripts that use relative
paths or tools from `~/.local/bin`. LazyRun therefore installs a tiny wrapper that
fixes both, running the original script file when it is still there (so `dirname $0`
keeps pointing at its own folder). *Run* in the list launches through
`gio launch <desktop file>`, so `Terminal=true` and `Path=` behave exactly like the
desktop menu — handy for scripts that ask for input or need `sudo`.
- **Web app shortcut** — opens the URL as an app: `--app=<url>` for
Chromium-based browsers (Chrome, Chromium, Brave, Edge, Vivaldi, Helium,
including Flatpak builds), `--new-window` for Firefox.
@@ -142,9 +161,21 @@ GH_USER=oxpoe GH_REPO=lazyrun ./build.sh
## Roadmap
- v1.1 — edit existing shortcuts, detect binaries in `$PATH`, optional symlink
instead of a Desktop copy, import/export, dark mode.
- v1.2 — Firefox PWA via `--kiosk`, Flatpak/Snap targets, icon preview.
- Edit existing shortcuts, detect binaries in `$PATH`, optional symlink instead of a
Desktop copy, import/export, dark mode.
- Firefox PWA via `--kiosk`, Flatpak/Snap targets, icon preview.
Known limitations are tracked as [issues](https://github.com/oxpoe/lazyrun/issues);
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
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

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
go 1.24
go 1.26.0
require (
fyne.io/fyne/v2 v2.8.2
@@ -16,6 +16,7 @@ require (
github.com/anthonynsimon/bild v0.14.0 // indirect
github.com/clipperhouse/uax29/v2 v2.2.0 // 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/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 // 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/typesetting v0.3.5 // 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/safejs v0.1.0 // indirect
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // 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/ncruces/go-strftime v1.0.0 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.5.1 // 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/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/yuin/goldmark v1.8.2 // 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
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/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/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/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
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/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
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/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
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/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
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/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/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
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/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
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/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
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/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.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/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
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/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
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"
)
// currentColor diisi ke SVG yang memakai fill="currentColor".
const currentColor = "#4f46e5"
// currentColor diisi ke SVG yang memakai fill="currentColor"
// (warna brand: dusk pink, terlihat di background terang maupun gelap).
const currentColor = "#c25c9e"
// 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 {
t.Fatal("ikon currentColor tidak terwarnai (transparan)")
}
// brand indigo #4F46E5: biru > merah > hijau
if !(b > r && r > g) {
t.Errorf("warna pengganti bukan brand indigo: r=%d g=%d b=%d", r>>8, g>>8, b>>8)
// brand dusk pink #C25C9E: merah > biru > hijau
if !(r > b && b > g) {
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
Icon string // path absolut atau nama ikon
Categories string // sudah diakhiri ";" atau kosong
Terminal bool // true = jalankan di terminal (untuk script interaktif)
WorkingDir string // folder kerja saat dijalankan (Path=), opsional
// NoDisplay menyembunyikan entry dari menu aplikasi. Dipakai kalau user
// hanya memilih Desktop: file tetap ada di applications/ sebagai sumber kebenaran.
NoDisplay bool
@@ -29,7 +31,10 @@ func (e Entry) Render() string {
if e.Icon != "" {
fmt.Fprintf(&b, "Icon=%s\n", escapeValue(e.Icon))
}
b.WriteString("Terminal=false\n")
fmt.Fprintf(&b, "Terminal=%t\n", e.Terminal)
if e.WorkingDir != "" {
fmt.Fprintf(&b, "Path=%s\n", escapeValue(e.WorkingDir))
}
if e.Categories != "" {
fmt.Fprintf(&b, "Categories=%s\n", escapeValue(e.Categories))
}
@@ -71,6 +76,9 @@ func escapeValue(s string) string {
return r.Replace(s)
}
// Slug mengembalikan nama berkas yang aman dari nama aplikasi (dipakai UI).
func Slug(name string) string { return slugify(name) }
// slugify membuat nama file yang aman dari nama aplikasi.
func slugify(name string) string {
var b strings.Builder
+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
IconData []byte // opsional: ikon yang sudah diunduh UI
Name string
Terminal bool // jalankan di terminal (script interaktif)
Category string
Menu bool
Desktop bool
@@ -48,10 +49,9 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) {
return sc, fmt.Errorf("%s already exists, rename the app or remove that file first", binDst)
}
if !samePath(o.BinaryPath, binDst) {
if err := copyFile(o.BinaryPath, binDst, 0o755); err != nil {
return sc, err
}
kind := DetectKind(o.BinaryPath)
if err := installBinary(kind, o.BinaryPath, binDst); err != nil {
return sc, err
}
iconPath, iconSource, err := installIconFor(iconOptions{
IconPath: o.IconPath,
@@ -66,8 +66,7 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) {
return sc, err
}
// jenis aplikasi dideteksi otomatis; .exe Windows dijalankan lewat wine
kind := DetectKind(o.BinaryPath)
// .exe Windows dijalankan lewat wine; script lewat wrapper (PATH & cwd)
execArgs := []string{binDst}
if kind == KindWindows {
wine, err := exec.LookPath("wine")
@@ -82,8 +81,16 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) {
ExecArgs: execArgs,
Icon: iconPath,
Categories: category(o.Category, "Utility;"),
Terminal: o.Terminal && kind != KindWindows,
NoDisplay: !o.Menu,
}
if kind == KindScript {
// desktop environment menjalankan dengan cwd = home, jadi folder asli
// script dipakai sebagai Path= kalau masih ada
if _, err := os.Stat(filepath.Dir(o.BinaryPath)); err == nil {
entry.WorkingDir = filepath.Dir(o.BinaryPath)
}
}
if err := WriteDesktop(deskDst, entry); err != nil {
return sc, err
}
@@ -94,6 +101,8 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) {
Type: store.TypeExecutable,
Kind: kind,
ExecArgs: entry.ExecArgs,
Categories: entry.Categories,
Terminal: entry.Terminal,
BinaryPath: binDst,
IconPath: iconPath,
IconSource: iconSource,
@@ -189,6 +198,7 @@ func InstallWebApp(o WebOptions) (store.Shortcut, error) {
Type: store.TypeWebApp,
Kind: KindPWA,
URL: pageURL,
Categories: entry.Categories,
Browser: strings.Join(o.Browser.Exec, " "),
ExecArgs: entry.ExecArgs,
IconPath: iconPath,
+60
View File
@@ -105,3 +105,63 @@ func desktopCopy(desktopPath, slug string) (string, error) {
}
return dst, copyFile(desktopPath, dst, 0o755)
}
// installBinary menyalin berkas target ke binDst.
//
// Script dapat perlakuan khusus: yang disalin adalah script aslinya (dengan
// akhiran .real) plus wrapper kecil. Desktop environment menjalankan shortcut
// dengan cwd = home dan PATH sesi, sehingga script yang memakai path relatif
// atau tool di ~/.local/bin gagal; wrapper yang membereskan keduanya.
func installBinary(kind, src, binDst string) error {
if kind == KindScript {
if err := copyFile(src, binDst+scriptSuffix, 0o755); err != nil {
return err
}
return writeScriptWrapper(binDst, binDst+scriptSuffix, src)
}
if samePath(src, binDst) {
return nil
}
return copyFile(src, binDst, 0o755)
}
// writeFileMode menulis berkas dengan mode tertentu (dipakai saat restore).
func writeFileMode(path string, data []byte, mode os.FileMode) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, data, mode)
}
// scriptSuffix adalah akhiran berkas script asli di samping wrapper.
const scriptSuffix = ".real"
// writeScriptWrapper menulis wrapper yang menyiapkan PATH dan folder kerja
// sebelum menjalankan script.
//
// Script asli di folder asalnya dipakai lebih dulu kalau masih ada (supaya
// `dirname $0` dan berkas di sebelahnya tetap mengarah ke sana); salinan di
// ~/.local/bin jadi cadangan kalau script aslinya sudah pindah/dihapus.
func writeScriptWrapper(path, real, originFile string) error {
body := "#!/bin/sh\n" +
"# dibuat oleh LazyRun: siapkan PATH & folder kerja lalu jalankan script\n" +
`PATH="$HOME/.local/bin:$PATH"` + "\n" +
"export PATH\n"
originDir := filepath.Dir(originFile)
if originDir != "" && originDir != "." && originDir != "/" {
quoted := shellQuote(originDir)
body += "if [ -d " + quoted + " ]; then cd " + quoted + " || exit 1; fi\n"
}
if originFile != "" && originFile != real {
body += "if [ -f " + shellQuote(originFile) + " ]; then exec " + shellQuote(originFile) + ` "$@"; fi` + "\n"
}
body += "exec " + shellQuote(real) + ` "$@"` + "\n"
return os.WriteFile(path, []byte(body), 0o755)
}
// shellQuote mengutip nilai untuk dipakai di dalam script shell.
func shellQuote(value string) string {
return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
}
+78
View File
@@ -267,3 +267,81 @@ func TestUninstallKeepsBinaryByDefault(t *testing.T) {
t.Errorf("binary harus tetap ada: %v", err)
}
}
func TestScriptWrapperPrefersOriginalFile(t *testing.T) {
home := testEnv(t)
dir := filepath.Join(home, "proyek")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
src := filepath.Join(dir, "bench.sh")
if err := os.WriteFile(src, []byte("#!/bin/sh\necho asli \"$(pwd)\"\n"), 0o755); err != nil {
t.Fatal(err)
}
sc, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Bench", Menu: true})
if err != nil {
t.Fatal(err)
}
wrapper, err := os.ReadFile(sc.BinaryPath)
if err != nil {
t.Fatal(err)
}
text := string(wrapper)
if !strings.Contains(text, shellQuote(src)) {
t.Errorf("wrapper tidak memakai script asli:\n%s", text)
}
if !strings.Contains(text, shellQuote(dir)) {
t.Errorf("wrapper tidak cd ke folder asli:\n%s", text)
}
if !strings.Contains(text, "PATH=\"$HOME/.local/bin:$PATH\"") {
t.Errorf("wrapper tidak menyiapkan PATH:\n%s", text)
}
}
func TestInstallCLIAppGetsTerminalEntry(t *testing.T) {
testEnv(t)
// pakai binary tes ini sendiri: ELF asli tanpa library GUI
self, err := os.Executable()
if err != nil {
t.Fatal(err)
}
src := self
// binary tanpa library GUI -> dianggap aplikasi terminal
terminal := RunsInTerminal(src)
if !terminal {
t.Fatal("binary CLI harus terdeteksi butuh terminal")
}
sc, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "TUI Tool", Menu: true, Terminal: terminal})
if err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(sc.DesktopPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "Terminal=true\n") {
t.Errorf("aplikasi CLI harus Terminal=true:\n%s", content)
}
}
func TestInstallGUIAppKeepsTerminalFalse(t *testing.T) {
home := testEnv(t)
src := fakeBinary(t, home, "gui-tool")
// simulasi aplikasi GUI: tidak butuh terminal
sc, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "GUI Tool", Menu: true, Terminal: false})
if err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(sc.DesktopPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "Terminal=false\n") {
t.Errorf("aplikasi GUI tidak boleh membuka terminal:\n%s", content)
}
}
+178
View File
@@ -0,0 +1,178 @@
package shortcut
import (
"errors"
"os"
"os/exec"
"path/filepath"
"strings"
"lazyrun/internal/icon"
"lazyrun/internal/store"
"lazyrun/internal/xdg"
)
// Restore memasang ulang shortcut dari data cadangan ke mesin ini:
// binary dan ikon ditulis dari isi cadangan (kalau ada), lalu .desktop dibuat
// dengan path lokal. Metadata yang menunjuk ke komputer lain tidak dipakai
// mentah-mentah, jadi hasil restore langsung bisa dipakai.
func Restore(item store.Shortcut, binary, iconData []byte) (store.Shortcut, error) {
name := strings.TrimSpace(item.Name)
if name == "" {
return item, errors.New("nama shortcut kosong")
}
sl := slugify(name)
if sl == "" {
return item, errors.New("nama shortcut tidak valid")
}
restored := item
restored.Slug = sl
restored.Name = name
restored.DesktopCopyPath = ""
restored.IconPath = ""
if restored.Type == store.TypeExecutable {
if err := restoreBinary(&restored, sl, binary); err != nil {
return restored, err
}
}
if restored.Type == store.TypeWebApp && len(restored.ExecArgs) == 0 && restored.Browser != "" {
restored.ExecArgs = webAppArgs(restored.Browser, restored.URL)
}
iconPath, err := restoreIcon(restored, sl, iconData)
if err != nil {
return restored, err
}
restored.IconPath = iconPath
entry := Entry{
Name: name,
ExecArgs: restored.ExecArgs,
Icon: iconPath,
Categories: restored.Categories,
Terminal: restored.Terminal && restored.Kind != KindWindows,
NoDisplay: !restored.Targets.Menu,
}
if entry.Categories == "" {
entry.Categories = "Utility;"
}
if restored.Kind == KindScript {
if dir := scriptOriginDir(restored); dir != "" {
entry.WorkingDir = dir
}
}
deskDst := filepath.Join(xdg.ApplicationsDir(), sl+".desktop")
if err := WriteDesktop(deskDst, entry); err != nil {
return restored, err
}
restored.DesktopPath = deskDst
if restored.Targets.Desktop {
copyPath := filepath.Join(xdg.DesktopDir(), sl+".desktop")
if err := copyFile(deskDst, copyPath, 0o755); err != nil {
return restored, err
}
restored.DesktopCopyPath = copyPath
}
go RefreshCache()
return restored, nil
}
// restoreBinary menulis binary/script dari cadangan ke ~/.local/bin.
// Kalau cadangan tidak memuat isinya, berkas lokal yang masih ada dipakai.
func restoreBinary(restored *store.Shortcut, slug string, binary []byte) error {
binDst := filepath.Join(xdg.BinDir(), slug)
originDir := scriptOriginDir(*restored)
if len(binary) == 0 {
// tidak ada isi cadangan: pakai berkas lama kalau masih ada di mesin ini
if _, err := os.Stat(restored.BinaryPath); err != nil {
restored.BinaryPath = ""
restored.ExecArgs = nil
return nil
}
binary, _ = os.ReadFile(restored.BinaryPath)
}
if err := writeFileMode(binDst, binary, 0o755); err != nil {
return err
}
kind := DetectKind(binDst)
if kind == KindScript {
// script dijalankan lewat wrapper: script aslinya disimpan sebagai .real
if err := os.Rename(binDst, binDst+scriptSuffix); err != nil {
return err
}
originFile := ""
if originDir != "" {
originFile = filepath.Join(originDir, filepath.Base(restored.BinaryPath))
}
if err := writeScriptWrapper(binDst, binDst+scriptSuffix, originFile); err != nil {
return err
}
}
restored.Kind = kind
restored.BinaryPath = binDst
restored.ExecArgs = []string{binDst}
if kind == KindWindows {
if wine, err := exec.LookPath("wine"); err == nil {
restored.ExecArgs = []string{wine, binDst}
}
}
return nil
}
// restoreIcon memakai ikon dari cadangan; kalau tidak ada, coba unduh ulang
// dari sumber yang tercatat, lalu jatuh ke ikon default.
func restoreIcon(item store.Shortcut, slug string, iconData []byte) (string, error) {
if len(iconData) > 0 {
if path, err := icon.Save(iconData, slug); err == nil {
return path, nil
}
}
source := item.IconSource
if source != "" {
options := iconOptions{Slug: slug}
switch {
case strings.HasPrefix(source, iconSourceFavicon):
options.PageURL = strings.TrimPrefix(source, iconSourceFavicon)
case strings.HasPrefix(source, iconSourceSearch):
options.Query = strings.TrimPrefix(source, iconSourceSearch)
case strings.HasPrefix(source, "http"):
options.IconURL = source
}
if path, _, err := installIconFor(options); err == nil && path != "" {
return path, nil
}
}
return icon.Default(slug)
}
// scriptOriginDir menebak folder asal script dari metadata (dipakai sebagai
// Path= dan folder kerja wrapper).
func scriptOriginDir(item store.Shortcut) string {
dir := filepath.Dir(item.BinaryPath)
if dir == "" || dir == "." || dir == "/" {
return ""
}
return dir
}
// webAppArgs menyusun argv PWA dari perintah browser yang tersimpan.
func webAppArgs(browser, pageURL string) []string {
args := strings.Fields(browser)
if len(args) == 0 {
return nil
}
if strings.Contains(strings.ToLower(args[0]), "firefox") {
return append(args, "--new-window", pageURL)
}
return append(args, "--app="+pageURL)
}
+166
View File
@@ -0,0 +1,166 @@
package shortcut
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"lazyrun/internal/store"
)
func TestRestoreExecutableFromBackup(t *testing.T) {
home := testEnv(t)
backup := store.Shortcut{
Slug: "winbox",
Name: "Winbox",
Type: store.TypeExecutable,
Kind: "binary",
BinaryPath: "/komputer-lama/.local/bin/winbox", // path dari mesin lain
IconPath: "/komputer-lama/.local/share/icons/winbox.png",
DesktopPath: "/komputer-lama/.local/share/applications/winbox.desktop",
Categories: "Network;",
Targets: store.Targets{Menu: true, Desktop: true},
}
restored, err := Restore(backup, append([]byte{0x7f, 'E', 'L', 'F', 0x02, 0x01, 0x01, 0x00}, []byte("isi")...), nil)
if err != nil {
t.Fatal(err)
}
wantBin := filepath.Join(home, ".local", "bin", "winbox")
if restored.BinaryPath != wantBin {
t.Errorf("binary = %s, mau %s", restored.BinaryPath, wantBin)
}
if st, err := os.Stat(wantBin); err != nil || st.Mode().Perm()&0o111 == 0 {
t.Fatalf("binary hasil restore tidak executable: %v %v", st, err)
}
// path dari mesin lama tidak boleh dipakai
if strings.Contains(restored.DesktopPath, "komputer-lama") {
t.Errorf("desktop path masih menunjuk mesin lama: %s", restored.DesktopPath)
}
if restored.DesktopCopyPath != filepath.Join(home, "Desktop", "winbox.desktop") {
t.Errorf("salinan desktop = %s", restored.DesktopCopyPath)
}
content, err := os.ReadFile(restored.DesktopPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "Exec="+wantBin+"\n") || !strings.Contains(string(content), "Categories=Network;\n") {
t.Errorf("isi .desktop hasil restore salah:\n%s", content)
}
if _, err := os.Stat(restored.IconPath); err != nil {
t.Errorf("ikon hasil restore tidak ada: %v", err)
}
}
func TestRestoreScriptGetsWrapper(t *testing.T) {
home := testEnv(t)
backup := store.Shortcut{
Name: "Deploy",
Type: store.TypeExecutable,
Kind: "script",
BinaryPath: "/komputer-lama/proyek/deploy.sh",
Terminal: true,
Targets: store.Targets{Menu: true},
}
restored, err := Restore(backup, []byte("#!/bin/sh\necho halo\n"), nil)
if err != nil {
t.Fatal(err)
}
if restored.Kind != KindScript {
t.Errorf("kind = %q", restored.Kind)
}
if _, err := os.Stat(filepath.Join(home, ".local", "bin", "deploy")); err != nil {
t.Errorf("wrapper tidak dibuat: %v", err)
}
if _, err := os.Stat(filepath.Join(home, ".local", "bin", "deploy"+scriptSuffix)); err != nil {
t.Errorf("script asli tidak dibuat: %v", err)
}
content, err := os.ReadFile(restored.DesktopPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "Terminal=true") {
t.Errorf("flag terminal hilang:\n%s", content)
}
}
func TestRestoreWithoutBinaryBlobMarksMissing(t *testing.T) {
testEnv(t)
backup := store.Shortcut{
Name: "Hilang",
Type: store.TypeExecutable,
BinaryPath: "/komputer-lama/.local/bin/hilang",
Targets: store.Targets{Menu: true},
}
restored, err := Restore(backup, nil, nil)
if err != nil {
t.Fatal(err)
}
if restored.BinaryPath != "" {
t.Errorf("binary mestinya kosong (tidak bisa dipulihkan), dapat %s", restored.BinaryPath)
}
if restored.ExecArgs != nil {
t.Errorf("exec args mestinya kosong: %v", restored.ExecArgs)
}
// .desktop tetap dibuat supaya entri bisa dibetulkan lewat edit
if _, err := os.Stat(restored.DesktopPath); err != nil {
t.Errorf(".desktop tidak dibuat: %v", err)
}
}
func TestRestoreWebAppRebuildsExec(t *testing.T) {
testEnv(t)
backup := store.Shortcut{
Name: "WhatsApp Web",
Type: store.TypeWebApp,
Kind: KindPWA,
URL: "https://web.whatsapp.com",
Browser: "flatpak run org.chromium.Chromium",
Targets: store.Targets{Menu: true},
}
restored, err := Restore(backup, nil, nil)
if err != nil {
t.Fatal(err)
}
if len(restored.ExecArgs) != 4 || restored.ExecArgs[3] != "--app=https://web.whatsapp.com" {
t.Errorf("exec args PWA salah: %v", restored.ExecArgs)
}
}
func TestRestoreIconFromSource(t *testing.T) {
testEnv(t)
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = w.Write(append(pngMagic, []byte("ikon dari internet")...))
}))
defer srv.Close()
backup := store.Shortcut{
Name: "Icon",
Type: store.TypeExecutable,
BinaryPath: "",
IconPath: "/komputer-lama/ikon.png",
IconSource: srv.URL + "/ikon.png",
Targets: store.Targets{Menu: true},
}
restored, err := Restore(backup, nil, nil)
if err != nil {
t.Fatal(err)
}
data, err := os.ReadFile(restored.IconPath)
if err != nil {
t.Fatalf("ikon tidak dipasang: %v", err)
}
if !strings.Contains(string(data), "ikon dari internet") {
t.Errorf("ikon tidak diunduh ulang dari sumber: %q", data)
}
}
+48
View File
@@ -0,0 +1,48 @@
package shortcut
import (
"debug/elf"
"strings"
)
// guiLibs adalah library yang menandakan aplikasi berjendela sendiri.
// Kalau binary tidak memakai satu pun dari ini, biasanya ia program terminal.
var guiLibs = []string{
"libX11", "libxcb", "libwayland-client", "libgtk", "libgdk",
"libQt5", "libQt6", "libSDL", "libGL", "libEGL", "libglfw", "libglib",
}
// RunsInTerminal menebak apakah program perlu jendela terminal.
//
// Script dan aplikasi baris perintah butuh Terminal=true di .desktop,
// sedangkan aplikasi GUI tidak (kalau dipaksa, jendela terminal ikut terbuka).
// Tebakannya dari daftar library dinamis: tanpa library GUI dianggap CLI.
func RunsInTerminal(path string) bool {
kind := DetectKind(path)
if kind == KindScript {
return true
}
if kind == KindWindows {
return false // .exe Windows (lewat wine) umumnya GUI
}
f, err := elf.Open(path)
if err != nil {
return false
}
defer f.Close()
libs, err := f.DynString(elf.DT_NEEDED)
if err != nil {
// binary statis (Go murni) tanpa dependency dinamis: kemungkinan CLI
return true
}
for _, lib := range libs {
for _, gui := range guiLibs {
if strings.Contains(lib, gui) {
return false
}
}
}
return true
}
+69
View File
@@ -0,0 +1,69 @@
package shortcut
import (
"os"
"os/exec"
"path/filepath"
"testing"
)
func TestRunsInTerminal(t *testing.T) {
dir := t.TempDir()
script := filepath.Join(dir, "tool.sh")
if err := os.WriteFile(script, []byte("#!/bin/sh\necho hai\n"), 0o755); err != nil {
t.Fatal(err)
}
if !RunsInTerminal(script) {
t.Error("script harus dianggap butuh terminal")
}
exe := filepath.Join(dir, "win.exe")
if err := os.WriteFile(exe, append([]byte("MZ\x90\x00"), make([]byte, 32)...), 0o755); err != nil {
t.Fatal(err)
}
if RunsInTerminal(exe) {
t.Error("aplikasi Windows (wine) tidak perlu terminal")
}
// binary CLI tanpa library GUI: pakai binary tes ini sendiri
self, err := os.Executable()
if err != nil {
t.Fatal(err)
}
if !RunsInTerminal(self) {
t.Error("binary CLI (tanpa lib GUI) harus dianggap butuh terminal")
}
// berkas tidak ada / bukan ELF
if RunsInTerminal(filepath.Join(dir, "tidak-ada")) {
t.Error("berkas tidak ada tidak boleh dianggap butuh terminal")
}
}
func TestRunsInTerminalOnGUIBinary(t *testing.T) {
// pakai aplikasi GUI nyata kalau ada di mesin ini
candidates := []string{"/opt/helium-browser-bin/helium", "/usr/bin/vivaldi"}
for _, path := range candidates {
if _, err := os.Stat(path); err != nil {
continue
}
if RunsInTerminal(path) {
t.Errorf("%s memakai lib GUI, tidak boleh dianggap butuh terminal", path)
}
return
}
t.Skip("tidak ada binary GUI untuk diuji")
}
func TestRunsInTerminalOnRealCLITool(t *testing.T) {
// lazydocker (TUI Go) adalah kasus nyata yang dilaporkan user
if _, err := os.Stat("/home/oxpoe/.local/bin/lazydocker"); err != nil {
t.Skip("lazydocker tidak ada di mesin ini")
}
if !RunsInTerminal("/home/oxpoe/.local/bin/lazydocker") {
t.Error("lazydocker adalah TUI, harus butuh terminal")
}
}
var _ = exec.LookPath
+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 err := os.Remove(sc.BinaryPath); err != nil && !os.IsNotExist(err) {
errs = append(errs, err)
// script punya dua berkas: wrapper + script aslinya
for _, path := range []string{sc.BinaryPath, sc.BinaryPath + scriptSuffix} {
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
errs = append(errs, err)
}
}
}
+2
View File
@@ -29,6 +29,8 @@ type Shortcut struct {
URL string `json:"url,omitempty"`
Browser string `json:"browser,omitempty"`
ExecArgs []string `json:"exec_args,omitempty"`
Categories string `json:"categories,omitempty"`
Terminal bool `json:"terminal,omitempty"`
BinaryPath string `json:"binary_path,omitempty"`
IconPath string `json:"icon_path,omitempty"`
IconSource string `json:"icon_source,omitempty"`
+1 -1
View File
@@ -17,7 +17,7 @@ import (
// pulseGradient membuat garis aksen bergerak bolak-balik antara indigo dan cyan.
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
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)
desktop := widget.NewCheck(T("check_desktop"), nil)
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) {
binPath = path
binLabel.SetText(filepath.Base(path) + " · " + detectedKind(path))
// aplikasi terminal (TUI/CLI/script) perlu Terminal=true;
// tebakannya dari library yang dipakai binary
terminal.SetChecked(shortcut.RunsInTerminal(path))
if strings.TrimSpace(nameEntry.Text) == "" {
nameEntry.SetText(appNameFromFile(path))
}
})
})
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) {
iconPath, iconURL, iconData = path, "", nil
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) {
iconURL, iconPath, iconData = result.URL, "", data
iconLabel.SetText(result.Name)
@@ -64,7 +68,7 @@ func (ui *app) showExecutableDialog() {
})
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 {
case binPath == "":
dialog.ShowInformation(T("err_title"), T("need_binary"), ui.win)
@@ -83,6 +87,7 @@ func (ui *app) showExecutableDialog() {
IconData: iconData,
Name: nameEntry.Text,
Category: category.Selected,
Terminal: terminal.Checked,
Menu: menu.Checked,
Desktop: desktop.Checked,
}
@@ -91,8 +96,6 @@ func (ui *app) showExecutableDialog() {
func(sc store.Shortcut, err error) { ui.added(sc, err, d.Hide) },
)
})
install.Importance = widget.HighImportance
form := widget.NewForm(
widget.NewFormItem(T("field_binary"), container.NewBorder(nil, nil, nil, browseBin, binLabel)),
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_category"), category),
)
hint := textLine(T("exec_hint"), 12, false, colorNameMuted)
content := container.NewVScroll(container.NewVBox(
closeRow(func() { d.Hide() }),
form,
@@ -107,13 +111,15 @@ func (ui *app) showExecutableDialog() {
sectionTitle(T("section_location")),
menu,
desktop,
muted(T("exec_hint")),
terminal,
hint,
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),
))
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)
}
+7 -5
View File
@@ -87,10 +87,11 @@ func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) {
entry.OnSubmitted = func(string) { search() }
// status di barisnya sendiri: supaya tidak bertabrakan dengan tombol
// 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(),
widget.NewButtonWithIcon(T("back"), theme.NavigateBackIcon(), func() { d.Hide() }),
widget.NewButtonWithIcon(T("search"), theme.SearchIcon(), search),
newButton(T("back"), theme.NavigateBackIcon(), T("tip_back"), widget.MediumImportance, func() { d.Hide() }),
newButton(T("search"), theme.SearchIcon(), T("tip_search"), widget.HighImportance, search),
))
content := container.NewBorder(
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.SetOnClosed(pushHintTarget(hint))
showDialog(d, ui.win, 560, 470) // sekaligus menampilkan dialog
search()
}
@@ -126,7 +128,7 @@ func (ui *app) iconChoice(result icon.Result, onPick func(icon.Result, []byte),
var mu sync.Mutex
var raw []byte
button := widget.NewButton("", func() {
button := newButton("", nil, result.Name, widget.LowImportance, func() {
mu.Lock()
picked := raw
mu.Unlock()
@@ -135,8 +137,8 @@ func (ui *app) iconChoice(result icon.Result, onPick func(icon.Result, []byte),
d.Hide()
}
})
button.Importance = widget.LowImportance
button.Icon = theme.NewThemedResource(theme.FileImageIcon()) // placeholder selama diunduh
button.Refresh()
go func() {
slot <- struct{}{}
+66
View File
@@ -10,7 +10,9 @@ import (
"fyne.io/fyne/v2/test"
"lazyrun/internal/db"
"lazyrun/internal/icon"
"lazyrun/internal/store"
)
// TestNoEmptyDismissButton memastikan modal tidak memakai dialog.NewCustom,
@@ -62,6 +64,17 @@ func TestDialogsOpenWithoutPanic(t *testing.T) {
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) {})
if top := ui.win.Canvas().Overlays().Top(); top == nil {
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) {
iconPath, iconURL, iconData = path, "", nil
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) {
iconURL, iconPath, iconData = result.URL, "", data
iconLabel.SetText(result.Name)
@@ -68,7 +68,7 @@ func (ui *app) showWebAppDialog() {
})
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 {
case strings.TrimSpace(urlEntry.Text) == "":
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) },
)
})
install.Importance = widget.HighImportance
form := widget.NewForm(
widget.NewFormItem(T("field_url"), urlEntry),
widget.NewFormItem(T("field_name"), nameEntry),
@@ -105,6 +103,7 @@ func (ui *app) showWebAppDialog() {
widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil,
container.NewHBox(onlineIcon, browseIcon), iconLabel)),
)
hint := textLine(T("web_hint"), 12, false, colorNameMuted)
content := container.NewVScroll(container.NewVBox(
closeRow(func() { d.Hide() }),
form,
@@ -112,13 +111,14 @@ func (ui *app) showWebAppDialog() {
sectionTitle(T("section_location")),
menu,
desktop,
muted(T("web_hint")),
hint,
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),
))
d = dialog.NewCustomWithoutButtons(T("new_web_title"), content, ui.win)
d.SetOnClosed(pushHintTarget(hint))
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")
}
}
+30 -11
View File
@@ -1,7 +1,9 @@
package ui
import (
"errors"
"net/url"
"os"
"os/exec"
"fyne.io/fyne/v2"
@@ -27,18 +29,15 @@ func (ui *app) openURL(raw string) {
}
}
// launch menjalankan shortcut: pakai argv hasil install, fallback ke binary/URL.
// launch menjalankan shortcut.
//
// Kalau ada, `gio launch <file .desktop>` dipakai supaya aturannya sama dengan
// menu desktop: Terminal=true membuka jendela terminal (script yang butuh sudo
// atau input bisa jalan) dan Path= ikut dihormati.
func (ui *app) launch(sc store.Shortcut) {
args := sc.ExecArgs
if len(args) == 0 {
switch {
case sc.Type == store.TypeWebApp && sc.URL != "":
args = []string{"xdg-open", sc.URL}
case sc.BinaryPath != "":
args = []string{sc.BinaryPath}
}
}
if len(args) == 0 {
args, err := launchCommand(sc)
if err != nil {
dialog.ShowError(err, ui.win)
return
}
@@ -49,3 +48,23 @@ func (ui *app) launch(sc store.Shortcut) {
}
go cmd.Wait() // supaya proses anak tidak jadi zombie
}
// launchCommand menyusun perintah untuk menjalankan shortcut.
func launchCommand(sc store.Shortcut) ([]string, error) {
if sc.DesktopPath != "" {
if gio, err := exec.LookPath("gio"); err == nil {
if _, err := os.Stat(sc.DesktopPath); err == nil {
return []string{gio, "launch", sc.DesktopPath}, nil
}
}
}
switch {
case len(sc.ExecArgs) > 0:
return sc.ExecArgs, nil
case sc.Type == store.TypeWebApp && sc.URL != "":
return []string{"xdg-open", sc.URL}, nil
case sc.BinaryPath != "":
return []string{sc.BinaryPath}, nil
}
return nil, errors.New("shortcut ini tidak punya perintah yang bisa dijalankan")
}
+27 -22
View File
@@ -32,6 +32,8 @@ type app struct {
icons *iconCache
count *canvas.Text // angka jumlah shortcut di header daftar
versionText *canvas.Text // versi di footer; berubah kalau ada update
hint *canvas.Text // keterangan tombol yang sedang disorot
accent *canvas.LinearGradient
version string
}
@@ -39,6 +41,7 @@ type app struct {
func Run(a fyne.App, version string) {
ui, loadErr := build(a, version)
ui.win.Show()
pushHintTarget(ui.hint) // target default: baris hint di footer
ui.flashCards()
ui.scheduleUpdateCheck()
if loadErr != nil {
@@ -66,9 +69,8 @@ func build(a fyne.App, version string) (*app, error) {
st, err := store.Load(xdg.MetadataPath())
ui.store = st
addExec := widget.NewButtonWithIcon(T("btn_executable"), theme.ContentAddIcon(), ui.showExecutableDialog)
addExec.Importance = widget.HighImportance
addWeb := widget.NewButtonWithIcon(T("btn_webapp"), theme.ContentAddIcon(), ui.showWebAppDialog)
addExec := newButton(T("btn_executable"), theme.ContentAddIcon(), T("tip_executable"), widget.HighImportance, ui.showExecutableDialog)
addWeb := newButton(T("btn_webapp"), theme.ContentAddIcon(), T("tip_webapp"), widget.MediumImportance, ui.showWebAppDialog)
leftHeader := container.NewHBox(logoBox(newLogo(), 40), container.NewVBox(
heading("LazyRun"),
@@ -77,19 +79,22 @@ func build(a fyne.App, version string) (*app, error) {
rightHeader := container.NewHBox(addExec, addWeb)
header := container.NewBorder(nil, nil, leftHeader, rightHeader)
update := widget.NewButtonWithIcon(T("check_updates"), theme.DownloadIcon(), ui.checkUpdates)
github := widget.NewButton(T("github"), func() { ui.openURL(repoURL) })
donate := widget.NewButtonWithIcon(T("donate"),
fyne.NewStaticResource("heart.png", assets.Heart), func() { ui.openURL(donateURL) })
update := newButton(T("check_updates"), theme.DownloadIcon(), T("tip_check_updates"), widget.MediumImportance, ui.checkUpdates)
github := newButton(T("github"), nil, T("tip_github"), widget.MediumImportance, func() { ui.openURL(repoURL) })
donate := newButton(T("donate"), fyne.NewStaticResource("heart.png", assets.Heart), T("tip_donate"), widget.MediumImportance, 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(
widget.NewSeparator(), nil,
container.NewHBox(update, github, donate),
container.NewHBox(update, export, importBtn, github, donate),
ui.versionText,
ui.hint,
)
accent := accentBar()
pulseGradient(accent)
top := container.NewVBox(header, accent,
ui.accent = accentBar()
pulseGradient(ui.accent)
top := container.NewVBox(header, ui.accent,
container.NewHBox(sectionLabel(T("installed")), countChip))
body := container.NewBorder(
top,
@@ -144,16 +149,17 @@ func (ui *app) row(sc store.Shortcut) *hoverCard {
}
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) {
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 {
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,
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.
func (ui *app) syncIcon(sc store.Shortcut) {
progress := dialog.NewProgressInfinite(T("sync_icon"), T("working"), ui.win)
progress.Show()
hide := ui.showLoading(T("sync_icon"), T("working"))
go func() {
path, err := shortcut.RefreshIcon(sc)
fyne.Do(func() {
progress.Hide()
hide()
if err != nil {
dialog.ShowError(err, ui.win)
return
@@ -291,14 +296,14 @@ func (ui *app) confirmDelete(sc store.Shortcut) {
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)) {
progress := dialog.NewProgressInfinite(T("install"), T("working"), ui.win)
progress.Show()
hide := ui.showLoading(T("install"), T("working"))
go func() {
sc, err := work()
fyne.Do(func() {
progress.Hide()
hide()
done(sc, err)
})
}()
+38
View File
@@ -25,12 +25,14 @@ var tr = map[string][2]string{
"searching": {"Searching...", "Mencari..."},
"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."},
"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."},
"icon_search_title": {"Icon from the internet", "Ikon dari internet"},
"sync_icon": {"Sync icon", "Sinkron ikon"},
"browse": {"Browse...", "Pilih..."},
"install": {"Install", "Install"},
"cancel": {"Cancel", "Batal"},
"save": {"Save", "Simpan"},
"back": {"Back", "Kembali"},
"delete": {"Delete", "Hapus"},
"working": {"Working...", "Sedang diproses..."},
@@ -47,6 +49,8 @@ var tr = map[string][2]string{
"section_location": {"Shortcut location", "Lokasi shortcut"},
"check_menu": {"Application menu", "Menu aplikasi"},
"check_desktop": {"Desktop", "Desktop"},
"check_terminal": {"Run in a terminal window (CLI/TUI apps)", "Jalankan di jendela terminal (aplikasi CLI/TUI)"},
"terminal_suggest": {"This looks like a terminal app - tick the box above if it closes right away.", "Ini kelihatan aplikasi terminal - centang kotak di atas kalau menutup begitu saja."},
"exec_hint": {"The binary is copied to ~/.local/bin and can be uninstalled from the list.", "Binary disalin ke ~/.local/bin dan bisa dihapus lagi dari daftar."},
"web_hint": {"The icon comes from the site's favicon, or is looked up online if there is none.", "Ikon diambil dari favicon situs, atau dicari otomatis di internet kalau tidak ada."},
@@ -78,6 +82,40 @@ var tr = map[string][2]string{
"update_restart": {"Restart now", "Restart sekarang"},
"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."},
"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()
+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.
func accentBar() *canvas.LinearGradient {
bar := canvas.NewLinearGradient(brandIndigo, brandCyan, 90)
bar := canvas.NewLinearGradient(brandAmber, brandMagenta, 90)
bar.SetMinSize(fyne.NewSize(0, 3))
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.
func thumb(path string, fallback fyne.Resource) fyne.CanvasObject {
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
// selalu ada jalan keluar yang terlihat walau tombol bawah terpotong.
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.
+20 -16
View File
@@ -7,10 +7,11 @@ import (
"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 (
brandIndigo = color.NRGBA{R: 0x4F, G: 0x46, B: 0xE5, A: 0xFF}
brandCyan = color.NRGBA{R: 0x06, G: 0xB6, B: 0xD4, A: 0xFF}
brandPurple = color.NRGBA{R: 0x6A, G: 0x3D, B: 0x9A, 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.
@@ -31,31 +32,34 @@ func (b brand) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.
dark := variant == theme.VariantDark
switch name {
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:
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:
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:
return withAlpha(brandIndigo, pick[uint8](dark, 0x2E, 0x14))
return withAlpha(brandAmber, pick[uint8](dark, 0x1F, 0x18))
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:
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:
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:
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:
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:
return withAlpha(brandIndigo, pick[uint8](dark, 0x44, 0x26))
return withAlpha(brandPurple, pick[uint8](dark, 0x66, 0x2E))
case colorNameChipDesk:
return withAlpha(brandCyan, pick[uint8](dark, 0x3D, 0x2C))
return withAlpha(brandMagenta, pick[uint8](dark, 0x4E, 0x30))
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:
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)
}
+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")
}
}
+13 -15
View File
@@ -97,9 +97,9 @@ func (ui *app) markUpdatePending(rel update.Release) {
// showUpdateDialog menawarkan update: unduh sekarang, buka halaman rilis, atau nanti.
func (ui *app) showUpdateDialog(rel update.Release) {
notes := widget.NewLabel(rel.Notes)
notes.Wrapping = fyne.TextWrapWord
notes.Importance = widget.LowImportance
// catatan rilis GitHub berformat markdown; render supaya blok kode/daftar
// tidak tampil sebagai teks mentah
notes := widget.NewRichTextFromMarkdown(rel.Notes)
// catatan rilis di tengah (bukan top): VScroll di Fyne punya MinSize kecil,
// jadi kalau dipasang sebagai top dari Border isinya terpotong.
@@ -110,18 +110,18 @@ func (ui *app) showUpdateDialog(rel update.Release) {
buttons := container.NewHBox(
logoBox(newLogo(), 20),
spacer(),
widget.NewButton(T("update_later"), func() { d.Hide() }),
widget.NewButton(T("update_page"), func() { ui.openURL(rel.PageURL) }),
newButton(T("update_later"), nil, T("tip_later"), widget.MediumImportance, func() { d.Hide() }),
newButton(T("update_page"), nil, T("tip_update_page"), widget.MediumImportance, func() { ui.openURL(rel.PageURL) }),
)
if updates := ui.updaterBinary(); updates != "" {
// 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()
ui.runUpdater(updates, rel)
}))
} 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()
ui.downloadUpdate(rel)
}))
@@ -151,12 +151,11 @@ func (ui *app) downloadUpdate(rel update.Release) {
return
}
progress := dialog.NewProgressInfinite(T("check_updates"), T("update_downloading"), ui.win)
progress.Show()
hide := ui.showLoading(T("check_updates"), T("update_downloading"))
go func() {
err := update.Download(rel, appImage)
fyne.Do(func() {
progress.Hide()
hide()
if err != nil {
dialog.ShowError(err, ui.win)
return
@@ -174,12 +173,11 @@ func (ui *app) runUpdater(updater string, rel update.Release) {
return
}
progress := dialog.NewProgressInfinite(T("check_updates"), T("update_downloading"), ui.win)
progress.Show()
hide := ui.showLoading(T("check_updates"), T("update_downloading"))
go func() {
err := exec.Command(updater, appImage).Run()
fyne.Do(func() {
progress.Hide()
hide()
if err != nil {
dialog.ShowError(err, ui.win)
return
@@ -195,8 +193,8 @@ func (ui *app) offerRestart() {
content := container.NewVBox(
widget.NewLabel(T("update_installed")),
container.NewHBox(spacer(),
widget.NewButton(T("update_later"), func() { d.Hide() }),
widget.NewButtonWithIcon(T("update_restart"), theme.ViewRefreshIcon(), ui.restart),
newButton(T("update_later"), nil, T("tip_later"), widget.MediumImportance, func() { d.Hide() }),
newButton(T("update_restart"), theme.ViewRefreshIcon(), T("tip_restart"), widget.HighImportance, ui.restart),
),
)
d = dialog.NewCustomWithoutButtons(T("check_updates"), content, ui.win)