LazyRun v1.0.0: shortcut maker untuk executable dan PWA

- Fungsi A: shortcut dari file executable (ELF/AppImage/script)
- Fungsi B: shortcut PWA/URL dengan deteksi browser (native, /opt, Flatpak)
- Checkbox Menu Aplikasi / Desktop, desktop-only pakai NoDisplay=true
- List + uninstall + deteksi shortcut yang file .desktop-nya hilang
- Metadata ~/.config/lazyrun/shortcuts.json, refresh cache XDG/KDE
- AppImage + zsync update info via build.sh
This commit is contained in:
oxpoe committed 2026-10-13 08:00:12 +07:00
1 parent 9cd5a1f82d
commit da31f7d7d7
31 files changed
+2916

No files matched your search

+1
View File
@@ -0,0 +1 @@
dist/
+101
View File
@@ -0,0 +1,101 @@
# LazyRun — lazy shortcut build
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
AppImage, no terminal needed.
- **Executable shortcut** — picks an ELF/AppImage/script, copies it to
`~/.local/bin/`, installs the icon, writes the `.desktop` entry.
- **Web app shortcut** — opens the URL as an app: `--app=<url>` for
Chromium-based browsers (Chrome, Chromium, Brave, Edge, Vivaldi, Helium,
including Flatpak builds), `--new-window` for Firefox.
- **Shortcut location** — checkbox for *Application menu* and/or *Desktop*,
at least one is required. Desktop-only entries are written with
`NoDisplay=true` so they do not show up in the menu.
- **Manage** — list installed shortcuts, see where they live, mark entries whose
`.desktop` file was deleted manually, uninstall (optionally removing the
copied binary).
- **Auto refresh** — calls `update-desktop-database` and
`kbuildsycoca6`/`kbuildsycoca5` after install/uninstall.
- **i18n** — UI in English or Indonesian, chosen from `$LANG`.
- **Self update** — AppImage with embedded zsync update information, optional
`AppImageUpdate` integration via *Check for Updates*.
## Install
Download the AppImage from [Releases](../../releases), then:
```bash
chmod +x lazyrun-*-x86_64.AppImage
./lazyrun-*-x86_64.AppImage
```
Keep it wherever you like (for example `~/.local/bin/`). Automatic updates need
[AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) in `$PATH`.
## Usage
1. **+ Executable** or **+ Web App**.
2. Fill in the form (name and icon are pre-filled/auto-detected).
3. Pick *Application menu* and/or *Desktop*, then **Install**.
## What it writes
| Purpose | Path |
|---|---|
| Copied binary | `~/.local/bin/<name>` |
| Icon | `~/.local/share/icons/hicolor/512x512/apps/<name>.png` |
| Menu entry (source of truth) | `~/.local/share/applications/<name>.desktop` |
| Desktop copy | `~/Desktop/<name>.desktop` (from `xdg-user-dir DESKTOP`) |
| LazyRun metadata | `~/.config/lazyrun/shortcuts.json` |
Everything stays inside the home directory — no root, no system directories.
Filenames use a slug of the app name (`WhatsApp Web` → `whatsapp-web.desktop`);
the `Name=` field keeps the name you typed.
## Build
Requirements: Go 1.24+, cgo, and X11/OpenGL development headers (Fyne).
```bash
./build.sh # dist/lazyrun + dist/lazyrun-<version>-<arch>.AppImage
SKIP_APPIMAGE=1 ./build.sh # binary only
go test ./... # unit + install/uninstall tests
```
`build.sh` downloads `linuxdeploy` into `dist/tools/` on first run and needs
network for the AppImage step. To embed zsync update information, set
`GH_USER` and `GH_REPO`:
```bash
GH_USER=oxpoe GH_REPO=lazyrun ./build.sh
```
## Roadmap
- v1.1 — edit existing shortcuts, detect binaries in `$PATH`, optional symlink
instead of a Desktop copy, import/export, dark mode.
- v1.2 — Firefox PWA via `--kiosk`, Flatpak/Snap targets, icon preview.
## Disclaimer / Catatan
**EN** — LazyRun is a small hobby project by **oxpoe**, made for fun and to make
daily Linux desktop life a bit simpler: turn a portable binary or a web page
into a proper application shortcut without opening a terminal. It only writes
inside your home directory, but it does copy files and create launchers: check
what you point it at, keep backups of anything irreplaceable, and use it at your
own risk. Provided "as is", without warranty of any kind. LazyRun is not
affiliated with any browser vendor or application it creates shortcuts for.
**ID** — LazyRun adalah proyek iseng **oxpoe**, dibuat untuk senang-senang dan
supaya urusan shortcut di desktop Linux jadi lebih simpel: binary portable atau
halaman web bisa jadi shortcut aplikasi tanpa buka terminal. Aplikasi ini hanya
menulis di dalam home directory, tetapi tetap menyalin file dan membuat
launcher: periksa dulu apa yang kamu pilih, backup data penting, dan gunakan
dengan risiko sendiri. Disediakan "apa adanya", tanpa jaminan apa pun. LazyRun
tidak berafiliasi dengan vendor browser atau aplikasi apa pun yang dibuatkan
shortcut-nya.
## License
MIT.
+7
View File
@@ -0,0 +1,7 @@
// Package assets menyimpan aset yang di-embed ke dalam binary.
package assets
import _ "embed"
//go:embed icon.png
var Icon []byte
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 27 KiB

