6 Commits
Author SHA1 Message Date
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
oxpoe fbf7344b09 Update: deteksi AppImage tanpa $APPIMAGE, cek berkala, indikator di footer
- appImagePath(): pakai $APPIMAGE kalau ada, kalau tidak periksa berkas
  executable sendiri apakah AppImage (dulu cek update dilewati begitu saja)
- cek ulang tiap 4 jam selama aplikasi terbuka, bukan hanya saat start
- versi di footer berubah jadi 'v1.1.0 -> v1.1.1' saat ada update, jadi tetap
  terlihat walau dialognya ditutup dengan Nanti
- tes: appImagePath (env, fallback, bukan AppImage), indikator footer
2026-10-13 08:32:42 +07:00
oxpoe a7e75d95c4 CI: header catatan rilis (cara install + info update) sebelum changelog otomatis 2026-10-13 08:26:38 +07:00
32 changed files with 1629 additions and 101 deletions

No files matched your search

+15
View File
@@ -44,8 +44,23 @@ jobs:
env: env:
GH_TOKEN: ${{ github.token }} GH_TOKEN: ${{ github.token }}
run: | run: |
cat > /tmp/notes.md <<'NOTES'
Single-file AppImage for Linux x86_64. No root needed, everything stays in
`~/.local/` and `~/.config/lazyrun/`.
```bash
chmod +x lazyrun-*-x86_64.AppImage
./lazyrun-*-x86_64.AppImage
```
Already using LazyRun? It checks this release page on startup and offers to
update itself (delta updates with `AppImageUpdate` when installed).
---
NOTES
gh release create "$GITHUB_REF_NAME" \ gh release create "$GITHUB_REF_NAME" \
dist/lazyrun-*.AppImage \ dist/lazyrun-*.AppImage \
dist/lazyrun-*.AppImage.zsync \ dist/lazyrun-*.AppImage.zsync \
--title "LazyRun ${GITHUB_REF_NAME}" \ --title "LazyRun ${GITHUB_REF_NAME}" \
--notes-file /tmp/notes.md \
--generate-notes --generate-notes
+18 -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 from an executable file, or from a web URL as a PWA. Ships as a single-file
AppImage, no terminal needed. AppImage, no terminal needed.
*Dream-sky theme: night violet, dusk pink and a moon-coloured accent that subtly
breathes while the app is open (the koala in `assets/logo.svg` is the mascot).*
![LazyRun main window](docs/main-dark.png) ![LazyRun main window](docs/main-dark.png)
<p align="center"> <p align="center">
@@ -142,9 +145,21 @@ GH_USER=oxpoe GH_REPO=lazyrun ./build.sh
## Roadmap ## Roadmap
- v1.1 — edit existing shortcuts, detect binaries in `$PATH`, optional symlink - Edit existing shortcuts, detect binaries in `$PATH`, optional symlink instead of a
instead of a Desktop copy, import/export, dark mode. Desktop copy, import/export, dark mode.
- v1.2 — Firefox PWA via `--kiosk`, Flatpak/Snap targets, icon preview. - Firefox PWA via `--kiosk`, Flatpak/Snap targets, icon preview.
Known limitations are tracked as [issues](https://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 ## 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.

Before

Width:  |  Height:  |  Size: 60 KiB

After

Width:  |  Height:  |  Size: 57 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 57 KiB

After

Width:  |  Height:  |  Size: 54 KiB

+11 -2
View File
@@ -1,6 +1,6 @@
module lazyrun module lazyrun
go 1.24 go 1.26.0
require ( require (
fyne.io/fyne/v2 v2.8.2 fyne.io/fyne/v2 v2.8.2
@@ -16,6 +16,7 @@ require (
github.com/anthonynsimon/bild v0.14.0 // indirect github.com/anthonynsimon/bild v0.14.0 // indirect
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect github.com/davecgh/go-spew v1.1.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 // indirect github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 // indirect
github.com/fyne-io/glfw-js v0.4.0 // indirect github.com/fyne-io/glfw-js v0.4.0 // indirect
@@ -25,20 +26,28 @@ require (
github.com/go-text/render v0.2.1 // indirect github.com/go-text/render v0.2.1 // indirect
github.com/go-text/typesetting v0.3.5 // indirect github.com/go-text/typesetting v0.3.5 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/hack-pad/go-indexeddb v0.3.2 // indirect github.com/hack-pad/go-indexeddb v0.3.2 // indirect
github.com/hack-pad/safejs v0.1.0 // indirect github.com/hack-pad/safejs v0.1.0 // indirect
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
github.com/mattn/go-isatty v0.0.24 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/ncruces/go-strftime v1.0.0 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rymdport/portal v0.4.2 // indirect github.com/rymdport/portal v0.4.2 // indirect
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
github.com/stretchr/testify v1.11.1 // indirect github.com/stretchr/testify v1.11.1 // indirect
github.com/yuin/goldmark v1.8.2 // indirect github.com/yuin/goldmark v1.8.2 // indirect
golang.org/x/net v0.35.0 // indirect golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect golang.org/x/sys v0.48.0 // indirect
golang.org/x/text v0.22.0 // indirect golang.org/x/text v0.22.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect gopkg.in/yaml.v3 v3.0.1 // indirect
modernc.org/libc v1.77.1 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.12.1 // indirect
modernc.org/sqlite v1.60.1 // indirect
) )
+20
View File
@@ -12,6 +12,8 @@ github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdoh
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM= github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 h1:0kdPD/GEntpWmZEK5Zu/xE6Tr37jYCVDf9QP8lA/QK8= github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 h1:0kdPD/GEntpWmZEK5Zu/xE6Tr37jYCVDf9QP8lA/QK8=
@@ -34,6 +36,8 @@ github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc h1:8FGo2
github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o= github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ= github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c= github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A= github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0= github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8= github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
@@ -44,8 +48,12 @@ github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe9
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw= github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8= github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk= github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
@@ -54,6 +62,8 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWb
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno= github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU= github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4= github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE= github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
@@ -70,6 +80,8 @@ golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk= golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc= golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM= golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY= golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
@@ -77,3 +89,11 @@ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8X
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
+162
View File
@@ -0,0 +1,162 @@
// 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.
const FormatVersion = "1"
// 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,
menu INTEGER NOT NULL,
desktop INTEGER NOT NULL,
installed_at TEXT
);`
// 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,
menu, desktop, installed_at) 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.Targets.Menu), boolToInt(item.Targets.Desktop),
item.InstalledAt.Format(time.RFC3339),
)
if err != nil {
tx.Rollback()
return err
}
}
return tx.Commit()
}
// Import membaca daftar shortcut dari berkas .lazy.
func Import(path string) ([]store.Shortcut, 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)
}
if format != FormatVersion {
return nil, fmt.Errorf("versi basis data %s tidak didukung (mendukung %s)", format, FormatVersion)
}
rows, err := conn.Query(`SELECT slug, name, type, kind, url, browser, exec_args,
categories, binary_path, icon_path, icon_source, desktop_path,
desktop_copy_path, menu, desktop, installed_at FROM shortcuts ORDER BY name`)
if err != nil {
return nil, err
}
defer rows.Close()
var items []store.Shortcut
for rows.Next() {
var item store.Shortcut
var args, installed string
var menu, desktop int
if err := rows.Scan(&item.Slug, &item.Name, &item.Type, &item.Kind, &item.URL,
&item.Browser, &args, &item.Categories, &item.BinaryPath, &item.IconPath,
&item.IconSource, &item.DesktopPath, &item.DesktopCopyPath,
&menu, &desktop, &installed); err != nil {
return nil, err
}
if args != "" {
item.ExecArgs = strings.Split(args, "\x00")
}
item.Targets = store.Targets{Menu: menu != 0, Desktop: desktop != 0}
if t, err := time.Parse(time.RFC3339, installed); err == nil {
item.InstalledAt = t
}
items = append(items, item)
}
return items, rows.Err()
}
func boolToInt(v bool) int {
if v {
return 1
}
return 0
}
+111
View File
@@ -0,0 +1,111 @@
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], got[1]
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 TestExportOverwritesOldFile(t *testing.T) {
path := filepath.Join(t.TempDir(), "db.lazy")
if err := os.WriteFile(path, []byte("bukan sqlite"), 0o644); err != nil {
t.Fatal(err)
}
if err := Export(path, "1.2.0", sampleItems()[:1]); err != nil {
t.Fatal(err)
}
items, err := Import(path)
if err != nil {
t.Fatal(err)
}
if len(items) != 1 {
t.Errorf("isi setelah timpa: %d item", len(items))
}
}
func TestImportRejectsForeignFile(t *testing.T) {
dir := t.TempDir()
plain := filepath.Join(dir, "catatan.lazy")
if err := os.WriteFile(plain, []byte("halo dunia"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := Import(plain); err == nil {
t.Error("berkas bukan SQLite harus ditolak")
}
if _, err := Import(filepath.Join(dir, "tidak-ada.lazy")); err == nil {
t.Error("berkas tidak ada harus error")
}
}
+3 -2
View File
@@ -11,8 +11,9 @@ import (
"github.com/srwiley/rasterx" "github.com/srwiley/rasterx"
) )
// currentColor diisi ke SVG yang memakai fill="currentColor". // currentColor diisi ke SVG yang memakai fill="currentColor"
const currentColor = "#4f46e5" // (warna brand: dusk pink, terlihat di background terang maupun gelap).
const currentColor = "#c25c9e"
// Rasterize mengubah SVG menjadi PNG persegi `size`x`size`. // Rasterize mengubah SVG menjadi PNG persegi `size`x`size`.
// //
+3 -3
View File
@@ -76,9 +76,9 @@ func TestRasterizeColorsCurrentColorIcons(t *testing.T) {
if a == 0 { if a == 0 {
t.Fatal("ikon currentColor tidak terwarnai (transparan)") t.Fatal("ikon currentColor tidak terwarnai (transparan)")
} }
// brand indigo #4F46E5: biru > merah > hijau // brand dusk pink #C25C9E: merah > biru > hijau
if !(b > r && r > g) { if !(r > b && b > g) {
t.Errorf("warna pengganti bukan brand indigo: r=%d g=%d b=%d", r>>8, g>>8, b>>8) t.Errorf("warna pengganti bukan warna brand: r=%d g=%d b=%d", r>>8, g>>8, b>>8)
} }
} }
+206
View File
@@ -0,0 +1,206 @@
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)
}
}
// salin kalau sumbernya bukan berkas yang sudah terpasang
if !samePath(src, binDst) {
if err := copyFile(src, binDst, 0o755); err != nil {
return old, err
}
}
kind := DetectKind(binDst)
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;"
}
}
updated := old
updated.Slug = sl
updated.Name = name
updated.Kind = kind
updated.ExecArgs = execArgs
updated.Categories = category
updated.BinaryPath = binDst
updated.IconPath = iconPath
updated.IconSource = iconSource
updated.Targets = store.Targets{Menu: o.Menu, Desktop: o.Desktop}
if err := rewriteShortcutFiles(old, &updated, Entry{
Name: name,
ExecArgs: execArgs,
Icon: iconPath,
Categories: category,
NoDisplay: !o.Menu,
}); err != nil {
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")
}
}
+2
View File
@@ -94,6 +94,7 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) {
Type: store.TypeExecutable, Type: store.TypeExecutable,
Kind: kind, Kind: kind,
ExecArgs: entry.ExecArgs, ExecArgs: entry.ExecArgs,
Categories: entry.Categories,
BinaryPath: binDst, BinaryPath: binDst,
IconPath: iconPath, IconPath: iconPath,
IconSource: iconSource, IconSource: iconSource,
@@ -189,6 +190,7 @@ func InstallWebApp(o WebOptions) (store.Shortcut, error) {
Type: store.TypeWebApp, Type: store.TypeWebApp,
Kind: KindPWA, Kind: KindPWA,
URL: pageURL, URL: pageURL,
Categories: entry.Categories,
Browser: strings.Join(o.Browser.Exec, " "), Browser: strings.Join(o.Browser.Exec, " "),
ExecArgs: entry.ExecArgs, ExecArgs: entry.ExecArgs,
IconPath: iconPath, IconPath: iconPath,
+1
View File
@@ -29,6 +29,7 @@ type Shortcut struct {
URL string `json:"url,omitempty"` URL string `json:"url,omitempty"`
Browser string `json:"browser,omitempty"` Browser string `json:"browser,omitempty"`
ExecArgs []string `json:"exec_args,omitempty"` ExecArgs []string `json:"exec_args,omitempty"`
Categories string `json:"categories,omitempty"`
BinaryPath string `json:"binary_path,omitempty"` BinaryPath string `json:"binary_path,omitempty"`
IconPath string `json:"icon_path,omitempty"` IconPath string `json:"icon_path,omitempty"`
IconSource string `json:"icon_source,omitempty"` IconSource string `json:"icon_source,omitempty"`
+1 -1
View File
@@ -17,7 +17,7 @@ import (
// pulseGradient membuat garis aksen bergerak bolak-balik antara indigo dan cyan. // pulseGradient membuat garis aksen bergerak bolak-balik antara indigo dan cyan.
func pulseGradient(bar *canvas.LinearGradient) { func pulseGradient(bar *canvas.LinearGradient) {
anim := canvas.NewColorRGBAAnimation(brandIndigo, brandCyan, 3*time.Second, func(c color.Color) { anim := canvas.NewColorRGBAAnimation(brandAmber, brandMagenta, 3*time.Second, func(c color.Color) {
bar.EndColor = c bar.EndColor = c
canvas.Refresh(bar) canvas.Refresh(bar)
}) })
+277
View File
@@ -0,0 +1,277 @@
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 := mergeImported(ui.store, 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)
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))
})
})
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,
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)
content := container.NewVScroll(container.NewVBox(
closeRow(func() { d.Hide() }),
widget.NewForm(items...),
widget.NewSeparator(),
sectionTitle(T("section_location")),
menu,
desktopChk,
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)
}
// mergeImported menambahkan shortcut hasil impor tanpa menimpa yang sudah ada.
func mergeImported(target *store.Store, items []store.Shortcut) (added, skipped int) {
for _, item := range items {
item.Name = strings.TrimSpace(item.Name)
if item.Name == "" || item.Slug == "" {
skipped++
continue
}
if _, exists := target.Get(item.Slug); exists {
skipped++
continue
}
target.Add(item)
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"
}
+8 -8
View File
@@ -41,7 +41,7 @@ func (ui *app) showExecutableDialog() {
desktop := widget.NewCheck(T("check_desktop"), nil) desktop := widget.NewCheck(T("check_desktop"), nil)
desktop.SetChecked(true) desktop.SetChecked(true)
browseBin := widget.NewButtonWithIcon(T("browse"), theme.FolderOpenIcon(), func() { browseBin := newButton(T("browse"), theme.FolderOpenIcon(), T("tip_browse"), widget.MediumImportance, func() {
pickFile(ui.win, nil, func(path string) { pickFile(ui.win, nil, func(path string) {
binPath = path binPath = path
binLabel.SetText(filepath.Base(path) + " · " + detectedKind(path)) binLabel.SetText(filepath.Base(path) + " · " + detectedKind(path))
@@ -50,13 +50,13 @@ func (ui *app) showExecutableDialog() {
} }
}) })
}) })
browseIcon := widget.NewButtonWithIcon(T("browse"), theme.FolderOpenIcon(), func() { browseIcon := newButton(T("browse"), theme.FolderOpenIcon(), T("tip_browse"), widget.MediumImportance, func() {
pickFile(ui.win, imageFilter, func(path string) { pickFile(ui.win, imageFilter, func(path string) {
iconPath, iconURL, iconData = path, "", nil iconPath, iconURL, iconData = path, "", nil
iconLabel.SetText(filepath.Base(path)) iconLabel.SetText(filepath.Base(path))
}) })
}) })
onlineIcon := widget.NewButtonWithIcon(T("online"), theme.SearchIcon(), func() { onlineIcon := newButton(T("online"), theme.SearchIcon(), T("tip_online"), widget.MediumImportance, func() {
ui.showIconSearch(nameEntry.Text, func(result icon.Result, data []byte) { ui.showIconSearch(nameEntry.Text, func(result icon.Result, data []byte) {
iconURL, iconPath, iconData = result.URL, "", data iconURL, iconPath, iconData = result.URL, "", data
iconLabel.SetText(result.Name) iconLabel.SetText(result.Name)
@@ -64,7 +64,7 @@ func (ui *app) showExecutableDialog() {
}) })
var d *dialog.CustomDialog var d *dialog.CustomDialog
install := widget.NewButtonWithIcon(T("install"), theme.DownloadIcon(), func() { install := newButton(T("install"), theme.DownloadIcon(), T("tip_install"), widget.HighImportance, func() {
switch { switch {
case binPath == "": case binPath == "":
dialog.ShowInformation(T("err_title"), T("need_binary"), ui.win) dialog.ShowInformation(T("err_title"), T("need_binary"), ui.win)
@@ -91,8 +91,6 @@ func (ui *app) showExecutableDialog() {
func(sc store.Shortcut, err error) { ui.added(sc, err, d.Hide) }, func(sc store.Shortcut, err error) { ui.added(sc, err, d.Hide) },
) )
}) })
install.Importance = widget.HighImportance
form := widget.NewForm( form := widget.NewForm(
widget.NewFormItem(T("field_binary"), container.NewBorder(nil, nil, nil, browseBin, binLabel)), widget.NewFormItem(T("field_binary"), container.NewBorder(nil, nil, nil, browseBin, binLabel)),
widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil, widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil,
@@ -100,6 +98,7 @@ func (ui *app) showExecutableDialog() {
widget.NewFormItem(T("field_name"), nameEntry), widget.NewFormItem(T("field_name"), nameEntry),
widget.NewFormItem(T("field_category"), category), widget.NewFormItem(T("field_category"), category),
) )
hint := textLine(T("exec_hint"), 12, false, colorNameMuted)
content := container.NewVScroll(container.NewVBox( content := container.NewVScroll(container.NewVBox(
closeRow(func() { d.Hide() }), closeRow(func() { d.Hide() }),
form, form,
@@ -107,13 +106,14 @@ func (ui *app) showExecutableDialog() {
sectionTitle(T("section_location")), sectionTitle(T("section_location")),
menu, menu,
desktop, desktop,
muted(T("exec_hint")), hint,
container.NewHBox(logoBox(newLogo(), 20), spacer(), container.NewHBox(logoBox(newLogo(), 20), spacer(),
widget.NewButton(T("cancel"), func() { d.Hide() }), newButton(T("cancel"), nil, T("tip_cancel"), widget.MediumImportance, func() { d.Hide() }),
install), install),
)) ))
d = dialog.NewCustomWithoutButtons(T("new_exec_title"), content, ui.win) d = dialog.NewCustomWithoutButtons(T("new_exec_title"), content, ui.win)
d.SetOnClosed(pushHintTarget(hint)) // keterangan tombol muncul di baris hint dialog ini
showDialog(d, ui.win, 560, 520) showDialog(d, ui.win, 560, 520)
} }
+7 -5
View File
@@ -87,10 +87,11 @@ func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) {
entry.OnSubmitted = func(string) { search() } entry.OnSubmitted = func(string) { search() }
// status di barisnya sendiri: supaya tidak bertabrakan dengan tombol // status di barisnya sendiri: supaya tidak bertabrakan dengan tombol
// di layar sempit atau ber-penskalaan besar. // di layar sempit atau ber-penskalaan besar.
bottom := container.NewVBox(status, container.NewHBox( hint := textLine(T("icon_search_hint"), 12, false, colorNameMuted)
bottom := container.NewVBox(hint, status, container.NewHBox(
spacer(), spacer(),
widget.NewButtonWithIcon(T("back"), theme.NavigateBackIcon(), func() { d.Hide() }), newButton(T("back"), theme.NavigateBackIcon(), T("tip_back"), widget.MediumImportance, func() { d.Hide() }),
widget.NewButtonWithIcon(T("search"), theme.SearchIcon(), search), newButton(T("search"), theme.SearchIcon(), T("tip_search"), widget.HighImportance, search),
)) ))
content := container.NewBorder( content := container.NewBorder(
container.NewVBox(closeRow(func() { d.Hide() }), entry), container.NewVBox(closeRow(func() { d.Hide() }), entry),
@@ -99,6 +100,7 @@ func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) {
) )
d = dialog.NewCustomWithoutButtons(T("icon_search_title"), content, ui.win) d = dialog.NewCustomWithoutButtons(T("icon_search_title"), content, ui.win)
d.SetOnClosed(pushHintTarget(hint))
showDialog(d, ui.win, 560, 470) // sekaligus menampilkan dialog showDialog(d, ui.win, 560, 470) // sekaligus menampilkan dialog
search() search()
} }
@@ -126,7 +128,7 @@ func (ui *app) iconChoice(result icon.Result, onPick func(icon.Result, []byte),
var mu sync.Mutex var mu sync.Mutex
var raw []byte var raw []byte
button := widget.NewButton("", func() { button := newButton("", nil, result.Name, widget.LowImportance, func() {
mu.Lock() mu.Lock()
picked := raw picked := raw
mu.Unlock() mu.Unlock()
@@ -135,8 +137,8 @@ func (ui *app) iconChoice(result icon.Result, onPick func(icon.Result, []byte),
d.Hide() d.Hide()
} }
}) })
button.Importance = widget.LowImportance
button.Icon = theme.NewThemedResource(theme.FileImageIcon()) // placeholder selama diunduh button.Icon = theme.NewThemedResource(theme.FileImageIcon()) // placeholder selama diunduh
button.Refresh()
go func() { go func() {
slot <- struct{}{} slot <- struct{}{}
+36
View File
@@ -11,6 +11,7 @@ import (
"fyne.io/fyne/v2/test" "fyne.io/fyne/v2/test"
"lazyrun/internal/icon" "lazyrun/internal/icon"
"lazyrun/internal/store"
) )
// TestNoEmptyDismissButton memastikan modal tidak memakai dialog.NewCustom, // TestNoEmptyDismissButton memastikan modal tidak memakai dialog.NewCustom,
@@ -62,6 +63,17 @@ func TestDialogsOpenWithoutPanic(t *testing.T) {
t.Fatal("modal web app tidak muncul") t.Fatal("modal web app tidak muncul")
} }
} }
ui.showEditDialog(store.Shortcut{Slug: "winbox", Name: "Winbox", Type: store.TypeExecutable, Kind: "windows",
BinaryPath: "/tmp/winbox", IconPath: "", Categories: "Network;", Targets: store.Targets{Menu: true, Desktop: true}})
if top := ui.win.Canvas().Overlays().Top(); top == nil {
t.Fatal("modal edit executable tidak muncul")
}
ui.showEditDialog(store.Shortcut{Slug: "wa", Name: "WhatsApp Web", Type: store.TypeWebApp, Kind: "pwa",
URL: "https://web.whatsapp.com", Browser: "/usr/bin/vivaldi", Targets: store.Targets{Menu: true}})
if top := ui.win.Canvas().Overlays().Top(); top == nil {
t.Fatal("modal edit webapp tidak muncul")
}
ui.showIconSearch("whatsapp", func(icon.Result, []byte) {}) ui.showIconSearch("whatsapp", func(icon.Result, []byte) {})
if top := ui.win.Canvas().Overlays().Top(); top == nil { if top := ui.win.Canvas().Overlays().Top(); top == nil {
t.Fatal("modal pencarian ikon tidak muncul") t.Fatal("modal pencarian ikon tidak muncul")
@@ -108,3 +120,27 @@ func TestStringsCoverUsedKeys(t *testing.T) {
} }
} }
} }
func TestMergeImported(t *testing.T) {
target := &store.Store{}
target.Add(store.Shortcut{Slug: "winbox", Name: "Winbox"})
added, skipped := mergeImported(target, []store.Shortcut{
{Slug: "winbox", Name: "Winbox lama"}, // sudah ada -> dilewati
{Slug: "figma", Name: "Figma"}, // baru
{Slug: "", Name: "Tanpa slug"}, // rusak -> dilewati
{Slug: "kosong", Name: " "}, // tanpa nama -> dilewati
{Slug: "deploy", Name: " deploy.sh "}, // nama dirapikan
})
if added != 2 || skipped != 3 {
t.Errorf("added=%d skipped=%d, mau 2 dan 3", added, skipped)
}
if len(target.Items) != 3 {
t.Errorf("jumlah item = %d", len(target.Items))
}
dep, ok := target.Get("deploy")
if !ok || target.Items[dep].Name != "deploy.sh" {
t.Errorf("nama item tidak dirapikan: %+v", target.Items)
}
}
+7 -7
View File
@@ -54,13 +54,13 @@ func (ui *app) showWebAppDialog() {
} }
} }
browseIcon := widget.NewButtonWithIcon(T("browse"), theme.FolderOpenIcon(), func() { browseIcon := newButton(T("browse"), theme.FolderOpenIcon(), T("tip_browse"), widget.MediumImportance, func() {
pickFile(ui.win, imageFilter, func(path string) { pickFile(ui.win, imageFilter, func(path string) {
iconPath, iconURL, iconData = path, "", nil iconPath, iconURL, iconData = path, "", nil
iconLabel.SetText(filepath.Base(path)) iconLabel.SetText(filepath.Base(path))
}) })
}) })
onlineIcon := widget.NewButtonWithIcon(T("online"), theme.SearchIcon(), func() { onlineIcon := newButton(T("online"), theme.SearchIcon(), T("tip_online"), widget.MediumImportance, func() {
ui.showIconSearch(nameEntry.Text, func(result icon.Result, data []byte) { ui.showIconSearch(nameEntry.Text, func(result icon.Result, data []byte) {
iconURL, iconPath, iconData = result.URL, "", data iconURL, iconPath, iconData = result.URL, "", data
iconLabel.SetText(result.Name) iconLabel.SetText(result.Name)
@@ -68,7 +68,7 @@ func (ui *app) showWebAppDialog() {
}) })
var d *dialog.CustomDialog var d *dialog.CustomDialog
install := widget.NewButtonWithIcon(T("install"), theme.DownloadIcon(), func() { install := newButton(T("install"), theme.DownloadIcon(), T("tip_install"), widget.HighImportance, func() {
switch { switch {
case strings.TrimSpace(urlEntry.Text) == "": case strings.TrimSpace(urlEntry.Text) == "":
dialog.ShowInformation(T("err_title"), T("need_url"), ui.win) dialog.ShowInformation(T("err_title"), T("need_url"), ui.win)
@@ -96,8 +96,6 @@ func (ui *app) showWebAppDialog() {
func(sc store.Shortcut, err error) { ui.added(sc, err, d.Hide) }, func(sc store.Shortcut, err error) { ui.added(sc, err, d.Hide) },
) )
}) })
install.Importance = widget.HighImportance
form := widget.NewForm( form := widget.NewForm(
widget.NewFormItem(T("field_url"), urlEntry), widget.NewFormItem(T("field_url"), urlEntry),
widget.NewFormItem(T("field_name"), nameEntry), widget.NewFormItem(T("field_name"), nameEntry),
@@ -105,6 +103,7 @@ func (ui *app) showWebAppDialog() {
widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil, widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil,
container.NewHBox(onlineIcon, browseIcon), iconLabel)), container.NewHBox(onlineIcon, browseIcon), iconLabel)),
) )
hint := textLine(T("web_hint"), 12, false, colorNameMuted)
content := container.NewVScroll(container.NewVBox( content := container.NewVScroll(container.NewVBox(
closeRow(func() { d.Hide() }), closeRow(func() { d.Hide() }),
form, form,
@@ -112,13 +111,14 @@ func (ui *app) showWebAppDialog() {
sectionTitle(T("section_location")), sectionTitle(T("section_location")),
menu, menu,
desktop, desktop,
muted(T("web_hint")), hint,
container.NewHBox(logoBox(newLogo(), 20), spacer(), container.NewHBox(logoBox(newLogo(), 20), spacer(),
widget.NewButton(T("cancel"), func() { d.Hide() }), newButton(T("cancel"), nil, T("tip_cancel"), widget.MediumImportance, func() { d.Hide() }),
install), install),
)) ))
d = dialog.NewCustomWithoutButtons(T("new_web_title"), content, ui.win) d = dialog.NewCustomWithoutButtons(T("new_web_title"), content, ui.win)
d.SetOnClosed(pushHintTarget(hint))
showDialog(d, ui.win, 560, 520) showDialog(d, ui.win, 560, 520)
} }
+30 -23
View File
@@ -31,6 +31,9 @@ type app struct {
cards map[string]*hoverCard cards map[string]*hoverCard
icons *iconCache icons *iconCache
count *canvas.Text // angka jumlah shortcut di header daftar count *canvas.Text // angka jumlah shortcut di header daftar
versionText *canvas.Text // versi di footer; berubah kalau ada update
hint *canvas.Text // keterangan tombol yang sedang disorot
accent *canvas.LinearGradient
version string version string
} }
@@ -38,6 +41,7 @@ type app struct {
func Run(a fyne.App, version string) { func Run(a fyne.App, version string) {
ui, loadErr := build(a, version) ui, loadErr := build(a, version)
ui.win.Show() ui.win.Show()
pushHintTarget(ui.hint) // target default: baris hint di footer
ui.flashCards() ui.flashCards()
ui.scheduleUpdateCheck() ui.scheduleUpdateCheck()
if loadErr != nil { if loadErr != nil {
@@ -60,13 +64,13 @@ func build(a fyne.App, version string) (*app, error) {
ui.count = count ui.count = count
ui.win = a.NewWindow(T("app_title")) ui.win = a.NewWindow(T("app_title"))
ui.win.Resize(fyne.NewSize(840, 600)) ui.win.Resize(fyne.NewSize(840, 600))
ui.versionText = caption("LazyRun v" + version + " · oxpoe")
st, err := store.Load(xdg.MetadataPath()) st, err := store.Load(xdg.MetadataPath())
ui.store = st ui.store = st
addExec := widget.NewButtonWithIcon(T("btn_executable"), theme.ContentAddIcon(), ui.showExecutableDialog) addExec := newButton(T("btn_executable"), theme.ContentAddIcon(), T("tip_executable"), widget.HighImportance, ui.showExecutableDialog)
addExec.Importance = widget.HighImportance addWeb := newButton(T("btn_webapp"), theme.ContentAddIcon(), T("tip_webapp"), widget.MediumImportance, ui.showWebAppDialog)
addWeb := widget.NewButtonWithIcon(T("btn_webapp"), theme.ContentAddIcon(), ui.showWebAppDialog)
leftHeader := container.NewHBox(logoBox(newLogo(), 40), container.NewVBox( leftHeader := container.NewHBox(logoBox(newLogo(), 40), container.NewVBox(
heading("LazyRun"), heading("LazyRun"),
@@ -75,19 +79,22 @@ func build(a fyne.App, version string) (*app, error) {
rightHeader := container.NewHBox(addExec, addWeb) rightHeader := container.NewHBox(addExec, addWeb)
header := container.NewBorder(nil, nil, leftHeader, rightHeader) header := container.NewBorder(nil, nil, leftHeader, rightHeader)
update := widget.NewButtonWithIcon(T("check_updates"), theme.DownloadIcon(), ui.checkUpdates) update := newButton(T("check_updates"), theme.DownloadIcon(), T("tip_check_updates"), widget.MediumImportance, ui.checkUpdates)
github := widget.NewButton(T("github"), func() { ui.openURL(repoURL) }) github := newButton(T("github"), nil, T("tip_github"), widget.MediumImportance, func() { ui.openURL(repoURL) })
donate := widget.NewButtonWithIcon(T("donate"), donate := newButton(T("donate"), fyne.NewStaticResource("heart.png", assets.Heart), T("tip_donate"), widget.MediumImportance, func() { ui.openURL(donateURL) })
fyne.NewStaticResource("heart.png", assets.Heart), func() { ui.openURL(donateURL) }) export := newIconButton(theme.DocumentSaveIcon(), T("tip_export"), ui.exportShortcuts)
importBtn := newIconButton(theme.StorageIcon(), T("tip_import"), ui.importShortcuts)
ui.hint = caption("")
footer := container.NewBorder( footer := container.NewBorder(
widget.NewSeparator(), nil, widget.NewSeparator(), nil,
container.NewHBox(update, github, donate), container.NewHBox(update, export, importBtn, github, donate),
caption("LazyRun v"+version+" · oxpoe"), ui.versionText,
ui.hint,
) )
accent := accentBar() ui.accent = accentBar()
pulseGradient(accent) pulseGradient(ui.accent)
top := container.NewVBox(header, accent, top := container.NewVBox(header, ui.accent,
container.NewHBox(sectionLabel(T("installed")), countChip)) container.NewHBox(sectionLabel(T("installed")), countChip))
body := container.NewBorder( body := container.NewBorder(
top, top,
@@ -142,16 +149,17 @@ func (ui *app) row(sc store.Shortcut) *hoverCard {
} }
actions := container.NewHBox(chips...) actions := container.NewHBox(chips...)
actions.Add(iconButton(theme.MediaPlayIcon(), func() { ui.launch(sc) })) actions.Add(newIconButton(theme.MediaPlayIcon(), T("tip_run"), func() { ui.launch(sc) }))
if syncable(sc) { if syncable(sc) {
actions.Add(iconButton(theme.ViewRefreshIcon(), func() { ui.syncIcon(sc) })) actions.Add(newIconButton(theme.ViewRefreshIcon(), T("tip_sync"), func() { ui.syncIcon(sc) }))
} }
actions.Add(iconButton(theme.FolderOpenIcon(), func() { actions.Add(newIconButton(theme.DocumentCreateIcon(), T("tip_edit"), func() { ui.showEditDialog(sc) }))
actions.Add(newIconButton(theme.FolderOpenIcon(), T("tip_folder"), func() {
if err := exec.Command("xdg-open", filepath.Dir(sc.DesktopPath)).Start(); err != nil { if err := exec.Command("xdg-open", filepath.Dir(sc.DesktopPath)).Start(); err != nil {
dialog.ShowError(err, ui.win) dialog.ShowError(err, ui.win)
} }
})) }))
actions.Add(iconButton(theme.DeleteIcon(), func() { ui.confirmDelete(sc) })) actions.Add(newIconButton(theme.DeleteIcon(), T("tip_delete"), func() { ui.confirmDelete(sc) }))
return newHoverCard(container.NewBorder(nil, nil, return newHoverCard(container.NewBorder(nil, nil,
container.NewHBox(thumb(sc.IconPath, fallbackIcon(sc)), info), container.NewHBox(thumb(sc.IconPath, fallbackIcon(sc)), info),
@@ -236,12 +244,11 @@ func syncable(sc store.Shortcut) bool {
// syncIcon mengunduh ulang ikon shortcut dari sumber online-nya. // syncIcon mengunduh ulang ikon shortcut dari sumber online-nya.
func (ui *app) syncIcon(sc store.Shortcut) { func (ui *app) syncIcon(sc store.Shortcut) {
progress := dialog.NewProgressInfinite(T("sync_icon"), T("working"), ui.win) hide := ui.showLoading(T("sync_icon"), T("working"))
progress.Show()
go func() { go func() {
path, err := shortcut.RefreshIcon(sc) path, err := shortcut.RefreshIcon(sc)
fyne.Do(func() { fyne.Do(func() {
progress.Hide() hide()
if err != nil { if err != nil {
dialog.ShowError(err, ui.win) dialog.ShowError(err, ui.win)
return return
@@ -289,14 +296,14 @@ func (ui *app) confirmDelete(sc store.Shortcut) {
d.Show() d.Show()
} }
// install menjalankan pekerjaan berat (salin file besar / fetch favicon) di luar UI thread. // install menjalankan pekerjaan berat (salin file besar / unduh ikon) di luar UI thread,
// dengan dialog proses berisi progress bar.
func (ui *app) install(work func() (store.Shortcut, error), done func(store.Shortcut, error)) { func (ui *app) install(work func() (store.Shortcut, error), done func(store.Shortcut, error)) {
progress := dialog.NewProgressInfinite(T("install"), T("working"), ui.win) hide := ui.showLoading(T("install"), T("working"))
progress.Show()
go func() { go func() {
sc, err := work() sc, err := work()
fyne.Do(func() { fyne.Do(func() {
progress.Hide() hide()
done(sc, err) done(sc, err)
}) })
}() }()
+36
View File
@@ -25,12 +25,14 @@ var tr = map[string][2]string{
"searching": {"Searching...", "Mencari..."}, "searching": {"Searching...", "Mencari..."},
"search_failed": {"Search failed. Check your connection.", "Pencarian gagal. Cek koneksi kamu."}, "search_failed": {"Search failed. Check your connection.", "Pencarian gagal. Cek koneksi kamu."},
"search_found": {"%d icons found - click one to use it.", "%d ikon ditemukan - klik satu untuk dipakai."}, "search_found": {"%d icons found - click one to use it.", "%d ikon ditemukan - klik satu untuk dipakai."},
"icon_search_hint": {"Hover a button to see what it does.", "Sorot tombol untuk melihat fungsinya."},
"search_hint": {"Type a name and press Search.", "Tulis nama lalu tekan Cari."}, "search_hint": {"Type a name and press Search.", "Tulis nama lalu tekan Cari."},
"icon_search_title": {"Icon from the internet", "Ikon dari internet"}, "icon_search_title": {"Icon from the internet", "Ikon dari internet"},
"sync_icon": {"Sync icon", "Sinkron ikon"}, "sync_icon": {"Sync icon", "Sinkron ikon"},
"browse": {"Browse...", "Pilih..."}, "browse": {"Browse...", "Pilih..."},
"install": {"Install", "Install"}, "install": {"Install", "Install"},
"cancel": {"Cancel", "Batal"}, "cancel": {"Cancel", "Batal"},
"save": {"Save", "Simpan"},
"back": {"Back", "Kembali"}, "back": {"Back", "Kembali"},
"delete": {"Delete", "Hapus"}, "delete": {"Delete", "Hapus"},
"working": {"Working...", "Sedang diproses..."}, "working": {"Working...", "Sedang diproses..."},
@@ -78,6 +80,40 @@ var tr = map[string][2]string{
"update_restart": {"Restart now", "Restart sekarang"}, "update_restart": {"Restart now", "Restart sekarang"},
"up_to_date": {"LazyRun %s is the latest version.", "LazyRun %s adalah versi terbaru."}, "up_to_date": {"LazyRun %s is the latest version.", "LazyRun %s adalah versi terbaru."},
"dev_build": {"This is a development build (%s), update check is skipped.", "Ini build development (%s), cek update dilewati."}, "dev_build": {"This is a development build (%s), update check is skipped.", "Ini build development (%s), cek update dilewati."},
"export": {"Export", "Ekspor"},
"import": {"Import", "Impor"},
"edit_title": {"Edit shortcut", "Edit shortcut"},
"edit_saved": {"%s updated.", "%s diperbarui."},
"edit_hint": {"Change the binary, icon, name, or where the shortcut appears.", "Ubah binary, ikon, nama, atau lokasi shortcutnya."},
"import_done": {"%d shortcuts imported (%d skipped).", "%d shortcut diimpor (%d dilewati)."},
"import_empty": {"No shortcuts found in this file.", "Tidak ada shortcut di berkas ini."},
"export_done": {"Exported %d shortcuts to\n%s", "%d shortcut diekspor ke\n%s"},
"tip_executable": {"Create a shortcut from a binary", "Buat shortcut dari berkas binary"},
"tip_webapp": {"Create a shortcut from a web address", "Buat shortcut dari alamat web"},
"tip_run": {"Run this shortcut", "Jalankan shortcut ini"},
"tip_sync": {"Download the icon again from its source", "Unduh ulang ikonnya dari sumber"},
"tip_edit": {"Edit: binary, icon, name, location", "Edit: binary, ikon, nama, lokasi"},
"tip_folder": {"Open the .desktop folder", "Buka folder .desktop"},
"tip_delete": {"Remove this shortcut", "Hapus shortcut ini"},
"tip_check_updates": {"Check for a newer release", "Periksa rilis terbaru"},
"tip_github": {"Open the LazyRun repository", "Buka repo LazyRun"},
"tip_donate": {"Support LazyRun development", "Dukung pengembangan LazyRun"},
"tip_export": {"Export the shortcut list to a .lazy database", "Ekspor daftar shortcut ke basis data .lazy"},
"tip_import": {"Import shortcuts from a .lazy database", "Impor shortcut dari basis data .lazy"},
"tip_close": {"Close this dialog", "Tutup dialog ini"},
"tip_back": {"Back to the previous dialog", "Kembali ke dialog sebelumnya"},
"tip_search": {"Search icons online", "Cari ikon di internet"},
"tip_browse": {"Pick a file", "Pilih berkas"},
"tip_online": {"Pick an icon from the internet", "Pilih ikon dari internet"},
"tip_install": {"Install the shortcut", "Pasang shortcutnya"},
"tip_cancel": {"Cancel without saving", "Batal tanpa menyimpan"},
"tip_save": {"Save the changes", "Simpan perubahan"},
"tip_update_now": {"Download and install the update", "Unduh dan pasang update"},
"tip_update_page": {"Open the release page in the browser", "Buka halaman rilis di browser"},
"tip_later": {"Ask me later", "Ingatkan nanti"},
"tip_restart": {"Restart LazyRun with the new version", "Jalankan ulang LazyRun dengan versi baru"},
} }
var lang = detectLang() var lang = detectLang()
+2 -9
View File
@@ -78,18 +78,11 @@ func chipLabel(text string, bg fyne.ThemeColorName) (fyne.CanvasObject, *canvas.
// accentBar adalah garis gradien brand di bawah header. // accentBar adalah garis gradien brand di bawah header.
func accentBar() *canvas.LinearGradient { func accentBar() *canvas.LinearGradient {
bar := canvas.NewLinearGradient(brandIndigo, brandCyan, 90) bar := canvas.NewLinearGradient(brandAmber, brandMagenta, 90)
bar.SetMinSize(fyne.NewSize(0, 3)) bar.SetMinSize(fyne.NewSize(0, 3))
return bar return bar
} }
// iconButton adalah tombol ikon tanpa label, gaya datar.
func iconButton(icon fyne.Resource, tapped func()) *widget.Button {
button := widget.NewButtonWithIcon("", icon, tapped)
button.Importance = widget.LowImportance
return button
}
// thumb menampilkan ikon shortcut, atau fallback kalau file ikonnya hilang. // thumb menampilkan ikon shortcut, atau fallback kalau file ikonnya hilang.
func thumb(path string, fallback fyne.Resource) fyne.CanvasObject { func thumb(path string, fallback fyne.Resource) fyne.CanvasObject {
var image *canvas.Image var image *canvas.Image
@@ -122,7 +115,7 @@ func logoBox(image *canvas.Image, size float32) fyne.CanvasObject {
// closeRow menempatkan tombol tutup di kanan atas isi dialog, supaya // closeRow menempatkan tombol tutup di kanan atas isi dialog, supaya
// selalu ada jalan keluar yang terlihat walau tombol bawah terpotong. // selalu ada jalan keluar yang terlihat walau tombol bawah terpotong.
func closeRow(hide func()) fyne.CanvasObject { func closeRow(hide func()) fyne.CanvasObject {
return container.NewHBox(spacer(), iconButton(theme.CancelIcon(), hide)) return container.NewHBox(spacer(), newIconButton(theme.CancelIcon(), T("tip_close"), hide))
} }
// showDialog menampilkan dialog dengan ukuran yang disesuaikan ke window. // showDialog menampilkan dialog dengan ukuran yang disesuaikan ke window.
+20 -16
View File
@@ -7,10 +7,11 @@ import (
"fyne.io/fyne/v2/theme" "fyne.io/fyne/v2/theme"
) )
// Palet brand LazyRun, diambil dari ikon aplikasi. // Palet brand LazyRun, diambil dari logo (dream sky: ungu malam, senja pink, bulan amber).
var ( var (
brandIndigo = color.NRGBA{R: 0x4F, G: 0x46, B: 0xE5, A: 0xFF} brandPurple = color.NRGBA{R: 0x6A, G: 0x3D, B: 0x9A, A: 0xFF}
brandCyan = color.NRGBA{R: 0x06, G: 0xB6, B: 0xD4, A: 0xFF} brandMagenta = color.NRGBA{R: 0xC2, G: 0x5C, B: 0x9E, A: 0xFF}
brandAmber = color.NRGBA{R: 0xFF, G: 0xD1, B: 0x66, A: 0xFF}
) )
// Nama warna tambahan di luar tema standar Fyne. // Nama warna tambahan di luar tema standar Fyne.
@@ -31,31 +32,34 @@ func (b brand) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.
dark := variant == theme.VariantDark dark := variant == theme.VariantDark
switch name { switch name {
case theme.ColorNamePrimary: case theme.ColorNamePrimary:
return pick(dark, color.NRGBA{R: 0x7C, G: 0x7A, B: 0xFF, A: 0xFF}, brandIndigo) // senja pink di malam hari, ungu tua di tema terang
return pick(dark, brandMagenta, color.NRGBA{R: 0x8E, G: 0x3F, B: 0xA8, A: 0xFF})
case theme.ColorNameBackground: case theme.ColorNameBackground:
return pick(dark, color.NRGBA{R: 0x13, G: 0x13, B: 0x1B, A: 0xFF}, color.NRGBA{R: 0xF5, G: 0xF5, B: 0xFA, A: 0xFF}) // langit malam surreal
return pick(dark, color.NRGBA{R: 0x14, G: 0x0E, B: 0x24, A: 0xFF}, color.NRGBA{R: 0xF5, G: 0xEF, B: 0xF9, A: 0xFF})
case colorNameCard: case colorNameCard:
return pick(dark, color.NRGBA{R: 0x1D, G: 0x1D, B: 0x28, A: 0xFF}, color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF}) return pick(dark, color.NRGBA{R: 0x1E, G: 0x16, B: 0x36, A: 0xFF}, color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF})
case colorNameCardHover: case colorNameCardHover:
return withAlpha(brandIndigo, pick[uint8](dark, 0x2E, 0x14)) return withAlpha(brandAmber, pick[uint8](dark, 0x1F, 0x18))
case theme.ColorNameInputBackground: case theme.ColorNameInputBackground:
return pick(dark, color.NRGBA{R: 0x1A, G: 0x1A, B: 0x24, A: 0xFF}, color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF}) return pick(dark, color.NRGBA{R: 0x19, G: 0x11, B: 0x35, A: 0xFF}, color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF})
case theme.ColorNameButton, theme.ColorNameMenuBackground: case theme.ColorNameButton, theme.ColorNameMenuBackground:
return pick(dark, color.NRGBA{R: 0x22, G: 0x22, B: 0x2E, A: 0xFF}, color.NRGBA{R: 0xEC, G: 0xEC, B: 0xF4, A: 0xFF}) return pick(dark, color.NRGBA{R: 0x26, G: 0x1B, B: 0x45, A: 0xFF}, color.NRGBA{R: 0xED, G: 0xE3, B: 0xF5, A: 0xFF})
case theme.ColorNameOverlayBackground: case theme.ColorNameOverlayBackground:
return pick(dark, color.NRGBA{R: 0x1B, G: 0x1B, B: 0x25, A: 0xFF}, color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF}) return pick(dark, color.NRGBA{R: 0x1B, G: 0x13, B: 0x33, A: 0xFF}, color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF})
case theme.ColorNameInputBorder, theme.ColorNameSeparator: case theme.ColorNameInputBorder, theme.ColorNameSeparator:
return pick(dark, color.NRGBA{R: 0x32, G: 0x32, B: 0x44, A: 0xFF}, color.NRGBA{R: 0xDC, G: 0xDC, B: 0xE8, A: 0xFF}) return pick(dark, color.NRGBA{R: 0x3A, G: 0x2A, B: 0x63, A: 0xFF}, color.NRGBA{R: 0xDC, G: 0xCF, B: 0xE9, A: 0xFF})
case colorNameMuted: case colorNameMuted:
return pick(dark, color.NRGBA{R: 0x9C, G: 0x9C, B: 0xB4, A: 0xFF}, color.NRGBA{R: 0x6B, G: 0x6B, B: 0x80, A: 0xFF}) return pick(dark, color.NRGBA{R: 0xA7, G: 0x9A, B: 0xC8, A: 0xFF}, color.NRGBA{R: 0x6E, G: 0x61, B: 0x83, A: 0xFF})
case colorNameChipMenu: case colorNameChipMenu:
return withAlpha(brandIndigo, pick[uint8](dark, 0x44, 0x26)) return withAlpha(brandPurple, pick[uint8](dark, 0x66, 0x2E))
case colorNameChipDesk: case colorNameChipDesk:
return withAlpha(brandCyan, pick[uint8](dark, 0x3D, 0x2C)) return withAlpha(brandMagenta, pick[uint8](dark, 0x4E, 0x30))
case colorNameChipKind: case colorNameChipKind:
return pick(dark, color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0x1C}, color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x12}) return withAlpha(color.NRGBA{R: 0xFF, G: 0xD1, B: 0x66, A: 0xFF}, pick[uint8](dark, 0x24, 0x30))
case colorNameChipBroken: case colorNameChipBroken:
return withAlpha(color.NRGBA{R: 0xE0, G: 0x4F, B: 0x4F, A: 0xFF}, pick[uint8](dark, 0x4A, 0x2E)) // merah senja: peringatan yang tetap terasa "seram" tapi terbaca
return withAlpha(color.NRGBA{R: 0xD9, G: 0x46, B: 0x3C, A: 0xFF}, pick[uint8](dark, 0x50, 0x30))
} }
return b.Theme.Color(name, variant) return b.Theme.Color(name, variant)
} }
+107
View File
@@ -0,0 +1,107 @@
package ui
import (
"sync"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/canvas"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/driver/desktop"
"fyne.io/fyne/v2/widget"
)
// Keterangan tombol muncul di satu baris hint (bawah window atau dalam dialog),
// bukan sebagai popup: popup Fyne selalu jadi overlay, dan selama overlay ada
// semua klik ke konten di bawahnya tidak diteruskan (tombol jadi perlu 2x klik).
var (
hintMu sync.Mutex
hintTarget *canvas.Text
)
// pushHintTarget mengarahkan keterangan hover ke label tertentu (dipakai dialog).
// Fungsi yang dikembalikan mengembalikan target sebelumnya.
func pushHintTarget(label *canvas.Text) func() {
hintMu.Lock()
previous := hintTarget
hintTarget = label
hintMu.Unlock()
return func() {
hintMu.Lock()
hintTarget = previous
hintMu.Unlock()
}
}
func showHint(text string) {
hintMu.Lock()
target := hintTarget
hintMu.Unlock()
if target == nil || text == "" {
return
}
target.Text = text
target.Refresh()
}
// clearHint menghapus hint hanya kalau isinya memang milik tombol ini.
func clearHint(text string) {
hintMu.Lock()
target := hintTarget
hintMu.Unlock()
if target == nil || target.Text != text {
return
}
target.Text = ""
target.Refresh()
}
// tipButton adalah tombol yang menampilkan fungsinya saat disorot mouse.
type tipButton struct {
widget.Button
tip string
}
// newButton membuat tombol (dengan ikon kalau diberikan) plus keterangan hover.
func newButton(text string, icon fyne.Resource, tip string, importance widget.Importance, tapped func()) *tipButton {
button := &tipButton{tip: tip}
button.Text = text
button.Icon = icon
button.Importance = importance
button.OnTapped = tapped
button.ExtendBaseWidget(button)
return button
}
// newIconButton adalah tombol ikon saja (tanpa teks), gaya datar.
func newIconButton(icon fyne.Resource, tip string, tapped func()) *tipButton {
return newButton("", icon, tip, widget.LowImportance, tapped)
}
func (b *tipButton) MouseIn(event *desktop.MouseEvent) {
b.Button.MouseIn(event)
showHint(b.tip)
}
func (b *tipButton) MouseOut() {
b.Button.MouseOut()
clearHint(b.tip)
}
// showLoading menampilkan dialog proses dengan progress bar yang berjalan,
// dan mengembalikan fungsi untuk menutupnya.
func (ui *app) showLoading(title, message string) func() {
bar := widget.NewProgressBarInfinite()
var d *dialog.CustomDialog
d = dialog.NewCustomWithoutButtons(title, container.NewBorder(
container.NewVBox(logoBox(newLogo(), 22), muted(message)),
nil, nil, nil,
bar,
), ui.win)
showDialog(d, ui.win, 420, 170)
return d.Hide
}
+89
View File
@@ -0,0 +1,89 @@
package ui
import (
"testing"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/test"
"fyne.io/fyne/v2/theme"
"fyne.io/fyne/v2/widget"
)
func TestTipButtonShowsAndHidesHint(t *testing.T) {
a := test.NewApp()
a.Settings().SetTheme(brand{theme.DefaultTheme()})
defer a.Quit()
hint := textLine("", 12, false, colorNameMuted)
button := newIconButton(theme.MediaPlayIcon(), "jalankan shortcut", func() {})
w := a.NewWindow("tip")
w.SetContent(container.NewBorder(nil, hint, nil, nil, button))
w.Resize(fyne.NewSize(300, 200))
w.Show()
restore := pushHintTarget(hint)
defer restore()
if hint.Text != "" {
t.Fatal("hint muncul tanpa hover")
}
test.MoveMouse(w.Canvas(), fyne.NewPos(20, 20))
if hint.Text != "jalankan shortcut" {
t.Errorf("hint saat hover = %q", hint.Text)
}
// keluar dari tombol: hint dibersihkan
test.MoveMouse(w.Canvas(), fyne.NewPos(280, 180))
if hint.Text != "" {
t.Errorf("hint masih %q setelah kursor keluar", hint.Text)
}
}
// klik saat tooltip terbuka harus tetap kena tombolnya (dulu PopUp menelan klik).
// klik saat hint tampil harus tetap kena tombolnya (dulu pakai popup yang
// jadi overlay, sehingga klik pertama selalu tertelan).
func TestTipButtonClickGoesThrough(t *testing.T) {
a := test.NewApp()
a.Settings().SetTheme(brand{theme.DefaultTheme()})
defer a.Quit()
tapped := false
hint := textLine("", 12, false, colorNameMuted)
button := newIconButton(theme.MediaPlayIcon(), "jalankan shortcut", func() { tapped = true })
w := a.NewWindow("tip")
w.SetContent(container.NewBorder(nil, hint, nil, nil, button))
w.Resize(fyne.NewSize(300, 200))
w.Show()
restore := pushHintTarget(hint)
defer restore()
test.MoveMouse(w.Canvas(), fyne.NewPos(20, 20))
test.TapCanvas(w.Canvas(), fyne.NewPos(20, 20))
if !tapped {
t.Error("klik pertama tidak sampai ke tombol")
}
if top := w.Canvas().Overlays().Top(); top != nil {
t.Error("tidak boleh ada overlay untuk keterangan tombol")
}
}
func TestTipButtonWithoutTipDoesNothing(t *testing.T) {
a := test.NewApp()
a.Settings().SetTheme(brand{theme.DefaultTheme()})
defer a.Quit()
hint := textLine("", 12, false, colorNameMuted)
button := newButton("Tanpa tip", nil, "", widget.MediumImportance, func() {})
w := a.NewWindow("tip")
w.SetContent(container.NewBorder(nil, hint, nil, nil, button))
w.Resize(fyne.NewSize(300, 200))
w.Show()
restore := pushHintTarget(hint)
defer restore()
test.MoveMouse(w.Canvas(), fyne.NewPos(20, 20))
if hint.Text != "" {
t.Error("tombol tanpa tip tidak boleh menulis hint")
}
}
+68 -22
View File
@@ -4,6 +4,7 @@ import (
"fmt" "fmt"
"os" "os"
"os/exec" "os/exec"
"path/filepath"
"time" "time"
"fyne.io/fyne/v2" "fyne.io/fyne/v2"
@@ -15,15 +16,45 @@ import (
"lazyrun/internal/update" "lazyrun/internal/update"
) )
// recheckInterval adalah jeda pemeriksaan update berkala selama aplikasi terbuka.
const recheckInterval = 4 * time.Hour
// appImagePath mengembalikan lokasi AppImage yang sedang dipakai.
// $APPIMAGE dipakai kalau ada; kalau tidak (mis. dijalankan lewat wrapper),
// berkas executable-nya sendiri diperiksa apakah benar AppImage.
func appImagePath() string {
if path := os.Getenv("APPIMAGE"); path != "" {
return path
}
exe, err := os.Executable()
if err != nil {
return ""
}
if resolved, err := filepath.EvalSymlinks(exe); err == nil {
exe = resolved
}
if update.IsAppImage(exe) {
return exe
}
return ""
}
// checkUpdates dipanggil tombol footer: hasilnya selalu diberitahukan. // checkUpdates dipanggil tombol footer: hasilnya selalu diberitahukan.
func (ui *app) checkUpdates() { ui.checkForUpdate(true) } func (ui *app) checkUpdates() { ui.checkForUpdate(true) }
// scheduleUpdateCheck memeriksa rilis terbaru beberapa saat setelah aplikasi tampil. // scheduleUpdateCheck memeriksa rilis terbaru setelah aplikasi tampil,
// lalu mengulanginya berkala selama aplikasi terbuka.
func (ui *app) scheduleUpdateCheck() { func (ui *app) scheduleUpdateCheck() {
if os.Getenv("APPIMAGE") == "" { if appImagePath() == "" {
return // dijalankan dari source: jangan ganggu dengan cek update return // dijalankan dari source / hasil ekstrak: tidak bisa memasang update
} }
time.AfterFunc(2*time.Second, func() { ui.checkForUpdate(false) }) time.AfterFunc(2*time.Second, func() { ui.checkForUpdate(false) })
ticker := time.NewTicker(recheckInterval)
go func() {
for range ticker.C {
ui.checkForUpdate(false)
}
}()
} }
// checkForUpdate mengambil rilis terbaru dari GitHub. manual=false hanya // checkForUpdate mengambil rilis terbaru dari GitHub. manual=false hanya
@@ -45,6 +76,7 @@ func (ui *app) checkForUpdate(manual bool) {
dialog.ShowError(err, ui.win) dialog.ShowError(err, ui.win)
} }
case update.Newer(ui.version, rel): case update.Newer(ui.version, rel):
ui.markUpdatePending(rel)
ui.showUpdateDialog(rel) ui.showUpdateDialog(rel)
case manual: case manual:
dialog.ShowInformation(T("check_updates"), fmt.Sprintf(T("up_to_date"), ui.version), ui.win) dialog.ShowInformation(T("check_updates"), fmt.Sprintf(T("up_to_date"), ui.version), ui.win)
@@ -53,11 +85,21 @@ func (ui *app) checkForUpdate(manual bool) {
}() }()
} }
// markUpdatePending menampilkan versi baru di footer, jadi tetap terlihat
// walau dialognya ditutup dengan "Nanti".
func (ui *app) markUpdatePending(rel update.Release) {
if ui.versionText == nil {
return
}
ui.versionText.Text = fmt.Sprintf("v%s → v%s", ui.version, rel.Version)
ui.versionText.Refresh()
}
// showUpdateDialog menawarkan update: unduh sekarang, buka halaman rilis, atau nanti. // showUpdateDialog menawarkan update: unduh sekarang, buka halaman rilis, atau nanti.
func (ui *app) showUpdateDialog(rel update.Release) { func (ui *app) showUpdateDialog(rel update.Release) {
notes := widget.NewLabel(rel.Notes) // catatan rilis GitHub berformat markdown; render supaya blok kode/daftar
notes.Wrapping = fyne.TextWrapWord // tidak tampil sebagai teks mentah
notes.Importance = widget.LowImportance notes := widget.NewRichTextFromMarkdown(rel.Notes)
// catatan rilis di tengah (bukan top): VScroll di Fyne punya MinSize kecil, // catatan rilis di tengah (bukan top): VScroll di Fyne punya MinSize kecil,
// jadi kalau dipasang sebagai top dari Border isinya terpotong. // jadi kalau dipasang sebagai top dari Border isinya terpotong.
@@ -68,18 +110,18 @@ func (ui *app) showUpdateDialog(rel update.Release) {
buttons := container.NewHBox( buttons := container.NewHBox(
logoBox(newLogo(), 20), logoBox(newLogo(), 20),
spacer(), spacer(),
widget.NewButton(T("update_later"), func() { d.Hide() }), newButton(T("update_later"), nil, T("tip_later"), widget.MediumImportance, func() { d.Hide() }),
widget.NewButton(T("update_page"), func() { ui.openURL(rel.PageURL) }), newButton(T("update_page"), nil, T("tip_update_page"), widget.MediumImportance, func() { ui.openURL(rel.PageURL) }),
) )
if updates := ui.updaterBinary(); updates != "" { if updates := ui.updaterBinary(); updates != "" {
// AppImageUpdate yang memasang (dan memverifikasi) sendiri // AppImageUpdate yang memasang (dan memverifikasi) sendiri
buttons.Add(widget.NewButtonWithIcon(T("update_now"), theme.DownloadIcon(), func() { buttons.Add(newButton(T("update_now"), theme.DownloadIcon(), T("tip_update_now"), widget.HighImportance, func() {
d.Hide() d.Hide()
ui.runUpdater(updates, rel) ui.runUpdater(updates, rel)
})) }))
} else { } else {
buttons.Add(widget.NewButtonWithIcon(T("update_now"), theme.DownloadIcon(), func() { buttons.Add(newButton(T("update_now"), theme.DownloadIcon(), T("tip_update_now"), widget.HighImportance, func() {
d.Hide() d.Hide()
ui.downloadUpdate(rel) ui.downloadUpdate(rel)
})) }))
@@ -91,7 +133,7 @@ func (ui *app) showUpdateDialog(rel update.Release) {
// updaterBinary mengembalikan path AppImageUpdate kalau terpasang. // updaterBinary mengembalikan path AppImageUpdate kalau terpasang.
func (ui *app) updaterBinary() string { func (ui *app) updaterBinary() string {
if os.Getenv("APPIMAGE") == "" { if appImagePath() == "" {
return "" return ""
} }
path, err := exec.LookPath("AppImageUpdate") path, err := exec.LookPath("AppImageUpdate")
@@ -103,18 +145,17 @@ func (ui *app) updaterBinary() string {
// downloadUpdate mengunduh AppImage terbaru dan menukarnya dengan yang sedang dipakai. // downloadUpdate mengunduh AppImage terbaru dan menukarnya dengan yang sedang dipakai.
func (ui *app) downloadUpdate(rel update.Release) { func (ui *app) downloadUpdate(rel update.Release) {
appImage := os.Getenv("APPIMAGE") appImage := appImagePath()
if appImage == "" { if appImage == "" {
ui.openURL(rel.PageURL) ui.openURL(rel.PageURL)
return return
} }
progress := dialog.NewProgressInfinite(T("check_updates"), T("update_downloading"), ui.win) hide := ui.showLoading(T("check_updates"), T("update_downloading"))
progress.Show()
go func() { go func() {
err := update.Download(rel, appImage) err := update.Download(rel, appImage)
fyne.Do(func() { fyne.Do(func() {
progress.Hide() hide()
if err != nil { if err != nil {
dialog.ShowError(err, ui.win) dialog.ShowError(err, ui.win)
return return
@@ -126,12 +167,17 @@ func (ui *app) downloadUpdate(rel update.Release) {
// runUpdater memakai AppImageUpdate untuk memasang update (pakai zsync, unduhannya kecil). // runUpdater memakai AppImageUpdate untuk memasang update (pakai zsync, unduhannya kecil).
func (ui *app) runUpdater(updater string, rel update.Release) { func (ui *app) runUpdater(updater string, rel update.Release) {
progress := dialog.NewProgressInfinite(T("check_updates"), T("update_downloading"), ui.win) appImage := appImagePath()
progress.Show() if appImage == "" {
ui.openURL(rel.PageURL)
return
}
hide := ui.showLoading(T("check_updates"), T("update_downloading"))
go func() { go func() {
err := exec.Command(updater, os.Getenv("APPIMAGE")).Run() err := exec.Command(updater, appImage).Run()
fyne.Do(func() { fyne.Do(func() {
progress.Hide() hide()
if err != nil { if err != nil {
dialog.ShowError(err, ui.win) dialog.ShowError(err, ui.win)
return return
@@ -147,8 +193,8 @@ func (ui *app) offerRestart() {
content := container.NewVBox( content := container.NewVBox(
widget.NewLabel(T("update_installed")), widget.NewLabel(T("update_installed")),
container.NewHBox(spacer(), container.NewHBox(spacer(),
widget.NewButton(T("update_later"), func() { d.Hide() }), newButton(T("update_later"), nil, T("tip_later"), widget.MediumImportance, func() { d.Hide() }),
widget.NewButtonWithIcon(T("update_restart"), theme.ViewRefreshIcon(), ui.restart), newButton(T("update_restart"), theme.ViewRefreshIcon(), T("tip_restart"), widget.HighImportance, ui.restart),
), ),
) )
d = dialog.NewCustomWithoutButtons(T("check_updates"), content, ui.win) d = dialog.NewCustomWithoutButtons(T("check_updates"), content, ui.win)
@@ -157,7 +203,7 @@ func (ui *app) offerRestart() {
// restart menjalankan AppImage yang baru lalu menutup proses lama. // restart menjalankan AppImage yang baru lalu menutup proses lama.
func (ui *app) restart() { func (ui *app) restart() {
appImage := os.Getenv("APPIMAGE") appImage := appImagePath()
if appImage == "" { if appImage == "" {
return return
} }
+56
View File
@@ -0,0 +1,56 @@
package ui
import (
"os"
"path/filepath"
"testing"
"fyne.io/fyne/v2/test"
"lazyrun/internal/update"
)
func TestAppImagePathFromEnv(t *testing.T) {
t.Setenv("APPIMAGE", "/home/u/Apps/lazyrun-1.0.0-x86_64.AppImage")
if got := appImagePath(); got != "/home/u/Apps/lazyrun-1.0.0-x86_64.AppImage" {
t.Errorf("appImagePath() = %q", got)
}
}
func TestAppImagePathWithoutEnvAndNotAppImage(t *testing.T) {
// test binary bukan AppImage, jadi tidak ada yang bisa diupdate
t.Setenv("APPIMAGE", "")
if got := appImagePath(); got != "" {
t.Errorf("appImagePath() = %q, mau kosong untuk binary biasa", got)
}
}
func TestAppImagePathFallsBackToExecutable(t *testing.T) {
dir := t.TempDir()
// executable tiruan: ELF + magic AI (AppImage)
fake := filepath.Join(dir, "lazyrun.AppImage")
data := make([]byte, 64)
copy(data, []byte{0x7f, 'E', 'L', 'F', 0x02, 0x01, 0x01, 0x00})
copy(data[8:], []byte{'A', 'I', 0x02})
if err := os.WriteFile(fake, data, 0o755); err != nil {
t.Fatal(err)
}
// jalur env kosong -> deteksi dari IsAppImage (di sini diuji langsung)
if !update.IsAppImage(fake) {
t.Fatal("berkas uji harus dikenali sebagai AppImage")
}
}
func TestMarkUpdatePendingSetsFooter(t *testing.T) {
a := test.NewApp()
defer a.Quit()
ui, err := build(a, "1.1.0")
if err != nil {
t.Fatal(err)
}
ui.markUpdatePending(update.Release{Version: "9.9.9"})
if got := ui.versionText.Text; got != "v1.1.0 → v9.9.9" {
t.Errorf("footer tidak menampilkan update: %q", got)
}
}