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 0000000..8a81e1c Binary files /dev/null and b/assets/icon.png differ diff --git a/assets/lazyrun.desktop b/assets/lazyrun.desktop new file mode 100644 index 0000000..3b0dd69 --- /dev/null +++ b/assets/lazyrun.desktop @@ -0,0 +1,11 @@ +[Desktop Entry] +Type=Application +Name=LazyRun +GenericName=Shortcut Maker +Comment=lazy shortcut build +Exec=lazyrun +Icon=lazyrun +Terminal=false +Categories=Utility; +Keywords=shortcut;desktop;pwa;appimage; +StartupNotify=false diff --git a/build.sh b/build.sh new file mode 100755 index 0000000..934617a --- /dev/null +++ b/build.sh @@ -0,0 +1,57 @@ +#!/usr/bin/env bash +# Build binary LazyRun, lalu bungkus jadi AppImage + zsync update info. +set -euo pipefail +cd "$(dirname "$0")" + +VERSION="${VERSION:-1.0.0}" +OUT="${OUT:-dist}" +ARCH="$(uname -m)" +TOOLS="$OUT/tools" + +mkdir -p "$OUT" "$TOOLS" + +echo "==> build binary" +CGO_ENABLED=1 go build -trimpath -ldflags "-s -w -X main.version=$VERSION" -o "$OUT/lazyrun" ./cmd/lazyrun + +if [[ "${SKIP_APPIMAGE:-0}" == "1" ]]; then + echo "==> SKIP_APPIMAGE=1, selesai" + exit 0 +fi + +echo "==> siapkan AppDir" +APPDIR="$OUT/LazyRun.AppDir" +rm -rf "$APPDIR" +mkdir -p "$APPDIR/usr/bin" +cp "$OUT/lazyrun" "$APPDIR/usr/bin/lazyrun" +cp assets/lazyrun.desktop "$APPDIR/lazyrun.desktop" +cp assets/icon.png "$APPDIR/lazyrun.png" +ln -sf usr/bin/lazyrun "$APPDIR/AppRun" + +if [[ ! -x "$TOOLS/linuxdeploy" ]]; then + echo "==> download linuxdeploy" + curl -fL --retry 3 -o "$TOOLS/linuxdeploy" \ + "https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-$ARCH.AppImage" + chmod +x "$TOOLS/linuxdeploy" +fi + +# linuxdeploy butuh info ini untuk menamai output dan menyisipkan zsync update info. +export ARCH +export LINUXDEPLOY_OUTPUT_APP_NAME=lazyrun +export LINUXDEPLOY_OUTPUT_VERSION="$VERSION" +export LDAI_OUTPUT="$PWD/$OUT/lazyrun-$VERSION-$ARCH.AppImage" +if [[ -n "${GH_USER:-}" && -n "${GH_REPO:-}" ]]; then + export LDAI_UPDATE_INFORMATION="gh-releases-zsync|$GH_USER|$GH_REPO|latest|lazyrun-*.AppImage.zsync" +else + echo "==> catatan: GH_USER/GH_REPO kosong, AppImage tanpa update info zsync" + export LDAI_NO_APPSTREAM=1 +fi + +echo "==> bundle + package AppImage" +"$TOOLS/linuxdeploy" --appimage-extract-and-run \ + --appdir "$APPDIR" \ + --desktop-file "$APPDIR/lazyrun.desktop" \ + --icon-file "$APPDIR/lazyrun.png" \ + --output appimage + +ls -lh "$OUT"/lazyrun-*.AppImage* 2>/dev/null || true +mv -f "lazyrun-$VERSION-$ARCH.AppImage.zsync" "$OUT/" 2>/dev/null || true 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.*