From da31f7d7d7a173ea3ecdfe4d63285a9a12463b3a Mon Sep 17 00:00:00 2001 From: oxpoe Date: Tue, 13 Oct 2026 06:14:53 +0700 Subject: [PATCH] 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 --- .gitignore | 1 + README.md | 101 +++++++ assets/assets.go | 7 + assets/icon.png | Bin 0 -> 27907 bytes assets/lazyrun.desktop | 11 + build.sh | 57 ++++ cmd/lazyrun/main.go | 14 + go.mod | 42 +++ go.sum | 79 ++++++ internal/browser/detect.go | 115 ++++++++ internal/icon/favicon.go | 175 ++++++++++++ internal/icon/favicon_test.go | 112 ++++++++ internal/icon/install.go | 82 ++++++ internal/shortcut/cache.go | 26 ++ internal/shortcut/desktop.go | 89 ++++++ internal/shortcut/desktop_test.go | 89 ++++++ internal/shortcut/executable.go | 213 +++++++++++++++ internal/shortcut/install.go | 105 ++++++++ internal/shortcut/install_test.go | 250 +++++++++++++++++ internal/shortcut/uninstall.go | 41 +++ internal/store/store.go | 123 +++++++++ internal/store/store_test.go | 60 +++++ internal/ui/dialog_executable.go | 131 +++++++++ internal/ui/dialog_webapp.go | 141 ++++++++++ internal/ui/main_window.go | 180 +++++++++++++ internal/ui/main_window_test.go | 35 +++ internal/ui/strings.go | 83 ++++++ internal/ui/update.go | 26 ++ internal/xdg/xdg.go | 54 ++++ internal/xdg/xdg_test.go | 39 +++ prd.md | 435 ++++++++++++++++++++++++++++++ 31 files changed, 2916 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 assets/assets.go create mode 100644 assets/icon.png create mode 100644 assets/lazyrun.desktop create mode 100755 build.sh create mode 100644 cmd/lazyrun/main.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 internal/browser/detect.go create mode 100644 internal/icon/favicon.go create mode 100644 internal/icon/favicon_test.go create mode 100644 internal/icon/install.go create mode 100644 internal/shortcut/cache.go create mode 100644 internal/shortcut/desktop.go create mode 100644 internal/shortcut/desktop_test.go create mode 100644 internal/shortcut/executable.go create mode 100644 internal/shortcut/install.go create mode 100644 internal/shortcut/install_test.go create mode 100644 internal/shortcut/uninstall.go create mode 100644 internal/store/store.go create mode 100644 internal/store/store_test.go create mode 100644 internal/ui/dialog_executable.go create mode 100644 internal/ui/dialog_webapp.go create mode 100644 internal/ui/main_window.go create mode 100644 internal/ui/main_window_test.go create mode 100644 internal/ui/strings.go create mode 100644 internal/ui/update.go create mode 100644 internal/xdg/xdg.go create mode 100644 internal/xdg/xdg_test.go create mode 100644 prd.md diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..849ddff --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +dist/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..af29985 --- /dev/null +++ b/README.md @@ -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=` 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/` | +| Icon | `~/.local/share/icons/hicolor/512x512/apps/.png` | +| Menu entry (source of truth) | `~/.local/share/applications/.desktop` | +| Desktop copy | `~/Desktop/.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--.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. diff --git a/assets/assets.go b/assets/assets.go new file mode 100644 index 0000000..b49efeb --- /dev/null +++ b/assets/assets.go @@ -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 diff --git a/assets/icon.png b/assets/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..8a81e1c72b5cb4813f81c5b42adf76eb5f31eaac GIT binary patch literal 27907 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 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 diff --git a/cmd/lazyrun/main.go b/cmd/lazyrun/main.go new file mode 100644 index 0000000..eb40181 --- /dev/null +++ b/cmd/lazyrun/main.go @@ -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) +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..f3db88b --- /dev/null +++ b/go.mod @@ -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 +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..6907866 --- /dev/null +++ b/go.sum @@ -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= diff --git a/internal/browser/detect.go b/internal/browser/detect.go new file mode 100644 index 0000000..d985817 --- /dev/null +++ b/internal/browser/detect.go @@ -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=. + 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 +} diff --git a/internal/icon/favicon.go b/internal/icon/favicon.go new file mode 100644 index 0000000..ff67dd5 --- /dev/null +++ b/internal/icon/favicon.go @@ -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 . +var ( + linkTag = regexp.MustCompile(`(?is)]*>`) + 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(" 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 +} diff --git a/internal/icon/favicon_test.go b/internal/icon/favicon_test.go new file mode 100644 index 0000000..9fcec1e --- /dev/null +++ b/internal/icon/favicon_test.go @@ -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, ``+ + ``+ + ``+ + ``) + 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, `no icon`) + 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(``)); 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) + } +} diff --git a/internal/icon/install.go b/internal/icon/install.go new file mode 100644 index 0000000..9caf11d --- /dev/null +++ b/internal/icon/install.go @@ -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 } diff --git a/internal/shortcut/cache.go b/internal/shortcut/cache.go new file mode 100644 index 0000000..7e62d50 --- /dev/null +++ b/internal/shortcut/cache.go @@ -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 + } + } +} diff --git a/internal/shortcut/desktop.go b/internal/shortcut/desktop.go new file mode 100644 index 0000000..be5f074 --- /dev/null +++ b/internal/shortcut/desktop.go @@ -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(), "-") +} diff --git a/internal/shortcut/desktop_test.go b/internal/shortcut/desktop_test.go new file mode 100644 index 0000000..a9a3141 --- /dev/null +++ b/internal/shortcut/desktop_test.go @@ -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") + } +} diff --git a/internal/shortcut/executable.go b/internal/shortcut/executable.go new file mode 100644 index 0000000..a4c55fd --- /dev/null +++ b/internal/shortcut/executable.go @@ -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 +} diff --git a/internal/shortcut/install.go b/internal/shortcut/install.go new file mode 100644 index 0000000..1ac32ca --- /dev/null +++ b/internal/shortcut/install.go @@ -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) +} diff --git a/internal/shortcut/install_test.go b/internal/shortcut/install_test.go new file mode 100644 index 0000000..522a8a3 --- /dev/null +++ b/internal/shortcut/install_test.go @@ -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(``)) + 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) + } +} diff --git a/internal/shortcut/uninstall.go b/internal/shortcut/uninstall.go new file mode 100644 index 0000000..e865236 --- /dev/null +++ b/internal/shortcut/uninstall.go @@ -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...) +} diff --git a/internal/store/store.go b/internal/store/store.go new file mode 100644 index 0000000..7dde37c --- /dev/null +++ b/internal/store/store.go @@ -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 +} diff --git a/internal/store/store_test.go b/internal/store/store_test.go new file mode 100644 index 0000000..1663ccd --- /dev/null +++ b/internal/store/store_test.go @@ -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) + } +} diff --git a/internal/ui/dialog_executable.go b/internal/ui/dialog_executable.go new file mode 100644 index 0000000..6f4c08e --- /dev/null +++ b/internal/ui/dialog_executable.go @@ -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 +} diff --git a/internal/ui/dialog_webapp.go b/internal/ui/dialog_webapp.go new file mode 100644 index 0000000..06232f7 --- /dev/null +++ b/internal/ui/dialog_webapp.go @@ -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() +} diff --git a/internal/ui/main_window.go b/internal/ui/main_window.go new file mode 100644 index 0000000..78e0c04 --- /dev/null +++ b/internal/ui/main_window.go @@ -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() } diff --git a/internal/ui/main_window_test.go b/internal/ui/main_window_test.go new file mode 100644 index 0000000..7349a87 --- /dev/null +++ b/internal/ui/main_window_test.go @@ -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) + } +} diff --git a/internal/ui/strings.go b/internal/ui/strings.go new file mode 100644 index 0000000..409fd45 --- /dev/null +++ b/internal/ui/strings.go @@ -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" +} diff --git a/internal/ui/update.go b/internal/ui/update.go new file mode 100644 index 0000000..0826261 --- /dev/null +++ b/internal/ui/update.go @@ -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) + } +} diff --git a/internal/xdg/xdg.go b/internal/xdg/xdg.go new file mode 100644 index 0000000..9252da1 --- /dev/null +++ b/internal/xdg/xdg.go @@ -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") +} diff --git a/internal/xdg/xdg_test.go b/internal/xdg/xdg_test.go new file mode 100644 index 0000000..3619dd1 --- /dev/null +++ b/internal/xdg/xdg_test.go @@ -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) + } +} diff --git a/prd.md b/prd.md new file mode 100644 index 0000000..25e1547 --- /dev/null +++ b/prd.md @@ -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/` (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: ` --app=` + - Firefox: ` --new-window ` +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.*