Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
581cd07dba | ||
|
|
fdb503e152 | ||
|
|
deaa30689c | ||
|
|
c1aed1e286 | ||
|
|
25a979f24c | ||
|
|
ab6dafbe5b | ||
|
|
fbf7344b09 | ||
|
|
a7e75d95c4 | ||
|
|
d6f6c69c5a | ||
|
|
41ada9a506 | ||
|
|
ae2569a795 |
No files matched your search
@@ -0,0 +1,31 @@
|
|||||||
|
name: CI
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches: [main]
|
||||||
|
pull_request:
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
test:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
# Fyne butuh header X11/OpenGL dan cgo untuk dikompilasi.
|
||||||
|
- name: Install build dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
libgl1-mesa-dev libx11-dev xorg-dev libxkbcommon-dev libwayland-dev
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Vet
|
||||||
|
run: go vet ./...
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: go test -race ./...
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
name: Release
|
||||||
|
|
||||||
|
# Tinggal bikin tag: git tag v1.2.0 && git push --tags
|
||||||
|
# Versi AppImage diambil dari nama tag, jadi tidak perlu ubah file apa pun.
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
tags: ["v*"]
|
||||||
|
workflow_dispatch:
|
||||||
|
|
||||||
|
permissions:
|
||||||
|
contents: write
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
release:
|
||||||
|
runs-on: ubuntu-22.04
|
||||||
|
steps:
|
||||||
|
- uses: actions/checkout@v4
|
||||||
|
|
||||||
|
- name: Install build dependencies
|
||||||
|
run: |
|
||||||
|
sudo apt-get update
|
||||||
|
sudo apt-get install -y --no-install-recommends \
|
||||||
|
libgl1-mesa-dev libx11-dev xorg-dev libxkbcommon-dev libwayland-dev
|
||||||
|
|
||||||
|
- uses: actions/setup-go@v5
|
||||||
|
with:
|
||||||
|
go-version-file: go.mod
|
||||||
|
cache: true
|
||||||
|
|
||||||
|
- name: Test
|
||||||
|
run: go test ./...
|
||||||
|
|
||||||
|
- name: Build AppImage
|
||||||
|
env:
|
||||||
|
# GH_USER/GH_REPO dipakai build.sh untuk menyisipkan info update zsync
|
||||||
|
# (gh-releases-zsync|<user>|<repo>|latest|lazyrun-*.AppImage.zsync)
|
||||||
|
GH_USER: ${{ github.repository_owner }}
|
||||||
|
GH_REPO: ${{ github.event.repository.name }}
|
||||||
|
run: |
|
||||||
|
VERSION="${GITHUB_REF_NAME#v}" ./build.sh
|
||||||
|
ls -lh dist/lazyrun-*.AppImage*
|
||||||
|
|
||||||
|
- name: Create release
|
||||||
|
env:
|
||||||
|
GH_TOKEN: ${{ github.token }}
|
||||||
|
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" \
|
||||||
|
dist/lazyrun-*.AppImage \
|
||||||
|
dist/lazyrun-*.AppImage.zsync \
|
||||||
|
--title "LazyRun ${GITHUB_REF_NAME}" \
|
||||||
|
--notes-file /tmp/notes.md \
|
||||||
|
--generate-notes
|
||||||
@@ -1,14 +1,42 @@
|
|||||||
# LazyRun — lazy shortcut build
|
# LazyRun — lazy shortcut build
|
||||||
|
|
||||||
|
[](https://github.com/oxpoe/lazyrun/actions/workflows/ci.yml)
|
||||||
|
[](https://github.com/oxpoe/lazyrun/releases/latest)
|
||||||
|
[](LICENSE)
|
||||||
|

|
||||||
|

|
||||||
|
|
||||||
Desktop app (Go + Fyne) that creates application shortcuts on Linux in a few clicks:
|
Desktop app (Go + Fyne) that creates application shortcuts on Linux in a few clicks:
|
||||||
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).*
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/dialog-executable.png" width="49%" alt="New executable shortcut dialog">
|
||||||
|
<img src="docs/dialog-icons.png" width="49%" alt="Icon search dialog">
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/dialog-loading.png" width="49%" alt="Progress bar while installing">
|
||||||
|
<img src="docs/main-light.png" width="49%" alt="LazyRun in light theme">
|
||||||
|
</p>
|
||||||
|
|
||||||
- **Repo:** <https://github.com/oxpoe/lazyrun>
|
- **Repo:** <https://github.com/oxpoe/lazyrun>
|
||||||
- **Donate:** <https://donasi.oxpoe.qzz.io/>
|
- **Donate:** <https://donasi.oxpoe.qzz.io/>
|
||||||
|
|
||||||
- **Executable shortcut** — picks an ELF/AppImage/script, copies it to
|
- **Executable shortcut** — picks an ELF/AppImage/script/Windows `.exe`, copies it to
|
||||||
`~/.local/bin/`, installs the icon, writes the `.desktop` entry.
|
`~/.local/bin/`, installs the icon, writes the `.desktop` entry.
|
||||||
|
- **Automatic type label** — every shortcut is tagged with what it actually is,
|
||||||
|
detected from the file: `Go`, `AppImage`, `Electron`, `Script`, `Windows`, `Binary`
|
||||||
|
(`.exe` files are launched through `wine`), or `PWA` for web apps.
|
||||||
|
- **Scripts run properly** — the desktop environment starts programs with your home as
|
||||||
|
the working directory and the session `PATH`, which breaks scripts that use relative
|
||||||
|
paths or tools from `~/.local/bin`. LazyRun therefore installs a tiny wrapper that
|
||||||
|
fixes both (and can open the script in a terminal window when you tick that option).
|
||||||
- **Web app shortcut** — opens the URL as an app: `--app=<url>` for
|
- **Web app shortcut** — opens the URL as an app: `--app=<url>` for
|
||||||
Chromium-based browsers (Chrome, Chromium, Brave, Edge, Vivaldi, Helium,
|
Chromium-based browsers (Chrome, Chromium, Brave, Edge, Vivaldi, Helium,
|
||||||
including Flatpak builds), `--new-window` for Firefox.
|
including Flatpak builds), `--new-window` for Firefox.
|
||||||
@@ -39,6 +67,9 @@ AppImage, no terminal needed.
|
|||||||
|
|
||||||
## Install
|
## Install
|
||||||
|
|
||||||
|
Needs a desktop Linux with the usual GL/X11 or Wayland libraries (any KDE Plasma or
|
||||||
|
GNOME install has them). No root, nothing outside your home directory.
|
||||||
|
|
||||||
Download the AppImage from [Releases](https://github.com/oxpoe/lazyrun/releases), then:
|
Download the AppImage from [Releases](https://github.com/oxpoe/lazyrun/releases), then:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
@@ -66,6 +97,12 @@ Keep it wherever you like (for example `~/.local/bin/`). Automatic updates need
|
|||||||
| LazyRun metadata | `~/.config/lazyrun/shortcuts.json` |
|
| LazyRun metadata | `~/.config/lazyrun/shortcuts.json` |
|
||||||
|
|
||||||
Everything stays inside the home directory — no root, no system directories.
|
Everything stays inside the home directory — no root, no system directories.
|
||||||
|
|
||||||
|
The UI follows your system light/dark setting:
|
||||||
|
|
||||||
|
<p align="center">
|
||||||
|
<img src="docs/main-light.png" width="520" alt="LazyRun in light theme">
|
||||||
|
</p>
|
||||||
Filenames use a slug of the app name (`WhatsApp Web` → `whatsapp-web.desktop`);
|
Filenames use a slug of the app name (`WhatsApp Web` → `whatsapp-web.desktop`);
|
||||||
the `Name=` field keeps the name you typed.
|
the `Name=` field keeps the name you typed.
|
||||||
|
|
||||||
@@ -82,9 +119,20 @@ start (only when running as an AppImage) and offers to install it:
|
|||||||
|
|
||||||
*Check for Updates* in the footer runs the same flow manually.
|
*Check for Updates* in the footer runs the same flow manually.
|
||||||
|
|
||||||
**Publishing a release** so updates reach users: tag it `vX.Y.Z`, upload
|
**Publishing a release** is just a tag — GitHub Actions builds and uploads
|
||||||
`lazyrun-X.Y.Z-x86_64.AppImage` plus the `.zsync` file produced by `build.sh` as
|
everything (`.github/workflows/release.yml`):
|
||||||
release assets, and mark it as the latest release.
|
|
||||||
|
```bash
|
||||||
|
git tag v1.1.0 && git push --tags
|
||||||
|
```
|
||||||
|
|
||||||
|
The workflow builds the AppImage with the version taken from the tag, runs the
|
||||||
|
tests, and publishes a release with the `.AppImage` + `.zsync` assets and
|
||||||
|
auto-generated notes. To build a release locally instead:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
VERSION=1.1.0 GH_USER=oxpoe GH_REPO=lazyrun ./build.sh
|
||||||
|
```
|
||||||
|
|
||||||
## Build
|
## Build
|
||||||
|
|
||||||
@@ -106,9 +154,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
|
||||||
|
|
||||||
|
|||||||
|
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 |
|
After Width: | Height: | Size: 92 KiB |
|
After Width: | Height: | Size: 77 KiB |
|
After Width: | Height: | Size: 80 KiB |
|
After Width: | Height: | Size: 59 KiB |
|
After Width: | Height: | Size: 54 KiB |
@@ -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
|
||||||
)
|
)
|
||||||
@@ -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=
|
||||||
@@ -0,0 +1,201 @@
|
|||||||
|
// Package db menyimpan daftar shortcut LazyRun dalam satu berkas SQLite (.lazy)
|
||||||
|
// supaya bisa dipindah antar komputer.
|
||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"database/sql"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
_ "modernc.org/sqlite"
|
||||||
|
|
||||||
|
"lazyrun/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// FormatVersion dipakai untuk mengenali berkas .lazy buatan LazyRun.
|
||||||
|
// Versi 1 hanya metadata; versi 2 menyertakan isi binary dan ikon.
|
||||||
|
const FormatVersion = "2"
|
||||||
|
|
||||||
|
// DefaultName adalah nama berkas cadangan yang disarankan.
|
||||||
|
const DefaultName = "lazyrun-shortcuts.lazy"
|
||||||
|
|
||||||
|
const schema = `
|
||||||
|
CREATE TABLE meta (
|
||||||
|
key TEXT PRIMARY KEY,
|
||||||
|
value TEXT NOT NULL
|
||||||
|
);
|
||||||
|
CREATE TABLE shortcuts (
|
||||||
|
slug TEXT PRIMARY KEY,
|
||||||
|
name TEXT NOT NULL,
|
||||||
|
type TEXT NOT NULL,
|
||||||
|
kind TEXT,
|
||||||
|
url TEXT,
|
||||||
|
browser TEXT,
|
||||||
|
exec_args TEXT,
|
||||||
|
categories TEXT,
|
||||||
|
binary_path TEXT,
|
||||||
|
icon_path TEXT,
|
||||||
|
icon_source TEXT,
|
||||||
|
desktop_path TEXT,
|
||||||
|
desktop_copy_path TEXT,
|
||||||
|
terminal INTEGER NOT NULL DEFAULT 0,
|
||||||
|
menu INTEGER NOT NULL,
|
||||||
|
desktop INTEGER NOT NULL,
|
||||||
|
installed_at TEXT,
|
||||||
|
binary_data BLOB,
|
||||||
|
icon_data BLOB
|
||||||
|
);`
|
||||||
|
|
||||||
|
// Export menulis seluruh daftar shortcut ke berkas SQLite baru.
|
||||||
|
func Export(path string, appVersion string, items []store.Shortcut) error {
|
||||||
|
if path == "" {
|
||||||
|
return errors.New("lokasi berkas ekspor kosong")
|
||||||
|
}
|
||||||
|
_ = os.Remove(path) // mulai dari berkas baru
|
||||||
|
|
||||||
|
conn, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
if _, err := conn.Exec(schema); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
meta := map[string]string{
|
||||||
|
"format": FormatVersion,
|
||||||
|
"app_version": appVersion,
|
||||||
|
"exported_at": time.Now().UTC().Format(time.RFC3339),
|
||||||
|
}
|
||||||
|
for key, value := range meta {
|
||||||
|
if _, err := conn.Exec("INSERT INTO meta(key, value) VALUES(?, ?)", key, value); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
tx, err := conn.Begin()
|
||||||
|
if err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
insert, err := tx.Prepare(`INSERT INTO shortcuts(
|
||||||
|
slug, name, type, kind, url, browser, exec_args, categories,
|
||||||
|
binary_path, icon_path, icon_source, desktop_path, desktop_copy_path,
|
||||||
|
terminal, menu, desktop, installed_at, binary_data, icon_data)
|
||||||
|
VALUES(?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`)
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
defer insert.Close()
|
||||||
|
|
||||||
|
for _, item := range items {
|
||||||
|
_, err := insert.Exec(
|
||||||
|
item.Slug, item.Name, item.Type, item.Kind, item.URL, item.Browser,
|
||||||
|
strings.Join(item.ExecArgs, "\x00"), item.Categories,
|
||||||
|
item.BinaryPath, item.IconPath, item.IconSource, item.DesktopPath,
|
||||||
|
item.DesktopCopyPath, boolToInt(item.Terminal), boolToInt(item.Targets.Menu), boolToInt(item.Targets.Desktop),
|
||||||
|
item.InstalledAt.Format(time.RFC3339), readBlob(item.BinaryPath), readBlob(item.IconPath),
|
||||||
|
)
|
||||||
|
if err != nil {
|
||||||
|
tx.Rollback()
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return tx.Commit()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Backup adalah satu shortcut beserta isi berkasnya dari .lazy.
|
||||||
|
type Backup struct {
|
||||||
|
Item store.Shortcut
|
||||||
|
Binary []byte // isi binary/script (kalau ada)
|
||||||
|
Icon []byte // isi berkas ikon (kalau ada)
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxBlobFile adalah batas ukuran berkas yang ikut disimpan ke cadangan.
|
||||||
|
const maxBlobFile = 512 << 20
|
||||||
|
|
||||||
|
// readBlob membaca isi berkas untuk disimpan ke cadangan; gagal/kebesaran = kosong.
|
||||||
|
func readBlob(path string) []byte {
|
||||||
|
if path == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
info, err := os.Stat(path)
|
||||||
|
if err != nil || info.IsDir() || info.Size() > maxBlobFile {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return data
|
||||||
|
}
|
||||||
|
|
||||||
|
// Import membaca daftar shortcut dari berkas .lazy.
|
||||||
|
func Import(path string) ([]Backup, error) {
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
conn, err := sql.Open("sqlite", path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer conn.Close()
|
||||||
|
|
||||||
|
var format string
|
||||||
|
if err := conn.QueryRow("SELECT value FROM meta WHERE key = 'format'").Scan(&format); err != nil {
|
||||||
|
return nil, fmt.Errorf("bukan basis data LazyRun: %w", err)
|
||||||
|
}
|
||||||
|
switch format {
|
||||||
|
case FormatVersion:
|
||||||
|
// versi sekarang: ada blobs
|
||||||
|
case "1":
|
||||||
|
// cadangan lama: hanya metadata, berkas dipulihkan dari sumber lain
|
||||||
|
default:
|
||||||
|
return nil, fmt.Errorf("versi basis data %s tidak didukung (mendukung %s)", format, FormatVersion)
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, err := conn.Query(`SELECT slug, name, type, kind, url, browser, exec_args,
|
||||||
|
categories, binary_path, icon_path, icon_source, desktop_path,
|
||||||
|
desktop_copy_path, terminal, menu, desktop, installed_at,
|
||||||
|
binary_data, icon_data FROM shortcuts ORDER BY name`)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var items []Backup
|
||||||
|
for rows.Next() {
|
||||||
|
var item store.Shortcut
|
||||||
|
var args, installed string
|
||||||
|
var terminal, menu, desktop int
|
||||||
|
var binary, iconBlob []byte
|
||||||
|
if err := rows.Scan(&item.Slug, &item.Name, &item.Type, &item.Kind, &item.URL,
|
||||||
|
&item.Browser, &args, &item.Categories, &item.BinaryPath, &item.IconPath,
|
||||||
|
&item.IconSource, &item.DesktopPath, &item.DesktopCopyPath,
|
||||||
|
&terminal, &menu, &desktop, &installed, &binary, &iconBlob); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if args != "" {
|
||||||
|
item.ExecArgs = strings.Split(args, "\x00")
|
||||||
|
}
|
||||||
|
item.Terminal = terminal != 0
|
||||||
|
item.Targets = store.Targets{Menu: menu != 0, Desktop: desktop != 0}
|
||||||
|
if t, err := time.Parse(time.RFC3339, installed); err == nil {
|
||||||
|
item.InstalledAt = t
|
||||||
|
}
|
||||||
|
items = append(items, Backup{Item: item, Binary: binary, Icon: iconBlob})
|
||||||
|
}
|
||||||
|
return items, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
func boolToInt(v bool) int {
|
||||||
|
if v {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package db
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"lazyrun/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func sampleItems() []store.Shortcut {
|
||||||
|
return []store.Shortcut{
|
||||||
|
{
|
||||||
|
Slug: "winbox",
|
||||||
|
Name: "Winbox",
|
||||||
|
Type: store.TypeExecutable,
|
||||||
|
Kind: "windows",
|
||||||
|
ExecArgs: []string{"/usr/bin/wine", "/home/u/.local/bin/winbox"},
|
||||||
|
Categories: "Network;",
|
||||||
|
BinaryPath: "/home/u/.local/bin/winbox",
|
||||||
|
IconPath: "/home/u/.local/share/icons/winbox.png",
|
||||||
|
IconSource: "search:winbox",
|
||||||
|
DesktopPath: "/home/u/.local/share/applications/winbox.desktop",
|
||||||
|
Targets: store.Targets{Menu: true, Desktop: true},
|
||||||
|
InstalledAt: time.Date(2026, 10, 13, 10, 0, 0, 0, time.UTC),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Slug: "whatsapp-web",
|
||||||
|
Name: "WhatsApp Web",
|
||||||
|
Type: store.TypeWebApp,
|
||||||
|
Kind: "pwa",
|
||||||
|
URL: "https://web.whatsapp.com",
|
||||||
|
Browser: "flatpak run org.chromium.Chromium",
|
||||||
|
ExecArgs: []string{"flatpak", "run", "org.chromium.Chromium", "--app=https://web.whatsapp.com"},
|
||||||
|
Categories: "Network;WebBrowser;",
|
||||||
|
DesktopPath: "/home/u/.local/share/applications/whatsapp-web.desktop",
|
||||||
|
DesktopCopyPath: "/home/u/Desktop/whatsapp-web.desktop",
|
||||||
|
Targets: store.Targets{Menu: true},
|
||||||
|
InstalledAt: time.Date(2026, 10, 13, 10, 5, 0, 0, time.UTC),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportImportRoundtrip(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "db.lazy")
|
||||||
|
items := sampleItems()
|
||||||
|
|
||||||
|
if err := Export(path, "1.2.0", items); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(path); err != nil {
|
||||||
|
t.Fatalf("berkas ekspor tidak ada: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := Import(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != len(items) {
|
||||||
|
t.Fatalf("impor %d item, mau %d", len(got), len(items))
|
||||||
|
}
|
||||||
|
|
||||||
|
// urutan hasil impor berdasarkan nama: WhatsApp Web lalu Winbox
|
||||||
|
web, exe := got[0].Item, got[1].Item
|
||||||
|
if web.Slug != "whatsapp-web" || web.URL != "https://web.whatsapp.com" || !web.Targets.Menu || web.Targets.Desktop {
|
||||||
|
t.Errorf("data webapp tidak utuh: %+v", web)
|
||||||
|
}
|
||||||
|
if exe.Slug != "winbox" || exe.Kind != "windows" || len(exe.ExecArgs) != 2 || exe.ExecArgs[0] != "/usr/bin/wine" {
|
||||||
|
t.Errorf("data executable tidak utuh: %+v", exe)
|
||||||
|
}
|
||||||
|
if exe.Categories != "Network;" || !exe.Targets.Desktop {
|
||||||
|
t.Errorf("kategori/target hilang: %+v", exe)
|
||||||
|
}
|
||||||
|
if !exe.InstalledAt.Equal(items[0].InstalledAt) {
|
||||||
|
t.Errorf("installed_at berubah: %v", exe.InstalledAt)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportStoresFileContents(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
binary := filepath.Join(home, "app")
|
||||||
|
if err := os.WriteFile(binary, []byte("isi-binary-penting"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
iconFile := filepath.Join(home, "app.png")
|
||||||
|
iconBytes := append([]byte("\x89PNG\r\n\x1a\n"), []byte("ikon")...)
|
||||||
|
if err := os.WriteFile(iconFile, iconBytes, 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
items := []store.Shortcut{{
|
||||||
|
Slug: "app", Name: "App", Type: store.TypeExecutable, Kind: "binary",
|
||||||
|
BinaryPath: binary, IconPath: iconFile, DesktopPath: filepath.Join(home, "app.desktop"),
|
||||||
|
Terminal: true, Targets: store.Targets{Menu: true},
|
||||||
|
}}
|
||||||
|
|
||||||
|
path := filepath.Join(home, "db.lazy")
|
||||||
|
if err := Export(path, "1.2.0", items); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
got, err := Import(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if string(got[0].Binary) != "isi-binary-penting" {
|
||||||
|
t.Errorf("isi binary tidak ikut tercadang: %q", got[0].Binary)
|
||||||
|
}
|
||||||
|
if string(got[0].Icon) != string(iconBytes) {
|
||||||
|
t.Errorf("isi ikon tidak ikut tercadang: %q", got[0].Icon)
|
||||||
|
}
|
||||||
|
if !got[0].Item.Terminal {
|
||||||
|
t.Error("flag terminal hilang")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportSkipsMissingFiles(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
items := []store.Shortcut{{
|
||||||
|
Slug: "hilang", Name: "Hilang", Type: store.TypeExecutable,
|
||||||
|
BinaryPath: filepath.Join(home, "tidak-ada"), DesktopPath: filepath.Join(home, "x.desktop"),
|
||||||
|
Targets: store.Targets{Menu: true},
|
||||||
|
}}
|
||||||
|
|
||||||
|
path := filepath.Join(home, "db.lazy")
|
||||||
|
if err := Export(path, "1.2.0", items); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
got, err := Import(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(got) != 1 || len(got[0].Binary) != 0 {
|
||||||
|
t.Errorf("berkas hilang tidak boleh bikin ekspor gagal: %+v", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExportOverwritesOldFile(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "db.lazy")
|
||||||
|
if err := os.WriteFile(path, []byte("bukan sqlite"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := Export(path, "1.2.0", sampleItems()[:1]); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
items, err := Import(path)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(items) != 1 {
|
||||||
|
t.Errorf("isi setelah timpa: %d item", len(items))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportRejectsForeignFile(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
plain := filepath.Join(dir, "catatan.lazy")
|
||||||
|
if err := os.WriteFile(plain, []byte("halo dunia"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if _, err := Import(plain); err == nil {
|
||||||
|
t.Error("berkas bukan SQLite harus ditolak")
|
||||||
|
}
|
||||||
|
|
||||||
|
if _, err := Import(filepath.Join(dir, "tidak-ada.lazy")); err == nil {
|
||||||
|
t.Error("berkas tidak ada harus error")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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`.
|
||||||
//
|
//
|
||||||
|
|||||||
@@ -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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
package shortcut
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"lazyrun/internal/db"
|
||||||
|
"lazyrun/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestBackupRestoreEndToEnd meniru alur backup: pasang beberapa shortcut,
|
||||||
|
// ekspor ke berkas .lazy, lalu pulihkan di "komputer lain" (HOME berbeda)
|
||||||
|
// dan pastikan hasilnya benar-benar jalan.
|
||||||
|
func TestBackupRestoreEndToEnd(t *testing.T) {
|
||||||
|
codeDir := t.TempDir()
|
||||||
|
homeA := t.TempDir()
|
||||||
|
|
||||||
|
// ---- komputer A: pasang shortcut ----
|
||||||
|
t.Setenv("HOME", homeA)
|
||||||
|
t.Setenv("XDG_DATA_HOME", "")
|
||||||
|
t.Setenv("PATH", "")
|
||||||
|
|
||||||
|
site := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
if r.URL.Path == "/" {
|
||||||
|
_, _ = w.Write([]byte(`<html><head><link rel="icon" type="image/png" sizes="128x128" href="/web.png"></head></html>`))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
_, _ = w.Write(append(pngMagic, []byte("ikon web")...))
|
||||||
|
}))
|
||||||
|
defer site.Close()
|
||||||
|
|
||||||
|
// script yang butuh folder kerjanya + tool di ~/.local/bin
|
||||||
|
scriptSrc := filepath.Join(codeDir, "deploy.sh")
|
||||||
|
if err := os.WriteFile(scriptSrc, []byte(`#!/bin/sh
|
||||||
|
[ -f ./data.txt ] && echo "data ok" || echo "data hilang"
|
||||||
|
case "$PATH" in *".local/bin"*) echo "path ok";; *) echo "path hilang";; esac
|
||||||
|
`), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(codeDir, "data.txt"), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
script, err := InstallExecutable(ExecOptions{BinaryPath: scriptSrc, Name: "Deploy", Category: "Utility", Menu: true, Desktop: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
binarySrc := fakeBinary(t, codeDir, "tool")
|
||||||
|
binaryShortcut, err := InstallExecutable(ExecOptions{BinaryPath: binarySrc, Name: "Tool", Menu: true})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
web, err := InstallWebApp(WebOptions{
|
||||||
|
URL: site.URL, Name: "Situs Web",
|
||||||
|
Browser: BrowserArg{Name: "Chromium", Exec: []string{"/usr/bin/chromium"}},
|
||||||
|
Menu: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- ekspor ke .lazy ----
|
||||||
|
backupPath := filepath.Join(t.TempDir(), "db.lazy")
|
||||||
|
if err := db.Export(backupPath, "1.2.0", []store.Shortcut{script, binaryShortcut, web}); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- komputer B: HOME baru, pulihkan ----
|
||||||
|
homeB := t.TempDir()
|
||||||
|
t.Setenv("HOME", homeB)
|
||||||
|
|
||||||
|
backups, err := db.Import(backupPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(backups) != 3 {
|
||||||
|
t.Fatalf("cadangan berisi %d entri, mau 3", len(backups))
|
||||||
|
}
|
||||||
|
|
||||||
|
restored := map[string]store.Shortcut{}
|
||||||
|
for _, backup := range backups {
|
||||||
|
item, err := Restore(backup.Item, backup.Binary, backup.Icon)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("restore %s: %v", backup.Item.Name, err)
|
||||||
|
}
|
||||||
|
restored[backup.Item.Name] = item
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- semua berkas harus di home B, bukan home A ----
|
||||||
|
for name, item := range restored {
|
||||||
|
if item.BinaryPath != "" && !strings.HasPrefix(item.BinaryPath, homeB) {
|
||||||
|
t.Errorf("%s: binary masih menunjuk home lama: %s", name, item.BinaryPath)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(item.DesktopPath, homeB) {
|
||||||
|
t.Errorf("%s: .desktop masih menunjuk home lama: %s", name, item.DesktopPath)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(item.DesktopPath); err != nil {
|
||||||
|
t.Errorf("%s: .desktop tidak ada: %v", name, err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(item.IconPath); err != nil {
|
||||||
|
t.Errorf("%s: ikon tidak ada: %v", name, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// script hasil restore harus jalan seperti dijalankan desktop environment
|
||||||
|
deploy := restored["Deploy"]
|
||||||
|
if !strings.HasPrefix(deploy.DesktopPath, homeB) {
|
||||||
|
t.Fatal("script tidak terpulihkan")
|
||||||
|
}
|
||||||
|
cmd := exec.Command(deploy.ExecArgs[0], deploy.ExecArgs[1:]...)
|
||||||
|
cmd.Dir = homeB
|
||||||
|
cmd.Env = []string{"HOME=" + homeB, "PATH=/usr/bin:/bin"}
|
||||||
|
out, err := cmd.CombinedOutput()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("script hasil restore gagal jalan: %v\n%s", err, out)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(out), "path ok") {
|
||||||
|
t.Errorf("PATH tidak disiapkan wrapper: %s", out)
|
||||||
|
}
|
||||||
|
|
||||||
|
// binary biasa hasil restore juga harus executable
|
||||||
|
tool := restored["Tool"]
|
||||||
|
if st, err := os.Stat(tool.BinaryPath); err != nil || st.Mode().Perm()&0o111 == 0 {
|
||||||
|
t.Errorf("binary hasil restore tidak executable: %v %v", st, err)
|
||||||
|
}
|
||||||
|
if !strings.HasPrefix(tool.DesktopCopyPath, homeB) && tool.Targets.Desktop {
|
||||||
|
t.Errorf("salinan desktop salah: %s", tool.DesktopCopyPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// web app: exec args tetap menunjuk browser + URL
|
||||||
|
if !strings.Contains(strings.Join(restored["Situs Web"].ExecArgs, " "), "--app="+site.URL) {
|
||||||
|
t.Errorf("exec web app salah: %v", restored["Situs Web"].ExecArgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -14,6 +14,8 @@ type Entry struct {
|
|||||||
ExecArgs []string // argv lengkap; tiap arg di-quote sesuai spec Exec
|
ExecArgs []string // argv lengkap; tiap arg di-quote sesuai spec Exec
|
||||||
Icon string // path absolut atau nama ikon
|
Icon string // path absolut atau nama ikon
|
||||||
Categories string // sudah diakhiri ";" atau kosong
|
Categories string // sudah diakhiri ";" atau kosong
|
||||||
|
Terminal bool // true = jalankan di terminal (untuk script interaktif)
|
||||||
|
WorkingDir string // folder kerja saat dijalankan (Path=), opsional
|
||||||
// NoDisplay menyembunyikan entry dari menu aplikasi. Dipakai kalau user
|
// NoDisplay menyembunyikan entry dari menu aplikasi. Dipakai kalau user
|
||||||
// hanya memilih Desktop: file tetap ada di applications/ sebagai sumber kebenaran.
|
// hanya memilih Desktop: file tetap ada di applications/ sebagai sumber kebenaran.
|
||||||
NoDisplay bool
|
NoDisplay bool
|
||||||
@@ -29,7 +31,10 @@ func (e Entry) Render() string {
|
|||||||
if e.Icon != "" {
|
if e.Icon != "" {
|
||||||
fmt.Fprintf(&b, "Icon=%s\n", escapeValue(e.Icon))
|
fmt.Fprintf(&b, "Icon=%s\n", escapeValue(e.Icon))
|
||||||
}
|
}
|
||||||
b.WriteString("Terminal=false\n")
|
fmt.Fprintf(&b, "Terminal=%t\n", e.Terminal)
|
||||||
|
if e.WorkingDir != "" {
|
||||||
|
fmt.Fprintf(&b, "Path=%s\n", escapeValue(e.WorkingDir))
|
||||||
|
}
|
||||||
if e.Categories != "" {
|
if e.Categories != "" {
|
||||||
fmt.Fprintf(&b, "Categories=%s\n", escapeValue(e.Categories))
|
fmt.Fprintf(&b, "Categories=%s\n", escapeValue(e.Categories))
|
||||||
}
|
}
|
||||||
@@ -71,6 +76,9 @@ func escapeValue(s string) string {
|
|||||||
return r.Replace(s)
|
return r.Replace(s)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Slug mengembalikan nama berkas yang aman dari nama aplikasi (dipakai UI).
|
||||||
|
func Slug(name string) string { return slugify(name) }
|
||||||
|
|
||||||
// slugify membuat nama file yang aman dari nama aplikasi.
|
// slugify membuat nama file yang aman dari nama aplikasi.
|
||||||
func slugify(name string) string {
|
func slugify(name string) string {
|
||||||
var b strings.Builder
|
var b strings.Builder
|
||||||
|
|||||||
@@ -0,0 +1,212 @@
|
|||||||
|
package shortcut
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"lazyrun/internal/store"
|
||||||
|
"lazyrun/internal/xdg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// EditExecutable memperbarui shortcut executable yang sudah ada:
|
||||||
|
// binary bisa diganti (disalin ulang), begitu juga ikon, nama, dan lokasinya.
|
||||||
|
func EditExecutable(old store.Shortcut, o ExecOptions) (store.Shortcut, error) {
|
||||||
|
name := strings.TrimSpace(o.Name)
|
||||||
|
if err := validate(name, o.Menu, o.Desktop); err != nil {
|
||||||
|
return old, err
|
||||||
|
}
|
||||||
|
|
||||||
|
src := o.BinaryPath
|
||||||
|
if src == "" {
|
||||||
|
src = old.BinaryPath
|
||||||
|
}
|
||||||
|
if err := checkRunnable(src); err != nil {
|
||||||
|
return old, fmt.Errorf("%s: %w", filepath.Base(src), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
sl := slugify(name)
|
||||||
|
binDst := filepath.Join(xdg.BinDir(), sl)
|
||||||
|
if sl != old.Slug {
|
||||||
|
if _, err := os.Stat(filepath.Join(xdg.ApplicationsDir(), sl+".desktop")); err == nil {
|
||||||
|
return old, fmt.Errorf("shortcut %q already exists", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kind := DetectKind(src)
|
||||||
|
if err := installBinary(kind, src, binDst); err != nil {
|
||||||
|
return old, err
|
||||||
|
}
|
||||||
|
|
||||||
|
execArgs := []string{binDst}
|
||||||
|
if kind == KindWindows {
|
||||||
|
wine, err := exec.LookPath("wine")
|
||||||
|
if err != nil {
|
||||||
|
return old, errors.New("this is a Windows .exe; install wine first so the shortcut can run it")
|
||||||
|
}
|
||||||
|
execArgs = []string{wine, binDst}
|
||||||
|
}
|
||||||
|
|
||||||
|
iconPath, iconSource := old.IconPath, old.IconSource
|
||||||
|
if o.IconPath != "" || o.IconURL != "" || len(o.IconData) > 0 {
|
||||||
|
path, source, err := installIconFor(iconOptions{
|
||||||
|
IconPath: o.IconPath, IconURL: o.IconURL, IconData: o.IconData,
|
||||||
|
Query: name, Slug: sl,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return old, err
|
||||||
|
}
|
||||||
|
iconPath, iconSource = path, source
|
||||||
|
}
|
||||||
|
|
||||||
|
category := category(o.Category, "Utility;")
|
||||||
|
if o.Category == "" {
|
||||||
|
category = old.Categories
|
||||||
|
if category == "" {
|
||||||
|
category = "Utility;"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
entry := Entry{
|
||||||
|
Name: name,
|
||||||
|
ExecArgs: execArgs,
|
||||||
|
Icon: iconPath,
|
||||||
|
Categories: category,
|
||||||
|
Terminal: o.Terminal && kind != KindWindows,
|
||||||
|
NoDisplay: !o.Menu,
|
||||||
|
}
|
||||||
|
if kind == KindScript {
|
||||||
|
if _, err := os.Stat(filepath.Dir(src)); err == nil {
|
||||||
|
entry.WorkingDir = filepath.Dir(src)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
updated := old
|
||||||
|
updated.Slug = sl
|
||||||
|
updated.Name = name
|
||||||
|
updated.Kind = kind
|
||||||
|
updated.ExecArgs = execArgs
|
||||||
|
updated.Categories = category
|
||||||
|
updated.Terminal = entry.Terminal
|
||||||
|
updated.BinaryPath = binDst
|
||||||
|
updated.IconPath = iconPath
|
||||||
|
updated.IconSource = iconSource
|
||||||
|
updated.Targets = store.Targets{Menu: o.Menu, Desktop: o.Desktop}
|
||||||
|
|
||||||
|
if err := rewriteShortcutFiles(old, &updated, entry); err != nil {
|
||||||
|
return old, err
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// EditWebApp memperbarui shortcut web app (URL, browser, ikon, nama, lokasi).
|
||||||
|
func EditWebApp(old store.Shortcut, o WebOptions) (store.Shortcut, error) {
|
||||||
|
name := strings.TrimSpace(o.Name)
|
||||||
|
if err := validate(name, o.Menu, o.Desktop); err != nil {
|
||||||
|
return old, err
|
||||||
|
}
|
||||||
|
if len(o.Browser.Exec) == 0 {
|
||||||
|
return old, errors.New("no browser selected")
|
||||||
|
}
|
||||||
|
pageURL, err := normalizeURL(o.URL)
|
||||||
|
if err != nil {
|
||||||
|
return old, err
|
||||||
|
}
|
||||||
|
|
||||||
|
sl := slugify(name)
|
||||||
|
if sl != old.Slug {
|
||||||
|
if _, err := os.Stat(filepath.Join(xdg.ApplicationsDir(), sl+".desktop")); err == nil {
|
||||||
|
return old, fmt.Errorf("shortcut %q already exists", name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
iconPath, iconSource := old.IconPath, old.IconSource
|
||||||
|
if o.IconPath != "" || o.IconURL != "" || len(o.IconData) > 0 {
|
||||||
|
path, source, err := installIconFor(iconOptions{
|
||||||
|
IconPath: o.IconPath, IconURL: o.IconURL, IconData: o.IconData,
|
||||||
|
PageURL: pageURL, Query: name, Slug: sl,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return old, err
|
||||||
|
}
|
||||||
|
iconPath, iconSource = path, source
|
||||||
|
}
|
||||||
|
|
||||||
|
appArgs := o.Browser.Exec[1:]
|
||||||
|
if o.Browser.Firefox {
|
||||||
|
appArgs = append(appArgs, "--new-window", pageURL)
|
||||||
|
} else {
|
||||||
|
appArgs = append(appArgs, "--app="+pageURL)
|
||||||
|
}
|
||||||
|
execArgs := append([]string{o.Browser.Exec[0]}, appArgs...)
|
||||||
|
|
||||||
|
updated := old
|
||||||
|
updated.Slug = sl
|
||||||
|
updated.Name = name
|
||||||
|
updated.Kind = KindPWA
|
||||||
|
updated.URL = pageURL
|
||||||
|
updated.Browser = strings.Join(o.Browser.Exec, " ")
|
||||||
|
updated.ExecArgs = execArgs
|
||||||
|
updated.IconPath = iconPath
|
||||||
|
updated.IconSource = iconSource
|
||||||
|
updated.Targets = store.Targets{Menu: o.Menu, Desktop: o.Desktop}
|
||||||
|
|
||||||
|
if err := rewriteShortcutFiles(old, &updated, Entry{
|
||||||
|
Name: name,
|
||||||
|
ExecArgs: execArgs,
|
||||||
|
Icon: iconPath,
|
||||||
|
Categories: old.Categories,
|
||||||
|
NoDisplay: !o.Menu,
|
||||||
|
}); err != nil {
|
||||||
|
return old, err
|
||||||
|
}
|
||||||
|
return updated, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// rewriteShortcutFiles menulis ulang .desktop (dan salinannya) untuk shortcut
|
||||||
|
// yang diedit, termasuk membereskan berkas lama kalau nama/slug berubah.
|
||||||
|
// updated diisi lewat pointer karena path-nya berubah di sini.
|
||||||
|
func rewriteShortcutFiles(old store.Shortcut, updated *store.Shortcut, entry Entry) error {
|
||||||
|
if entry.Categories == "" {
|
||||||
|
entry.Categories = "Utility;"
|
||||||
|
}
|
||||||
|
|
||||||
|
newDesk := filepath.Join(xdg.ApplicationsDir(), updated.Slug+".desktop")
|
||||||
|
if err := WriteDesktop(newDesk, entry); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
updated.DesktopPath = newDesk
|
||||||
|
|
||||||
|
// salinan Desktop: dibuat kalau diminta, dihapus kalau tidak
|
||||||
|
newCopy := ""
|
||||||
|
if updated.Targets.Desktop {
|
||||||
|
newCopy = filepath.Join(xdg.DesktopDir(), updated.Slug+".desktop")
|
||||||
|
if err := copyFile(newDesk, newCopy, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
updated.DesktopCopyPath = newCopy
|
||||||
|
|
||||||
|
// bersihkan berkas lama kalau lokasinya berubah
|
||||||
|
if old.DesktopPath != "" && old.DesktopPath != newDesk {
|
||||||
|
_ = os.Remove(old.DesktopPath)
|
||||||
|
}
|
||||||
|
if old.DesktopCopyPath != "" && old.DesktopCopyPath != newCopy {
|
||||||
|
_ = os.Remove(old.DesktopCopyPath)
|
||||||
|
}
|
||||||
|
if old.IconPath != "" && old.IconPath != updated.IconPath {
|
||||||
|
_ = os.Remove(old.IconPath)
|
||||||
|
}
|
||||||
|
// binary lama dibuang kalau nama berkasnya berubah
|
||||||
|
if old.Type == store.TypeExecutable && old.BinaryPath != "" && old.BinaryPath != updated.BinaryPath {
|
||||||
|
if !samePath(old.BinaryPath, updated.BinaryPath) {
|
||||||
|
_ = os.Remove(old.BinaryPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
go RefreshCache()
|
||||||
|
return nil
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"net/url"
|
"net/url"
|
||||||
"os"
|
"os"
|
||||||
|
"os/exec"
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -20,6 +21,7 @@ type ExecOptions struct {
|
|||||||
IconURL string // opsional: URL ikon
|
IconURL string // opsional: URL ikon
|
||||||
IconData []byte // opsional: ikon yang sudah diunduh UI
|
IconData []byte // opsional: ikon yang sudah diunduh UI
|
||||||
Name string
|
Name string
|
||||||
|
Terminal bool // jalankan di terminal (script interaktif)
|
||||||
Category string
|
Category string
|
||||||
Menu bool
|
Menu bool
|
||||||
Desktop bool
|
Desktop bool
|
||||||
@@ -47,10 +49,9 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) {
|
|||||||
return sc, fmt.Errorf("%s already exists, rename the app or remove that file first", binDst)
|
return sc, fmt.Errorf("%s already exists, rename the app or remove that file first", binDst)
|
||||||
}
|
}
|
||||||
|
|
||||||
if !samePath(o.BinaryPath, binDst) {
|
kind := DetectKind(o.BinaryPath)
|
||||||
if err := copyFile(o.BinaryPath, binDst, 0o755); err != nil {
|
if err := installBinary(kind, o.BinaryPath, binDst); err != nil {
|
||||||
return sc, err
|
return sc, err
|
||||||
}
|
|
||||||
}
|
}
|
||||||
iconPath, iconSource, err := installIconFor(iconOptions{
|
iconPath, iconSource, err := installIconFor(iconOptions{
|
||||||
IconPath: o.IconPath,
|
IconPath: o.IconPath,
|
||||||
@@ -65,13 +66,31 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) {
|
|||||||
return sc, err
|
return sc, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// .exe Windows dijalankan lewat wine; script lewat wrapper (PATH & cwd)
|
||||||
|
execArgs := []string{binDst}
|
||||||
|
if kind == KindWindows {
|
||||||
|
wine, err := exec.LookPath("wine")
|
||||||
|
if err != nil {
|
||||||
|
return sc, errors.New("this is a Windows .exe; install wine first so the shortcut can run it")
|
||||||
|
}
|
||||||
|
execArgs = []string{wine, binDst}
|
||||||
|
}
|
||||||
|
|
||||||
entry := Entry{
|
entry := Entry{
|
||||||
Name: name,
|
Name: name,
|
||||||
ExecArgs: []string{binDst},
|
ExecArgs: execArgs,
|
||||||
Icon: iconPath,
|
Icon: iconPath,
|
||||||
Categories: category(o.Category, "Utility;"),
|
Categories: category(o.Category, "Utility;"),
|
||||||
|
Terminal: o.Terminal && kind != KindWindows,
|
||||||
NoDisplay: !o.Menu,
|
NoDisplay: !o.Menu,
|
||||||
}
|
}
|
||||||
|
if kind == KindScript {
|
||||||
|
// desktop environment menjalankan dengan cwd = home, jadi folder asli
|
||||||
|
// script dipakai sebagai Path= kalau masih ada
|
||||||
|
if _, err := os.Stat(filepath.Dir(o.BinaryPath)); err == nil {
|
||||||
|
entry.WorkingDir = filepath.Dir(o.BinaryPath)
|
||||||
|
}
|
||||||
|
}
|
||||||
if err := WriteDesktop(deskDst, entry); err != nil {
|
if err := WriteDesktop(deskDst, entry); err != nil {
|
||||||
return sc, err
|
return sc, err
|
||||||
}
|
}
|
||||||
@@ -80,7 +99,10 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) {
|
|||||||
Slug: sl,
|
Slug: sl,
|
||||||
Name: name,
|
Name: name,
|
||||||
Type: store.TypeExecutable,
|
Type: store.TypeExecutable,
|
||||||
|
Kind: kind,
|
||||||
ExecArgs: entry.ExecArgs,
|
ExecArgs: entry.ExecArgs,
|
||||||
|
Categories: entry.Categories,
|
||||||
|
Terminal: entry.Terminal,
|
||||||
BinaryPath: binDst,
|
BinaryPath: binDst,
|
||||||
IconPath: iconPath,
|
IconPath: iconPath,
|
||||||
IconSource: iconSource,
|
IconSource: iconSource,
|
||||||
@@ -174,7 +196,9 @@ func InstallWebApp(o WebOptions) (store.Shortcut, error) {
|
|||||||
Slug: sl,
|
Slug: sl,
|
||||||
Name: name,
|
Name: name,
|
||||||
Type: store.TypeWebApp,
|
Type: store.TypeWebApp,
|
||||||
|
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,
|
||||||
|
|||||||
@@ -46,10 +46,12 @@ func checkRunnable(path string) error {
|
|||||||
head := make([]byte, 4)
|
head := make([]byte, 4)
|
||||||
n, _ := io.ReadFull(f, head)
|
n, _ := io.ReadFull(f, head)
|
||||||
head = head[:n]
|
head = head[:n]
|
||||||
if bytes.HasPrefix(head, []byte{0x7f, 'E', 'L', 'F'}) || bytes.HasPrefix(head, []byte("#!")) {
|
if bytes.HasPrefix(head, []byte{0x7f, 'E', 'L', 'F'}) ||
|
||||||
|
bytes.HasPrefix(head, []byte("#!")) ||
|
||||||
|
bytes.HasPrefix(head, []byte("MZ")) { // .exe Windows (dijalankan lewat wine)
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
return errors.New("not an executable binary (ELF, AppImage, or script only)")
|
return errors.New("not an executable (ELF, AppImage, script, or Windows .exe)")
|
||||||
}
|
}
|
||||||
|
|
||||||
// copyFile menyalin src ke dst lewat file sementara supaya dst tidak pernah setengah tertulis.
|
// copyFile menyalin src ke dst lewat file sementara supaya dst tidak pernah setengah tertulis.
|
||||||
@@ -103,3 +105,53 @@ func desktopCopy(desktopPath, slug string) (string, error) {
|
|||||||
}
|
}
|
||||||
return dst, copyFile(desktopPath, dst, 0o755)
|
return dst, copyFile(desktopPath, dst, 0o755)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// installBinary menyalin berkas target ke binDst.
|
||||||
|
//
|
||||||
|
// Script dapat perlakuan khusus: yang disalin adalah script aslinya (dengan
|
||||||
|
// akhiran .real) plus wrapper kecil. Desktop environment menjalankan shortcut
|
||||||
|
// dengan cwd = home dan PATH sesi, sehingga script yang memakai path relatif
|
||||||
|
// atau tool di ~/.local/bin gagal; wrapper yang membereskan keduanya.
|
||||||
|
func installBinary(kind, src, binDst string) error {
|
||||||
|
if kind == KindScript {
|
||||||
|
if err := copyFile(src, binDst+scriptSuffix, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return writeScriptWrapper(binDst, binDst+scriptSuffix, filepath.Dir(src))
|
||||||
|
}
|
||||||
|
if samePath(src, binDst) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return copyFile(src, binDst, 0o755)
|
||||||
|
}
|
||||||
|
|
||||||
|
// writeFileMode menulis berkas dengan mode tertentu (dipakai saat restore).
|
||||||
|
func writeFileMode(path string, data []byte, mode os.FileMode) error {
|
||||||
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return os.WriteFile(path, data, mode)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scriptSuffix adalah akhiran berkas script asli di samping wrapper.
|
||||||
|
const scriptSuffix = ".real"
|
||||||
|
|
||||||
|
// writeScriptWrapper menulis wrapper yang menyiapkan PATH dan folder kerja
|
||||||
|
// sebelum menjalankan script aslinya.
|
||||||
|
func writeScriptWrapper(path, real, originDir string) error {
|
||||||
|
body := "#!/bin/sh\n" +
|
||||||
|
"# dibuat oleh LazyRun: siapkan PATH & folder kerja lalu jalankan script aslinya\n" +
|
||||||
|
`PATH="$HOME/.local/bin:$PATH"` + "\n" +
|
||||||
|
"export PATH\n"
|
||||||
|
if originDir != "" && originDir != "." {
|
||||||
|
quoted := shellQuote(originDir)
|
||||||
|
body += "if [ -d " + quoted + " ]; then cd " + quoted + " || exit 1; fi\n"
|
||||||
|
}
|
||||||
|
body += "exec " + shellQuote(real) + ` "$@"` + "\n"
|
||||||
|
return os.WriteFile(path, []byte(body), 0o755)
|
||||||
|
}
|
||||||
|
|
||||||
|
// shellQuote mengutip nilai untuk dipakai di dalam script shell.
|
||||||
|
func shellQuote(value string) string {
|
||||||
|
return "'" + strings.ReplaceAll(value, "'", `'\''`) + "'"
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
package shortcut
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"debug/buildinfo"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Jenis aplikasi yang bisa dikenali LazyRun.
|
||||||
|
const (
|
||||||
|
KindGo = "go"
|
||||||
|
KindAppImage = "appimage"
|
||||||
|
KindElectron = "electron"
|
||||||
|
KindScript = "script"
|
||||||
|
KindWindows = "windows"
|
||||||
|
KindBinary = "binary"
|
||||||
|
KindPWA = "pwa"
|
||||||
|
)
|
||||||
|
|
||||||
|
// DetectKind menebak jenis berkas yang dipilih user:
|
||||||
|
// binary Go, AppImage, aplikasi Electron, script, .exe Windows, atau binary biasa.
|
||||||
|
func DetectKind(path string) string {
|
||||||
|
f, err := os.Open(path)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
head := make([]byte, 12)
|
||||||
|
n, _ := f.Read(head)
|
||||||
|
f.Close()
|
||||||
|
head = head[:n]
|
||||||
|
|
||||||
|
switch {
|
||||||
|
case bytes.HasPrefix(head, []byte("MZ")):
|
||||||
|
return KindWindows
|
||||||
|
case bytes.HasPrefix(head, []byte("#!")):
|
||||||
|
return KindScript
|
||||||
|
case isAppImageHead(head):
|
||||||
|
return KindAppImage
|
||||||
|
case bytes.HasPrefix(head, []byte{0x7f, 'E', 'L', 'F'}):
|
||||||
|
if _, err := buildinfo.ReadFile(path); err == nil {
|
||||||
|
return KindGo
|
||||||
|
}
|
||||||
|
if hasElectronResources(path) {
|
||||||
|
return KindElectron
|
||||||
|
}
|
||||||
|
return KindBinary
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// hasElectronResources mencari berkas penanda Electron di sebelah binary.
|
||||||
|
func hasElectronResources(path string) bool {
|
||||||
|
resources := filepath.Join(filepath.Dir(path), "resources")
|
||||||
|
for _, name := range []string{"app.asar", "default_app.asar", "app"} {
|
||||||
|
if _, err := os.Stat(filepath.Join(resources, name)); err == nil {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
// isAppImageHead memeriksa magic AppImage (ELF + "AI" pada offset 8).
|
||||||
|
// Sengaja terpisah dari internal/update supaya paket ini tidak tergantung ke sana.
|
||||||
|
func isAppImageHead(head []byte) bool {
|
||||||
|
return len(head) >= 11 &&
|
||||||
|
head[0] == 0x7f && head[1] == 'E' && head[2] == 'L' && head[3] == 'F' &&
|
||||||
|
head[8] == 'A' && head[9] == 'I' && (head[10] == 0x01 || head[10] == 0x02)
|
||||||
|
}
|
||||||
@@ -0,0 +1,80 @@
|
|||||||
|
package shortcut
|
||||||
|
|
||||||
|
import (
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestDetectKind(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
|
||||||
|
write := func(name string, head []byte) string {
|
||||||
|
path := filepath.Join(dir, name)
|
||||||
|
if err := os.WriteFile(path, append(head, make([]byte, 64)...), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
elf := []byte{0x7f, 'E', 'L', 'F', 0x02, 0x01, 0x01, 0x00}
|
||||||
|
|
||||||
|
cases := []struct {
|
||||||
|
name string
|
||||||
|
path string
|
||||||
|
want string
|
||||||
|
}{
|
||||||
|
{"windows exe", write("winbox.exe", []byte("MZ\x90\x00")), KindWindows},
|
||||||
|
{"script", write("tool.sh", []byte("#!/bin/sh\n")), KindScript},
|
||||||
|
{"appimage", write("app.AppImage", append(append([]byte{}, elf...), 'A', 'I', 0x02, 0x00)), KindAppImage},
|
||||||
|
{"binary biasa", write("binary", elf), KindBinary},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, c := range cases {
|
||||||
|
t.Run(c.name, func(t *testing.T) {
|
||||||
|
if got := DetectKind(c.path); got != c.want {
|
||||||
|
t.Errorf("DetectKind() = %q, mau %q", got, c.want)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// binary Go: test ini sendiri hasil build Go, jadi harus terdeteksi "go"
|
||||||
|
exe, err := os.Executable()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := DetectKind(exe); got != KindGo {
|
||||||
|
t.Errorf("DetectKind(test binary) = %q, mau %q", got, KindGo)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectKindElectron(t *testing.T) {
|
||||||
|
dir := t.TempDir()
|
||||||
|
app := filepath.Join(dir, "myapp")
|
||||||
|
if err := os.WriteFile(app, append([]byte{0x7f, 'E', 'L', 'F', 0x02, 0x01, 0x01, 0x00}, make([]byte, 32)...), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.MkdirAll(filepath.Join(dir, "resources"), 0o755); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(dir, "resources", "app.asar"), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := DetectKind(app); got != KindElectron {
|
||||||
|
t.Errorf("DetectKind(electron) = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestDetectKindUnknownFile(t *testing.T) {
|
||||||
|
path := filepath.Join(t.TempDir(), "catatan.txt")
|
||||||
|
if err := os.WriteFile(path, []byte("halo"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got := DetectKind(path); got != "" {
|
||||||
|
t.Errorf("teks biasa tidak punya kind, dapat %q", got)
|
||||||
|
}
|
||||||
|
if got := DetectKind(filepath.Join(t.TempDir(), "tidak-ada")); got != "" {
|
||||||
|
t.Errorf("berkas tidak ada harus kosong, dapat %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,174 @@
|
|||||||
|
package shortcut
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"lazyrun/internal/icon"
|
||||||
|
"lazyrun/internal/store"
|
||||||
|
"lazyrun/internal/xdg"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Restore memasang ulang shortcut dari data cadangan ke mesin ini:
|
||||||
|
// binary dan ikon ditulis dari isi cadangan (kalau ada), lalu .desktop dibuat
|
||||||
|
// dengan path lokal. Metadata yang menunjuk ke komputer lain tidak dipakai
|
||||||
|
// mentah-mentah, jadi hasil restore langsung bisa dipakai.
|
||||||
|
func Restore(item store.Shortcut, binary, iconData []byte) (store.Shortcut, error) {
|
||||||
|
name := strings.TrimSpace(item.Name)
|
||||||
|
if name == "" {
|
||||||
|
return item, errors.New("nama shortcut kosong")
|
||||||
|
}
|
||||||
|
sl := slugify(name)
|
||||||
|
if sl == "" {
|
||||||
|
return item, errors.New("nama shortcut tidak valid")
|
||||||
|
}
|
||||||
|
|
||||||
|
restored := item
|
||||||
|
restored.Slug = sl
|
||||||
|
restored.Name = name
|
||||||
|
restored.DesktopCopyPath = ""
|
||||||
|
restored.IconPath = ""
|
||||||
|
|
||||||
|
if restored.Type == store.TypeExecutable {
|
||||||
|
if err := restoreBinary(&restored, sl, binary); err != nil {
|
||||||
|
return restored, err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if restored.Type == store.TypeWebApp && len(restored.ExecArgs) == 0 && restored.Browser != "" {
|
||||||
|
restored.ExecArgs = webAppArgs(restored.Browser, restored.URL)
|
||||||
|
}
|
||||||
|
|
||||||
|
iconPath, err := restoreIcon(restored, sl, iconData)
|
||||||
|
if err != nil {
|
||||||
|
return restored, err
|
||||||
|
}
|
||||||
|
restored.IconPath = iconPath
|
||||||
|
|
||||||
|
entry := Entry{
|
||||||
|
Name: name,
|
||||||
|
ExecArgs: restored.ExecArgs,
|
||||||
|
Icon: iconPath,
|
||||||
|
Categories: restored.Categories,
|
||||||
|
Terminal: restored.Terminal && restored.Kind != KindWindows,
|
||||||
|
NoDisplay: !restored.Targets.Menu,
|
||||||
|
}
|
||||||
|
if entry.Categories == "" {
|
||||||
|
entry.Categories = "Utility;"
|
||||||
|
}
|
||||||
|
if restored.Kind == KindScript {
|
||||||
|
if dir := scriptOriginDir(restored); dir != "" {
|
||||||
|
entry.WorkingDir = dir
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
deskDst := filepath.Join(xdg.ApplicationsDir(), sl+".desktop")
|
||||||
|
if err := WriteDesktop(deskDst, entry); err != nil {
|
||||||
|
return restored, err
|
||||||
|
}
|
||||||
|
restored.DesktopPath = deskDst
|
||||||
|
|
||||||
|
if restored.Targets.Desktop {
|
||||||
|
copyPath := filepath.Join(xdg.DesktopDir(), sl+".desktop")
|
||||||
|
if err := copyFile(deskDst, copyPath, 0o755); err != nil {
|
||||||
|
return restored, err
|
||||||
|
}
|
||||||
|
restored.DesktopCopyPath = copyPath
|
||||||
|
}
|
||||||
|
|
||||||
|
go RefreshCache()
|
||||||
|
return restored, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreBinary menulis binary/script dari cadangan ke ~/.local/bin.
|
||||||
|
// Kalau cadangan tidak memuat isinya, berkas lokal yang masih ada dipakai.
|
||||||
|
func restoreBinary(restored *store.Shortcut, slug string, binary []byte) error {
|
||||||
|
binDst := filepath.Join(xdg.BinDir(), slug)
|
||||||
|
originDir := scriptOriginDir(*restored)
|
||||||
|
|
||||||
|
if len(binary) == 0 {
|
||||||
|
// tidak ada isi cadangan: pakai berkas lama kalau masih ada di mesin ini
|
||||||
|
if _, err := os.Stat(restored.BinaryPath); err != nil {
|
||||||
|
restored.BinaryPath = ""
|
||||||
|
restored.ExecArgs = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
binary, _ = os.ReadFile(restored.BinaryPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := writeFileMode(binDst, binary, 0o755); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
kind := DetectKind(binDst)
|
||||||
|
if kind == KindScript {
|
||||||
|
// script dijalankan lewat wrapper: script aslinya disimpan sebagai .real
|
||||||
|
if err := os.Rename(binDst, binDst+scriptSuffix); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if err := writeScriptWrapper(binDst, binDst+scriptSuffix, originDir); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
restored.Kind = kind
|
||||||
|
restored.BinaryPath = binDst
|
||||||
|
restored.ExecArgs = []string{binDst}
|
||||||
|
if kind == KindWindows {
|
||||||
|
if wine, err := exec.LookPath("wine"); err == nil {
|
||||||
|
restored.ExecArgs = []string{wine, binDst}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreIcon memakai ikon dari cadangan; kalau tidak ada, coba unduh ulang
|
||||||
|
// dari sumber yang tercatat, lalu jatuh ke ikon default.
|
||||||
|
func restoreIcon(item store.Shortcut, slug string, iconData []byte) (string, error) {
|
||||||
|
if len(iconData) > 0 {
|
||||||
|
if path, err := icon.Save(iconData, slug); err == nil {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
source := item.IconSource
|
||||||
|
if source != "" {
|
||||||
|
options := iconOptions{Slug: slug}
|
||||||
|
switch {
|
||||||
|
case strings.HasPrefix(source, iconSourceFavicon):
|
||||||
|
options.PageURL = strings.TrimPrefix(source, iconSourceFavicon)
|
||||||
|
case strings.HasPrefix(source, iconSourceSearch):
|
||||||
|
options.Query = strings.TrimPrefix(source, iconSourceSearch)
|
||||||
|
case strings.HasPrefix(source, "http"):
|
||||||
|
options.IconURL = source
|
||||||
|
}
|
||||||
|
if path, _, err := installIconFor(options); err == nil && path != "" {
|
||||||
|
return path, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return icon.Default(slug)
|
||||||
|
}
|
||||||
|
|
||||||
|
// scriptOriginDir menebak folder asal script dari metadata (dipakai sebagai
|
||||||
|
// Path= dan folder kerja wrapper).
|
||||||
|
func scriptOriginDir(item store.Shortcut) string {
|
||||||
|
dir := filepath.Dir(item.BinaryPath)
|
||||||
|
if dir == "" || dir == "." || dir == "/" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
return dir
|
||||||
|
}
|
||||||
|
|
||||||
|
// webAppArgs menyusun argv PWA dari perintah browser yang tersimpan.
|
||||||
|
func webAppArgs(browser, pageURL string) []string {
|
||||||
|
args := strings.Fields(browser)
|
||||||
|
if len(args) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if strings.Contains(strings.ToLower(args[0]), "firefox") {
|
||||||
|
return append(args, "--new-window", pageURL)
|
||||||
|
}
|
||||||
|
return append(args, "--app="+pageURL)
|
||||||
|
}
|
||||||
@@ -0,0 +1,166 @@
|
|||||||
|
package shortcut
|
||||||
|
|
||||||
|
import (
|
||||||
|
"net/http"
|
||||||
|
"net/http/httptest"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"lazyrun/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestRestoreExecutableFromBackup(t *testing.T) {
|
||||||
|
home := testEnv(t)
|
||||||
|
|
||||||
|
backup := store.Shortcut{
|
||||||
|
Slug: "winbox",
|
||||||
|
Name: "Winbox",
|
||||||
|
Type: store.TypeExecutable,
|
||||||
|
Kind: "binary",
|
||||||
|
BinaryPath: "/komputer-lama/.local/bin/winbox", // path dari mesin lain
|
||||||
|
IconPath: "/komputer-lama/.local/share/icons/winbox.png",
|
||||||
|
DesktopPath: "/komputer-lama/.local/share/applications/winbox.desktop",
|
||||||
|
Categories: "Network;",
|
||||||
|
Targets: store.Targets{Menu: true, Desktop: true},
|
||||||
|
}
|
||||||
|
restored, err := Restore(backup, append([]byte{0x7f, 'E', 'L', 'F', 0x02, 0x01, 0x01, 0x00}, []byte("isi")...), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
wantBin := filepath.Join(home, ".local", "bin", "winbox")
|
||||||
|
if restored.BinaryPath != wantBin {
|
||||||
|
t.Errorf("binary = %s, mau %s", restored.BinaryPath, wantBin)
|
||||||
|
}
|
||||||
|
if st, err := os.Stat(wantBin); err != nil || st.Mode().Perm()&0o111 == 0 {
|
||||||
|
t.Fatalf("binary hasil restore tidak executable: %v %v", st, err)
|
||||||
|
}
|
||||||
|
// path dari mesin lama tidak boleh dipakai
|
||||||
|
if strings.Contains(restored.DesktopPath, "komputer-lama") {
|
||||||
|
t.Errorf("desktop path masih menunjuk mesin lama: %s", restored.DesktopPath)
|
||||||
|
}
|
||||||
|
if restored.DesktopCopyPath != filepath.Join(home, "Desktop", "winbox.desktop") {
|
||||||
|
t.Errorf("salinan desktop = %s", restored.DesktopCopyPath)
|
||||||
|
}
|
||||||
|
content, err := os.ReadFile(restored.DesktopPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(content), "Exec="+wantBin+"\n") || !strings.Contains(string(content), "Categories=Network;\n") {
|
||||||
|
t.Errorf("isi .desktop hasil restore salah:\n%s", content)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(restored.IconPath); err != nil {
|
||||||
|
t.Errorf("ikon hasil restore tidak ada: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreScriptGetsWrapper(t *testing.T) {
|
||||||
|
home := testEnv(t)
|
||||||
|
|
||||||
|
backup := store.Shortcut{
|
||||||
|
Name: "Deploy",
|
||||||
|
Type: store.TypeExecutable,
|
||||||
|
Kind: "script",
|
||||||
|
BinaryPath: "/komputer-lama/proyek/deploy.sh",
|
||||||
|
Terminal: true,
|
||||||
|
Targets: store.Targets{Menu: true},
|
||||||
|
}
|
||||||
|
restored, err := Restore(backup, []byte("#!/bin/sh\necho halo\n"), nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if restored.Kind != KindScript {
|
||||||
|
t.Errorf("kind = %q", restored.Kind)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(home, ".local", "bin", "deploy")); err != nil {
|
||||||
|
t.Errorf("wrapper tidak dibuat: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(filepath.Join(home, ".local", "bin", "deploy"+scriptSuffix)); err != nil {
|
||||||
|
t.Errorf("script asli tidak dibuat: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
content, err := os.ReadFile(restored.DesktopPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(content), "Terminal=true") {
|
||||||
|
t.Errorf("flag terminal hilang:\n%s", content)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreWithoutBinaryBlobMarksMissing(t *testing.T) {
|
||||||
|
testEnv(t)
|
||||||
|
|
||||||
|
backup := store.Shortcut{
|
||||||
|
Name: "Hilang",
|
||||||
|
Type: store.TypeExecutable,
|
||||||
|
BinaryPath: "/komputer-lama/.local/bin/hilang",
|
||||||
|
Targets: store.Targets{Menu: true},
|
||||||
|
}
|
||||||
|
restored, err := Restore(backup, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if restored.BinaryPath != "" {
|
||||||
|
t.Errorf("binary mestinya kosong (tidak bisa dipulihkan), dapat %s", restored.BinaryPath)
|
||||||
|
}
|
||||||
|
if restored.ExecArgs != nil {
|
||||||
|
t.Errorf("exec args mestinya kosong: %v", restored.ExecArgs)
|
||||||
|
}
|
||||||
|
// .desktop tetap dibuat supaya entri bisa dibetulkan lewat edit
|
||||||
|
if _, err := os.Stat(restored.DesktopPath); err != nil {
|
||||||
|
t.Errorf(".desktop tidak dibuat: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreWebAppRebuildsExec(t *testing.T) {
|
||||||
|
testEnv(t)
|
||||||
|
|
||||||
|
backup := store.Shortcut{
|
||||||
|
Name: "WhatsApp Web",
|
||||||
|
Type: store.TypeWebApp,
|
||||||
|
Kind: KindPWA,
|
||||||
|
URL: "https://web.whatsapp.com",
|
||||||
|
Browser: "flatpak run org.chromium.Chromium",
|
||||||
|
Targets: store.Targets{Menu: true},
|
||||||
|
}
|
||||||
|
restored, err := Restore(backup, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if len(restored.ExecArgs) != 4 || restored.ExecArgs[3] != "--app=https://web.whatsapp.com" {
|
||||||
|
t.Errorf("exec args PWA salah: %v", restored.ExecArgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestRestoreIconFromSource(t *testing.T) {
|
||||||
|
testEnv(t)
|
||||||
|
|
||||||
|
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
_, _ = w.Write(append(pngMagic, []byte("ikon dari internet")...))
|
||||||
|
}))
|
||||||
|
defer srv.Close()
|
||||||
|
|
||||||
|
backup := store.Shortcut{
|
||||||
|
Name: "Icon",
|
||||||
|
Type: store.TypeExecutable,
|
||||||
|
BinaryPath: "",
|
||||||
|
IconPath: "/komputer-lama/ikon.png",
|
||||||
|
IconSource: srv.URL + "/ikon.png",
|
||||||
|
Targets: store.Targets{Menu: true},
|
||||||
|
}
|
||||||
|
restored, err := Restore(backup, nil, nil)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
data, err := os.ReadFile(restored.IconPath)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ikon tidak dipasang: %v", err)
|
||||||
|
}
|
||||||
|
if !strings.Contains(string(data), "ikon dari internet") {
|
||||||
|
t.Errorf("ikon tidak diunduh ulang dari sumber: %q", data)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -31,8 +31,11 @@ func Uninstall(sc store.Shortcut, o RemoveOptions) error {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if o.Binary && sc.Type == store.TypeExecutable && sc.BinaryPath != "" {
|
if o.Binary && sc.Type == store.TypeExecutable && sc.BinaryPath != "" {
|
||||||
if err := os.Remove(sc.BinaryPath); err != nil && !os.IsNotExist(err) {
|
// script punya dua berkas: wrapper + script aslinya
|
||||||
errs = append(errs, err)
|
for _, path := range []string{sc.BinaryPath, sc.BinaryPath + scriptSuffix} {
|
||||||
|
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
|
||||||
|
errs = append(errs, err)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,9 +25,12 @@ type Shortcut struct {
|
|||||||
Slug string `json:"slug"`
|
Slug string `json:"slug"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Type string `json:"type"`
|
Type string `json:"type"`
|
||||||
|
Kind string `json:"kind,omitempty"` // hasil auto-detect: go, appimage, electron, script, windows, binary, pwa
|
||||||
URL string `json:"url,omitempty"`
|
URL string `json:"url,omitempty"`
|
||||||
Browser string `json:"browser,omitempty"`
|
Browser string `json:"browser,omitempty"`
|
||||||
ExecArgs []string `json:"exec_args,omitempty"`
|
ExecArgs []string `json:"exec_args,omitempty"`
|
||||||
|
Categories string `json:"categories,omitempty"`
|
||||||
|
Terminal bool `json:"terminal,omitempty"`
|
||||||
BinaryPath string `json:"binary_path,omitempty"`
|
BinaryPath string `json:"binary_path,omitempty"`
|
||||||
IconPath string `json:"icon_path,omitempty"`
|
IconPath string `json:"icon_path,omitempty"`
|
||||||
IconSource string `json:"icon_source,omitempty"`
|
IconSource string `json:"icon_source,omitempty"`
|
||||||
|
|||||||
@@ -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)
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -0,0 +1,299 @@
|
|||||||
|
package ui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"fyne.io/fyne/v2"
|
||||||
|
"fyne.io/fyne/v2/container"
|
||||||
|
"fyne.io/fyne/v2/dialog"
|
||||||
|
"fyne.io/fyne/v2/storage"
|
||||||
|
"fyne.io/fyne/v2/theme"
|
||||||
|
"fyne.io/fyne/v2/widget"
|
||||||
|
|
||||||
|
"lazyrun/internal/browser"
|
||||||
|
"lazyrun/internal/db"
|
||||||
|
"lazyrun/internal/icon"
|
||||||
|
"lazyrun/internal/shortcut"
|
||||||
|
"lazyrun/internal/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
var lazyFilter = storage.NewExtensionFileFilter([]string{".lazy"})
|
||||||
|
|
||||||
|
// exportShortcuts menyimpan daftar shortcut ke satu berkas SQLite (.lazy),
|
||||||
|
// mis. "db.lazy", supaya bisa dipindah ke komputer lain.
|
||||||
|
func (ui *app) exportShortcuts() {
|
||||||
|
save := dialog.NewFileSave(func(w fyne.URIWriteCloser, err error) {
|
||||||
|
if err != nil {
|
||||||
|
dialog.ShowError(err, ui.win)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if w == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := w.URI().Path()
|
||||||
|
w.Close() // berkas kosong dari dialog akan diganti basis data
|
||||||
|
if !strings.HasSuffix(strings.ToLower(path), ".lazy") {
|
||||||
|
path += ".lazy"
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Export(path, ui.version, ui.store.Items); err != nil {
|
||||||
|
dialog.ShowError(err, ui.win)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
dialog.ShowInformation(T("export"), fmt.Sprintf(T("export_done"), len(ui.store.Items), path), ui.win)
|
||||||
|
}, ui.win)
|
||||||
|
save.SetFileName(db.DefaultName)
|
||||||
|
save.SetFilter(lazyFilter)
|
||||||
|
save.Show()
|
||||||
|
}
|
||||||
|
|
||||||
|
// importShortcuts menambahkan shortcut dari berkas .lazy tanpa menimpa yang sudah ada.
|
||||||
|
func (ui *app) importShortcuts() {
|
||||||
|
open := dialog.NewFileOpen(func(r fyne.URIReadCloser, err error) {
|
||||||
|
if err != nil {
|
||||||
|
dialog.ShowError(err, ui.win)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if r == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
path := r.URI().Path()
|
||||||
|
r.Close()
|
||||||
|
|
||||||
|
items, err := db.Import(path)
|
||||||
|
if err != nil {
|
||||||
|
dialog.ShowError(err, ui.win)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(items) == 0 {
|
||||||
|
dialog.ShowInformation(T("import"), T("import_empty"), ui.win)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
added, skipped := ui.restoreImported(items)
|
||||||
|
if err := ui.store.Save(); err != nil {
|
||||||
|
dialog.ShowError(err, ui.win)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ui.reload()
|
||||||
|
dialog.ShowInformation(T("import"), fmt.Sprintf(T("import_done"), added, skipped), ui.win)
|
||||||
|
}, ui.win)
|
||||||
|
open.SetFilter(lazyFilter)
|
||||||
|
open.Show()
|
||||||
|
}
|
||||||
|
|
||||||
|
// showEditDialog mengubah shortcut yang sudah ada: binary/URL, ikon, nama, lokasi.
|
||||||
|
func (ui *app) showEditDialog(sc store.Shortcut) {
|
||||||
|
isWeb := sc.Type == store.TypeWebApp
|
||||||
|
|
||||||
|
var iconPath, iconURL, binPath string
|
||||||
|
var iconData []byte
|
||||||
|
|
||||||
|
nameEntry := widget.NewEntry()
|
||||||
|
nameEntry.SetText(sc.Name)
|
||||||
|
iconLabel := muted(iconLabelOf(sc))
|
||||||
|
menu := widget.NewCheck(T("check_menu"), nil)
|
||||||
|
menu.SetChecked(sc.Targets.Menu)
|
||||||
|
desktopChk := widget.NewCheck(T("check_desktop"), nil)
|
||||||
|
desktopChk.SetChecked(sc.Targets.Desktop)
|
||||||
|
terminal := widget.NewCheck(T("check_terminal"), nil)
|
||||||
|
terminal.SetChecked(sc.Terminal)
|
||||||
|
if sc.Kind != shortcut.KindScript {
|
||||||
|
terminal.Hide()
|
||||||
|
}
|
||||||
|
|
||||||
|
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))
|
||||||
|
if shortcut.DetectKind(path) == shortcut.KindScript {
|
||||||
|
terminal.Show()
|
||||||
|
} else {
|
||||||
|
terminal.SetChecked(false)
|
||||||
|
terminal.Hide()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
})
|
||||||
|
category := widget.NewSelect(categories, nil)
|
||||||
|
category.SetSelected(categoryOf(sc))
|
||||||
|
|
||||||
|
// widget khusus web app
|
||||||
|
urlEntry := widget.NewEntry()
|
||||||
|
urlEntry.SetText(sc.URL)
|
||||||
|
browserSel := widget.NewSelect(browserNames(ui.browsers), nil)
|
||||||
|
browserSel.SetSelected(currentBrowser(ui.browsers, sc.Browser))
|
||||||
|
|
||||||
|
iconItem := widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil,
|
||||||
|
container.NewHBox(onlineIcon, browseIcon), iconLabel))
|
||||||
|
|
||||||
|
items := []*widget.FormItem{}
|
||||||
|
if isWeb {
|
||||||
|
items = append(items,
|
||||||
|
widget.NewFormItem(T("field_url"), urlEntry),
|
||||||
|
widget.NewFormItem(T("field_name"), nameEntry),
|
||||||
|
iconItem,
|
||||||
|
widget.NewFormItem(T("field_browser"), browserSel),
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
items = append(items,
|
||||||
|
widget.NewFormItem(T("field_binary"), container.NewBorder(nil, nil, nil, browseBin, binLabel)),
|
||||||
|
widget.NewFormItem(T("field_name"), nameEntry),
|
||||||
|
iconItem,
|
||||||
|
widget.NewFormItem(T("field_category"), category),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
var d *dialog.CustomDialog
|
||||||
|
save := newButton(T("save"), theme.DocumentSaveIcon(), T("tip_save"), widget.HighImportance, func() {
|
||||||
|
switch {
|
||||||
|
case strings.TrimSpace(nameEntry.Text) == "":
|
||||||
|
dialog.ShowInformation(T("err_title"), T("need_name"), ui.win)
|
||||||
|
return
|
||||||
|
case !menu.Checked && !desktopChk.Checked:
|
||||||
|
dialog.ShowInformation(T("err_title"), T("need_target"), ui.win)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.install(func() (store.Shortcut, error) {
|
||||||
|
if isWeb {
|
||||||
|
b := ui.browsers[indexOf(ui.browsers, browserSel.Selected)]
|
||||||
|
return shortcut.EditWebApp(sc, shortcut.WebOptions{
|
||||||
|
URL: urlEntry.Text,
|
||||||
|
Name: nameEntry.Text,
|
||||||
|
Browser: shortcut.BrowserArg{Name: b.Name, Exec: b.Exec, Firefox: b.Kind == browser.Firefox},
|
||||||
|
IconPath: iconPath,
|
||||||
|
IconURL: iconURL,
|
||||||
|
IconData: iconData,
|
||||||
|
Menu: menu.Checked,
|
||||||
|
Desktop: desktopChk.Checked,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
path := binPath
|
||||||
|
if path == "" {
|
||||||
|
path = sc.BinaryPath
|
||||||
|
}
|
||||||
|
return shortcut.EditExecutable(sc, shortcut.ExecOptions{
|
||||||
|
BinaryPath: path,
|
||||||
|
IconPath: iconPath,
|
||||||
|
IconURL: iconURL,
|
||||||
|
IconData: iconData,
|
||||||
|
Name: nameEntry.Text,
|
||||||
|
Category: category.Selected,
|
||||||
|
Terminal: terminal.Checked,
|
||||||
|
Menu: menu.Checked,
|
||||||
|
Desktop: desktopChk.Checked,
|
||||||
|
})
|
||||||
|
}, func(updated store.Shortcut, err error) {
|
||||||
|
if err != nil {
|
||||||
|
dialog.ShowError(err, ui.win)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ui.store.Add(updated)
|
||||||
|
if err := ui.store.Save(); err != nil {
|
||||||
|
dialog.ShowError(err, ui.win)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
d.Hide()
|
||||||
|
ui.reload()
|
||||||
|
if card, ok := ui.cards[updated.Slug]; ok {
|
||||||
|
card.flash(0)
|
||||||
|
}
|
||||||
|
dialog.ShowInformation(T("edit_title"), fmt.Sprintf(T("edit_saved"), updated.Name), ui.win)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
hint := textLine(T("edit_hint"), 12, false, colorNameMuted)
|
||||||
|
content := container.NewVScroll(container.NewVBox(
|
||||||
|
closeRow(func() { d.Hide() }),
|
||||||
|
widget.NewForm(items...),
|
||||||
|
widget.NewSeparator(),
|
||||||
|
sectionTitle(T("section_location")),
|
||||||
|
menu,
|
||||||
|
desktopChk,
|
||||||
|
terminal,
|
||||||
|
hint,
|
||||||
|
container.NewHBox(logoBox(newLogo(), 20), spacer(),
|
||||||
|
newButton(T("cancel"), nil, T("tip_cancel"), widget.MediumImportance, func() { d.Hide() }),
|
||||||
|
save),
|
||||||
|
))
|
||||||
|
|
||||||
|
d = dialog.NewCustomWithoutButtons(T("edit_title"), content, ui.win)
|
||||||
|
d.SetOnClosed(pushHintTarget(hint))
|
||||||
|
showDialog(d, ui.win, 560, 520)
|
||||||
|
}
|
||||||
|
|
||||||
|
// restoreImported memasang ulang setiap entri cadangan ke mesin ini
|
||||||
|
// (binary, ikon, .desktop) lalu menambahkannya ke daftar, tanpa menimpa
|
||||||
|
// shortcut yang sudah ada.
|
||||||
|
func (ui *app) restoreImported(items []db.Backup) (added, skipped int) {
|
||||||
|
for _, backup := range items {
|
||||||
|
item := backup.Item
|
||||||
|
item.Name = strings.TrimSpace(item.Name)
|
||||||
|
if item.Name == "" {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, exists := ui.store.Get(shortcut.Slug(item.Name)); exists {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
restored, err := shortcut.Restore(item, backup.Binary, backup.Icon)
|
||||||
|
if err != nil {
|
||||||
|
skipped++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
ui.store.Add(restored)
|
||||||
|
added++
|
||||||
|
}
|
||||||
|
return added, skipped
|
||||||
|
}
|
||||||
|
|
||||||
|
// iconLabelOf menampilkan ikon yang sedang dipakai shortcut.
|
||||||
|
func iconLabelOf(sc store.Shortcut) string {
|
||||||
|
if sc.IconPath == "" {
|
||||||
|
return T("icon_auto")
|
||||||
|
}
|
||||||
|
return filepath.Base(sc.IconPath)
|
||||||
|
}
|
||||||
|
|
||||||
|
// currentBrowser mengembalikan nama browser yang cocok dengan yang tersimpan.
|
||||||
|
func currentBrowser(list []browser.Browser, saved string) string {
|
||||||
|
for _, b := range list {
|
||||||
|
if strings.Join(b.Exec, " ") == saved {
|
||||||
|
return b.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(list) > 0 {
|
||||||
|
return list[0].Name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// categoryOf mengambil kategori pertama yang tersimpan.
|
||||||
|
func categoryOf(sc store.Shortcut) string {
|
||||||
|
first, _, _ := strings.Cut(strings.TrimSuffix(sc.Categories, ";"), ";")
|
||||||
|
for _, c := range categories {
|
||||||
|
if c == first {
|
||||||
|
return c
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return "Utility"
|
||||||
|
}
|
||||||
@@ -40,23 +40,32 @@ func (ui *app) showExecutableDialog() {
|
|||||||
menu.SetChecked(true)
|
menu.SetChecked(true)
|
||||||
desktop := widget.NewCheck(T("check_desktop"), nil)
|
desktop := widget.NewCheck(T("check_desktop"), nil)
|
||||||
desktop.SetChecked(true)
|
desktop.SetChecked(true)
|
||||||
|
terminal := widget.NewCheck(T("check_terminal"), nil)
|
||||||
|
terminal.Hide() // hanya relevan untuk script
|
||||||
|
|
||||||
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))
|
binLabel.SetText(filepath.Base(path) + " · " + detectedKind(path))
|
||||||
|
// terminal hanya perlu untuk script; tampilkan kalau terdeteksi
|
||||||
|
if shortcut.DetectKind(path) == shortcut.KindScript {
|
||||||
|
terminal.Show()
|
||||||
|
} else {
|
||||||
|
terminal.SetChecked(false)
|
||||||
|
terminal.Hide()
|
||||||
|
}
|
||||||
if strings.TrimSpace(nameEntry.Text) == "" {
|
if strings.TrimSpace(nameEntry.Text) == "" {
|
||||||
nameEntry.SetText(appNameFromFile(path))
|
nameEntry.SetText(appNameFromFile(path))
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
browseIcon := widget.NewButtonWithIcon(T("browse"), theme.FolderOpenIcon(), func() {
|
browseIcon := newButton(T("browse"), theme.FolderOpenIcon(), T("tip_browse"), widget.MediumImportance, func() {
|
||||||
pickFile(ui.win, imageFilter, func(path string) {
|
pickFile(ui.win, imageFilter, func(path string) {
|
||||||
iconPath, iconURL, iconData = path, "", nil
|
iconPath, iconURL, iconData = path, "", nil
|
||||||
iconLabel.SetText(filepath.Base(path))
|
iconLabel.SetText(filepath.Base(path))
|
||||||
})
|
})
|
||||||
})
|
})
|
||||||
onlineIcon := widget.NewButtonWithIcon(T("online"), theme.SearchIcon(), func() {
|
onlineIcon := newButton(T("online"), theme.SearchIcon(), T("tip_online"), widget.MediumImportance, func() {
|
||||||
ui.showIconSearch(nameEntry.Text, func(result icon.Result, data []byte) {
|
ui.showIconSearch(nameEntry.Text, func(result icon.Result, data []byte) {
|
||||||
iconURL, iconPath, iconData = result.URL, "", data
|
iconURL, iconPath, iconData = result.URL, "", data
|
||||||
iconLabel.SetText(result.Name)
|
iconLabel.SetText(result.Name)
|
||||||
@@ -64,7 +73,7 @@ func (ui *app) showExecutableDialog() {
|
|||||||
})
|
})
|
||||||
|
|
||||||
var d *dialog.CustomDialog
|
var d *dialog.CustomDialog
|
||||||
install := widget.NewButtonWithIcon(T("install"), theme.DownloadIcon(), func() {
|
install := newButton(T("install"), theme.DownloadIcon(), T("tip_install"), widget.HighImportance, func() {
|
||||||
switch {
|
switch {
|
||||||
case binPath == "":
|
case binPath == "":
|
||||||
dialog.ShowInformation(T("err_title"), T("need_binary"), ui.win)
|
dialog.ShowInformation(T("err_title"), T("need_binary"), ui.win)
|
||||||
@@ -83,6 +92,7 @@ func (ui *app) showExecutableDialog() {
|
|||||||
IconData: iconData,
|
IconData: iconData,
|
||||||
Name: nameEntry.Text,
|
Name: nameEntry.Text,
|
||||||
Category: category.Selected,
|
Category: category.Selected,
|
||||||
|
Terminal: terminal.Checked,
|
||||||
Menu: menu.Checked,
|
Menu: menu.Checked,
|
||||||
Desktop: desktop.Checked,
|
Desktop: desktop.Checked,
|
||||||
}
|
}
|
||||||
@@ -91,8 +101,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 +108,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 +116,15 @@ func (ui *app) showExecutableDialog() {
|
|||||||
sectionTitle(T("section_location")),
|
sectionTitle(T("section_location")),
|
||||||
menu,
|
menu,
|
||||||
desktop,
|
desktop,
|
||||||
muted(T("exec_hint")),
|
terminal,
|
||||||
|
hint,
|
||||||
container.NewHBox(logoBox(newLogo(), 20), spacer(),
|
container.NewHBox(logoBox(newLogo(), 20), spacer(),
|
||||||
widget.NewButton(T("cancel"), func() { d.Hide() }),
|
newButton(T("cancel"), nil, T("tip_cancel"), widget.MediumImportance, func() { d.Hide() }),
|
||||||
install),
|
install),
|
||||||
))
|
))
|
||||||
|
|
||||||
d = dialog.NewCustomWithoutButtons(T("new_exec_title"), content, ui.win)
|
d = dialog.NewCustomWithoutButtons(T("new_exec_title"), content, ui.win)
|
||||||
|
d.SetOnClosed(pushHintTarget(hint)) // keterangan tombol muncul di baris hint dialog ini
|
||||||
showDialog(d, ui.win, 560, 520)
|
showDialog(d, ui.win, 560, 520)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,6 +147,15 @@ func pickFile(win fyne.Window, filter storage.FileFilter, onPick func(path strin
|
|||||||
fd.Show()
|
fd.Show()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// detectedKind menampilkan hasil auto-detect jenis berkas di dialog.
|
||||||
|
func detectedKind(path string) string {
|
||||||
|
kind := shortcut.DetectKind(path)
|
||||||
|
if kind == "" {
|
||||||
|
return T("kind_unknown")
|
||||||
|
}
|
||||||
|
return kind
|
||||||
|
}
|
||||||
|
|
||||||
func appNameFromFile(path string) string {
|
func appNameFromFile(path string) string {
|
||||||
base := filepath.Base(path)
|
base := filepath.Base(path)
|
||||||
for _, ext := range stripExts {
|
for _, ext := range stripExts {
|
||||||
|
|||||||
@@ -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{}{}
|
||||||
|
|||||||
@@ -10,7 +10,9 @@ import (
|
|||||||
|
|
||||||
"fyne.io/fyne/v2/test"
|
"fyne.io/fyne/v2/test"
|
||||||
|
|
||||||
|
"lazyrun/internal/db"
|
||||||
"lazyrun/internal/icon"
|
"lazyrun/internal/icon"
|
||||||
|
"lazyrun/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestNoEmptyDismissButton memastikan modal tidak memakai dialog.NewCustom,
|
// TestNoEmptyDismissButton memastikan modal tidak memakai dialog.NewCustom,
|
||||||
@@ -62,6 +64,17 @@ func TestDialogsOpenWithoutPanic(t *testing.T) {
|
|||||||
t.Fatal("modal web app tidak muncul")
|
t.Fatal("modal web app tidak muncul")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
ui.showEditDialog(store.Shortcut{Slug: "winbox", Name: "Winbox", Type: store.TypeExecutable, Kind: "windows",
|
||||||
|
BinaryPath: "/tmp/winbox", IconPath: "", Categories: "Network;", Targets: store.Targets{Menu: true, Desktop: true}})
|
||||||
|
if top := ui.win.Canvas().Overlays().Top(); top == nil {
|
||||||
|
t.Fatal("modal edit executable tidak muncul")
|
||||||
|
}
|
||||||
|
ui.showEditDialog(store.Shortcut{Slug: "wa", Name: "WhatsApp Web", Type: store.TypeWebApp, Kind: "pwa",
|
||||||
|
URL: "https://web.whatsapp.com", Browser: "/usr/bin/vivaldi", Targets: store.Targets{Menu: true}})
|
||||||
|
if top := ui.win.Canvas().Overlays().Top(); top == nil {
|
||||||
|
t.Fatal("modal edit webapp tidak muncul")
|
||||||
|
}
|
||||||
|
|
||||||
ui.showIconSearch("whatsapp", func(icon.Result, []byte) {})
|
ui.showIconSearch("whatsapp", func(icon.Result, []byte) {})
|
||||||
if top := ui.win.Canvas().Overlays().Top(); top == nil {
|
if top := ui.win.Canvas().Overlays().Top(); top == nil {
|
||||||
t.Fatal("modal pencarian ikon tidak muncul")
|
t.Fatal("modal pencarian ikon tidak muncul")
|
||||||
@@ -108,3 +121,56 @@ func TestStringsCoverUsedKeys(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestRestoreImported memasang ulang entri cadangan: berkas dibuat di HOME saat ini
|
||||||
|
// dan entri yang sudah ada tidak ditimpa.
|
||||||
|
func TestRestoreImported(t *testing.T) {
|
||||||
|
home := t.TempDir()
|
||||||
|
t.Setenv("HOME", home)
|
||||||
|
t.Setenv("XDG_DATA_HOME", "")
|
||||||
|
t.Setenv("PATH", "")
|
||||||
|
|
||||||
|
script := []byte("#!/bin/sh\necho halo\n")
|
||||||
|
items := []db.Backup{
|
||||||
|
{Item: store.Shortcut{Slug: "lama", Name: "Deploy", Type: store.TypeExecutable, Kind: "script",
|
||||||
|
BinaryPath: "/komputer-lama/.local/bin/deploy", Targets: store.Targets{Menu: true}}, Binary: script},
|
||||||
|
{Item: store.Shortcut{Name: "Figma", Type: store.TypeWebApp, Kind: "pwa",
|
||||||
|
URL: "https://figma.com", Browser: "/usr/bin/vivaldi", Targets: store.Targets{Menu: true}}},
|
||||||
|
}
|
||||||
|
|
||||||
|
a := test.NewApp()
|
||||||
|
defer a.Quit()
|
||||||
|
ui, err := build(a, "test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
ui.store.Items = nil
|
||||||
|
ui.store.Add(store.Shortcut{Slug: "figma", Name: "Figma"}) // sudah ada -> dilewati
|
||||||
|
|
||||||
|
added, skipped := ui.restoreImported(items)
|
||||||
|
if added != 1 || skipped != 1 {
|
||||||
|
t.Fatalf("added=%d skipped=%d, mau 1 dan 1", added, skipped)
|
||||||
|
}
|
||||||
|
|
||||||
|
deploy, ok := ui.store.Get("deploy")
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("shortcut hasil restore tidak masuk daftar")
|
||||||
|
}
|
||||||
|
sc := ui.store.Items[deploy]
|
||||||
|
want := filepath.Join(home, ".local", "bin", "deploy")
|
||||||
|
if sc.BinaryPath != want {
|
||||||
|
t.Errorf("binary = %s, mau %s", sc.BinaryPath, want)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(want + ".real"); err != nil {
|
||||||
|
t.Errorf("script asli (.real) tidak ditulis: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := os.Stat(sc.DesktopPath); err != nil {
|
||||||
|
t.Errorf(".desktop tidak dibuat: %v", err)
|
||||||
|
}
|
||||||
|
if sc.IconPath == "" {
|
||||||
|
t.Error("ikon tidak dipasang")
|
||||||
|
}
|
||||||
|
if sc.Kind != "script" {
|
||||||
|
t.Errorf("kind hasil restore = %q", sc.Kind)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -24,20 +24,24 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
type app struct {
|
type app struct {
|
||||||
win fyne.Window
|
win fyne.Window
|
||||||
store *store.Store
|
store *store.Store
|
||||||
browsers []browser.Browser
|
browsers []browser.Browser
|
||||||
rows *fyne.Container
|
rows *fyne.Container
|
||||||
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
|
||||||
version string
|
versionText *canvas.Text // versi di footer; berubah kalau ada update
|
||||||
|
hint *canvas.Text // keterangan tombol yang sedang disorot
|
||||||
|
accent *canvas.LinearGradient
|
||||||
|
version string
|
||||||
}
|
}
|
||||||
|
|
||||||
// Run menampilkan window utama LazyRun sampai user menutupnya.
|
// Run menampilkan window utama LazyRun sampai user menutupnya.
|
||||||
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,
|
||||||
@@ -129,7 +136,8 @@ func (ui *app) row(sc store.Shortcut) *hoverCard {
|
|||||||
caption(metaText(sc)),
|
caption(metaText(sc)),
|
||||||
)
|
)
|
||||||
|
|
||||||
chips := make([]fyne.CanvasObject, 0, 3)
|
chips := make([]fyne.CanvasObject, 0, 4)
|
||||||
|
chips = append(chips, chip(kindLabel(sc), colorNameChipKind))
|
||||||
if sc.Targets.Menu {
|
if sc.Targets.Menu {
|
||||||
chips = append(chips, chip(T("chip_menu"), colorNameChipMenu))
|
chips = append(chips, chip(T("chip_menu"), colorNameChipMenu))
|
||||||
}
|
}
|
||||||
@@ -141,34 +149,61 @@ 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),
|
||||||
actions))
|
actions))
|
||||||
}
|
}
|
||||||
|
|
||||||
// metaText menampilkan tipe shortcut dan penanda singkatnya.
|
// kindLabel adalah label jenis aplikasi hasil auto-detect.
|
||||||
|
// Istilahnya teknis (nama teknologi), jadi tidak diterjemahkan.
|
||||||
|
func kindLabel(sc store.Shortcut) string {
|
||||||
|
switch sc.Kind {
|
||||||
|
case shortcut.KindGo:
|
||||||
|
return "Go"
|
||||||
|
case shortcut.KindAppImage:
|
||||||
|
return "AppImage"
|
||||||
|
case shortcut.KindElectron:
|
||||||
|
return "Electron"
|
||||||
|
case shortcut.KindScript:
|
||||||
|
return "Script"
|
||||||
|
case shortcut.KindWindows:
|
||||||
|
return "Windows"
|
||||||
|
case shortcut.KindBinary:
|
||||||
|
return "Binary"
|
||||||
|
case shortcut.KindPWA:
|
||||||
|
return "PWA"
|
||||||
|
}
|
||||||
|
// shortcut lama (sebelum ada auto-detect)
|
||||||
|
if sc.Type == store.TypeWebApp {
|
||||||
|
return "PWA"
|
||||||
|
}
|
||||||
|
return "Executable"
|
||||||
|
}
|
||||||
|
|
||||||
|
// metaText menampilkan penanda singkat: nama berkas binary atau host web app.
|
||||||
func metaText(sc store.Shortcut) string {
|
func metaText(sc store.Shortcut) string {
|
||||||
if sc.Type == store.TypeWebApp {
|
if sc.Type == store.TypeWebApp {
|
||||||
if host := hostOf(sc.URL); host != "" {
|
if host := hostOf(sc.URL); host != "" {
|
||||||
return "Web · " + host
|
return host
|
||||||
}
|
}
|
||||||
return "Web"
|
return sc.URL
|
||||||
}
|
}
|
||||||
if sc.BinaryPath != "" {
|
if sc.BinaryPath != "" {
|
||||||
return "Executable · " + filepath.Base(sc.BinaryPath)
|
return filepath.Base(sc.BinaryPath)
|
||||||
}
|
}
|
||||||
return "Executable"
|
return sc.Name
|
||||||
}
|
}
|
||||||
|
|
||||||
func fallbackIcon(sc store.Shortcut) fyne.Resource {
|
func fallbackIcon(sc store.Shortcut) fyne.Resource {
|
||||||
@@ -209,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
|
||||||
@@ -262,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)
|
||||||
})
|
})
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ import (
|
|||||||
|
|
||||||
"fyne.io/fyne/v2/test"
|
"fyne.io/fyne/v2/test"
|
||||||
|
|
||||||
|
"lazyrun/internal/shortcut"
|
||||||
"lazyrun/internal/store"
|
"lazyrun/internal/store"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -28,11 +29,37 @@ func TestBuildMainWindow(t *testing.T) {
|
|||||||
|
|
||||||
func TestMetaText(t *testing.T) {
|
func TestMetaText(t *testing.T) {
|
||||||
sc := store.Shortcut{Type: store.TypeWebApp, URL: "https://example.com/x", Targets: store.Targets{Menu: true}}
|
sc := store.Shortcut{Type: store.TypeWebApp, URL: "https://example.com/x", Targets: store.Targets{Menu: true}}
|
||||||
if got := metaText(sc); got != "Web · example.com" {
|
if got := metaText(sc); got != "example.com" {
|
||||||
t.Errorf("metaText() = %q", got)
|
t.Errorf("metaText() = %q", got)
|
||||||
}
|
}
|
||||||
exe := store.Shortcut{Type: store.TypeExecutable, BinaryPath: "/home/u/.local/bin/winbox"}
|
exe := store.Shortcut{Type: store.TypeExecutable, BinaryPath: "/home/u/.local/bin/winbox"}
|
||||||
if got := metaText(exe); got != "Executable · winbox" {
|
if got := metaText(exe); got != "winbox" {
|
||||||
t.Errorf("metaText() = %q", got)
|
t.Errorf("metaText() = %q", got)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestKindLabel(t *testing.T) {
|
||||||
|
cases := map[string]string{
|
||||||
|
shortcut.KindGo: "Go",
|
||||||
|
shortcut.KindAppImage: "AppImage",
|
||||||
|
shortcut.KindElectron: "Electron",
|
||||||
|
shortcut.KindScript: "Script",
|
||||||
|
shortcut.KindWindows: "Windows",
|
||||||
|
shortcut.KindBinary: "Binary",
|
||||||
|
shortcut.KindPWA: "PWA",
|
||||||
|
}
|
||||||
|
for kind, want := range cases {
|
||||||
|
sc := store.Shortcut{Kind: kind, Type: store.TypeExecutable}
|
||||||
|
if got := kindLabel(sc); got != want {
|
||||||
|
t.Errorf("kindLabel(%q) = %q, mau %q", kind, got, want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// data lama tanpa kind
|
||||||
|
if got := kindLabel(store.Shortcut{Type: store.TypeExecutable}); got != "Executable" {
|
||||||
|
t.Errorf("kindLabel(lama, executable) = %q", got)
|
||||||
|
}
|
||||||
|
if got := kindLabel(store.Shortcut{Type: store.TypeWebApp}); got != "PWA" {
|
||||||
|
t.Errorf("kindLabel(lama, webapp) = %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,18 +18,21 @@ var tr = map[string][2]string{
|
|||||||
"chip_desktop": {"Desktop", "Desktop"},
|
"chip_desktop": {"Desktop", "Desktop"},
|
||||||
"chip_broken": {"file missing", "file hilang"},
|
"chip_broken": {"file missing", "file hilang"},
|
||||||
"none": {"not selected", "belum dipilih"},
|
"none": {"not selected", "belum dipilih"},
|
||||||
|
"kind_unknown": {"unknown type", "jenis tidak dikenal"},
|
||||||
"icon_auto": {"automatic", "otomatis"},
|
"icon_auto": {"automatic", "otomatis"},
|
||||||
"online": {"Online", "Online"},
|
"online": {"Online", "Online"},
|
||||||
"search": {"Search", "Cari"},
|
"search": {"Search", "Cari"},
|
||||||
"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..."},
|
||||||
@@ -46,6 +49,7 @@ var tr = map[string][2]string{
|
|||||||
"section_location": {"Shortcut location", "Lokasi shortcut"},
|
"section_location": {"Shortcut location", "Lokasi shortcut"},
|
||||||
"check_menu": {"Application menu", "Menu aplikasi"},
|
"check_menu": {"Application menu", "Menu aplikasi"},
|
||||||
"check_desktop": {"Desktop", "Desktop"},
|
"check_desktop": {"Desktop", "Desktop"},
|
||||||
|
"check_terminal": {"Run in a terminal window (scripts)", "Jalankan di jendela terminal (script)"},
|
||||||
"exec_hint": {"The binary is copied to ~/.local/bin and can be uninstalled from the list.", "Binary disalin ke ~/.local/bin dan bisa dihapus lagi dari daftar."},
|
"exec_hint": {"The binary is copied to ~/.local/bin and can be uninstalled from the list.", "Binary disalin ke ~/.local/bin dan bisa dihapus lagi dari daftar."},
|
||||||
"web_hint": {"The icon comes from the site's favicon, or is looked up online if there is none.", "Ikon diambil dari favicon situs, atau dicari otomatis di internet kalau tidak ada."},
|
"web_hint": {"The icon comes from the site's favicon, or is looked up online if there is none.", "Ikon diambil dari favicon situs, atau dicari otomatis di internet kalau tidak ada."},
|
||||||
|
|
||||||
@@ -77,6 +81,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()
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -21,6 +22,7 @@ const (
|
|||||||
colorNameChipMenu fyne.ThemeColorName = "lazyrunChipMenu"
|
colorNameChipMenu fyne.ThemeColorName = "lazyrunChipMenu"
|
||||||
colorNameChipDesk fyne.ThemeColorName = "lazyrunChipDesktop"
|
colorNameChipDesk fyne.ThemeColorName = "lazyrunChipDesktop"
|
||||||
colorNameChipBroken fyne.ThemeColorName = "lazyrunChipBroken"
|
colorNameChipBroken fyne.ThemeColorName = "lazyrunChipBroken"
|
||||||
|
colorNameChipKind fyne.ThemeColorName = "lazyrunChipKind"
|
||||||
)
|
)
|
||||||
|
|
||||||
// brand menimpa sebagian warna/ukuran tema bawaan; terang/gelap tetap ikut sistem.
|
// brand menimpa sebagian warna/ukuran tema bawaan; terang/gelap tetap ikut sistem.
|
||||||
@@ -30,29 +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:
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||