+11
View File
@@ -0,0 +1,11 @@
[Desktop Entry]
Type=Application
Name=LazyRun
GenericName=Shortcut Maker
Comment=lazy shortcut build
Exec=lazyrun
Icon=lazyrun
Terminal=false
Categories=Utility;
Keywords=shortcut;desktop;pwa;appimage;
StartupNotify=false
Executable
+57
View File
@@ -0,0 +1,57 @@
#!/usr/bin/env bash
# Build binary LazyRun, lalu bungkus jadi AppImage + zsync update info.
set -euo pipefail
cd "$(dirname "$0")"
VERSION="${VERSION:-1.0.0}"
OUT="${OUT:-dist}"
ARCH="$(uname -m)"
TOOLS="$OUT/tools"
mkdir -p "$OUT" "$TOOLS"
echo "==> build binary"
CGO_ENABLED=1 go build -trimpath -ldflags "-s -w -X main.version=$VERSION" -o "$OUT/lazyrun" ./cmd/lazyrun
if [[ "${SKIP_APPIMAGE:-0}" == "1" ]]; then
echo "==> SKIP_APPIMAGE=1, selesai"
exit 0
fi
echo "==> siapkan AppDir"
APPDIR="$OUT/LazyRun.AppDir"
rm -rf "$APPDIR"
mkdir -p "$APPDIR/usr/bin"
cp "$OUT/lazyrun" "$APPDIR/usr/bin/lazyrun"
cp assets/lazyrun.desktop "$APPDIR/lazyrun.desktop"
cp assets/icon.png "$APPDIR/lazyrun.png"
ln -sf usr/bin/lazyrun "$APPDIR/AppRun"
if [[ ! -x "$TOOLS/linuxdeploy" ]]; then
echo "==> download linuxdeploy"
curl -fL --retry 3 -o "$TOOLS/linuxdeploy" \
"https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-$ARCH.AppImage"
chmod +x "$TOOLS/linuxdeploy"
fi
# linuxdeploy butuh info ini untuk menamai output dan menyisipkan zsync update info.
export ARCH
export LINUXDEPLOY_OUTPUT_APP_NAME=lazyrun
export LINUXDEPLOY_OUTPUT_VERSION="$VERSION"
export LDAI_OUTPUT="$PWD/$OUT/lazyrun-$VERSION-$ARCH.AppImage"
if [[ -n "${GH_USER:-}" && -n "${GH_REPO:-}" ]]; then
export LDAI_UPDATE_INFORMATION="gh-releases-zsync|$GH_USER|$GH_REPO|latest|lazyrun-*.AppImage.zsync"
else
echo "==> catatan: GH_USER/GH_REPO kosong, AppImage tanpa update info zsync"
export LDAI_NO_APPSTREAM=1
fi
echo "==> bundle + package AppImage"
"$TOOLS/linuxdeploy" --appimage-extract-and-run \
--appdir "$APPDIR" \
--desktop-file "$APPDIR/lazyrun.desktop" \
--icon-file "$APPDIR/lazyrun.png" \
--output appimage
ls -lh "$OUT"/lazyrun-*.AppImage* 2>/dev/null || true
mv -f "lazyrun-$VERSION-$ARCH.AppImage.zsync" "$OUT/" 2>/dev/null || true
+14
View File
@@ -0,0 +1,14 @@
package main
import (
"fyne.io/fyne/v2/app"
"lazyrun/internal/ui"
)
// version diisi saat build: -ldflags "-X main.version=1.0.0".
var version = "dev"
func main() {
ui.Run(app.NewWithID("io.github.lazyrun.LazyRun"), version)
}
+42
View File
@@ -0,0 +1,42 @@
module lazyrun
go 1.24
require fyne.io/fyne/v2 v2.8.2
require (
fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4 // indirect
github.com/BurntSushi/toml v1.6.0 // indirect
github.com/FyshOS/fancyfs v0.0.1 // indirect
github.com/anthonynsimon/bild v0.14.0 // indirect
github.com/clipperhouse/uax29/v2 v2.2.0 // indirect
github.com/davecgh/go-spew v1.1.1 // indirect
github.com/fsnotify/fsnotify v1.9.0 // indirect
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 // indirect
github.com/fyne-io/glfw-js v0.4.0 // indirect
github.com/fyne-io/image v0.1.1 // indirect
github.com/fyne-io/oksvg v0.2.1-0.20260918172519-a9af55fa95d9 // indirect
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 // indirect
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.2 // indirect
github.com/go-text/render v0.2.1 // indirect
github.com/go-text/typesetting v0.3.5 // indirect
github.com/godbus/dbus/v5 v5.2.2 // indirect
github.com/hack-pad/go-indexeddb v0.3.2 // indirect
github.com/hack-pad/safejs v0.1.0 // indirect
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade // indirect
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 // indirect
github.com/mattn/go-runewidth v0.0.24 // indirect
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 // indirect
github.com/nicksnyder/go-i18n/v2 v2.5.1 // indirect
github.com/pmezard/go-difflib v1.0.0 // indirect
github.com/rymdport/portal v0.4.2 // indirect
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c // indirect
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef // indirect
github.com/stretchr/testify v1.11.1 // indirect
github.com/yuin/goldmark v1.8.2 // indirect
golang.org/x/image v0.24.0 // indirect
golang.org/x/net v0.35.0 // indirect
golang.org/x/sys v0.30.0 // indirect
golang.org/x/text v0.22.0 // indirect
gopkg.in/yaml.v3 v3.0.1 // indirect
)
+79
View File
@@ -0,0 +1,79 @@
fyne.io/fyne/v2 v2.8.2 h1:gYNJj+ZEPd2wF3M8SlF5iL6febpZkZ1csheRFkuEfHU=
fyne.io/fyne/v2 v2.8.2/go.mod h1:eXHJ/D9Y58UcDw7SkguadCRI64M4flXyjoMn827Q5SI=
fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4 h1:149/+Wa5EsLLXfyj2pdTmvnQf2VIlgCIwSjcCTHYhIo=
fyne.io/systray v1.12.3-0.20260810170012-af4e8e793ec4/go.mod h1:RVwqP9nYMo7h5zViCBHri2FgjXF7H2cub7MAq4NSoLs=
github.com/BurntSushi/toml v1.6.0 h1:dRaEfpa2VI55EwlIW72hMRHdWouJeRF7TPYhI+AUQjk=
github.com/BurntSushi/toml v1.6.0/go.mod h1:ukJfTF/6rtPPRCnwkur4qwRxa8vTRFBF0uk2lLoLwho=
github.com/FyshOS/fancyfs v0.0.1 h1:kgvm7VvwOMLkYTqSflplp62SlMVWQ2uAoHw9CXwXHYg=
github.com/FyshOS/fancyfs v0.0.1/go.mod h1:S5SHVz/5R72iCXOxCqdcyTPSlg3JxNd0gaHyGBSrY8A=
github.com/anthonynsimon/bild v0.14.0 h1:IFRkmKdNdqmexXHfEU7rPlAmdUZ8BDZEGtGHDnGWync=
github.com/anthonynsimon/bild v0.14.0/go.mod h1:hcvEAyBjTW69qkKJTfpcDQ83sSZHxwOunsseDfeQhUs=
github.com/clipperhouse/uax29/v2 v2.2.0 h1:ChwIKnQN3kcZteTXMgb1wztSgaU+ZemkgWdohwgs8tY=
github.com/clipperhouse/uax29/v2 v2.2.0/go.mod h1:EFJ2TJMRUaplDxHKj1qAEhCtQPW2tJSwu5BF98AuoVM=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0=
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8 h1:0kdPD/GEntpWmZEK5Zu/xE6Tr37jYCVDf9QP8lA/QK8=
github.com/fyne-io/gl-js v0.2.1-0.20260315212741-029c47fd27e8/go.mod h1:ZcepK8vmOYLu96JoxbCKJy2ybr+g1pTnaBDdl7c3ajI=
github.com/fyne-io/glfw-js v0.4.0 h1:I9hREBeFyI10cNIqbMKYb1PRidyPDgwob8o2la9SfQo=
github.com/fyne-io/glfw-js v0.4.0/go.mod h1:SDchsFZh4n7nVuBoiowOhOgIBdz+qUQVeC1w9fe2yVU=
github.com/fyne-io/image v0.1.1 h1:WH0z4H7qfvNUw5l4p3bC1q70sa5+YWVt6HCj7y4VNyA=
github.com/fyne-io/image v0.1.1/go.mod h1:xrfYBh6yspc+KjkgdZU/ifUC9sPA5Iv7WYUBzQKK7JM=
github.com/fyne-io/oksvg v0.2.1-0.20260918172519-a9af55fa95d9 h1:owsdpq6a2KhaAA/hoHGQmFM7kOgKo3srXZVGnbEUzr0=
github.com/fyne-io/oksvg v0.2.1-0.20260918172519-a9af55fa95d9/go.mod h1:dJ9oEkPiWhnTFNCmRgEze+YNprJF7YRbpjgpWS4kzoI=
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276 h1:IO5P06Pcj9K04d+l4nrf3c2U56+dAotIFG6u4P1wAHI=
github.com/go-gl/gl v0.0.0-20260331235117-4566fea9a276/go.mod h1:9YTyiznxEY1fVinfM7RvRcjRHbw2xLBJ3AAGIT0I4Nw=
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.2 h1:ez76TQInhkDIMobWbOFcPJw6FeB7coXRAVZpKwF8NBg=
github.com/go-gl/glfw/v3.4/glfw v0.1.0-pre.2/go.mod h1:T5Dn0JwIJOX1euPZ/iT4tq6nFYtmukjcYa7937HuYK8=
github.com/go-text/render v0.2.1 h1:qwHhxqGUjjg4L0XyJWj7M7bpY75NZM+kBpv2Yfw5mcg=
github.com/go-text/render v0.2.1/go.mod h1:HCCAq8MUlm/WRcXshBb4K/n+IkjeXQ1c2Ba+yICSm0A=
github.com/go-text/typesetting v0.3.5 h1:XZPUooClHY0Vf/rFyUyuPRNEkawARaFzLMQcXLSEyPk=
github.com/go-text/typesetting v0.3.5/go.mod h1:XZO1hD+nQVyvVa5IicQk7FsCa4PFQaJ2soWAP1f//68=
github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc h1:8FGo2It5K75XkavhTiCKExUfVaVDS1feBnLCru5qeoY=
github.com/go-text/typesetting-utils v0.0.0-20260419141703-4ffe8874dabc/go.mod h1:3/62I4La/HBRX9TcTpBj4eipLiwzf+vhI+7whTc9V7o=
github.com/godbus/dbus/v5 v5.2.2 h1:TUR3TgtSVDmjiXOgAAyaZbYmIeP3DPkld3jgKGV8mXQ=
github.com/godbus/dbus/v5 v5.2.2/go.mod h1:3AAv2+hPq5rdnr5txxxRwiGjPXamgoIHgz9FPBfOp3c=
github.com/hack-pad/go-indexeddb v0.3.2 h1:DTqeJJYc1usa45Q5r52t01KhvlSN02+Oq+tQbSBI91A=
github.com/hack-pad/go-indexeddb v0.3.2/go.mod h1:QvfTevpDVlkfomY498LhstjwbPW6QC4VC/lxYb0Kom0=
github.com/hack-pad/safejs v0.1.0 h1:qPS6vjreAqh2amUqj4WNG1zIw7qlRQJ9K10eDKMCnE8=
github.com/hack-pad/safejs v0.1.0/go.mod h1:HdS+bKF1NrE72VoXZeWzxFOVQVUSqZJAG0xNCnb+Tio=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade h1:FmusiCI1wHw+XQbvL9M+1r/C3SPqKrmBaIOYwVfQoDE=
github.com/jeandeaual/go-locale v0.0.0-20250612000132-0ef82f21eade/go.mod h1:ZDXo8KHryOWSIqnsb/CiDq7hQUYryCgdVnxbj8tDG7o=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25 h1:YLvr1eE6cdCqjOe972w/cYF+FjW34v27+9Vo5106B4M=
github.com/jsummers/gobmp v0.0.0-20230614200233-a9de23ed2e25/go.mod h1:kLgvv7o6UM+0QSf0QjAse3wReFDsb9qbZJdfexWlrQw=
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU=
github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646 h1:zYyBkD/k9seD2A7fsi6Oo2LfFZAehjjQMERAvZLEDnQ=
github.com/nfnt/resize v0.0.0-20180221191011-83c6a9932646/go.mod h1:jpp1/29i3P1S/RLdc7JQKbRpFeM1dOBd8T9ki5s+AY8=
github.com/nicksnyder/go-i18n/v2 v2.5.1 h1:IxtPxYsR9Gp60cGXjfuR/llTqV8aYMsC472zD0D1vHk=
github.com/nicksnyder/go-i18n/v2 v2.5.1/go.mod h1:DrhgsSDZxoAfvVrBVLXoxZn/pN5TXqaDbq7ju94viiQ=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e h1:fD57ERR4JtEqsWbfPhv4DMiApHyliiK5xCTNVSPiaAs=
github.com/niemeyer/pretty v0.0.0-20200227124842-a10e7caefd8e/go.mod h1:zD1mROLANZcx1PVRCS0qkT7pwLkGfwJo4zjcN/Tysno=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/rymdport/portal v0.4.2 h1:7jKRSemwlTyVHHrTGgQg7gmNPJs88xkbKcIL3NlcmSU=
github.com/rymdport/portal v0.4.2/go.mod h1:kFF4jslnJ8pD5uCi17brj/ODlfIidOxlgUDTO5ncnC4=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c h1:km8GpoQut05eY3GiYWEedbTT0qnSxrCjsVbb7yKY1KE=
github.com/srwiley/oksvg v0.0.0-20221011165216-be6e8873101c/go.mod h1:cNQ3dwVJtS5Hmnjxy6AgTPd0Inb3pW05ftPSX7NZO7Q=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef h1:Ch6Q+AZUxDBCVqdkI8FSpFyZDtCVBc2VmejdNrm5rRQ=
github.com/srwiley/rasterx v0.0.0-20220730225603-2ab79fcdd4ef/go.mod h1:nXTWP6+gD5+LUJ8krVhhoeHjvHTutPxMYl5SvkcnJNE=
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
github.com/yuin/goldmark v1.8.2 h1:kEGpgqJXdgbkhcOgBxkC0X0PmoPG1ZyoZ117rDVp4zE=
github.com/yuin/goldmark v1.8.2/go.mod h1:ip/1k0VRfGynBgxOz0yCqHrbZXhcjxyuS66Brc7iBKg=
golang.org/x/image v0.24.0 h1:AN7zRgVsbvmTfNyqIbbOraYL8mSwcKncEj8ofjgzcMQ=
golang.org/x/image v0.24.0/go.mod h1:4b/ITuLfqYq1hqZcjofwctIhi7sZh2WaCjvsBNjjya8=
golang.org/x/net v0.35.0 h1:T5GQRQb2y08kTAByq9L4/bz8cipCdA8FbRTXewonqY8=
golang.org/x/net v0.35.0/go.mod h1:EglIi67kWsHKlRzzVMUD93VMSWGFOMSZgxFjparz1Qk=
golang.org/x/sys v0.30.0 h1:QjkSwP/36a20jFYWkSue1YwXzLmsV5Gfq7Eiy72C1uc=
golang.org/x/sys v0.30.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA=
golang.org/x/text v0.22.0 h1:bofq7m3/HAFvbF51jz3Q9wLg3jkvSPuiZu/pD1XwgtM=
golang.org/x/text v0.22.0/go.mod h1:YRoo4H8PVmsu+E3Ou7cqLVH8oXWIHVoX0jqUWALQhfY=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f h1:BLraFXnmrev5lT+xlilqcH8XK9/i0At2xKjWk4p6zsU=
gopkg.in/check.v1 v1.0.0-20200227125254-8fa46927fb4f/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
+115
View File
@@ -0,0 +1,115 @@
// Package browser mendeteksi browser yang terinstall untuk dipakai shortcut PWA.
package browser
import (
"os"
"os/exec"
"strings"
)
// Kind menentukan cara membuka URL dalam mode aplikasi.
type Kind int
const (
// Chromium membuka PWA dengan --app=<url>.
Chromium Kind = iota
// Firefox tidak punya mode app; dipakai --new-window.
Firefox
)
// Browser adalah browser siap dipanggil.
type Browser struct {
Name string
Exec []string // argv lengkap, sudah termasuk argumen flatpak
Kind Kind
}
// AppArgs adalah argumen yang membuka url sebagai aplikasi.
func (b Browser) AppArgs(url string) []string {
if b.Kind == Firefox {
return []string{"--new-window", url}
}
return []string{"--app=" + url}
}
type candidate struct {
name string
kind Kind
bin []string // nama di $PATH (tanpa "/") atau path absolut
}
var candidates = []candidate{
{"Google Chrome", Chromium, []string{"google-chrome", "google-chrome-stable", "/opt/google/chrome/chrome"}},
{"Chromium", Chromium, []string{"chromium", "chromium-browser"}},
{"Brave", Chromium, []string{"brave-browser", "brave", "/opt/brave.com/brave/brave"}},
{"Microsoft Edge", Chromium, []string{"microsoft-edge", "microsoft-edge-stable"}},
{"Vivaldi", Chromium, []string{"vivaldi", "vivaldi-stable", "/opt/vivaldi/vivaldi"}},
{"Helium", Chromium, []string{"helium", "/opt/helium-browser-bin/helium"}},
{"Firefox", Firefox, []string{"firefox", "firefox-esr", "/usr/lib/firefox/firefox"}},
}
var flatpakApps = []struct {
id, name string
kind Kind
}{
{"com.google.Chrome", "Google Chrome (Flatpak)", Chromium},
{"org.chromium.Chromium", "Chromium (Flatpak)", Chromium},
{"com.brave.Browser", "Brave (Flatpak)", Chromium},
{"com.microsoft.Edge", "Microsoft Edge (Flatpak)", Chromium},
{"org.mozilla.firefox", "Firefox (Flatpak)", Firefox},
}
// Detect mengembalikan browser yang ada di sistem, native dulu lalu flatpak.
func Detect() []Browser {
var out []Browser
seen := map[string]bool{}
add := func(b Browser) {
if !seen[b.Name] {
seen[b.Name] = true
out = append(out, b)
}
}
for _, c := range candidates {
for _, bin := range c.bin {
path := bin
if !strings.Contains(bin, "/") {
p, err := exec.LookPath(bin)
if err != nil {
continue
}
path = p
} else if st, err := os.Stat(bin); err != nil || st.IsDir() {
continue
}
add(Browser{Name: c.name, Exec: []string{path}, Kind: c.kind})
break
}
}
installed := flatpakList()
for _, f := range flatpakApps {
if installed[f.id] {
add(Browser{Name: f.name, Exec: []string{"flatpak", "run", f.id}, Kind: f.kind})
}
}
return out
}
func flatpakList() map[string]bool {
out := map[string]bool{}
if _, err := exec.LookPath("flatpak"); err != nil {
return out
}
data, err := exec.Command("flatpak", "list", "--app", "--columns=application").Output()
if err != nil {
return out
}
for _, line := range strings.Split(string(data), "\n") {
if id := strings.TrimSpace(line); id != "" {
out[id] = true
}
}
return out
}
+175
View File
@@ -0,0 +1,175 @@
// Package icon: favicon.go mengambil ikon dari halaman web untuk shortcut PWA.
package icon
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"sort"
"strings"
"time"
)
var client = &http.Client{Timeout: 8 * time.Second}
// shortcut: parsing HTML pakai regex, bukan parser penuh; cukup untuk tag <link rel=icon>.
var (
linkTag = regexp.MustCompile(`(?is)<link\b[^>]*>`)
relAttr = regexp.MustCompile(`(?is)\brel\s*=\s*["']?([^"'>]+)`)
hrefAttr = regexp.MustCompile(`(?is)\bhref\s*=\s*["']?([^"'\s>]+)`)
typeAttr = regexp.MustCompile(`(?is)\btype\s*=\s*["']?([^"'\s>]+)`)
sizeAttr = regexp.MustCompile(`(?is)\bsizes\s*=\s*["']?(\d+)`)
)
// Fetch mengambil favicon dari halaman. Return data + ekstensi file (".png", ".jpg", ".svg").
func Fetch(pageURL string) ([]byte, string, error) {
base, err := url.Parse(pageURL)
if err != nil {
return nil, "", err
}
var lastErr error
for _, candidate := range iconCandidates(base) {
data, err := get(candidate)
if err != nil {
lastErr = err
continue
}
if ext := sniffExt(data); ext != "" {
return data, ext, nil
}
if png := largestPNGFromICO(data); png != nil {
return png, ".png", nil
}
lastErr = errors.New("unsupported icon format at " + candidate)
}
if lastErr == nil {
lastErr = errors.New("no favicon found")
}
return nil, "", lastErr
}
// iconCandidate adalah kandidat URL ikon dengan skor prioritas.
type iconCandidate struct {
url string
score int
}
// iconCandidates mengumpulkan URL ikon dari HTML, ikon besar didahulukan, favicon.ico terakhir.
func iconCandidates(base *url.URL) []string {
var found []iconCandidate
if page, err := get(base.String()); err == nil {
for _, tag := range linkTag.FindAllString(string(page), -1) {
rel := strings.ToLower(attr(relAttr, tag))
if !strings.Contains(rel, "icon") || strings.Contains(rel, "mask-icon") {
continue
}
href := attr(hrefAttr, tag)
if href == "" {
continue
}
ref, err := url.Parse(href)
if err != nil {
continue
}
score := 1
if strings.Contains(strings.ToLower(attr(typeAttr, tag)), "png") {
score += 10
}
if size := attr(sizeAttr, tag); size != "" {
fmt.Sscanf(size, "%d", &score)
score += 10
}
found = append(found, iconCandidate{base.ResolveReference(ref).String(), score})
}
}
sort.SliceStable(found, func(i, j int) bool { return found[i].score > found[j].score })
out := make([]string, 0, len(found)+1)
seen := map[string]bool{}
for _, c := range found {
if !seen[c.url] {
seen[c.url] = true
out = append(out, c.url)
}
}
fallback := base.ResolveReference(&url.URL{Path: "/favicon.ico"}).String()
if !seen[fallback] {
out = append(out, fallback)
}
return out
}
func attr(re *regexp.Regexp, tag string) string {
if m := re.FindStringSubmatch(tag); m != nil {
return m[1]
}
return ""
}
func get(rawURL string) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) LazyRun")
req.Header.Set("Accept", "text/html,image/*;q=0.9,*/*;q=0.8")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s: %s", rawURL, resp.Status)
}
return io.ReadAll(io.LimitReader(resp.Body, 2<<20))
}
func sniffExt(data []byte) string {
head := data
if len(head) > 512 {
head = head[:512]
}
switch {
case bytes.HasPrefix(data, []byte("\x89PNG\r\n\x1a\n")):
return ".png"
case bytes.HasPrefix(data, []byte{0xff, 0xd8, 0xff}):
return ".jpg"
case bytes.Contains(bytes.ToLower(head), []byte("<svg")):
return ".svg"
}
return ""
}
// largestPNGFromICO mengambil PNG terbesar di dalam file .ico.
// ICO dari BMP lama tidak didukung (langka untuk favicon).
func largestPNGFromICO(data []byte) []byte {
if len(data) < 22 || data[0] != 0 || data[1] != 0 || data[2] != 1 || data[3] != 0 {
return nil
}
count := int(data[4]) | int(data[5])<<8
var best []byte
for i := 0; i < count; i++ {
off := 6 + i*16
if off+16 > len(data) {
break
}
size := int(data[off+8]) | int(data[off+9])<<8 | int(data[off+10])<<16 | int(data[off+11])<<24
start := int(data[off+12]) | int(data[off+13])<<8 | int(data[off+14])<<16 | int(data[off+15])<<24
if size <= 0 || start < 0 || start+size > len(data) {
continue
}
blob := data[start : start+size]
if bytes.HasPrefix(blob, []byte("\x89PNG\r\n\x1a\n")) && len(blob) > len(best) {
best = blob
}
}
return best
}
+112
View File
@@ -0,0 +1,112 @@
package icon
import (
"io"
"net/http"
"net/http/httptest"
"testing"
)
func icoWithPNG(png []byte) []byte {
ico := make([]byte, 22+len(png))
ico[2], ico[3] = 1, 0 // type = icon
ico[4], ico[5] = 1, 0 // jumlah gambar
put32 := func(off, v int) {
ico[off] = byte(v)
ico[off+1] = byte(v >> 8)
ico[off+2] = byte(v >> 16)
ico[off+3] = byte(v >> 24)
}
put32(14, len(png)) // bytesInRes
put32(18, 22) // imageOffset
copy(ico[22:], png)
return ico
}
var png = append([]byte("\x89PNG\r\n\x1a\n"), []byte("payload")...)
func TestFetchUsesLinkedIcon(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/":
io.WriteString(w, `<html><head>`+
`<link rel="apple-touch-icon" href="/small.ico">`+
`<link rel="icon" type="image/png" sizes="192x192" href="/big.png">`+
`</head></html>`)
case "/big.png":
w.Write(png)
default:
w.Write(icoWithPNG(png))
}
}))
defer srv.Close()
data, ext, err := Fetch(srv.URL)
if err != nil {
t.Fatal(err)
}
if ext != ".png" || string(data) != string(png) {
t.Errorf("ikon terpilih salah: ext=%s data=%q", ext, data)
}
}
func TestFetchFallsBackToFaviconICO(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
io.WriteString(w, `<html><head><title>no icon</title></head></html>`)
return
}
w.Write(icoWithPNG(png))
}))
defer srv.Close()
data, ext, err := Fetch(srv.URL)
if err != nil {
t.Fatal(err)
}
if ext != ".png" || string(data) != string(png) {
t.Errorf("PNG di dalam .ico harus dipakai: ext=%s data=%q", ext, data)
}
}
func TestFetchFailsWithoutIcon(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
defer srv.Close()
if _, _, err := Fetch(srv.URL); err == nil {
t.Error("tanpa favicon harus error supaya UI pakai ikon default")
}
}
func TestLargestPNGFromICO(t *testing.T) {
if got := largestPNGFromICO(icoWithPNG(png)); string(got) != string(png) {
t.Errorf("PNG di dalam .ico tidak terbaca: %q", got)
}
if got := largestPNGFromICO([]byte("bukan ico")); got != nil {
t.Errorf("data bukan ICO harus menghasilkan nil, dapat %q", got)
}
// ICO berisi BMP lama (bukan PNG) tidak didukung.
bmp := make([]byte, 40)
bmp[0], bmp[1], bmp[2], bmp[3] = 0, 0, 1, 0
bmp[4], bmp[5] = 1, 0
if got := largestPNGFromICO(bmp); got != nil {
t.Errorf("ICO BMP harus mengembalikan nil, dapat %q", got)
}
}
func TestSniffExt(t *testing.T) {
if ext := sniffExt(png); ext != ".png" {
t.Errorf("sniffExt(png) = %s", ext)
}
if ext := sniffExt([]byte(`<?xml version="1.0"?><svg xmlns="..."><path/></svg>`)); ext != ".svg" {
t.Errorf("sniffExt(svg) = %s", ext)
}
if ext := sniffExt([]byte("\xff\xd8\xff\xe0jpeg")); ext != ".jpg" {
t.Errorf("sniffExt(jpg) = %s", ext)
}
if ext := sniffExt([]byte("plain text")); ext != "" {
t.Errorf("sniffExt(text) = %s", ext)
}
}
+82
View File
@@ -0,0 +1,82 @@
// Package icon menyimpan ikon shortcut ke direktori ikon XDG.
package icon
import (
"errors"
"os"
"path/filepath"
"strings"
"lazyrun/assets"
"lazyrun/internal/xdg"
)
var exts = []string{".png", ".svg", ".jpg", ".jpeg", ".xpm"}
var nearbyNames = []string{"icon", "logo"}
// Save memasang ikon untuk shortcut dan mengembalikan path absolutnya.
// Urutan pilihan: ikon dari user, ikon di folder binary, lalu ikon default LazyRun.
func Save(userIcon, nearbyDir, nearbyBase, slug string) (string, error) {
if userIcon != "" {
return fromFile(userIcon, slug)
}
for _, name := range append([]string{nearbyBase}, nearbyNames...) {
if name == "" {
continue
}
if p := findNearby(nearbyDir, name); p != "" {
return fromFile(p, slug)
}
}
return Default(slug)
}
// Default memasang ikon bawaan LazyRun.
func Default(slug string) (string, error) {
return SaveBytes(assets.Icon, ".png", slug)
}
func findNearby(dir, base string) string {
base = strings.ToLower(base)
for _, ext := range exts {
p := filepath.Join(dir, base+ext)
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p
}
}
return ""
}
func fromFile(src, slug string) (string, error) {
data, err := os.ReadFile(src)
if err != nil {
return "", err
}
ext := strings.ToLower(filepath.Ext(src))
// ICO tidak dipakai desktop theme; pakai PNG di dalamnya kalau ada.
if ext == ".ico" {
png := largestPNGFromICO(data)
if png == nil {
return "", errors.New("this .ico has no PNG inside it, pick another icon")
}
return SaveBytes(png, ".png", slug)
}
return SaveBytes(data, ext, slug)
}
// SaveBytes menulis data ikon ke direktori ikon XDG.
func SaveBytes(data []byte, ext, slug string) (string, error) {
if ext == "" {
ext = ".png"
}
if err := os.MkdirAll(xdg.IconsDir(), 0o755); err != nil {
return "", err
}
// shortcut: ikon disimpan apa adanya di hicolor/512x512 tanpa resize;
// desktop environment men-skalanya sendiri. Tambah resize kalau ikon kecil terlihat pecah.
dest := filepath.Join(xdg.IconsDir(), slug+ext)
return dest, os.WriteFile(dest, data, 0o644)
}
// IconBytes adalah ikon default LazyRun.
func IconBytes() []byte { return assets.Icon }
+26
View File
@@ -0,0 +1,26 @@
package shortcut
import (
"context"
"os/exec"
"time"
"lazyrun/internal/xdg"
)
// RefreshCache memberi tahu desktop environment supaya .desktop baru langsung terbaca.
// Best-effort: kalau tool tidak ada atau gagal, menu tetap refresh saat login berikutnya.
func RefreshCache() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if p, err := exec.LookPath("update-desktop-database"); err == nil {
_ = exec.CommandContext(ctx, p, xdg.ApplicationsDir()).Run()
}
for _, tool := range []string{"kbuildsycoca6", "kbuildsycoca5"} {
if p, err := exec.LookPath(tool); err == nil {
_ = exec.CommandContext(ctx, p, "--noincremental").Run()
return
}
}
}
+89
View File
@@ -0,0 +1,89 @@
package shortcut
import (
"fmt"
"os"
"path/filepath"
"strings"
"unicode"
)
// Entry adalah isi file .desktop yang dihasilkan LazyRun.
type Entry struct {
Name string
ExecArgs []string // argv lengkap; tiap arg di-quote sesuai spec Exec
Icon string // path absolut atau nama ikon
Categories string // sudah diakhiri ";" atau kosong
// NoDisplay menyembunyikan entry dari menu aplikasi. Dipakai kalau user
// hanya memilih Desktop: file tetap ada di applications/ sebagai sumber kebenaran.
NoDisplay bool
}
// Render menghasilkan isi file .desktop.
func (e Entry) Render() string {
var b strings.Builder
b.WriteString("[Desktop Entry]\n")
b.WriteString("Type=Application\n")
fmt.Fprintf(&b, "Name=%s\n", escapeValue(e.Name))
fmt.Fprintf(&b, "Exec=%s\n", execArgs(e.ExecArgs))
if e.Icon != "" {
fmt.Fprintf(&b, "Icon=%s\n", escapeValue(e.Icon))
}
b.WriteString("Terminal=false\n")
if e.Categories != "" {
fmt.Fprintf(&b, "Categories=%s\n", escapeValue(e.Categories))
}
if e.NoDisplay {
b.WriteString("NoDisplay=true\n")
}
b.WriteString("Comment=Created by LazyRun\n")
return b.String()
}
// WriteDesktop menulis entry .desktop ke path.
func WriteDesktop(path string, e Entry) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, []byte(e.Render()), 0o644)
}
func execArgs(args []string) string {
quoted := make([]string, len(args))
for i, a := range args {
quoted[i] = execQuote(a)
}
return strings.Join(quoted, " ")
}
// execQuote mengutip satu argumen sesuai aturan field Exec (spec Desktop Entry).
func execQuote(s string) string {
if s != "" && !strings.ContainsAny(s, " \t\n\"'\\><~|&;$*?#()`") {
return s
}
r := strings.NewReplacer("\\", "\\\\", "\"", "\\\"", "`", "\\`", "$", "\\$", "\n", "\\n")
return "\"" + r.Replace(s) + "\""
}
// escapeValue meng-escape nilai string biasa (Name, Icon, Categories).
func escapeValue(s string) string {
r := strings.NewReplacer("\\", "\\\\", "\n", "\\n", "\r", "\\r", "\t", "\\t")
return r.Replace(s)
}
// slugify membuat nama file yang aman dari nama aplikasi.
func slugify(name string) string {
var b strings.Builder
dash := false
for _, r := range strings.ToLower(name) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', unicode.IsLetter(r), unicode.IsDigit(r):
b.WriteRune(r)
dash = false
case b.Len() > 0 && !dash:
b.WriteByte('-')
dash = true
}
}
return strings.Trim(b.String(), "-")
}
+89
View File
@@ -0,0 +1,89 @@
package shortcut
import (
"strings"
"testing"
)
func TestRender(t *testing.T) {
e := Entry{
Name: "Winbox",
ExecArgs: []string{"/home/user/.local/bin/winbox"},
Icon: "/home/user/.local/share/icons/hicolor/512x512/apps/winbox.png",
Categories: "Network;",
}
want := `[Desktop Entry]
Type=Application
Name=Winbox
Exec=/home/user/.local/bin/winbox
Icon=/home/user/.local/share/icons/hicolor/512x512/apps/winbox.png
Terminal=false
Categories=Network;
Comment=Created by LazyRun
`
if got := e.Render(); got != want {
t.Errorf("Render()\ngot:\n%s\nwant:\n%s", got, want)
}
}
func TestRenderNoDisplayWhenDesktopOnly(t *testing.T) {
e := Entry{Name: "X", ExecArgs: []string{"/x"}, NoDisplay: true}
if !strings.Contains(e.Render(), "NoDisplay=true\n") {
t.Errorf("entry desktop-only harus NoDisplay=true:\n%s", e.Render())
}
}
func TestExecQuote(t *testing.T) {
tests := []struct {
in, want string
}{
{"/usr/bin/winbox", "/usr/bin/winbox"},
{"/home/user/my app/app", `"/home/user/my app/app"`},
{"/opt/a$b", `"/opt/a\$b"`},
{"back\\slash path", `"back\\slash path"`},
{`say "hi"`, `"say \"hi\""`},
{"", `""`},
{"--app=https://web.whatsapp.com", "--app=https://web.whatsapp.com"},
}
for _, tc := range tests {
if got := execQuote(tc.in); got != tc.want {
t.Errorf("execQuote(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestExecArgsJoinsArgs(t *testing.T) {
got := execArgs([]string{"/usr/bin/chromium", "--app=https://web.whatsapp.com"})
want := "/usr/bin/chromium --app=https://web.whatsapp.com"
if got != want {
t.Errorf("execArgs() = %q, want %q", got, want)
}
}
func TestSlugify(t *testing.T) {
tests := map[string]string{
"WhatsApp Web": "whatsapp-web",
"Winbox": "winbox",
"my-go-app": "my-go-app",
" spaced ": "spaced",
"Café #1": "café-1",
"!!": "",
}
for in, want := range tests {
if got := slugify(in); got != want {
t.Errorf("slugify(%q) = %q, want %q", in, got, want)
}
}
}
func TestNormalizeURL(t *testing.T) {
if got, err := normalizeURL("web.whatsapp.com"); err != nil || got != "https://web.whatsapp.com" {
t.Errorf("normalizeURL tanpa skema = %q, %v", got, err)
}
if _, err := normalizeURL(""); err == nil {
t.Error("URL kosong harus error")
}
if _, err := normalizeURL("ftp://example.com"); err == nil {
t.Error("skema selain http(s) harus error")
}
}
+213
View File
@@ -0,0 +1,213 @@
package shortcut
import (
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"lazyrun/internal/icon"
"lazyrun/internal/store"
"lazyrun/internal/xdg"
)
// ExecOptions adalah input Fungsi A: shortcut dari file executable.
type ExecOptions struct {
BinaryPath string
IconPath string // opsional
Name string
Category string
Menu bool
Desktop bool
}
// InstallExecutable menyalin binary + ikon, menulis .desktop, lalu refresh cache.
func InstallExecutable(o ExecOptions) (store.Shortcut, error) {
var sc store.Shortcut
name := strings.TrimSpace(o.Name)
if err := validate(name, o.Menu, o.Desktop); err != nil {
return sc, err
}
if err := checkRunnable(o.BinaryPath); err != nil {
return sc, fmt.Errorf("%s: %w", filepath.Base(o.BinaryPath), err)
}
sl := slugify(name)
binDst := filepath.Join(xdg.BinDir(), sl)
deskDst := filepath.Join(xdg.ApplicationsDir(), sl+".desktop")
if _, err := os.Stat(deskDst); err == nil {
return sc, fmt.Errorf("shortcut %q already exists, uninstall it first", name)
}
if st, err := os.Stat(binDst); err == nil && !st.IsDir() && !samePath(o.BinaryPath, binDst) {
return sc, fmt.Errorf("%s already exists, rename the app or remove that file first", binDst)
}
if !samePath(o.BinaryPath, binDst) {
if err := copyFile(o.BinaryPath, binDst, 0o755); err != nil {
return sc, err
}
}
iconPath, err := icon.Save(o.IconPath, filepath.Dir(o.BinaryPath), strings.TrimSuffix(filepath.Base(o.BinaryPath), filepath.Ext(o.BinaryPath)), sl)
if err != nil {
return sc, err
}
entry := Entry{
Name: name,
ExecArgs: []string{binDst},
Icon: iconPath,
Categories: category(o.Category, "Utility;"),
NoDisplay: !o.Menu,
}
if err := WriteDesktop(deskDst, entry); err != nil {
return sc, err
}
sc = store.Shortcut{
Slug: sl,
Name: name,
Type: store.TypeExecutable,
BinaryPath: binDst,
IconPath: iconPath,
DesktopPath: deskDst,
Targets: store.Targets{Menu: o.Menu, Desktop: o.Desktop},
InstalledAt: time.Now().UTC(),
}
if o.Desktop {
cp, err := desktopCopy(deskDst, sl)
if err != nil {
return sc, err
}
sc.DesktopCopyPath = cp
}
go RefreshCache()
return sc, nil
}
// WebOptions adalah input Fungsi B: shortcut dari URL (PWA).
type WebOptions struct {
URL string
Name string
Browser BrowserArg
IconPath string // opsional; kosong = auto-fetch favicon
Menu bool
Desktop bool
}
// BrowserArg adalah browser target yang dipilih di UI.
type BrowserArg struct {
Name string
Exec []string
// Firefox membuka dengan --new-window, sisanya (Chromium-based) dengan --app=.
Firefox bool
}
// InstallWebApp menulis .desktop yang membuka URL sebagai aplikasi.
func InstallWebApp(o WebOptions) (store.Shortcut, error) {
var sc store.Shortcut
name := strings.TrimSpace(o.Name)
if err := validate(name, o.Menu, o.Desktop); err != nil {
return sc, err
}
if len(o.Browser.Exec) == 0 {
return sc, errors.New("no browser selected")
}
pageURL, err := normalizeURL(o.URL)
if err != nil {
return sc, err
}
sl := slugify(name)
deskDst := filepath.Join(xdg.ApplicationsDir(), sl+".desktop")
if _, err := os.Stat(deskDst); err == nil {
return sc, fmt.Errorf("shortcut %q already exists, uninstall it first", name)
}
iconPath, err := installWebIcon(o.IconPath, pageURL, sl)
if err != nil {
return sc, err
}
appArgs := o.Browser.Exec[1:]
if o.Browser.Firefox {
appArgs = append(appArgs, "--new-window", pageURL)
} else {
appArgs = append(appArgs, "--app="+pageURL)
}
entry := Entry{
Name: name,
ExecArgs: append([]string{o.Browser.Exec[0]}, appArgs...),
Icon: iconPath,
Categories: "Network;WebBrowser;",
NoDisplay: !o.Menu,
}
if err := WriteDesktop(deskDst, entry); err != nil {
return sc, err
}
sc = store.Shortcut{
Slug: sl,
Name: name,
Type: store.TypeWebApp,
URL: pageURL,
Browser: strings.Join(o.Browser.Exec, " "),
IconPath: iconPath,
DesktopPath: deskDst,
Targets: store.Targets{Menu: o.Menu, Desktop: o.Desktop},
InstalledAt: time.Now().UTC(),
}
if o.Desktop {
cp, err := desktopCopy(deskDst, sl)
if err != nil {
return sc, err
}
sc.DesktopCopyPath = cp
}
go RefreshCache()
return sc, nil
}
func installWebIcon(userIcon, pageURL, slug string) (string, error) {
if userIcon != "" {
return icon.Save(userIcon, "", "", slug)
}
if data, ext, err := icon.Fetch(pageURL); err == nil {
return icon.SaveBytes(data, ext, slug)
}
return icon.Default(slug)
}
func category(c, fallback string) string {
c = strings.TrimSpace(c)
if c == "" {
return fallback
}
return strings.TrimSuffix(c, ";") + ";"
}
// normalizeURL menambah skema https kalau user menulis domain saja.
func normalizeURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", errors.New("URL is required")
}
if !strings.Contains(raw, "://") {
raw = "https://" + raw
}
u, err := url.Parse(raw)
if err != nil {
return "", err
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", errors.New("only http/https URLs are supported")
}
if u.Host == "" {
return "", errors.New("URL has no host")
}
return u.String(), nil
}
+105
View File
@@ -0,0 +1,105 @@
package shortcut
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"strings"
"lazyrun/internal/xdg"
)
func validate(name string, menu, desktop bool) error {
if name == "" {
return errors.New("name is required")
}
if strings.ContainsAny(name, "\n\r") {
return errors.New("name must not contain line breaks")
}
if !menu && !desktop {
return errors.New("pick at least one location (menu or desktop)")
}
if slugify(name) == "" {
return errors.New("name must contain at least one letter or digit")
}
return nil
}
// checkRunnable memastikan file adalah ELF (binary/AppImage) atau script dengan shebang.
func checkRunnable(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
st, err := f.Stat()
if err != nil {
return err
}
if st.IsDir() {
return errors.New("this is a directory, not a file")
}
head := make([]byte, 4)
n, _ := io.ReadFull(f, head)
head = head[:n]
if bytes.HasPrefix(head, []byte{0x7f, 'E', 'L', 'F'}) || bytes.HasPrefix(head, []byte("#!")) {
return nil
}
return errors.New("not an executable binary (ELF, AppImage, or script only)")
}
// copyFile menyalin src ke dst lewat file sementara supaya dst tidak pernah setengah tertulis.
func copyFile(src, dst string, mode os.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(dst), ".lazyrun-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := io.Copy(tmp, in); err != nil {
tmp.Close()
return err
}
if err := tmp.Chmod(mode); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), dst)
}
func samePath(a, b string) bool {
sa, err := os.Stat(a)
if err != nil {
return false
}
sb, err := os.Stat(b)
if err != nil {
return false
}
return os.SameFile(sa, sb)
}
// desktopCopy menyalin .desktop ke ~/Desktop agar bisa diklik ganda.
func desktopCopy(desktopPath, slug string) (string, error) {
dst := filepath.Join(xdg.DesktopDir(), slug+".desktop")
if _, err := os.Stat(dst); err == nil && !samePath(dst, desktopPath) {
return "", errors.New(dst + " already exists")
}
return dst, copyFile(desktopPath, dst, 0o755)
}
+250
View File
@@ -0,0 +1,250 @@
package shortcut
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"lazyrun/internal/store"
)
var pngMagic = []byte("\x89PNG\r\n\x1a\n")
// testEnv mengarahkan semua path XDG ke tempdir dan mematikan tool eksternal,
// supaya tes tidak menyentuh ~/.local atau ~/Desktop milik user.
func testEnv(t *testing.T) string {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_DATA_HOME", "")
t.Setenv("XDG_CONFIG_HOME", "")
t.Setenv("PATH", "")
return home
}
func fakeBinary(t *testing.T, dir, name string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, append([]byte{0x7f, 'E', 'L', 'F'}, []byte("fake")...), 0o755); err != nil {
t.Fatal(err)
}
return path
}
func TestInstallExecutable(t *testing.T) {
home := testEnv(t)
src := fakeBinary(t, home, "winbox")
sc, err := InstallExecutable(ExecOptions{
BinaryPath: src,
Name: "Winbox",
Category: "Network",
Menu: true,
Desktop: true,
})
if err != nil {
t.Fatal(err)
}
if sc.BinaryPath != filepath.Join(home, ".local", "bin", "winbox") {
t.Errorf("binary tersalin ke %s", sc.BinaryPath)
}
if st, err := os.Stat(sc.BinaryPath); err != nil || st.Mode().Perm()&0o111 == 0 {
t.Errorf("binary harus executable: %v %v", st, err)
}
content, err := os.ReadFile(sc.DesktopPath)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"Name=Winbox\n",
"Exec=" + sc.BinaryPath + "\n",
"Categories=Network;\n",
"Icon=" + sc.IconPath + "\n",
} {
if !strings.Contains(string(content), want) {
t.Errorf("isi .desktop tidak memuat %q:\n%s", want, content)
}
}
if strings.Contains(string(content), "NoDisplay") {
t.Errorf("menu tercentang tidak boleh NoDisplay:\n%s", content)
}
if sc.DesktopCopyPath != filepath.Join(home, "Desktop", "winbox.desktop") {
t.Errorf("salinan desktop di %s", sc.DesktopCopyPath)
}
if st, err := os.Stat(sc.DesktopCopyPath); err != nil || st.Mode().Perm()&0o111 == 0 {
t.Errorf("file di Desktop harus executable: %v %v", st, err)
}
if _, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Winbox", Menu: true, Desktop: true}); err == nil {
t.Error("install kedua dengan nama sama harus ditolak")
}
}
func TestInstallExecutableDesktopOnly(t *testing.T) {
home := testEnv(t)
src := fakeBinary(t, home, "tool")
sc, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Tool", Menu: false, Desktop: true})
if err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(sc.DesktopPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "NoDisplay=true") {
t.Errorf("desktop-only harus disembunyikan dari menu:\n%s", content)
}
}
func TestInstallExecutableRejectsNonBinary(t *testing.T) {
home := testEnv(t)
path := filepath.Join(home, "notes.txt")
if err := os.WriteFile(path, []byte("halo"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := InstallExecutable(ExecOptions{BinaryPath: path, Name: "Notes", Menu: true}); err == nil {
t.Error("file teks biasa harus ditolak")
}
}
func TestInstallExecutableRequiresTarget(t *testing.T) {
home := testEnv(t)
src := fakeBinary(t, home, "tool")
if _, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Tool"}); err == nil {
t.Error("tanpa target harus ditolak")
}
}
func TestInstallWebApp(t *testing.T) {
testEnv(t)
page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/":
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(`<html><head><link rel="icon" type="image/png" sizes="128x128" href="/fav.png"></head></html>`))
case "/fav.png":
_, _ = w.Write(append(pngMagic, []byte("fake")...))
default:
http.NotFound(w, r)
}
}))
defer page.Close()
sc, err := InstallWebApp(WebOptions{
URL: page.URL,
Name: "WhatsApp Web",
Browser: BrowserArg{Name: "Chromium", Exec: []string{"/usr/bin/chromium"}},
Menu: true,
Desktop: true,
})
if err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(sc.DesktopPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "Exec=/usr/bin/chromium --app="+page.URL+"\n") {
t.Errorf("Exec PWA salah:\n%s", content)
}
if filepath.Ext(sc.IconPath) != ".png" {
t.Errorf("favicon harus tersimpan sebagai .png, dapat %s", sc.IconPath)
}
if st, err := os.Stat(sc.IconPath); err != nil || st.Size() == 0 {
t.Errorf("ikon favicon tidak tertulis: %v %v", st, err)
}
if sc.Type != store.TypeWebApp {
t.Errorf("type = %s", sc.Type)
}
if sc.URL != page.URL {
t.Errorf("url = %s", sc.URL)
}
}
func TestInstallWebAppFirefoxUsesNewWindow(t *testing.T) {
testEnv(t)
sc, err := InstallWebApp(WebOptions{
URL: "https://example.com",
Name: "Example",
Browser: BrowserArg{Name: "Firefox", Exec: []string{"/usr/bin/firefox"}, Firefox: true},
Menu: true,
})
if err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(sc.DesktopPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "Exec=/usr/bin/firefox --new-window https://example.com\n") {
t.Errorf("Exec Firefox salah:\n%s", content)
}
}
func TestInstallWebAppFlatpakBrowser(t *testing.T) {
testEnv(t)
sc, err := InstallWebApp(WebOptions{
URL: "https://example.com",
Name: "Example",
Browser: BrowserArg{Name: "Chromium (Flatpak)", Exec: []string{"flatpak", "run", "org.chromium.Chromium"}},
Menu: true,
})
if err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(sc.DesktopPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "Exec=flatpak run org.chromium.Chromium --app=https://example.com\n") {
t.Errorf("Exec flatpak salah:\n%s", content)
}
}
func TestUninstall(t *testing.T) {
home := testEnv(t)
src := fakeBinary(t, home, "gone")
sc, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Gone", Menu: true, Desktop: true})
if err != nil {
t.Fatal(err)
}
if err := Uninstall(sc, RemoveOptions{Binary: true}); err != nil {
t.Fatal(err)
}
for _, path := range []string{sc.DesktopPath, sc.DesktopCopyPath, sc.IconPath, sc.BinaryPath} {
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("%s masih ada setelah uninstall", path)
}
}
// uninstall kedua harus aman (file sudah tidak ada)
if err := Uninstall(sc, RemoveOptions{Binary: true}); err != nil {
t.Errorf("uninstall ulang: %v", err)
}
}
func TestUninstallKeepsBinaryByDefault(t *testing.T) {
home := testEnv(t)
src := fakeBinary(t, home, "keep")
sc, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Keep", Menu: true})
if err != nil {
t.Fatal(err)
}
if err := Uninstall(sc, RemoveOptions{}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(sc.BinaryPath); err != nil {
t.Errorf("binary harus tetap ada: %v", err)
}
}
+41
View File
@@ -0,0 +1,41 @@
package shortcut
import (
"errors"
"os"
"lazyrun/internal/store"
)
// RemoveOptions mengatur apa saja yang ikut dihapus saat uninstall.
type RemoveOptions struct {
// Binary true = binary hasil salinan di ~/.local/bin juga dihapus.
Binary bool
}
// Uninstall menghapus .desktop (menu + desktop), ikon, dan opsional binary.
func Uninstall(sc store.Shortcut, o RemoveOptions) error {
var errs []error
for _, path := range []string{sc.DesktopCopyPath, sc.DesktopPath} {
if path == "" {
continue
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
errs = append(errs, err)
}
}
if sc.IconPath != "" {
if err := os.Remove(sc.IconPath); err != nil && !os.IsNotExist(err) {
errs = append(errs, err)
}
}
if o.Binary && sc.Type == store.TypeExecutable && sc.BinaryPath != "" {
if err := os.Remove(sc.BinaryPath); err != nil && !os.IsNotExist(err) {
errs = append(errs, err)
}
}
go RefreshCache()
return errors.Join(errs...)
}
+123
View File
@@ -0,0 +1,123 @@
// Package store menyimpan daftar shortcut milik LazyRun dalam satu file JSON.
package store
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
const (
TypeExecutable = "executable"
TypeWebApp = "webapp"
)
// Targets adalah lokasi shortcut yang dipilih user.
type Targets struct {
Menu bool `json:"menu"`
Desktop bool `json:"desktop"`
}
// Shortcut adalah satu baris metadata di shortcuts.json.
type Shortcut struct {
Slug string `json:"slug"`
Name string `json:"name"`
Type string `json:"type"`
URL string `json:"url,omitempty"`
Browser string `json:"browser,omitempty"`
BinaryPath string `json:"binary_path,omitempty"`
IconPath string `json:"icon_path,omitempty"`
DesktopPath string `json:"desktop_path"`
DesktopCopyPath string `json:"desktop_copy_path,omitempty"`
Targets Targets `json:"targets"`
InstalledAt time.Time `json:"installed_at"`
}
// Broken berarti file .desktop sudah hilang (dihapus manual oleh user).
func (s Shortcut) Broken() bool {
_, err := os.Stat(s.DesktopPath)
return err != nil
}
// Store adalah isi shortcuts.json (array di level atas).
type Store struct {
Path string
Items []Shortcut
}
// Load membaca store dari path. File yang belum ada bukan error.
// File yang rusak di-rename ke *.bak supaya tidak menimpa data lama.
func Load(path string) (*Store, error) {
s := &Store{Path: path}
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return s, nil
}
if err != nil {
return s, err
}
if err := json.Unmarshal(data, &s.Items); err != nil {
bak := path + ".bak"
_ = os.Rename(path, bak)
return s, fmt.Errorf("shortcuts.json rusak, dipindahkan ke %s: %w", bak, err)
}
return s, nil
}
// Save menulis store secara atomik supaya file tidak pernah setengah tertulis.
func (s *Store) Save() error {
if err := os.MkdirAll(filepath.Dir(s.Path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(s.Items, "", " ")
if err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(s.Path), ".shortcuts-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(append(data, '\n')); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmp.Name(), 0o644); err != nil {
return err
}
return os.Rename(tmp.Name(), s.Path)
}
// Add menambahkan shortcut; slug yang sama menimpa entry lama.
func (s *Store) Add(sc Shortcut) {
if i, ok := s.Get(sc.Slug); ok {
s.Items[i] = sc
return
}
s.Items = append(s.Items, sc)
}
// Get mencari index shortcut berdasarkan slug.
func (s *Store) Get(slug string) (int, bool) {
for i, sc := range s.Items {
if sc.Slug == slug {
return i, true
}
}
return 0, false
}
// Remove menghapus shortcut berdasarkan slug.
func (s *Store) Remove(slug string) bool {
i, ok := s.Get(slug)
if !ok {
return false
}
s.Items = append(s.Items[:i], s.Items[i+1:]...)
return true
}
+60
View File
@@ -0,0 +1,60 @@
package store
import (
"os"
"path/filepath"
"testing"
)
func TestLoadMissingFileIsEmpty(t *testing.T) {
s, err := Load(filepath.Join(t.TempDir(), "shortcuts.json"))
if err != nil {
t.Fatal(err)
}
if len(s.Items) != 0 {
t.Errorf("store baru harus kosong, dapat %d item", len(s.Items))
}
}
func TestSaveLoadRoundtrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "nested", "shortcuts.json")
s, err := Load(path)
if err != nil {
t.Fatal(err)
}
s.Add(Shortcut{Slug: "winbox", Name: "Winbox", Type: TypeExecutable, Targets: Targets{Menu: true}})
s.Add(Shortcut{Slug: "wa", Name: "WhatsApp Web", Type: TypeWebApp, Targets: Targets{Menu: true, Desktop: true}})
if err := s.Save(); err != nil {
t.Fatal(err)
}
got, err := Load(path)
if err != nil {
t.Fatal(err)
}
if len(got.Items) != 2 || got.Items[1].Name != "WhatsApp Web" {
t.Fatalf("hasil load salah: %+v", got.Items)
}
if _, ok := got.Get("wa"); !ok {
t.Error("Get by slug gagal")
}
if !got.Remove("wa") || len(got.Items) != 1 {
t.Error("Remove gagal")
}
}
func TestLoadCorruptFileIsBackedUp(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "shortcuts.json")
if err := os.WriteFile(path, []byte("{bukan json"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := Load(path); err == nil {
t.Fatal("file rusak harus dilaporkan sebagai error")
}
if _, err := os.Stat(path + ".bak"); err != nil {
t.Errorf("file rusak harus di-rename ke .bak: %v", err)
}
}
+131
View File
@@ -0,0 +1,131 @@
package ui
import (
"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/widget"
"lazyrun/internal/shortcut"
"lazyrun/internal/store"
)
var categories = []string{
"Utility", "Network", "Development", "Graphics",
"AudioVideo", "Office", "Game", "Education", "System", "Settings",
}
var (
imageFilter = storage.NewExtensionFileFilter([]string{".png", ".svg", ".jpg", ".jpeg", ".xpm", ".ico"})
stripExts = []string{".AppImage", ".appimage", ".sh", ".bin", ".run", ".desktop"}
)
func (ui *app) showExecutableDialog() {
var binPath, iconPath string
binLabel := widget.NewLabel(T("none"))
iconLabel := widget.NewLabel(T("icon_auto"))
nameEntry := widget.NewEntry()
category := widget.NewSelect(categories, nil)
category.SetSelected("Utility")
menu := widget.NewCheck(T("check_menu"), nil)
menu.SetChecked(true)
desktop := widget.NewCheck(T("check_desktop"), nil)
desktop.SetChecked(true)
browseBin := widget.NewButton(T("browse"), func() {
pickFile(ui.win, nil, func(path string) {
binPath = path
binLabel.SetText(filepath.Base(path))
if strings.TrimSpace(nameEntry.Text) == "" {
nameEntry.SetText(appNameFromFile(path))
}
})
})
browseIcon := widget.NewButton(T("browse"), func() {
pickFile(ui.win, imageFilter, func(path string) {
iconPath = path
iconLabel.SetText(filepath.Base(path))
})
})
var d *dialog.CustomDialog
install := widget.NewButton(T("install"), func() {
switch {
case binPath == "":
dialog.ShowInformation(T("err_title"), T("need_binary"), ui.win)
return
case strings.TrimSpace(nameEntry.Text) == "":
dialog.ShowInformation(T("err_title"), T("need_name"), ui.win)
return
case !menu.Checked && !desktop.Checked:
dialog.ShowInformation(T("err_title"), T("need_target"), ui.win)
return
}
opts := shortcut.ExecOptions{
BinaryPath: binPath,
IconPath: iconPath,
Name: nameEntry.Text,
Category: category.Selected,
Menu: menu.Checked,
Desktop: desktop.Checked,
}
ui.install(
func() (store.Shortcut, error) { return shortcut.InstallExecutable(opts) },
func(sc store.Shortcut, err error) { ui.added(sc, err, d.Hide) },
)
})
install.Importance = widget.HighImportance
form := widget.NewForm(
widget.NewFormItem(T("field_binary"), container.NewBorder(nil, nil, nil, browseBin, binLabel)),
widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil, browseIcon, iconLabel)),
widget.NewFormItem(T("field_name"), nameEntry),
widget.NewFormItem(T("field_category"), category),
)
content := container.NewVBox(
form,
widget.NewSeparator(),
heading(T("section_location")),
menu,
desktop,
container.NewHBox(spacer(), widget.NewButton(T("cancel"), func() { d.Hide() }), install),
)
d = dialog.NewCustom(T("new_exec_title"), "", content, ui.win)
d.Resize(fyne.NewSize(520, 460))
d.Show()
}
// pickFile membuka file picker dan mengirim path absolut ke onPick.
func pickFile(win fyne.Window, filter storage.FileFilter, onPick func(path string)) {
fd := dialog.NewFileOpen(func(r fyne.URIReadCloser, err error) {
if err != nil {
dialog.ShowError(err, win)
return
}
if r == nil {
return
}
defer r.Close()
onPick(r.URI().Path())
}, win)
if filter != nil {
fd.SetFilter(filter)
}
fd.Show()
}
func appNameFromFile(path string) string {
base := filepath.Base(path)
for _, ext := range stripExts {
if strings.HasSuffix(base, ext) {
return strings.TrimSuffix(base, ext)
}
}
return base
}
+141
View File
@@ -0,0 +1,141 @@
package ui
import (
"net/url"
"path/filepath"
"strings"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/widget"
"lazyrun/internal/browser"
"lazyrun/internal/shortcut"
"lazyrun/internal/store"
)
func (ui *app) showWebAppDialog() {
if len(ui.browsers) == 0 {
dialog.ShowInformation(T("err_title"), T("no_browser"), ui.win)
return
}
var iconPath string
var nameTouched, filling bool
urlEntry := widget.NewEntry()
urlEntry.SetPlaceHolder("https://web.whatsapp.com")
nameEntry := widget.NewEntry()
iconLabel := widget.NewLabel(T("icon_auto"))
picker := widget.NewSelect(browserNames(ui.browsers), nil)
picker.SetSelected(ui.browsers[0].Name)
menu := widget.NewCheck(T("check_menu"), nil)
menu.SetChecked(true)
desktop := widget.NewCheck(T("check_desktop"), nil)
desktop.SetChecked(true)
nameEntry.OnChanged = func(string) {
if !filling {
nameTouched = true
}
}
urlEntry.OnChanged = func(value string) {
if nameTouched {
return
}
if host := hostOf(value); host != "" {
filling = true
nameEntry.SetText(host)
filling = false
}
}
browseIcon := widget.NewButton(T("browse"), func() {
pickFile(ui.win, imageFilter, func(path string) {
iconPath = path
iconLabel.SetText(filepath.Base(path))
})
})
var d *dialog.CustomDialog
install := widget.NewButton(T("install"), func() {
switch {
case strings.TrimSpace(urlEntry.Text) == "":
dialog.ShowInformation(T("err_title"), T("need_url"), ui.win)
return
case strings.TrimSpace(nameEntry.Text) == "":
dialog.ShowInformation(T("err_title"), T("need_name"), ui.win)
return
case !menu.Checked && !desktop.Checked:
dialog.ShowInformation(T("err_title"), T("need_target"), ui.win)
return
}
b := ui.browsers[indexOf(ui.browsers, picker.Selected)]
opts := shortcut.WebOptions{
URL: urlEntry.Text,
Name: nameEntry.Text,
Browser: shortcut.BrowserArg{Name: b.Name, Exec: b.Exec, Firefox: b.Kind == browser.Firefox},
IconPath: iconPath,
Menu: menu.Checked,
Desktop: desktop.Checked,
}
ui.install(
func() (store.Shortcut, error) { return shortcut.InstallWebApp(opts) },
func(sc store.Shortcut, err error) { ui.added(sc, err, d.Hide) },
)
})
install.Importance = widget.HighImportance
form := widget.NewForm(
widget.NewFormItem(T("field_url"), urlEntry),
widget.NewFormItem(T("field_name"), nameEntry),
widget.NewFormItem(T("field_browser"), picker),
widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil, browseIcon, iconLabel)),
)
content := container.NewVBox(
form,
widget.NewSeparator(),
heading(T("section_location")),
menu,
desktop,
container.NewHBox(spacer(), widget.NewButton(T("cancel"), func() { d.Hide() }), install),
)
d = dialog.NewCustom(T("new_web_title"), "", content, ui.win)
d.Resize(fyne.NewSize(520, 440))
d.Show()
}
func browserNames(list []browser.Browser) []string {
names := make([]string, len(list))
for i, b := range list {
names[i] = b.Name
}
return names
}
func indexOf(list []browser.Browser, name string) int {
for i, b := range list {
if b.Name == name {
return i
}
}
return 0
}
// hostOf mengambil hostname dari URL yang mungkin ditulis tanpa skema.
func hostOf(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if !strings.Contains(raw, "://") {
raw = "https://" + raw
}
u, err := url.Parse(raw)
if err != nil {
return ""
}
return u.Hostname()
}
+180
View File
@@ -0,0 +1,180 @@
// Package ui berisi window utama dan dialog LazyRun.
package ui
import (
"fmt"
"os/exec"
"path/filepath"
"strings"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget"
"lazyrun/assets"
"lazyrun/internal/browser"
"lazyrun/internal/shortcut"
"lazyrun/internal/store"
"lazyrun/internal/xdg"
)
type app struct {
win fyne.Window
store *store.Store
browsers []browser.Browser
rows *fyne.Container
version string
}
// Run menampilkan window utama LazyRun sampai user menutupnya.
func Run(a fyne.App, version string) {
ui, loadErr := build(a, version)
ui.win.Show()
if loadErr != nil {
dialog.ShowError(loadErr, ui.win)
}
a.Run()
}
// build menyiapkan window utama tanpa menampilkannya (dipakai Run dan tes headless).
func build(a fyne.App, version string) (*app, error) {
ui := &app{version: version}
a.SetIcon(fyne.NewStaticResource("lazyrun.png", assets.Icon))
ui.browsers = browser.Detect()
ui.rows = container.NewVBox()
ui.win = a.NewWindow(T("app_title"))
ui.win.Resize(fyne.NewSize(760, 540))
top := container.NewHBox(
widget.NewButton(T("btn_executable"), ui.showExecutableDialog),
widget.NewButton(T("btn_webapp"), ui.showWebAppDialog),
)
bottom := container.NewBorder(nil, nil,
widget.NewButton(T("check_updates"), ui.checkUpdates),
widget.NewLabel("v"+version))
body := container.NewBorder(heading(T("installed")), nil, nil, nil, container.NewVScroll(ui.rows))
ui.win.SetContent(container.NewBorder(top, bottom, nil, nil, body))
st, err := store.Load(xdg.MetadataPath())
ui.store = st
ui.reload()
return ui, err
}
func (ui *app) reload() {
if st, err := store.Load(ui.store.Path); err == nil {
ui.store = st
}
ui.rows.RemoveAll()
if len(ui.store.Items) == 0 {
ui.rows.Add(widget.NewLabel(T("empty")))
}
for _, sc := range ui.store.Items {
ui.rows.Add(ui.row(sc))
}
}
func (ui *app) row(sc store.Shortcut) fyne.CanvasObject {
title := widget.NewLabelWithStyle(sc.Name, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
info := widget.NewLabel(describe(sc))
open := widget.NewButton(T("folder"), func() {
if err := exec.Command("xdg-open", filepath.Dir(sc.DesktopPath)).Start(); err != nil {
dialog.ShowError(err, ui.win)
}
})
remove := widget.NewButton(T("delete"), func() { ui.confirmDelete(sc) })
return container.NewBorder(nil, widget.NewSeparator(), nil,
container.NewHBox(open, remove),
container.NewVBox(title, info))
}
func describe(sc store.Shortcut) string {
targets := make([]string, 0, 2)
if sc.Targets.Menu {
targets = append(targets, T("check_menu"))
}
if sc.Targets.Desktop {
targets = append(targets, T("check_desktop"))
}
loc := strings.Join(targets, " + ")
kind := "Executable"
if sc.Type == store.TypeWebApp {
kind = "Web"
}
line := kind + " · " + loc
if sc.Broken() {
line += " · " + T("broken")
}
return line
}
func (ui *app) confirmDelete(sc store.Shortcut) {
content := container.NewVBox(widget.NewLabel(fmt.Sprintf(T("confirm_delete_msg"), sc.Name)))
var removeBinary *widget.Check
if sc.Type == store.TypeExecutable && sc.BinaryPath != "" {
removeBinary = widget.NewCheck(T("also_binary"), nil)
content.Add(removeBinary)
}
d := dialog.NewCustomConfirm(T("confirm_delete_title"), T("delete"), T("cancel"), content, func(ok bool) {
if !ok {
return
}
opts := shortcut.RemoveOptions{}
if removeBinary != nil {
opts.Binary = removeBinary.Checked
}
if err := shortcut.Uninstall(sc, opts); err != nil {
dialog.ShowError(err, ui.win)
}
ui.store.Remove(sc.Slug)
if err := ui.store.Save(); err != nil {
dialog.ShowError(err, ui.win)
}
ui.reload()
}, ui.win)
d.Show()
}
// install menjalankan pekerjaan berat (salin file besar / fetch favicon) di luar UI thread.
func (ui *app) install(work func() (store.Shortcut, error), done func(store.Shortcut, error)) {
progress := dialog.NewProgressInfinite(T("install"), T("working"), ui.win)
progress.Show()
go func() {
sc, err := work()
fyne.Do(func() {
progress.Hide()
done(sc, err)
})
}()
}
// added menyimpan metadata shortcut baru; dipanggil dari callback install.
func (ui *app) added(sc store.Shortcut, err error, closeDialog func()) {
if err != nil {
dialog.ShowError(err, ui.win)
return
}
ui.store.Add(sc)
if err := ui.store.Save(); err != nil {
ui.store.Remove(sc.Slug)
dialog.ShowError(err, ui.win)
return
}
closeDialog()
ui.reload()
dialog.ShowInformation(T("success_title"), fmt.Sprintf(T("success_msg"), sc.Name), ui.win)
}
func heading(text string) *widget.Label {
return widget.NewLabelWithStyle(text, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
}
func spacer() fyne.CanvasObject { return layout.NewSpacer() }
+35
View File
@@ -0,0 +1,35 @@
package ui
import (
"testing"
"fyne.io/fyne/v2/test"
"lazyrun/internal/store"
)
func TestBuildMainWindow(t *testing.T) {
a := test.NewApp()
defer a.Quit()
ui, err := build(a, "test")
if err != nil {
t.Fatalf("build(): %v", err)
}
if ui.win.Content() == nil {
t.Fatal("window tanpa content")
}
// dialog harus bisa dibuka tanpa panic
ui.showExecutableDialog()
ui.showWebAppDialog()
ui.checkUpdates()
}
func TestDescribe(t *testing.T) {
sc := store.Shortcut{Type: store.TypeWebApp, Targets: store.Targets{Menu: true}, DesktopPath: "/nonexistent/x.desktop"}
got := describe(sc)
if got != "Web · Application menu · missing" && got != "Web · Menu aplikasi · hilang" {
t.Errorf("describe() = %q", got)
}
}
+83
View File
@@ -0,0 +1,83 @@
package ui
import (
"os"
"strings"
)
// tr menyimpan teks UI: index 0 = Inggris, 1 = Indonesia.
var tr = map[string][2]string{
"app_title": {"LazyRun — lazy shortcut build", "LazyRun — lazy shortcut build"},
"btn_executable": {"+ Executable", "+ Executable"},
"btn_webapp": {"+ Web App", "+ Aplikasi Web"},
"installed": {"Installed shortcuts", "Shortcut terinstall"},
"empty": {"No shortcuts yet. Add one with the buttons above.", "Belum ada shortcut. Tambahkan lewat tombol di atas."},
"none": {"not selected", "belum dipilih"},
"icon_auto": {"automatic", "otomatis"},
"browse": {"Browse...", "Pilih..."},
"install": {"Install", "Install"},
"cancel": {"Cancel", "Batal"},
"delete": {"Delete", "Hapus"},
"folder": {"Folder", "Folder"},
"working": {"Working...", "Sedang diproses..."},
"new_exec_title": {"New Executable Shortcut", "Shortcut Executable Baru"},
"new_web_title": {"New Web App Shortcut", "Shortcut Web App Baru"},
"field_binary": {"Binary", "Binary"},
"field_icon": {"Icon (optional)", "Ikon (opsional)"},
"field_name": {"Name", "Nama"},
"field_category": {"Category", "Kategori"},
"field_url": {"URL", "URL"},
"field_browser": {"Browser", "Browser"},
"section_location": {"Shortcut location", "Lokasi shortcut"},
"check_menu": {"Application menu", "Menu aplikasi"},
"check_desktop": {"Desktop", "Desktop"},
"confirm_delete_title": {"Uninstall shortcut", "Hapus shortcut"},
"confirm_delete_msg": {"Remove %s?", "Hapus %s?"},
"also_binary": {"Also delete the copied binary", "Hapus binary hasil salinan juga"},
"success_title": {"Done", "Selesai"},
"success_msg": {"%s is ready.", "%s sudah siap."},
"err_title": {"Cannot continue", "Tidak bisa lanjut"},
"need_binary": {"Choose a binary first.", "Pilih binary dulu."},
"need_name": {"Enter a name first.", "Masukkan nama dulu."},
"need_url": {"Enter a URL first.", "Masukkan URL dulu."},
"need_target": {"Pick at least one location: menu or desktop.", "Pilih minimal satu lokasi: menu atau desktop."},
"no_browser": {"No supported browser found. Install Chrome, Chromium, Brave, Edge, Vivaldi, or Firefox first.", "Browser yang didukung tidak ditemukan. Install Chrome, Chromium, Brave, Edge, Vivaldi, atau Firefox dulu."},
"broken": {"missing", "hilang"},
"check_updates": {"Check for Updates", "Cek Update"},
"not_appimage": {"This build is not an AppImage (version %s). Update from where you got it.", "Build ini bukan AppImage (versi %s). Update dari sumber kamu mendapatkannya."},
"no_appimageupdate": {"AppImageUpdate is not installed. Install it to update LazyRun automatically.", "AppImageUpdate tidak terpasang. Install dulu supaya LazyRun bisa update otomatis."},
}
var lang = detectLang()
// T mengembalikan teks UI sesuai $LANG: "id*" → Indonesia, selain itu Inggris.
func T(key string) string {
s, ok := tr[key]
if !ok {
return key
}
if lang == "id" {
return s[1]
}
return s[0]
}
func detectLang() string {
v := os.Getenv("LC_ALL")
if v == "" {
v = os.Getenv("LC_MESSAGES")
}
if v == "" {
v = os.Getenv("LANG")
}
if strings.HasPrefix(strings.ToLower(v), "id") {
return "id"
}
return "en"
}
+26
View File
@@ -0,0 +1,26 @@
package ui
import (
"fmt"
"os"
"os/exec"
"fyne.io/fyne/v2/dialog"
)
// checkUpdates menjalankan AppImageUpdate kalau LazyRun berjalan sebagai AppImage.
func (ui *app) checkUpdates() {
image := os.Getenv("APPIMAGE")
if image == "" {
dialog.ShowInformation(T("check_updates"), fmt.Sprintf(T("not_appimage"), ui.version), ui.win)
return
}
update, err := exec.LookPath("AppImageUpdate")
if err != nil {
dialog.ShowInformation(T("check_updates"), T("no_appimageupdate"), ui.win)
return
}
if err := exec.Command(update, image).Start(); err != nil {
dialog.ShowError(err, ui.win)
}
}
+54
View File
@@ -0,0 +1,54 @@
// Package xdg menghitung path XDG yang dipakai LazyRun.
package xdg
import (
"os"
"os/exec"
"path/filepath"
"strings"
)
func home() string {
h, _ := os.UserHomeDir()
return h
}
func dataHome() string {
if v := os.Getenv("XDG_DATA_HOME"); v != "" {
return v
}
return filepath.Join(home(), ".local", "share")
}
func configHome() string {
if v := os.Getenv("XDG_CONFIG_HOME"); v != "" {
return v
}
return filepath.Join(home(), ".config")
}
// BinDir adalah lokasi binary target setelah di-install.
func BinDir() string { return filepath.Join(home(), ".local", "bin") }
// ApplicationsDir menampung file .desktop untuk menu aplikasi.
func ApplicationsDir() string { return filepath.Join(dataHome(), "applications") }
// IconsDir adalah direktori ikon aplikasi XDG.
func IconsDir() string {
return filepath.Join(dataHome(), "icons", "hicolor", "512x512", "apps")
}
// DesktopDir mengikuti xdg-user-dir, fallback ke ~/Desktop.
func DesktopDir() string {
if out, err := exec.Command("xdg-user-dir", "DESKTOP").Output(); err == nil {
if p := strings.TrimSpace(string(out)); p != "" {
return p
}
}
return filepath.Join(home(), "Desktop")
}
// MetadataPath adalah file daftar shortcut milik LazyRun.
func MetadataPath() string {
return filepath.Join(configHome(), "lazyrun", "shortcuts.json")
}
+39
View File
@@ -0,0 +1,39 @@
package xdg
import (
"path/filepath"
"testing"
)
func TestDataHomeOverride(t *testing.T) {
t.Setenv("HOME", "/home/tester")
t.Setenv("XDG_DATA_HOME", "/data")
if got := ApplicationsDir(); got != "/data/applications" {
t.Errorf("ApplicationsDir() = %s", got)
}
if got := IconsDir(); got != filepath.Join("/data", "icons/hicolor/512x512/apps") {
t.Errorf("IconsDir() = %s", got)
}
}
func TestFallbacks(t *testing.T) {
t.Setenv("HOME", "/home/tester")
t.Setenv("XDG_DATA_HOME", "")
t.Setenv("XDG_CONFIG_HOME", "")
if got := ApplicationsDir(); got != "/home/tester/.local/share/applications" {
t.Errorf("ApplicationsDir() = %s", got)
}
if got := BinDir(); got != "/home/tester/.local/bin" {
t.Errorf("BinDir() = %s", got)
}
if got := MetadataPath(); got != "/home/tester/.config/lazyrun/shortcuts.json" {
t.Errorf("MetadataPath() = %s", got)
}
// xdg-user-dir tidak ada di $PATH → fallback ke ~/Desktop.
t.Setenv("PATH", "")
if got := DesktopDir(); got != "/home/tester/Desktop" {
t.Errorf("DesktopDir() = %s", got)
}
}
+435
View File
@@ -0,0 +1,435 @@
# PRD: LazyRun
> **lazy shortcut build**
**Versi:** 1.0
**Tanggal:** 2026-10-13
**Status:** Draft
**Platform:** Linux (KDE prioritas, GNOME fallback)
**Distribusi:** AppImage
---
## 1. Ringkasan
**LazyRun** adalah aplikasi desktop Linux berbasis Go yang memungkinkan pengguna membuat shortcut aplikasi — baik dari **file executable** maupun **PWA (URL web)** — ke menu aplikasi dan/atau desktop, hanya dalam beberapa klik. Dibungkus sebagai AppImage, LazyRun portable, mudah di-update, dan tidak memerlukan terminal.
> **Tagline:** *lazy shortcut build*
---
## 2. Masalah yang Diselesaikan
1. Membuat shortcut `.desktop` di Linux masih manual: harus tahu path XDG, format file, dan cara refresh cache KDE/GNOME.
2. Binary portable (Winbox, hasil build Go, tool CLI) sering tidak muncul di menu setelah diunduh.
3. PWA seperti WhatsApp Web, YouTube Music, atau Figma tidak bisa diakses sebagai "aplikasi" tanpa browser berbasis Chromium dengan flag khusus.
4. Tidak ada tool sederhana yang menggabungkan kedua kebutuhan di atas dengan antarmuka GUI.
---
## 3. Tujuan
- Membuat shortcut aplikasi dalam **< 30 detik** tanpa terminal.
- Mendukung **dua sumber**: Executable dan PWA.
- Menyediakan kontrol fleksibel: shortcut muncul di **menu aplikasi**, **desktop**, atau **keduanya**.
- Install, uninstall, dan update shortcut dengan mudah.
- Aplikasi LazyRun sendiri portable sebagai **single-file AppImage**.
### Non-Goals (v1.0)
- Tidak mendukung Windows/macOS.
- Tidak mengubah target menjadi AppImage.
- Tidak mengelola dependency binary target.
- Tidak menyediakan browser embedded untuk PWA.
---
## 4. Target Pengguna
- Pengguna Linux desktop yang sering mengunduh binary portable.
- Developer Go yang ingin hasil build muncul di menu.
- Pengguna yang ingin "install" PWA tanpa Chrome/Edge.
- Pengguna KDE Plasma (prioritas) dan GNOME.
---
## 5. Fitur Utama
### 5.1 Fungsi A — Shortcut dari Executable
Membuat shortcut dari file binary eksekutabel (ELF, AppImage, script dengan shebang).
**Input:**
- File binary (file picker).
- Ikon (opsional; auto-detect dari folder yang sama jika kosong).
- Nama aplikasi (auto-filled dari nama file).
- Kategori (Utility, Network, Development, dll).
- **Target lokasi** (lihat §5.3).
**Proses:**
1. Salin binary ke `~/.local/bin/<nama>` (mode `0755`).
2. Salin ikon ke `~/.local/share/icons/hicolor/512x512/apps/`.
3. Tulis file `.desktop` di `~/.local/share/applications/`.
4. Jika "Desktop" dicentang, salin juga ke `~/Desktop/` dan set `chmod +x`.
5. Refresh cache: `update-desktop-database` + `kbuildsycoca6 --noincremental`.
### 5.2 Fungsi B — Shortcut dari PWA (URL)
Membuat shortcut yang membuka URL di browser dalam mode app.
**Input:**
- URL (contoh: `https://web.whatsapp.com`).
- Nama aplikasi.
- Browser target (auto-detect: Chrome, Chromium, Brave, Edge, Firefox).
- Ikon (opsional; auto-fetch favicon dari URL jika kosong).
- **Target lokasi** (lihat §5.3).
**Proses:**
1. Fetch favicon atau pakai ikon default.
2. Simpan ikon ke direktori ikon XDG.
3. Tulis `.desktop` dengan `Exec=` menyesuaikan browser:
- Chromium-based: `<browser> --app=<URL>`
- Firefox: `<browser> --new-window <URL>`
4. Jika "Desktop" dicentang, salin ke `~/Desktop/` dan set `chmod +x`.
5. Refresh cache.
### 5.3 Kontrol Lokasi Shortcut (Fitur Kunci)
Setiap kali membuat shortcut, pengguna memilih lokasi via **checkbox** yang fleksibel:
```
┌─ Lokasi Shortcut ─────────────────────┐
│ [✓] Menu Aplikasi │
│ [ ] Desktop │
└───────────────────────────────────────┘
```
| Menu Aplikasi | Desktop | Hasil |
|:---:|:---:|---|
| ✓ | ✗ | Hanya muncul di menu aplikasi (launcher) |
| ✗ | ✓ | Hanya muncul di desktop |
| ✓ | ✓ | Muncul di keduanya (rekomendasi default) |
| ✗ | ✗ | Diblokir — minimal satu harus dipilih |
**Detail teknis:**
- **Menu Aplikasi**: file `.desktop` ditulis ke `~/.local/share/applications/`.
- **Desktop**: file `.desktop` yang sama di-*copy* ke `~/Desktop/` dengan `chmod +x` agar bisa diklik ganda.
- Sumber kebenaran tetap di `~/.local/share/applications/`; file di `~/Desktop/` adalah salinan (symlink opsional di v1.1).
- Saat uninstall, keduanya dihapus secara sinkron.
### 5.4 Manajemen Shortcut
- **List Shortcut**: menampilkan semua shortcut yang dibuat LazyRun.
- **Status**: menampilkan ikon/badge jika shortcut ada di Menu, Desktop, atau keduanya.
- **Uninstall**: hapus file `.desktop` dari semua lokasi, ikon, dan binary (opsional).
- **Open Location**: buka folder binary atau desktop di file manager.
### 5.5 Update Aplikasi LazyRun
- Bundle AppImage dengan dukungan **zsync**.
- Menu **"Check for Updates"** memanggil `AppImageUpdate`.
- Update otomatis dari GitHub Releases.
---
## 6. User Interface
### 6.1 Window Utama
```
┌──────────────────────────────────────────────────┐
│ LazyRun — lazy shortcut build │
├──────────────────────────────────────────────────┤
│ [+ Executable] [+ Web App] │
├──────────────────────────────────────────────────┤
│ Shortcut Terinstall: │
│ ┌────────────────────────────────────────────┐ │
│ │ 🟦 Winbox Network [Menu][Desk] │ │
│ │ 🌐 WhatsApp Web Web [Menu] │ │
│ │ 🟩 my-go-app Utility [Menu][Desk] │ │
│ └────────────────────────────────────────────┘ │
├──────────────────────────────────────────────────┤
│ [Check for Updates] v1.0.0 │
└──────────────────────────────────────────────────┘
```
### 6.2 Dialog "New Executable Shortcut"
```
┌──────────────────────────────────────────┐
│ New Executable Shortcut │
├──────────────────────────────────────────┤
│ Binary: [ Browse... ] │
│ Icon: [ Browse... ] (opsional) │
│ Nama: [ Winbox ] │
│ Kategori: [ Network ▼ ] │
│ │
│ ── Lokasi Shortcut ── │
│ [✓] Menu Aplikasi │
│ [✓] Desktop │
│ │
│ [ Cancel ] [ Install ] │
└──────────────────────────────────────────┘
```
### 6.3 Dialog "New Web App Shortcut"
```
┌──────────────────────────────────────────┐
│ New Web App Shortcut │
├──────────────────────────────────────────┤
│ URL: [ https://... ] │
│ Nama: [ WhatsApp Web ] │
│ Browser: [ Chrome ▼ ] │
│ Icon: [ Auto-fetch ] (opsi) │
│ │
│ ── Lokasi Shortcut ── │
│ [✓] Menu Aplikasi │
│ [✓] Desktop │
│ │
│ [ Cancel ] [ Install ] │
└──────────────────────────────────────────┘
```
---
## 7. Arsitektur Teknis
### 7.1 Stack
| Komponen | Teknologi |
|---|---|
| Bahasa | Go 1.21+ |
| GUI | Fyne |
| Desktop entry | XDG `.desktop` spec |
| Cache refresh | `update-desktop-database`, `kbuildsycoca6`/`5` |
| Distribusi | AppImage + `appimagetool -u` (zsync) |
### 7.2 Struktur Direktori Proyek
```
lazyrun/
├── cmd/lazyrun/main.go
├── internal/
│ ├── shortcut/
│ │ ├── executable.go # Fungsi A
│ │ ├── webapp.go # Fungsi B
│ │ ├── desktop.go # Generate .desktop
│ │ ├── desktopdir.go # Copy ke ~/Desktop
│ │ └── cache.go # Refresh cache
│ ├── icon/
│ │ ├── favicon.go
│ │ └── install.go
│ ├── browser/detect.go
│ ├── store/store.go # Metadata JSON
│ └── ui/
│ ├── main_window.go
│ ├── tab_executable.go
│ └── tab_webapp.go
├── assets/
│ ├── icon.png
│ └── lazyrun.desktop
└── build.sh
```
### 7.3 Path XDG
| Tujuan | Path |
|---|---|
| Binary | `~/.local/bin/` |
| Ikon | `~/.local/share/icons/hicolor/512x512/apps/` |
| Menu aplikasi | `~/.local/share/applications/` |
| Desktop | `~/Desktop/` (via `xdg-user-dir DESKTOP`) |
| Metadata | `~/.config/lazyrun/shortcuts.json` |
### 7.4 Metadata Internal
`~/.config/lazyrun/shortcuts.json` menyimpan daftar shortcut LazyRun:
```json
[
{
"name": "Winbox",
"type": "executable",
"binary_path": "/home/user/.local/bin/winbox",
"icon_path": "/home/user/.local/share/icons/hicolor/512x512/apps/Winbox.png",
"desktop_path": "/home/user/.local/share/applications/Winbox.desktop",
"desktop_copy_path": "/home/user/Desktop/Winbox.desktop",
"targets": {
"menu": true,
"desktop": true
},
"installed_at": "2026-10-13T10:00:00Z"
}
]
```
---
## 8. Alur Install & Uninstall
### 8.1 Install
```
User klik Install
│
▼
Validasi input (min. satu target dicentang)
│
▼
Salin binary + ikon ke XDG paths
│
▼
Tulis .desktop di ~/.local/share/applications/
│
├── Jika [Menu] ✓ → selesai di sini
│
└── Jika [Desktop] ✓ → salin .desktop ke ~/Desktop/ + chmod +x
│
▼
Refresh cache (update-desktop-database + kbuildsycoca)
│
▼
Simpan metadata ke shortcuts.json
│
▼
Tampilkan notifikasi sukses
```
### 8.2 Uninstall
```
User klik 🗑 pada shortcut
│
▼
Konfirmasi dialog
│
▼
Hapus .desktop di semua lokasi (menu + desktop)
│
▼
Hapus ikon (opsional)
│
▼
Hapus binary (opsional, checkbox "hapus binary juga")
│
▼
Refresh cache
│
▼
Update shortcuts.json
```
---
## 9. Alur Build AppImage
```bash
# 1. Build binary
go build -o lazyrun ./cmd/lazyrun
# 2. Siapkan AppDir
mkdir -p lazyrun.AppDir/usr/bin
cp lazyrun lazyrun.AppDir/usr/bin/
cp assets/icon.png lazyrun.AppDir/lazyrun.png
cp assets/lazyrun.desktop lazyrun.AppDir/
ln -s usr/bin/lazyrun lazyrun.AppDir/AppRun
# 3. Build AppImage dengan dukungan update
./appimagetool-x86_64.AppImage \
-u "gh-releases-zsync|user|lazyrun|latest|lazyrun-*.AppImage.zsync" \
lazyrun.AppDir
```
---
## 10. Persyaratan Non-Fungsional
| Aspek | Target |
|---|---|
| Ukuran AppImage | < 30 MB |
| Startup time | < 2 detik |
| Bahasa UI | Indonesia + Inggris |
| Kompatibilitas | KDE Plasma 5 & 6, GNOME 42+ |
| Hak akses | Tanpa root, semua di `~/.local/` |
| Fallback cache | `kbuildsycoca6` → `kbuildsycoca5` → skip |
| Desktop path | Auto-detect via `xdg-user-dir DESKTOP` |
---
## 11. Roadmap
### v1.0 (MVP)
- [ ] GUI Fyne dengan window utama & 2 dialog.
- [ ] Fungsi A: shortcut dari executable.
- [ ] Fungsi B: shortcut dari PWA (Chrome/Chromium/Brave).
- [ ] Checkbox "Menu Aplikasi" & "Desktop" dengan validasi min. satu.
- [ ] Uninstall shortcut.
- [ ] Deteksi browser terinstall.
- [ ] Bundle AppImage + zsync update.
### v1.1
- [ ] Edit shortcut yang sudah ada.
- [ ] Auto-detect binary di `$PATH` (htop, nvim, dll).
- [ ] Symlink desktop (bukan copy) sebagai opsi.
- [ ] Import/Export daftar shortcut.
- [ ] Dark mode otomatis.
### v1.2
- [ ] Dukungan Firefox PWA (via `--kiosk`).
- [ ] Dukungan Flatpak & Snap.
- [ ] Preview ikon sebelum install.
---
## 12. Risiko & Mitigasi
| Risiko | Mitigasi |
|---|---|
| `~/Desktop/` bukan bahasa Inggris (mis. `~/Desktop` vs `~/デスクトップ`) | Gunakan `xdg-user-dir DESKTOP` |
| KDE versi beda (`kbuildsycoca5`/`6`) | Deteksi otomatis, fallback berurutan |
| Firefox tanpa mode PWA penuh | Fallback `--new-window`, atau rekomendasi Chromium |
| Favicon gagal di-fetch | Fallback ke ikon default LazyRun |
| User hapus file desktop manual | Deteksi saat list, tandai "broken", tawarkan repair |
| File `.desktop` di `~/Desktop/` perlu `chmod +x` | Set otomatis saat install |
---
## 13. Kriteria Sukses
- Shortcut dapat dibuat dalam **< 30 detik** tanpa terminal.
- 90% pengguna KDE melihat shortcut muncul **tanpa restart**.
- Checkbox Menu/Desktop berfungsi konsisten di KDE & GNOME.
- Berjalan sebagai **single-file AppImage** di Ubuntu, Fedora, Arch, openSUSE.
- Update otomatis berjalan tanpa intervensi manual.
---
## 14. Lampiran: Contoh File `.desktop` Hasil LazyRun
**Executable:**
```ini
[Desktop Entry]
Type=Application
Name=Winbox
Exec=/home/user/.local/bin/winbox
Icon=/home/user/.local/share/icons/hicolor/512x512/apps/Winbox.png
Terminal=false
Categories=Network;
Comment=Created by LazyRun
```
**PWA:**
```ini
[Desktop Entry]
Type=Application
Name=WhatsApp Web
Exec=/usr/bin/google-chrome --app=https://web.whatsapp.com
Icon=/home/user/.local/share/icons/hicolor/512x512/apps/WhatsApp Web.png
Terminal=false
Categories=Network;WebBrowser;
Comment=Created by LazyRun
```
---
*PRD ini adalah living document. Perubahan signifikan akan dicatat di changelog versi berikutnya.*