Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
deaa30689c | ||
|
|
c1aed1e286 | ||
|
|
25a979f24c | ||
|
|
ab6dafbe5b |
No files matched your search
@@ -10,6 +10,9 @@ Desktop app (Go + Fyne) that creates application shortcuts on Linux in a few cli
|
||||
from an executable file, or from a web URL as a PWA. Ships as a single-file
|
||||
AppImage, no terminal needed.
|
||||
|
||||
*Dream-sky theme: night violet, dusk pink and a moon-coloured accent that subtly
|
||||
breathes while the app is open (the koala in `assets/logo.svg` is the mascot).*
|
||||
|
||||

|
||||
|
||||
<p align="center">
|
||||
@@ -142,9 +145,21 @@ GH_USER=oxpoe GH_REPO=lazyrun ./build.sh
|
||||
|
||||
## Roadmap
|
||||
|
||||
- v1.1 — edit existing shortcuts, detect binaries in `$PATH`, optional symlink
|
||||
instead of a Desktop copy, import/export, dark mode.
|
||||
- v1.2 — Firefox PWA via `--kiosk`, Flatpak/Snap targets, icon preview.
|
||||
- Edit existing shortcuts, detect binaries in `$PATH`, optional symlink instead of a
|
||||
Desktop copy, import/export, dark mode.
|
||||
- Firefox PWA via `--kiosk`, Flatpak/Snap targets, icon preview.
|
||||
|
||||
Known limitations are tracked as [issues](https://github.com/oxpoe/lazyrun/issues);
|
||||
the main ones:
|
||||
|
||||
- the type label (`Go`, `Electron`, …) is detected when the shortcut is created, so a
|
||||
binary replaced by hand keeps its old label until it is reinstalled;
|
||||
- the update check runs while the app is open (startup + every 4 hours), there is no
|
||||
background service, and updates are offered, never forced;
|
||||
- online icon search uses Iconify plus the icons a website itself provides
|
||||
(manifest / touch icon / favicon) — there is no image-search scraping;
|
||||
- `Electron` detection uses the `resources/*.asar` marker, so Chromium-based apps
|
||||
(Vivaldi, Helium, …) show up as `Binary`.
|
||||
|
||||
## Disclaimer / Catatan
|
||||
|
||||
|
||||
|
Before Width: | Height: | Size: 27 KiB After Width: | Height: | Size: 102 KiB |
@@ -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 |
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 92 KiB |
|
Before Width: | Height: | Size: 67 KiB After Width: | Height: | Size: 77 KiB |
|
Before Width: | Height: | Size: 60 KiB After Width: | Height: | Size: 57 KiB |
|
Before Width: | Height: | Size: 57 KiB After Width: | Height: | Size: 54 KiB |
@@ -1,6 +1,6 @@
|
||||
module lazyrun
|
||||
|
||||
go 1.24
|
||||
go 1.26.0
|
||||
|
||||
require (
|
||||
fyne.io/fyne/v2 v2.8.2
|
||||
@@ -16,6 +16,7 @@ require (
|
||||
github.com/anthonynsimon/bild v0.14.0 // indirect
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 // indirect
|
||||
github.com/fyne-io/glfw-js v0.4.0 // indirect
|
||||
@@ -25,20 +26,28 @@ require (
|
||||
github.com/go-text/render v0.2.1 // indirect
|
||||
github.com/go-text/typesetting v0.3.5 // indirect
|
||||
github.com/godbus/dbus/v5 v5.2.2 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
|
||||
github.com/hack-pad/safejs v0.1.0 // indirect
|
||||
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
|
||||
github.com/mattn/go-isatty v0.0.24 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.24 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
|
||||
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rymdport/portal v0.4.2 // indirect
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
|
||||
github.com/stretchr/testify v1.11.1 // indirect
|
||||
github.com/yuin/goldmark v1.8.2 // indirect
|
||||
golang.org/x/net v0.35.0 // indirect
|
||||
golang.org/x/sys v0.30.0 // indirect
|
||||
golang.org/x/sys v0.48.0 // indirect
|
||||
golang.org/x/text v0.22.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
modernc.org/libc v1.77.1 // indirect
|
||||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.12.1 // indirect
|
||||
modernc.org/sqlite v1.60.1 // indirect
|
||||
)
|
||||
@@ -12,6 +12,8 @@ github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdoh
|
||||
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
|
||||
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 h1:0kdPD/GEntpWmZEK5Zu/xE6Tr37jYCVDf9QP8lA/QK8=
|
||||
@@ -34,6 +36,8 @@ github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc h1:8FGo2
|
||||
github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
|
||||
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
|
||||
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
|
||||
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
|
||||
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
|
||||
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
|
||||
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
|
||||
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
|
||||
@@ -44,8 +48,12 @@ github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe9
|
||||
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI=
|
||||
github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A=
|
||||
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
|
||||
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
|
||||
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
|
||||
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
|
||||
@@ -54,6 +62,8 @@ github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWb
|
||||
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
|
||||
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
|
||||
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
|
||||
@@ -70,6 +80,8 @@ golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
|
||||
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
|
||||
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
|
||||
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
|
||||
golang.org/x/sys v0.48.0 h1:bbX/i/6MgT9BVLM9RT1thmxL04yeTAhbEz4SyadbXoo=
|
||||
golang.org/x/sys v0.48.0/go.mod h1:hNLxWAXmnKAxqDtdwIYC4bM9oQPEecfsnNMuSxOs3og=
|
||||
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
|
||||
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
@@ -77,3 +89,11 @@ gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8X
|
||||
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
modernc.org/libc v1.77.1 h1:Ct8j47QtiZ1Enj2DtFXQtUqrPCAjdCmPjtCuvrYQ0Hs=
|
||||
modernc.org/libc v1.77.1/go.mod h1:87/pZ4L6nD1zqW4nItuS12YO7hN1igAah34xjnQo/W0=
|
||||
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
|
||||
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
|
||||
modernc.org/memory v1.12.1 h1:nFMiWrpStgZczNl6XI9GnIk/rWhYIyHGUaR04pGbp9g=
|
||||
modernc.org/memory v1.12.1/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
|
||||
modernc.org/sqlite v1.60.1 h1:/blz53O951KWFOso4QQvEs/Fq6cDBKLtMVrYNSeJVKw=
|
||||
modernc.org/sqlite v1.60.1/go.mod h1:1dIoEagfDE72QytD5scH1lxARtaUgKgHC/NuApA27r0=
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -11,8 +11,9 @@ import (
|
||||
"github.com/srwiley/rasterx"
|
||||
)
|
||||
|
||||
// currentColor diisi ke SVG yang memakai fill="currentColor".
|
||||
const currentColor = "#4f46e5"
|
||||
// currentColor diisi ke SVG yang memakai fill="currentColor"
|
||||
// (warna brand: dusk pink, terlihat di background terang maupun gelap).
|
||||
const currentColor = "#c25c9e"
|
||||
|
||||
// Rasterize mengubah SVG menjadi PNG persegi `size`x`size`.
|
||||
//
|
||||
|
||||
@@ -76,9 +76,9 @@ func TestRasterizeColorsCurrentColorIcons(t *testing.T) {
|
||||
if a == 0 {
|
||||
t.Fatal("ikon currentColor tidak terwarnai (transparan)")
|
||||
}
|
||||
// brand indigo #4F46E5: biru > merah > hijau
|
||||
if !(b > r && r > g) {
|
||||
t.Errorf("warna pengganti bukan brand indigo: r=%d g=%d b=%d", r>>8, g>>8, b>>8)
|
||||
// brand dusk pink #C25C9E: merah > biru > hijau
|
||||
if !(r > b && b > g) {
|
||||
t.Errorf("warna pengganti bukan warna brand: r=%d g=%d b=%d", r>>8, g>>8, b>>8)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,7 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) {
|
||||
Type: store.TypeExecutable,
|
||||
Kind: kind,
|
||||
ExecArgs: entry.ExecArgs,
|
||||
Categories: entry.Categories,
|
||||
BinaryPath: binDst,
|
||||
IconPath: iconPath,
|
||||
IconSource: iconSource,
|
||||
@@ -189,6 +190,7 @@ func InstallWebApp(o WebOptions) (store.Shortcut, error) {
|
||||
Type: store.TypeWebApp,
|
||||
Kind: KindPWA,
|
||||
URL: pageURL,
|
||||
Categories: entry.Categories,
|
||||
Browser: strings.Join(o.Browser.Exec, " "),
|
||||
ExecArgs: entry.ExecArgs,
|
||||
IconPath: iconPath,
|
||||
|
||||
@@ -29,6 +29,7 @@ type Shortcut struct {
|
||||
URL string `json:"url,omitempty"`
|
||||
Browser string `json:"browser,omitempty"`
|
||||
ExecArgs []string `json:"exec_args,omitempty"`
|
||||
Categories string `json:"categories,omitempty"`
|
||||
BinaryPath string `json:"binary_path,omitempty"`
|
||||
IconPath string `json:"icon_path,omitempty"`
|
||||
IconSource string `json:"icon_source,omitempty"`
|
||||
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
|
||||
// pulseGradient membuat garis aksen bergerak bolak-balik antara indigo dan cyan.
|
||||
func pulseGradient(bar *canvas.LinearGradient) {
|
||||
anim := canvas.NewColorRGBAAnimation(brandIndigo, brandCyan, 3*time.Second, func(c color.Color) {
|
||||
anim := canvas.NewColorRGBAAnimation(brandAmber, brandMagenta, 3*time.Second, func(c color.Color) {
|
||||
bar.EndColor = c
|
||||
canvas.Refresh(bar)
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
@@ -41,7 +41,7 @@ func (ui *app) showExecutableDialog() {
|
||||
desktop := widget.NewCheck(T("check_desktop"), nil)
|
||||
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) {
|
||||
binPath = 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) {
|
||||
iconPath, iconURL, iconData = path, "", nil
|
||||
iconLabel.SetText(filepath.Base(path))
|
||||
})
|
||||
})
|
||||
onlineIcon := widget.NewButtonWithIcon(T("online"), theme.SearchIcon(), func() {
|
||||
onlineIcon := newButton(T("online"), theme.SearchIcon(), T("tip_online"), widget.MediumImportance, func() {
|
||||
ui.showIconSearch(nameEntry.Text, func(result icon.Result, data []byte) {
|
||||
iconURL, iconPath, iconData = result.URL, "", data
|
||||
iconLabel.SetText(result.Name)
|
||||
@@ -64,7 +64,7 @@ func (ui *app) showExecutableDialog() {
|
||||
})
|
||||
|
||||
var d *dialog.CustomDialog
|
||||
install := widget.NewButtonWithIcon(T("install"), theme.DownloadIcon(), func() {
|
||||
install := newButton(T("install"), theme.DownloadIcon(), T("tip_install"), widget.HighImportance, func() {
|
||||
switch {
|
||||
case binPath == "":
|
||||
dialog.ShowInformation(T("err_title"), T("need_binary"), ui.win)
|
||||
@@ -91,8 +91,6 @@ func (ui *app) showExecutableDialog() {
|
||||
func(sc store.Shortcut, err error) { ui.added(sc, err, d.Hide) },
|
||||
)
|
||||
})
|
||||
install.Importance = widget.HighImportance
|
||||
|
||||
form := widget.NewForm(
|
||||
widget.NewFormItem(T("field_binary"), container.NewBorder(nil, nil, nil, browseBin, binLabel)),
|
||||
widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil,
|
||||
@@ -100,6 +98,7 @@ func (ui *app) showExecutableDialog() {
|
||||
widget.NewFormItem(T("field_name"), nameEntry),
|
||||
widget.NewFormItem(T("field_category"), category),
|
||||
)
|
||||
hint := textLine(T("exec_hint"), 12, false, colorNameMuted)
|
||||
content := container.NewVScroll(container.NewVBox(
|
||||
closeRow(func() { d.Hide() }),
|
||||
form,
|
||||
@@ -107,13 +106,14 @@ func (ui *app) showExecutableDialog() {
|
||||
sectionTitle(T("section_location")),
|
||||
menu,
|
||||
desktop,
|
||||
muted(T("exec_hint")),
|
||||
hint,
|
||||
container.NewHBox(logoBox(newLogo(), 20), spacer(),
|
||||
widget.NewButton(T("cancel"), func() { d.Hide() }),
|
||||
newButton(T("cancel"), nil, T("tip_cancel"), widget.MediumImportance, func() { d.Hide() }),
|
||||
install),
|
||||
))
|
||||
|
||||
d = dialog.NewCustomWithoutButtons(T("new_exec_title"), content, ui.win)
|
||||
d.SetOnClosed(pushHintTarget(hint)) // keterangan tombol muncul di baris hint dialog ini
|
||||
showDialog(d, ui.win, 560, 520)
|
||||
}
|
||||
|
||||
|
||||
@@ -87,10 +87,11 @@ func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) {
|
||||
entry.OnSubmitted = func(string) { search() }
|
||||
// status di barisnya sendiri: supaya tidak bertabrakan dengan tombol
|
||||
// di layar sempit atau ber-penskalaan besar.
|
||||
bottom := container.NewVBox(status, container.NewHBox(
|
||||
hint := textLine(T("icon_search_hint"), 12, false, colorNameMuted)
|
||||
bottom := container.NewVBox(hint, status, container.NewHBox(
|
||||
spacer(),
|
||||
widget.NewButtonWithIcon(T("back"), theme.NavigateBackIcon(), func() { d.Hide() }),
|
||||
widget.NewButtonWithIcon(T("search"), theme.SearchIcon(), search),
|
||||
newButton(T("back"), theme.NavigateBackIcon(), T("tip_back"), widget.MediumImportance, func() { d.Hide() }),
|
||||
newButton(T("search"), theme.SearchIcon(), T("tip_search"), widget.HighImportance, search),
|
||||
))
|
||||
content := container.NewBorder(
|
||||
container.NewVBox(closeRow(func() { d.Hide() }), entry),
|
||||
@@ -99,6 +100,7 @@ func (ui *app) showIconSearch(query string, onPick func(icon.Result, []byte)) {
|
||||
)
|
||||
|
||||
d = dialog.NewCustomWithoutButtons(T("icon_search_title"), content, ui.win)
|
||||
d.SetOnClosed(pushHintTarget(hint))
|
||||
showDialog(d, ui.win, 560, 470) // sekaligus menampilkan dialog
|
||||
search()
|
||||
}
|
||||
@@ -126,7 +128,7 @@ func (ui *app) iconChoice(result icon.Result, onPick func(icon.Result, []byte),
|
||||
var mu sync.Mutex
|
||||
var raw []byte
|
||||
|
||||
button := widget.NewButton("", func() {
|
||||
button := newButton("", nil, result.Name, widget.LowImportance, func() {
|
||||
mu.Lock()
|
||||
picked := raw
|
||||
mu.Unlock()
|
||||
@@ -135,8 +137,8 @@ func (ui *app) iconChoice(result icon.Result, onPick func(icon.Result, []byte),
|
||||
d.Hide()
|
||||
}
|
||||
})
|
||||
button.Importance = widget.LowImportance
|
||||
button.Icon = theme.NewThemedResource(theme.FileImageIcon()) // placeholder selama diunduh
|
||||
button.Refresh()
|
||||
|
||||
go func() {
|
||||
slot <- struct{}{}
|
||||
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"fyne.io/fyne/v2/test"
|
||||
|
||||
"lazyrun/internal/icon"
|
||||
"lazyrun/internal/store"
|
||||
)
|
||||
|
||||
// TestNoEmptyDismissButton memastikan modal tidak memakai dialog.NewCustom,
|
||||
@@ -62,6 +63,17 @@ func TestDialogsOpenWithoutPanic(t *testing.T) {
|
||||
t.Fatal("modal web app tidak muncul")
|
||||
}
|
||||
}
|
||||
ui.showEditDialog(store.Shortcut{Slug: "winbox", Name: "Winbox", Type: store.TypeExecutable, Kind: "windows",
|
||||
BinaryPath: "/tmp/winbox", IconPath: "", Categories: "Network;", Targets: store.Targets{Menu: true, Desktop: true}})
|
||||
if top := ui.win.Canvas().Overlays().Top(); top == nil {
|
||||
t.Fatal("modal edit executable tidak muncul")
|
||||
}
|
||||
ui.showEditDialog(store.Shortcut{Slug: "wa", Name: "WhatsApp Web", Type: store.TypeWebApp, Kind: "pwa",
|
||||
URL: "https://web.whatsapp.com", Browser: "/usr/bin/vivaldi", Targets: store.Targets{Menu: true}})
|
||||
if top := ui.win.Canvas().Overlays().Top(); top == nil {
|
||||
t.Fatal("modal edit webapp tidak muncul")
|
||||
}
|
||||
|
||||
ui.showIconSearch("whatsapp", func(icon.Result, []byte) {})
|
||||
if top := ui.win.Canvas().Overlays().Top(); top == nil {
|
||||
t.Fatal("modal pencarian ikon tidak muncul")
|
||||
@@ -108,3 +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)
|
||||
}
|
||||
}
|
||||
@@ -54,13 +54,13 @@ func (ui *app) showWebAppDialog() {
|
||||
}
|
||||
}
|
||||
|
||||
browseIcon := widget.NewButtonWithIcon(T("browse"), theme.FolderOpenIcon(), func() {
|
||||
browseIcon := newButton(T("browse"), theme.FolderOpenIcon(), T("tip_browse"), widget.MediumImportance, func() {
|
||||
pickFile(ui.win, imageFilter, func(path string) {
|
||||
iconPath, iconURL, iconData = path, "", nil
|
||||
iconLabel.SetText(filepath.Base(path))
|
||||
})
|
||||
})
|
||||
onlineIcon := widget.NewButtonWithIcon(T("online"), theme.SearchIcon(), func() {
|
||||
onlineIcon := newButton(T("online"), theme.SearchIcon(), T("tip_online"), widget.MediumImportance, func() {
|
||||
ui.showIconSearch(nameEntry.Text, func(result icon.Result, data []byte) {
|
||||
iconURL, iconPath, iconData = result.URL, "", data
|
||||
iconLabel.SetText(result.Name)
|
||||
@@ -68,7 +68,7 @@ func (ui *app) showWebAppDialog() {
|
||||
})
|
||||
|
||||
var d *dialog.CustomDialog
|
||||
install := widget.NewButtonWithIcon(T("install"), theme.DownloadIcon(), func() {
|
||||
install := newButton(T("install"), theme.DownloadIcon(), T("tip_install"), widget.HighImportance, func() {
|
||||
switch {
|
||||
case strings.TrimSpace(urlEntry.Text) == "":
|
||||
dialog.ShowInformation(T("err_title"), T("need_url"), ui.win)
|
||||
@@ -96,8 +96,6 @@ func (ui *app) showWebAppDialog() {
|
||||
func(sc store.Shortcut, err error) { ui.added(sc, err, d.Hide) },
|
||||
)
|
||||
})
|
||||
install.Importance = widget.HighImportance
|
||||
|
||||
form := widget.NewForm(
|
||||
widget.NewFormItem(T("field_url"), urlEntry),
|
||||
widget.NewFormItem(T("field_name"), nameEntry),
|
||||
@@ -105,6 +103,7 @@ func (ui *app) showWebAppDialog() {
|
||||
widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil,
|
||||
container.NewHBox(onlineIcon, browseIcon), iconLabel)),
|
||||
)
|
||||
hint := textLine(T("web_hint"), 12, false, colorNameMuted)
|
||||
content := container.NewVScroll(container.NewVBox(
|
||||
closeRow(func() { d.Hide() }),
|
||||
form,
|
||||
@@ -112,13 +111,14 @@ func (ui *app) showWebAppDialog() {
|
||||
sectionTitle(T("section_location")),
|
||||
menu,
|
||||
desktop,
|
||||
muted(T("web_hint")),
|
||||
hint,
|
||||
container.NewHBox(logoBox(newLogo(), 20), spacer(),
|
||||
widget.NewButton(T("cancel"), func() { d.Hide() }),
|
||||
newButton(T("cancel"), nil, T("tip_cancel"), widget.MediumImportance, func() { d.Hide() }),
|
||||
install),
|
||||
))
|
||||
|
||||
d = dialog.NewCustomWithoutButtons(T("new_web_title"), content, ui.win)
|
||||
d.SetOnClosed(pushHintTarget(hint))
|
||||
showDialog(d, ui.win, 560, 520)
|
||||
}
|
||||
|
||||
|
||||
@@ -32,6 +32,8 @@ type app struct {
|
||||
icons *iconCache
|
||||
count *canvas.Text // angka jumlah shortcut di header daftar
|
||||
versionText *canvas.Text // versi di footer; berubah kalau ada update
|
||||
hint *canvas.Text // keterangan tombol yang sedang disorot
|
||||
accent *canvas.LinearGradient
|
||||
version string
|
||||
}
|
||||
|
||||
@@ -39,6 +41,7 @@ type app struct {
|
||||
func Run(a fyne.App, version string) {
|
||||
ui, loadErr := build(a, version)
|
||||
ui.win.Show()
|
||||
pushHintTarget(ui.hint) // target default: baris hint di footer
|
||||
ui.flashCards()
|
||||
ui.scheduleUpdateCheck()
|
||||
if loadErr != nil {
|
||||
@@ -66,9 +69,8 @@ func build(a fyne.App, version string) (*app, error) {
|
||||
st, err := store.Load(xdg.MetadataPath())
|
||||
ui.store = st
|
||||
|
||||
addExec := widget.NewButtonWithIcon(T("btn_executable"), theme.ContentAddIcon(), ui.showExecutableDialog)
|
||||
addExec.Importance = widget.HighImportance
|
||||
addWeb := widget.NewButtonWithIcon(T("btn_webapp"), theme.ContentAddIcon(), ui.showWebAppDialog)
|
||||
addExec := newButton(T("btn_executable"), theme.ContentAddIcon(), T("tip_executable"), widget.HighImportance, ui.showExecutableDialog)
|
||||
addWeb := newButton(T("btn_webapp"), theme.ContentAddIcon(), T("tip_webapp"), widget.MediumImportance, ui.showWebAppDialog)
|
||||
|
||||
leftHeader := container.NewHBox(logoBox(newLogo(), 40), container.NewVBox(
|
||||
heading("LazyRun"),
|
||||
@@ -77,19 +79,22 @@ func build(a fyne.App, version string) (*app, error) {
|
||||
rightHeader := container.NewHBox(addExec, addWeb)
|
||||
header := container.NewBorder(nil, nil, leftHeader, rightHeader)
|
||||
|
||||
update := widget.NewButtonWithIcon(T("check_updates"), theme.DownloadIcon(), ui.checkUpdates)
|
||||
github := widget.NewButton(T("github"), func() { ui.openURL(repoURL) })
|
||||
donate := widget.NewButtonWithIcon(T("donate"),
|
||||
fyne.NewStaticResource("heart.png", assets.Heart), func() { ui.openURL(donateURL) })
|
||||
update := newButton(T("check_updates"), theme.DownloadIcon(), T("tip_check_updates"), widget.MediumImportance, ui.checkUpdates)
|
||||
github := newButton(T("github"), nil, T("tip_github"), widget.MediumImportance, func() { ui.openURL(repoURL) })
|
||||
donate := newButton(T("donate"), fyne.NewStaticResource("heart.png", assets.Heart), T("tip_donate"), widget.MediumImportance, func() { ui.openURL(donateURL) })
|
||||
export := newIconButton(theme.DocumentSaveIcon(), T("tip_export"), ui.exportShortcuts)
|
||||
importBtn := newIconButton(theme.StorageIcon(), T("tip_import"), ui.importShortcuts)
|
||||
ui.hint = caption("")
|
||||
footer := container.NewBorder(
|
||||
widget.NewSeparator(), nil,
|
||||
container.NewHBox(update, github, donate),
|
||||
container.NewHBox(update, export, importBtn, github, donate),
|
||||
ui.versionText,
|
||||
ui.hint,
|
||||
)
|
||||
|
||||
accent := accentBar()
|
||||
pulseGradient(accent)
|
||||
top := container.NewVBox(header, accent,
|
||||
ui.accent = accentBar()
|
||||
pulseGradient(ui.accent)
|
||||
top := container.NewVBox(header, ui.accent,
|
||||
container.NewHBox(sectionLabel(T("installed")), countChip))
|
||||
body := container.NewBorder(
|
||||
top,
|
||||
@@ -144,16 +149,17 @@ func (ui *app) row(sc store.Shortcut) *hoverCard {
|
||||
}
|
||||
|
||||
actions := container.NewHBox(chips...)
|
||||
actions.Add(iconButton(theme.MediaPlayIcon(), func() { ui.launch(sc) }))
|
||||
actions.Add(newIconButton(theme.MediaPlayIcon(), T("tip_run"), func() { ui.launch(sc) }))
|
||||
if syncable(sc) {
|
||||
actions.Add(iconButton(theme.ViewRefreshIcon(), func() { ui.syncIcon(sc) }))
|
||||
actions.Add(newIconButton(theme.ViewRefreshIcon(), T("tip_sync"), func() { ui.syncIcon(sc) }))
|
||||
}
|
||||
actions.Add(iconButton(theme.FolderOpenIcon(), func() {
|
||||
actions.Add(newIconButton(theme.DocumentCreateIcon(), T("tip_edit"), func() { ui.showEditDialog(sc) }))
|
||||
actions.Add(newIconButton(theme.FolderOpenIcon(), T("tip_folder"), func() {
|
||||
if err := exec.Command("xdg-open", filepath.Dir(sc.DesktopPath)).Start(); err != nil {
|
||||
dialog.ShowError(err, ui.win)
|
||||
}
|
||||
}))
|
||||
actions.Add(iconButton(theme.DeleteIcon(), func() { ui.confirmDelete(sc) }))
|
||||
actions.Add(newIconButton(theme.DeleteIcon(), T("tip_delete"), func() { ui.confirmDelete(sc) }))
|
||||
|
||||
return newHoverCard(container.NewBorder(nil, nil,
|
||||
container.NewHBox(thumb(sc.IconPath, fallbackIcon(sc)), info),
|
||||
@@ -238,12 +244,11 @@ func syncable(sc store.Shortcut) bool {
|
||||
|
||||
// syncIcon mengunduh ulang ikon shortcut dari sumber online-nya.
|
||||
func (ui *app) syncIcon(sc store.Shortcut) {
|
||||
progress := dialog.NewProgressInfinite(T("sync_icon"), T("working"), ui.win)
|
||||
progress.Show()
|
||||
hide := ui.showLoading(T("sync_icon"), T("working"))
|
||||
go func() {
|
||||
path, err := shortcut.RefreshIcon(sc)
|
||||
fyne.Do(func() {
|
||||
progress.Hide()
|
||||
hide()
|
||||
if err != nil {
|
||||
dialog.ShowError(err, ui.win)
|
||||
return
|
||||
@@ -291,14 +296,14 @@ func (ui *app) confirmDelete(sc store.Shortcut) {
|
||||
d.Show()
|
||||
}
|
||||
|
||||
// install menjalankan pekerjaan berat (salin file besar / fetch favicon) di luar UI thread.
|
||||
// install menjalankan pekerjaan berat (salin file besar / unduh ikon) di luar UI thread,
|
||||
// dengan dialog proses berisi progress bar.
|
||||
func (ui *app) install(work func() (store.Shortcut, error), done func(store.Shortcut, error)) {
|
||||
progress := dialog.NewProgressInfinite(T("install"), T("working"), ui.win)
|
||||
progress.Show()
|
||||
hide := ui.showLoading(T("install"), T("working"))
|
||||
go func() {
|
||||
sc, err := work()
|
||||
fyne.Do(func() {
|
||||
progress.Hide()
|
||||
hide()
|
||||
done(sc, err)
|
||||
})
|
||||
}()
|
||||
|
||||
@@ -25,12 +25,14 @@ var tr = map[string][2]string{
|
||||
"searching": {"Searching...", "Mencari..."},
|
||||
"search_failed": {"Search failed. Check your connection.", "Pencarian gagal. Cek koneksi kamu."},
|
||||
"search_found": {"%d icons found - click one to use it.", "%d ikon ditemukan - klik satu untuk dipakai."},
|
||||
"icon_search_hint": {"Hover a button to see what it does.", "Sorot tombol untuk melihat fungsinya."},
|
||||
"search_hint": {"Type a name and press Search.", "Tulis nama lalu tekan Cari."},
|
||||
"icon_search_title": {"Icon from the internet", "Ikon dari internet"},
|
||||
"sync_icon": {"Sync icon", "Sinkron ikon"},
|
||||
"browse": {"Browse...", "Pilih..."},
|
||||
"install": {"Install", "Install"},
|
||||
"cancel": {"Cancel", "Batal"},
|
||||
"save": {"Save", "Simpan"},
|
||||
"back": {"Back", "Kembali"},
|
||||
"delete": {"Delete", "Hapus"},
|
||||
"working": {"Working...", "Sedang diproses..."},
|
||||
@@ -78,6 +80,40 @@ var tr = map[string][2]string{
|
||||
"update_restart": {"Restart now", "Restart sekarang"},
|
||||
"up_to_date": {"LazyRun %s is the latest version.", "LazyRun %s adalah versi terbaru."},
|
||||
"dev_build": {"This is a development build (%s), update check is skipped.", "Ini build development (%s), cek update dilewati."},
|
||||
|
||||
"export": {"Export", "Ekspor"},
|
||||
"import": {"Import", "Impor"},
|
||||
"edit_title": {"Edit shortcut", "Edit shortcut"},
|
||||
"edit_saved": {"%s updated.", "%s diperbarui."},
|
||||
"edit_hint": {"Change the binary, icon, name, or where the shortcut appears.", "Ubah binary, ikon, nama, atau lokasi shortcutnya."},
|
||||
"import_done": {"%d shortcuts imported (%d skipped).", "%d shortcut diimpor (%d dilewati)."},
|
||||
"import_empty": {"No shortcuts found in this file.", "Tidak ada shortcut di berkas ini."},
|
||||
"export_done": {"Exported %d shortcuts to\n%s", "%d shortcut diekspor ke\n%s"},
|
||||
|
||||
"tip_executable": {"Create a shortcut from a binary", "Buat shortcut dari berkas binary"},
|
||||
"tip_webapp": {"Create a shortcut from a web address", "Buat shortcut dari alamat web"},
|
||||
"tip_run": {"Run this shortcut", "Jalankan shortcut ini"},
|
||||
"tip_sync": {"Download the icon again from its source", "Unduh ulang ikonnya dari sumber"},
|
||||
"tip_edit": {"Edit: binary, icon, name, location", "Edit: binary, ikon, nama, lokasi"},
|
||||
"tip_folder": {"Open the .desktop folder", "Buka folder .desktop"},
|
||||
"tip_delete": {"Remove this shortcut", "Hapus shortcut ini"},
|
||||
"tip_check_updates": {"Check for a newer release", "Periksa rilis terbaru"},
|
||||
"tip_github": {"Open the LazyRun repository", "Buka repo LazyRun"},
|
||||
"tip_donate": {"Support LazyRun development", "Dukung pengembangan LazyRun"},
|
||||
"tip_export": {"Export the shortcut list to a .lazy database", "Ekspor daftar shortcut ke basis data .lazy"},
|
||||
"tip_import": {"Import shortcuts from a .lazy database", "Impor shortcut dari basis data .lazy"},
|
||||
"tip_close": {"Close this dialog", "Tutup dialog ini"},
|
||||
"tip_back": {"Back to the previous dialog", "Kembali ke dialog sebelumnya"},
|
||||
"tip_search": {"Search icons online", "Cari ikon di internet"},
|
||||
"tip_browse": {"Pick a file", "Pilih berkas"},
|
||||
"tip_online": {"Pick an icon from the internet", "Pilih ikon dari internet"},
|
||||
"tip_install": {"Install the shortcut", "Pasang shortcutnya"},
|
||||
"tip_cancel": {"Cancel without saving", "Batal tanpa menyimpan"},
|
||||
"tip_save": {"Save the changes", "Simpan perubahan"},
|
||||
"tip_update_now": {"Download and install the update", "Unduh dan pasang update"},
|
||||
"tip_update_page": {"Open the release page in the browser", "Buka halaman rilis di browser"},
|
||||
"tip_later": {"Ask me later", "Ingatkan nanti"},
|
||||
"tip_restart": {"Restart LazyRun with the new version", "Jalankan ulang LazyRun dengan versi baru"},
|
||||
}
|
||||
|
||||
var lang = detectLang()
|
||||
|
||||
@@ -78,18 +78,11 @@ func chipLabel(text string, bg fyne.ThemeColorName) (fyne.CanvasObject, *canvas.
|
||||
|
||||
// accentBar adalah garis gradien brand di bawah header.
|
||||
func accentBar() *canvas.LinearGradient {
|
||||
bar := canvas.NewLinearGradient(brandIndigo, brandCyan, 90)
|
||||
bar := canvas.NewLinearGradient(brandAmber, brandMagenta, 90)
|
||||
bar.SetMinSize(fyne.NewSize(0, 3))
|
||||
return bar
|
||||
}
|
||||
|
||||
// iconButton adalah tombol ikon tanpa label, gaya datar.
|
||||
func iconButton(icon fyne.Resource, tapped func()) *widget.Button {
|
||||
button := widget.NewButtonWithIcon("", icon, tapped)
|
||||
button.Importance = widget.LowImportance
|
||||
return button
|
||||
}
|
||||
|
||||
// thumb menampilkan ikon shortcut, atau fallback kalau file ikonnya hilang.
|
||||
func thumb(path string, fallback fyne.Resource) fyne.CanvasObject {
|
||||
var image *canvas.Image
|
||||
@@ -122,7 +115,7 @@ func logoBox(image *canvas.Image, size float32) fyne.CanvasObject {
|
||||
// closeRow menempatkan tombol tutup di kanan atas isi dialog, supaya
|
||||
// selalu ada jalan keluar yang terlihat walau tombol bawah terpotong.
|
||||
func closeRow(hide func()) fyne.CanvasObject {
|
||||
return container.NewHBox(spacer(), iconButton(theme.CancelIcon(), hide))
|
||||
return container.NewHBox(spacer(), newIconButton(theme.CancelIcon(), T("tip_close"), hide))
|
||||
}
|
||||
|
||||
// showDialog menampilkan dialog dengan ukuran yang disesuaikan ke window.
|
||||
|
||||
@@ -7,10 +7,11 @@ import (
|
||||
"fyne.io/fyne/v2/theme"
|
||||
)
|
||||
|
||||
// Palet brand LazyRun, diambil dari ikon aplikasi.
|
||||
// Palet brand LazyRun, diambil dari logo (dream sky: ungu malam, senja pink, bulan amber).
|
||||
var (
|
||||
brandIndigo = color.NRGBA{R: 0x4F, G: 0x46, B: 0xE5, A: 0xFF}
|
||||
brandCyan = color.NRGBA{R: 0x06, G: 0xB6, B: 0xD4, A: 0xFF}
|
||||
brandPurple = color.NRGBA{R: 0x6A, G: 0x3D, B: 0x9A, A: 0xFF}
|
||||
brandMagenta = color.NRGBA{R: 0xC2, G: 0x5C, B: 0x9E, A: 0xFF}
|
||||
brandAmber = color.NRGBA{R: 0xFF, G: 0xD1, B: 0x66, A: 0xFF}
|
||||
)
|
||||
|
||||
// Nama warna tambahan di luar tema standar Fyne.
|
||||
@@ -31,31 +32,34 @@ func (b brand) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color.
|
||||
dark := variant == theme.VariantDark
|
||||
switch name {
|
||||
case theme.ColorNamePrimary:
|
||||
return pick(dark, color.NRGBA{R: 0x7C, G: 0x7A, B: 0xFF, A: 0xFF}, brandIndigo)
|
||||
// senja pink di malam hari, ungu tua di tema terang
|
||||
return pick(dark, brandMagenta, color.NRGBA{R: 0x8E, G: 0x3F, B: 0xA8, A: 0xFF})
|
||||
case theme.ColorNameBackground:
|
||||
return pick(dark, color.NRGBA{R: 0x13, G: 0x13, B: 0x1B, A: 0xFF}, color.NRGBA{R: 0xF5, G: 0xF5, B: 0xFA, A: 0xFF})
|
||||
// langit malam surreal
|
||||
return pick(dark, color.NRGBA{R: 0x14, G: 0x0E, B: 0x24, A: 0xFF}, color.NRGBA{R: 0xF5, G: 0xEF, B: 0xF9, A: 0xFF})
|
||||
case colorNameCard:
|
||||
return pick(dark, color.NRGBA{R: 0x1D, G: 0x1D, B: 0x28, A: 0xFF}, color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF})
|
||||
return pick(dark, color.NRGBA{R: 0x1E, G: 0x16, B: 0x36, A: 0xFF}, color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF})
|
||||
case colorNameCardHover:
|
||||
return withAlpha(brandIndigo, pick[uint8](dark, 0x2E, 0x14))
|
||||
return withAlpha(brandAmber, pick[uint8](dark, 0x1F, 0x18))
|
||||
case theme.ColorNameInputBackground:
|
||||
return pick(dark, color.NRGBA{R: 0x1A, G: 0x1A, B: 0x24, A: 0xFF}, color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF})
|
||||
return pick(dark, color.NRGBA{R: 0x19, G: 0x11, B: 0x35, A: 0xFF}, color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF})
|
||||
case theme.ColorNameButton, theme.ColorNameMenuBackground:
|
||||
return pick(dark, color.NRGBA{R: 0x22, G: 0x22, B: 0x2E, A: 0xFF}, color.NRGBA{R: 0xEC, G: 0xEC, B: 0xF4, A: 0xFF})
|
||||
return pick(dark, color.NRGBA{R: 0x26, G: 0x1B, B: 0x45, A: 0xFF}, color.NRGBA{R: 0xED, G: 0xE3, B: 0xF5, A: 0xFF})
|
||||
case theme.ColorNameOverlayBackground:
|
||||
return pick(dark, color.NRGBA{R: 0x1B, G: 0x1B, B: 0x25, A: 0xFF}, color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF})
|
||||
return pick(dark, color.NRGBA{R: 0x1B, G: 0x13, B: 0x33, A: 0xFF}, color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0xFF})
|
||||
case theme.ColorNameInputBorder, theme.ColorNameSeparator:
|
||||
return pick(dark, color.NRGBA{R: 0x32, G: 0x32, B: 0x44, A: 0xFF}, color.NRGBA{R: 0xDC, G: 0xDC, B: 0xE8, A: 0xFF})
|
||||
return pick(dark, color.NRGBA{R: 0x3A, G: 0x2A, B: 0x63, A: 0xFF}, color.NRGBA{R: 0xDC, G: 0xCF, B: 0xE9, A: 0xFF})
|
||||
case colorNameMuted:
|
||||
return pick(dark, color.NRGBA{R: 0x9C, G: 0x9C, B: 0xB4, A: 0xFF}, color.NRGBA{R: 0x6B, G: 0x6B, B: 0x80, A: 0xFF})
|
||||
return pick(dark, color.NRGBA{R: 0xA7, G: 0x9A, B: 0xC8, A: 0xFF}, color.NRGBA{R: 0x6E, G: 0x61, B: 0x83, A: 0xFF})
|
||||
case colorNameChipMenu:
|
||||
return withAlpha(brandIndigo, pick[uint8](dark, 0x44, 0x26))
|
||||
return withAlpha(brandPurple, pick[uint8](dark, 0x66, 0x2E))
|
||||
case colorNameChipDesk:
|
||||
return withAlpha(brandCyan, pick[uint8](dark, 0x3D, 0x2C))
|
||||
return withAlpha(brandMagenta, pick[uint8](dark, 0x4E, 0x30))
|
||||
case colorNameChipKind:
|
||||
return pick(dark, color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0x1C}, color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x12})
|
||||
return withAlpha(color.NRGBA{R: 0xFF, G: 0xD1, B: 0x66, A: 0xFF}, pick[uint8](dark, 0x24, 0x30))
|
||||
case colorNameChipBroken:
|
||||
return withAlpha(color.NRGBA{R: 0xE0, G: 0x4F, B: 0x4F, A: 0xFF}, pick[uint8](dark, 0x4A, 0x2E))
|
||||
// merah senja: peringatan yang tetap terasa "seram" tapi terbaca
|
||||
return withAlpha(color.NRGBA{R: 0xD9, G: 0x46, B: 0x3C, A: 0xFF}, pick[uint8](dark, 0x50, 0x30))
|
||||
}
|
||||
return b.Theme.Color(name, variant)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -97,9 +97,9 @@ func (ui *app) markUpdatePending(rel update.Release) {
|
||||
|
||||
// showUpdateDialog menawarkan update: unduh sekarang, buka halaman rilis, atau nanti.
|
||||
func (ui *app) showUpdateDialog(rel update.Release) {
|
||||
notes := widget.NewLabel(rel.Notes)
|
||||
notes.Wrapping = fyne.TextWrapWord
|
||||
notes.Importance = widget.LowImportance
|
||||
// catatan rilis GitHub berformat markdown; render supaya blok kode/daftar
|
||||
// tidak tampil sebagai teks mentah
|
||||
notes := widget.NewRichTextFromMarkdown(rel.Notes)
|
||||
|
||||
// catatan rilis di tengah (bukan top): VScroll di Fyne punya MinSize kecil,
|
||||
// jadi kalau dipasang sebagai top dari Border isinya terpotong.
|
||||
@@ -110,18 +110,18 @@ func (ui *app) showUpdateDialog(rel update.Release) {
|
||||
buttons := container.NewHBox(
|
||||
logoBox(newLogo(), 20),
|
||||
spacer(),
|
||||
widget.NewButton(T("update_later"), func() { d.Hide() }),
|
||||
widget.NewButton(T("update_page"), func() { ui.openURL(rel.PageURL) }),
|
||||
newButton(T("update_later"), nil, T("tip_later"), widget.MediumImportance, func() { d.Hide() }),
|
||||
newButton(T("update_page"), nil, T("tip_update_page"), widget.MediumImportance, func() { ui.openURL(rel.PageURL) }),
|
||||
)
|
||||
|
||||
if updates := ui.updaterBinary(); updates != "" {
|
||||
// AppImageUpdate yang memasang (dan memverifikasi) sendiri
|
||||
buttons.Add(widget.NewButtonWithIcon(T("update_now"), theme.DownloadIcon(), func() {
|
||||
buttons.Add(newButton(T("update_now"), theme.DownloadIcon(), T("tip_update_now"), widget.HighImportance, func() {
|
||||
d.Hide()
|
||||
ui.runUpdater(updates, rel)
|
||||
}))
|
||||
} else {
|
||||
buttons.Add(widget.NewButtonWithIcon(T("update_now"), theme.DownloadIcon(), func() {
|
||||
buttons.Add(newButton(T("update_now"), theme.DownloadIcon(), T("tip_update_now"), widget.HighImportance, func() {
|
||||
d.Hide()
|
||||
ui.downloadUpdate(rel)
|
||||
}))
|
||||
@@ -151,12 +151,11 @@ func (ui *app) downloadUpdate(rel update.Release) {
|
||||
return
|
||||
}
|
||||
|
||||
progress := dialog.NewProgressInfinite(T("check_updates"), T("update_downloading"), ui.win)
|
||||
progress.Show()
|
||||
hide := ui.showLoading(T("check_updates"), T("update_downloading"))
|
||||
go func() {
|
||||
err := update.Download(rel, appImage)
|
||||
fyne.Do(func() {
|
||||
progress.Hide()
|
||||
hide()
|
||||
if err != nil {
|
||||
dialog.ShowError(err, ui.win)
|
||||
return
|
||||
@@ -174,12 +173,11 @@ func (ui *app) runUpdater(updater string, rel update.Release) {
|
||||
return
|
||||
}
|
||||
|
||||
progress := dialog.NewProgressInfinite(T("check_updates"), T("update_downloading"), ui.win)
|
||||
progress.Show()
|
||||
hide := ui.showLoading(T("check_updates"), T("update_downloading"))
|
||||
go func() {
|
||||
err := exec.Command(updater, appImage).Run()
|
||||
fyne.Do(func() {
|
||||
progress.Hide()
|
||||
hide()
|
||||
if err != nil {
|
||||
dialog.ShowError(err, ui.win)
|
||||
return
|
||||
@@ -195,8 +193,8 @@ func (ui *app) offerRestart() {
|
||||
content := container.NewVBox(
|
||||
widget.NewLabel(T("update_installed")),
|
||||
container.NewHBox(spacer(),
|
||||
widget.NewButton(T("update_later"), func() { d.Hide() }),
|
||||
widget.NewButtonWithIcon(T("update_restart"), theme.ViewRefreshIcon(), ui.restart),
|
||||
newButton(T("update_later"), nil, T("tip_later"), widget.MediumImportance, func() { d.Hide() }),
|
||||
newButton(T("update_restart"), theme.ViewRefreshIcon(), T("tip_restart"), widget.HighImportance, ui.restart),
|
||||
),
|
||||
)
|
||||
d = dialog.NewCustomWithoutButtons(T("check_updates"), content, ui.win)
|
||||
|
||||