LazyRun v1.0.0: shortcut maker untuk executable dan PWA
- Fungsi A: shortcut dari file executable (ELF/AppImage/script) - Fungsi B: shortcut PWA/URL dengan deteksi browser (native, /opt, Flatpak) - Checkbox Menu Aplikasi / Desktop, desktop-only pakai NoDisplay=true - List + uninstall + deteksi shortcut yang file .desktop-nya hilang - Metadata ~/.config/lazyrun/shortcuts.json, refresh cache XDG/KDE - AppImage + zsync update info via build.sh
This commit is contained in:
1 parent
9cd5a1f82d
commit
da31f7d7d7
31 files changed
+2916
No files matched your search
@@ -0,0 +1 @@
|
||||
dist/
|
||||
@@ -0,0 +1,101 @@
|
||||
# LazyRun — lazy shortcut build
|
||||
|
||||
Desktop app (Go + Fyne) that creates application shortcuts on Linux in a few clicks:
|
||||
from an executable file, or from a web URL as a PWA. Ships as a single-file
|
||||
AppImage, no terminal needed.
|
||||
|
||||
- **Executable shortcut** — picks an ELF/AppImage/script, copies it to
|
||||
`~/.local/bin/`, installs the icon, writes the `.desktop` entry.
|
||||
- **Web app shortcut** — opens the URL as an app: `--app=<url>` for
|
||||
Chromium-based browsers (Chrome, Chromium, Brave, Edge, Vivaldi, Helium,
|
||||
including Flatpak builds), `--new-window` for Firefox.
|
||||
- **Shortcut location** — checkbox for *Application menu* and/or *Desktop*,
|
||||
at least one is required. Desktop-only entries are written with
|
||||
`NoDisplay=true` so they do not show up in the menu.
|
||||
- **Manage** — list installed shortcuts, see where they live, mark entries whose
|
||||
`.desktop` file was deleted manually, uninstall (optionally removing the
|
||||
copied binary).
|
||||
- **Auto refresh** — calls `update-desktop-database` and
|
||||
`kbuildsycoca6`/`kbuildsycoca5` after install/uninstall.
|
||||
- **i18n** — UI in English or Indonesian, chosen from `$LANG`.
|
||||
- **Self update** — AppImage with embedded zsync update information, optional
|
||||
`AppImageUpdate` integration via *Check for Updates*.
|
||||
|
||||
## Install
|
||||
|
||||
Download the AppImage from [Releases](../../releases), then:
|
||||
|
||||
```bash
|
||||
chmod +x lazyrun-*-x86_64.AppImage
|
||||
./lazyrun-*-x86_64.AppImage
|
||||
```
|
||||
|
||||
Keep it wherever you like (for example `~/.local/bin/`). Automatic updates need
|
||||
[AppImageUpdate](https://github.com/AppImageCommunity/AppImageUpdate) in `$PATH`.
|
||||
|
||||
## Usage
|
||||
|
||||
1. **+ Executable** or **+ Web App**.
|
||||
2. Fill in the form (name and icon are pre-filled/auto-detected).
|
||||
3. Pick *Application menu* and/or *Desktop*, then **Install**.
|
||||
|
||||
## What it writes
|
||||
|
||||
| Purpose | Path |
|
||||
|---|---|
|
||||
| Copied binary | `~/.local/bin/<name>` |
|
||||
| Icon | `~/.local/share/icons/hicolor/512x512/apps/<name>.png` |
|
||||
| Menu entry (source of truth) | `~/.local/share/applications/<name>.desktop` |
|
||||
| Desktop copy | `~/Desktop/<name>.desktop` (from `xdg-user-dir DESKTOP`) |
|
||||
| LazyRun metadata | `~/.config/lazyrun/shortcuts.json` |
|
||||
|
||||
Everything stays inside the home directory — no root, no system directories.
|
||||
Filenames use a slug of the app name (`WhatsApp Web` → `whatsapp-web.desktop`);
|
||||
the `Name=` field keeps the name you typed.
|
||||
|
||||
## Build
|
||||
|
||||
Requirements: Go 1.24+, cgo, and X11/OpenGL development headers (Fyne).
|
||||
|
||||
```bash
|
||||
./build.sh # dist/lazyrun + dist/lazyrun-<version>-<arch>.AppImage
|
||||
SKIP_APPIMAGE=1 ./build.sh # binary only
|
||||
go test ./... # unit + install/uninstall tests
|
||||
```
|
||||
|
||||
`build.sh` downloads `linuxdeploy` into `dist/tools/` on first run and needs
|
||||
network for the AppImage step. To embed zsync update information, set
|
||||
`GH_USER` and `GH_REPO`:
|
||||
|
||||
```bash
|
||||
GH_USER=oxpoe GH_REPO=lazyrun ./build.sh
|
||||
```
|
||||
|
||||
## Roadmap
|
||||
|
||||
- v1.1 — edit existing shortcuts, detect binaries in `$PATH`, optional symlink
|
||||
instead of a Desktop copy, import/export, dark mode.
|
||||
- v1.2 — Firefox PWA via `--kiosk`, Flatpak/Snap targets, icon preview.
|
||||
|
||||
## Disclaimer / Catatan
|
||||
|
||||
**EN** — LazyRun is a small hobby project by **oxpoe**, made for fun and to make
|
||||
daily Linux desktop life a bit simpler: turn a portable binary or a web page
|
||||
into a proper application shortcut without opening a terminal. It only writes
|
||||
inside your home directory, but it does copy files and create launchers: check
|
||||
what you point it at, keep backups of anything irreplaceable, and use it at your
|
||||
own risk. Provided "as is", without warranty of any kind. LazyRun is not
|
||||
affiliated with any browser vendor or application it creates shortcuts for.
|
||||
|
||||
**ID** — LazyRun adalah proyek iseng **oxpoe**, dibuat untuk senang-senang dan
|
||||
supaya urusan shortcut di desktop Linux jadi lebih simpel: binary portable atau
|
||||
halaman web bisa jadi shortcut aplikasi tanpa buka terminal. Aplikasi ini hanya
|
||||
menulis di dalam home directory, tetapi tetap menyalin file dan membuat
|
||||
launcher: periksa dulu apa yang kamu pilih, backup data penting, dan gunakan
|
||||
dengan risiko sendiri. Disediakan "apa adanya", tanpa jaminan apa pun. LazyRun
|
||||
tidak berafiliasi dengan vendor browser atau aplikasi apa pun yang dibuatkan
|
||||
shortcut-nya.
|
||||
|
||||
## License
|
||||
|
||||
MIT.
|
||||
@@ -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
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 27 KiB |
@@ -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
|
||||
@@ -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
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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=
|
||||
@@ -0,0 +1,115 @@
|
||||
// Package browser mendeteksi browser yang terinstall untuk dipakai shortcut PWA.
|
||||
package browser
|
||||
|
||||
import (
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Kind menentukan cara membuka URL dalam mode aplikasi.
|
||||
type Kind int
|
||||
|
||||
const (
|
||||
// Chromium membuka PWA dengan --app=<url>.
|
||||
Chromium Kind = iota
|
||||
// Firefox tidak punya mode app; dipakai --new-window.
|
||||
Firefox
|
||||
)
|
||||
|
||||
// Browser adalah browser siap dipanggil.
|
||||
type Browser struct {
|
||||
Name string
|
||||
Exec []string // argv lengkap, sudah termasuk argumen flatpak
|
||||
Kind Kind
|
||||
}
|
||||
|
||||
// AppArgs adalah argumen yang membuka url sebagai aplikasi.
|
||||
func (b Browser) AppArgs(url string) []string {
|
||||
if b.Kind == Firefox {
|
||||
return []string{"--new-window", url}
|
||||
}
|
||||
return []string{"--app=" + url}
|
||||
}
|
||||
|
||||
type candidate struct {
|
||||
name string
|
||||
kind Kind
|
||||
bin []string // nama di $PATH (tanpa "/") atau path absolut
|
||||
}
|
||||
|
||||
var candidates = []candidate{
|
||||
{"Google Chrome", Chromium, []string{"google-chrome", "google-chrome-stable", "/opt/google/chrome/chrome"}},
|
||||
{"Chromium", Chromium, []string{"chromium", "chromium-browser"}},
|
||||
{"Brave", Chromium, []string{"brave-browser", "brave", "/opt/brave.com/brave/brave"}},
|
||||
{"Microsoft Edge", Chromium, []string{"microsoft-edge", "microsoft-edge-stable"}},
|
||||
{"Vivaldi", Chromium, []string{"vivaldi", "vivaldi-stable", "/opt/vivaldi/vivaldi"}},
|
||||
{"Helium", Chromium, []string{"helium", "/opt/helium-browser-bin/helium"}},
|
||||
{"Firefox", Firefox, []string{"firefox", "firefox-esr", "/usr/lib/firefox/firefox"}},
|
||||
}
|
||||
|
||||
var flatpakApps = []struct {
|
||||
id, name string
|
||||
kind Kind
|
||||
}{
|
||||
{"com.google.Chrome", "Google Chrome (Flatpak)", Chromium},
|
||||
{"org.chromium.Chromium", "Chromium (Flatpak)", Chromium},
|
||||
{"com.brave.Browser", "Brave (Flatpak)", Chromium},
|
||||
{"com.microsoft.Edge", "Microsoft Edge (Flatpak)", Chromium},
|
||||
{"org.mozilla.firefox", "Firefox (Flatpak)", Firefox},
|
||||
}
|
||||
|
||||
// Detect mengembalikan browser yang ada di sistem, native dulu lalu flatpak.
|
||||
func Detect() []Browser {
|
||||
var out []Browser
|
||||
seen := map[string]bool{}
|
||||
|
||||
add := func(b Browser) {
|
||||
if !seen[b.Name] {
|
||||
seen[b.Name] = true
|
||||
out = append(out, b)
|
||||
}
|
||||
}
|
||||
|
||||
for _, c := range candidates {
|
||||
for _, bin := range c.bin {
|
||||
path := bin
|
||||
if !strings.Contains(bin, "/") {
|
||||
p, err := exec.LookPath(bin)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
path = p
|
||||
} else if st, err := os.Stat(bin); err != nil || st.IsDir() {
|
||||
continue
|
||||
}
|
||||
add(Browser{Name: c.name, Exec: []string{path}, Kind: c.kind})
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
installed := flatpakList()
|
||||
for _, f := range flatpakApps {
|
||||
if installed[f.id] {
|
||||
add(Browser{Name: f.name, Exec: []string{"flatpak", "run", f.id}, Kind: f.kind})
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func flatpakList() map[string]bool {
|
||||
out := map[string]bool{}
|
||||
if _, err := exec.LookPath("flatpak"); err != nil {
|
||||
return out
|
||||
}
|
||||
data, err := exec.Command("flatpak", "list", "--app", "--columns=application").Output()
|
||||
if err != nil {
|
||||
return out
|
||||
}
|
||||
for _, line := range strings.Split(string(data), "\n") {
|
||||
if id := strings.TrimSpace(line); id != "" {
|
||||
out[id] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
@@ -0,0 +1,175 @@
|
||||
// Package icon: favicon.go mengambil ikon dari halaman web untuk shortcut PWA.
|
||||
package icon
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
|
||||
var client = &http.Client{Timeout: 8 * time.Second}
|
||||
|
||||
// shortcut: parsing HTML pakai regex, bukan parser penuh; cukup untuk tag <link rel=icon>.
|
||||
var (
|
||||
linkTag = regexp.MustCompile(`(?is)<link\b[^>]*>`)
|
||||
relAttr = regexp.MustCompile(`(?is)\brel\s*=\s*["']?([^"'>]+)`)
|
||||
hrefAttr = regexp.MustCompile(`(?is)\bhref\s*=\s*["']?([^"'\s>]+)`)
|
||||
typeAttr = regexp.MustCompile(`(?is)\btype\s*=\s*["']?([^"'\s>]+)`)
|
||||
sizeAttr = regexp.MustCompile(`(?is)\bsizes\s*=\s*["']?(\d+)`)
|
||||
)
|
||||
|
||||
// Fetch mengambil favicon dari halaman. Return data + ekstensi file (".png", ".jpg", ".svg").
|
||||
func Fetch(pageURL string) ([]byte, string, error) {
|
||||
base, err := url.Parse(pageURL)
|
||||
if err != nil {
|
||||
return nil, "", err
|
||||
}
|
||||
|
||||
var lastErr error
|
||||
for _, candidate := range iconCandidates(base) {
|
||||
data, err := get(candidate)
|
||||
if err != nil {
|
||||
lastErr = err
|
||||
continue
|
||||
}
|
||||
if ext := sniffExt(data); ext != "" {
|
||||
return data, ext, nil
|
||||
}
|
||||
if png := largestPNGFromICO(data); png != nil {
|
||||
return png, ".png", nil
|
||||
}
|
||||
lastErr = errors.New("unsupported icon format at " + candidate)
|
||||
}
|
||||
if lastErr == nil {
|
||||
lastErr = errors.New("no favicon found")
|
||||
}
|
||||
return nil, "", lastErr
|
||||
}
|
||||
|
||||
// iconCandidate adalah kandidat URL ikon dengan skor prioritas.
|
||||
type iconCandidate struct {
|
||||
url string
|
||||
score int
|
||||
}
|
||||
|
||||
// iconCandidates mengumpulkan URL ikon dari HTML, ikon besar didahulukan, favicon.ico terakhir.
|
||||
func iconCandidates(base *url.URL) []string {
|
||||
var found []iconCandidate
|
||||
|
||||
if page, err := get(base.String()); err == nil {
|
||||
for _, tag := range linkTag.FindAllString(string(page), -1) {
|
||||
rel := strings.ToLower(attr(relAttr, tag))
|
||||
if !strings.Contains(rel, "icon") || strings.Contains(rel, "mask-icon") {
|
||||
continue
|
||||
}
|
||||
href := attr(hrefAttr, tag)
|
||||
if href == "" {
|
||||
continue
|
||||
}
|
||||
ref, err := url.Parse(href)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
score := 1
|
||||
if strings.Contains(strings.ToLower(attr(typeAttr, tag)), "png") {
|
||||
score += 10
|
||||
}
|
||||
if size := attr(sizeAttr, tag); size != "" {
|
||||
fmt.Sscanf(size, "%d", &score)
|
||||
score += 10
|
||||
}
|
||||
found = append(found, iconCandidate{base.ResolveReference(ref).String(), score})
|
||||
}
|
||||
}
|
||||
|
||||
sort.SliceStable(found, func(i, j int) bool { return found[i].score > found[j].score })
|
||||
|
||||
out := make([]string, 0, len(found)+1)
|
||||
seen := map[string]bool{}
|
||||
for _, c := range found {
|
||||
if !seen[c.url] {
|
||||
seen[c.url] = true
|
||||
out = append(out, c.url)
|
||||
}
|
||||
}
|
||||
fallback := base.ResolveReference(&url.URL{Path: "/favicon.ico"}).String()
|
||||
if !seen[fallback] {
|
||||
out = append(out, fallback)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func attr(re *regexp.Regexp, tag string) string {
|
||||
if m := re.FindStringSubmatch(tag); m != nil {
|
||||
return m[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func get(rawURL string) ([]byte, error) {
|
||||
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) LazyRun")
|
||||
req.Header.Set("Accept", "text/html,image/*;q=0.9,*/*;q=0.8")
|
||||
|
||||
resp, err := client.Do(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("%s: %s", rawURL, resp.Status)
|
||||
}
|
||||
return io.ReadAll(io.LimitReader(resp.Body, 2<<20))
|
||||
}
|
||||
|
||||
func sniffExt(data []byte) string {
|
||||
head := data
|
||||
if len(head) > 512 {
|
||||
head = head[:512]
|
||||
}
|
||||
switch {
|
||||
case bytes.HasPrefix(data, []byte("\x89PNG\r\n\x1a\n")):
|
||||
return ".png"
|
||||
case bytes.HasPrefix(data, []byte{0xff, 0xd8, 0xff}):
|
||||
return ".jpg"
|
||||
case bytes.Contains(bytes.ToLower(head), []byte("<svg")):
|
||||
return ".svg"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// largestPNGFromICO mengambil PNG terbesar di dalam file .ico.
|
||||
// ICO dari BMP lama tidak didukung (langka untuk favicon).
|
||||
func largestPNGFromICO(data []byte) []byte {
|
||||
if len(data) < 22 || data[0] != 0 || data[1] != 0 || data[2] != 1 || data[3] != 0 {
|
||||
return nil
|
||||
}
|
||||
count := int(data[4]) | int(data[5])<<8
|
||||
var best []byte
|
||||
for i := 0; i < count; i++ {
|
||||
off := 6 + i*16
|
||||
if off+16 > len(data) {
|
||||
break
|
||||
}
|
||||
size := int(data[off+8]) | int(data[off+9])<<8 | int(data[off+10])<<16 | int(data[off+11])<<24
|
||||
start := int(data[off+12]) | int(data[off+13])<<8 | int(data[off+14])<<16 | int(data[off+15])<<24
|
||||
if size <= 0 || start < 0 || start+size > len(data) {
|
||||
continue
|
||||
}
|
||||
blob := data[start : start+size]
|
||||
if bytes.HasPrefix(blob, []byte("\x89PNG\r\n\x1a\n")) && len(blob) > len(best) {
|
||||
best = blob
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
package icon
|
||||
|
||||
import (
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func icoWithPNG(png []byte) []byte {
|
||||
ico := make([]byte, 22+len(png))
|
||||
ico[2], ico[3] = 1, 0 // type = icon
|
||||
ico[4], ico[5] = 1, 0 // jumlah gambar
|
||||
put32 := func(off, v int) {
|
||||
ico[off] = byte(v)
|
||||
ico[off+1] = byte(v >> 8)
|
||||
ico[off+2] = byte(v >> 16)
|
||||
ico[off+3] = byte(v >> 24)
|
||||
}
|
||||
put32(14, len(png)) // bytesInRes
|
||||
put32(18, 22) // imageOffset
|
||||
copy(ico[22:], png)
|
||||
return ico
|
||||
}
|
||||
|
||||
var png = append([]byte("\x89PNG\r\n\x1a\n"), []byte("payload")...)
|
||||
|
||||
func TestFetchUsesLinkedIcon(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/":
|
||||
io.WriteString(w, `<html><head>`+
|
||||
`<link rel="apple-touch-icon" href="/small.ico">`+
|
||||
`<link rel="icon" type="image/png" sizes="192x192" href="/big.png">`+
|
||||
`</head></html>`)
|
||||
case "/big.png":
|
||||
w.Write(png)
|
||||
default:
|
||||
w.Write(icoWithPNG(png))
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
data, ext, err := Fetch(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ext != ".png" || string(data) != string(png) {
|
||||
t.Errorf("ikon terpilih salah: ext=%s data=%q", ext, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchFallsBackToFaviconICO(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/" {
|
||||
io.WriteString(w, `<html><head><title>no icon</title></head></html>`)
|
||||
return
|
||||
}
|
||||
w.Write(icoWithPNG(png))
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
data, ext, err := Fetch(srv.URL)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ext != ".png" || string(data) != string(png) {
|
||||
t.Errorf("PNG di dalam .ico harus dipakai: ext=%s data=%q", ext, data)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFetchFailsWithoutIcon(t *testing.T) {
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
if _, _, err := Fetch(srv.URL); err == nil {
|
||||
t.Error("tanpa favicon harus error supaya UI pakai ikon default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLargestPNGFromICO(t *testing.T) {
|
||||
if got := largestPNGFromICO(icoWithPNG(png)); string(got) != string(png) {
|
||||
t.Errorf("PNG di dalam .ico tidak terbaca: %q", got)
|
||||
}
|
||||
if got := largestPNGFromICO([]byte("bukan ico")); got != nil {
|
||||
t.Errorf("data bukan ICO harus menghasilkan nil, dapat %q", got)
|
||||
}
|
||||
// ICO berisi BMP lama (bukan PNG) tidak didukung.
|
||||
bmp := make([]byte, 40)
|
||||
bmp[0], bmp[1], bmp[2], bmp[3] = 0, 0, 1, 0
|
||||
bmp[4], bmp[5] = 1, 0
|
||||
if got := largestPNGFromICO(bmp); got != nil {
|
||||
t.Errorf("ICO BMP harus mengembalikan nil, dapat %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSniffExt(t *testing.T) {
|
||||
if ext := sniffExt(png); ext != ".png" {
|
||||
t.Errorf("sniffExt(png) = %s", ext)
|
||||
}
|
||||
if ext := sniffExt([]byte(`<?xml version="1.0"?><svg xmlns="..."><path/></svg>`)); ext != ".svg" {
|
||||
t.Errorf("sniffExt(svg) = %s", ext)
|
||||
}
|
||||
if ext := sniffExt([]byte("\xff\xd8\xff\xe0jpeg")); ext != ".jpg" {
|
||||
t.Errorf("sniffExt(jpg) = %s", ext)
|
||||
}
|
||||
if ext := sniffExt([]byte("plain text")); ext != "" {
|
||||
t.Errorf("sniffExt(text) = %s", ext)
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(), "-")
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -0,0 +1,250 @@
|
||||
package shortcut
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"lazyrun/internal/store"
|
||||
)
|
||||
|
||||
var pngMagic = []byte("\x89PNG\r\n\x1a\n")
|
||||
|
||||
// testEnv mengarahkan semua path XDG ke tempdir dan mematikan tool eksternal,
|
||||
// supaya tes tidak menyentuh ~/.local atau ~/Desktop milik user.
|
||||
func testEnv(t *testing.T) string {
|
||||
t.Helper()
|
||||
home := t.TempDir()
|
||||
t.Setenv("HOME", home)
|
||||
t.Setenv("XDG_DATA_HOME", "")
|
||||
t.Setenv("XDG_CONFIG_HOME", "")
|
||||
t.Setenv("PATH", "")
|
||||
return home
|
||||
}
|
||||
|
||||
func fakeBinary(t *testing.T, dir, name string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name)
|
||||
if err := os.WriteFile(path, append([]byte{0x7f, 'E', 'L', 'F'}, []byte("fake")...), 0o755); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
func TestInstallExecutable(t *testing.T) {
|
||||
home := testEnv(t)
|
||||
src := fakeBinary(t, home, "winbox")
|
||||
|
||||
sc, err := InstallExecutable(ExecOptions{
|
||||
BinaryPath: src,
|
||||
Name: "Winbox",
|
||||
Category: "Network",
|
||||
Menu: true,
|
||||
Desktop: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if sc.BinaryPath != filepath.Join(home, ".local", "bin", "winbox") {
|
||||
t.Errorf("binary tersalin ke %s", sc.BinaryPath)
|
||||
}
|
||||
if st, err := os.Stat(sc.BinaryPath); err != nil || st.Mode().Perm()&0o111 == 0 {
|
||||
t.Errorf("binary harus executable: %v %v", st, err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(sc.DesktopPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, want := range []string{
|
||||
"Name=Winbox\n",
|
||||
"Exec=" + sc.BinaryPath + "\n",
|
||||
"Categories=Network;\n",
|
||||
"Icon=" + sc.IconPath + "\n",
|
||||
} {
|
||||
if !strings.Contains(string(content), want) {
|
||||
t.Errorf("isi .desktop tidak memuat %q:\n%s", want, content)
|
||||
}
|
||||
}
|
||||
if strings.Contains(string(content), "NoDisplay") {
|
||||
t.Errorf("menu tercentang tidak boleh NoDisplay:\n%s", content)
|
||||
}
|
||||
|
||||
if sc.DesktopCopyPath != filepath.Join(home, "Desktop", "winbox.desktop") {
|
||||
t.Errorf("salinan desktop di %s", sc.DesktopCopyPath)
|
||||
}
|
||||
if st, err := os.Stat(sc.DesktopCopyPath); err != nil || st.Mode().Perm()&0o111 == 0 {
|
||||
t.Errorf("file di Desktop harus executable: %v %v", st, err)
|
||||
}
|
||||
|
||||
if _, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Winbox", Menu: true, Desktop: true}); err == nil {
|
||||
t.Error("install kedua dengan nama sama harus ditolak")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallExecutableDesktopOnly(t *testing.T) {
|
||||
home := testEnv(t)
|
||||
src := fakeBinary(t, home, "tool")
|
||||
|
||||
sc, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Tool", Menu: false, Desktop: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, err := os.ReadFile(sc.DesktopPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(content), "NoDisplay=true") {
|
||||
t.Errorf("desktop-only harus disembunyikan dari menu:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallExecutableRejectsNonBinary(t *testing.T) {
|
||||
home := testEnv(t)
|
||||
path := filepath.Join(home, "notes.txt")
|
||||
if err := os.WriteFile(path, []byte("halo"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := InstallExecutable(ExecOptions{BinaryPath: path, Name: "Notes", Menu: true}); err == nil {
|
||||
t.Error("file teks biasa harus ditolak")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallExecutableRequiresTarget(t *testing.T) {
|
||||
home := testEnv(t)
|
||||
src := fakeBinary(t, home, "tool")
|
||||
if _, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Tool"}); err == nil {
|
||||
t.Error("tanpa target harus ditolak")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWebApp(t *testing.T) {
|
||||
testEnv(t)
|
||||
|
||||
page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/":
|
||||
w.Header().Set("Content-Type", "text/html")
|
||||
_, _ = w.Write([]byte(`<html><head><link rel="icon" type="image/png" sizes="128x128" href="/fav.png"></head></html>`))
|
||||
case "/fav.png":
|
||||
_, _ = w.Write(append(pngMagic, []byte("fake")...))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer page.Close()
|
||||
|
||||
sc, err := InstallWebApp(WebOptions{
|
||||
URL: page.URL,
|
||||
Name: "WhatsApp Web",
|
||||
Browser: BrowserArg{Name: "Chromium", Exec: []string{"/usr/bin/chromium"}},
|
||||
Menu: true,
|
||||
Desktop: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
content, err := os.ReadFile(sc.DesktopPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(content), "Exec=/usr/bin/chromium --app="+page.URL+"\n") {
|
||||
t.Errorf("Exec PWA salah:\n%s", content)
|
||||
}
|
||||
if filepath.Ext(sc.IconPath) != ".png" {
|
||||
t.Errorf("favicon harus tersimpan sebagai .png, dapat %s", sc.IconPath)
|
||||
}
|
||||
if st, err := os.Stat(sc.IconPath); err != nil || st.Size() == 0 {
|
||||
t.Errorf("ikon favicon tidak tertulis: %v %v", st, err)
|
||||
}
|
||||
if sc.Type != store.TypeWebApp {
|
||||
t.Errorf("type = %s", sc.Type)
|
||||
}
|
||||
if sc.URL != page.URL {
|
||||
t.Errorf("url = %s", sc.URL)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWebAppFirefoxUsesNewWindow(t *testing.T) {
|
||||
testEnv(t)
|
||||
sc, err := InstallWebApp(WebOptions{
|
||||
URL: "https://example.com",
|
||||
Name: "Example",
|
||||
Browser: BrowserArg{Name: "Firefox", Exec: []string{"/usr/bin/firefox"}, Firefox: true},
|
||||
Menu: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, err := os.ReadFile(sc.DesktopPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(content), "Exec=/usr/bin/firefox --new-window https://example.com\n") {
|
||||
t.Errorf("Exec Firefox salah:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWebAppFlatpakBrowser(t *testing.T) {
|
||||
testEnv(t)
|
||||
sc, err := InstallWebApp(WebOptions{
|
||||
URL: "https://example.com",
|
||||
Name: "Example",
|
||||
Browser: BrowserArg{Name: "Chromium (Flatpak)", Exec: []string{"flatpak", "run", "org.chromium.Chromium"}},
|
||||
Menu: true,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, err := os.ReadFile(sc.DesktopPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.Contains(string(content), "Exec=flatpak run org.chromium.Chromium --app=https://example.com\n") {
|
||||
t.Errorf("Exec flatpak salah:\n%s", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstall(t *testing.T) {
|
||||
home := testEnv(t)
|
||||
src := fakeBinary(t, home, "gone")
|
||||
|
||||
sc, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Gone", Menu: true, Desktop: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Uninstall(sc, RemoveOptions{Binary: true}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, path := range []string{sc.DesktopPath, sc.DesktopCopyPath, sc.IconPath, sc.BinaryPath} {
|
||||
if _, err := os.Stat(path); !os.IsNotExist(err) {
|
||||
t.Errorf("%s masih ada setelah uninstall", path)
|
||||
}
|
||||
}
|
||||
|
||||
// uninstall kedua harus aman (file sudah tidak ada)
|
||||
if err := Uninstall(sc, RemoveOptions{Binary: true}); err != nil {
|
||||
t.Errorf("uninstall ulang: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUninstallKeepsBinaryByDefault(t *testing.T) {
|
||||
home := testEnv(t)
|
||||
src := fakeBinary(t, home, "keep")
|
||||
|
||||
sc, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Keep", Menu: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := Uninstall(sc, RemoveOptions{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(sc.BinaryPath); err != nil {
|
||||
t.Errorf("binary harus tetap ada: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -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...)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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() }
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,435 @@
|
||||
# PRD: LazyRun
|
||||
|
||||
> **lazy shortcut build**
|
||||
|
||||
**Versi:** 1.0
|
||||
**Tanggal:** 2026-10-13
|
||||
**Status:** Draft
|
||||
**Platform:** Linux (KDE prioritas, GNOME fallback)
|
||||
**Distribusi:** AppImage
|
||||
|
||||
---
|
||||
|
||||
## 1. Ringkasan
|
||||
|
||||
**LazyRun** adalah aplikasi desktop Linux berbasis Go yang memungkinkan pengguna membuat shortcut aplikasi — baik dari **file executable** maupun **PWA (URL web)** — ke menu aplikasi dan/atau desktop, hanya dalam beberapa klik. Dibungkus sebagai AppImage, LazyRun portable, mudah di-update, dan tidak memerlukan terminal.
|
||||
|
||||
> **Tagline:** *lazy shortcut build*
|
||||
|
||||
---
|
||||
|
||||
## 2. Masalah yang Diselesaikan
|
||||
|
||||
1. Membuat shortcut `.desktop` di Linux masih manual: harus tahu path XDG, format file, dan cara refresh cache KDE/GNOME.
|
||||
2. Binary portable (Winbox, hasil build Go, tool CLI) sering tidak muncul di menu setelah diunduh.
|
||||
3. PWA seperti WhatsApp Web, YouTube Music, atau Figma tidak bisa diakses sebagai "aplikasi" tanpa browser berbasis Chromium dengan flag khusus.
|
||||
4. Tidak ada tool sederhana yang menggabungkan kedua kebutuhan di atas dengan antarmuka GUI.
|
||||
|
||||
---
|
||||
|
||||
## 3. Tujuan
|
||||
|
||||
- Membuat shortcut aplikasi dalam **< 30 detik** tanpa terminal.
|
||||
- Mendukung **dua sumber**: Executable dan PWA.
|
||||
- Menyediakan kontrol fleksibel: shortcut muncul di **menu aplikasi**, **desktop**, atau **keduanya**.
|
||||
- Install, uninstall, dan update shortcut dengan mudah.
|
||||
- Aplikasi LazyRun sendiri portable sebagai **single-file AppImage**.
|
||||
|
||||
### Non-Goals (v1.0)
|
||||
- Tidak mendukung Windows/macOS.
|
||||
- Tidak mengubah target menjadi AppImage.
|
||||
- Tidak mengelola dependency binary target.
|
||||
- Tidak menyediakan browser embedded untuk PWA.
|
||||
|
||||
---
|
||||
|
||||
## 4. Target Pengguna
|
||||
|
||||
- Pengguna Linux desktop yang sering mengunduh binary portable.
|
||||
- Developer Go yang ingin hasil build muncul di menu.
|
||||
- Pengguna yang ingin "install" PWA tanpa Chrome/Edge.
|
||||
- Pengguna KDE Plasma (prioritas) dan GNOME.
|
||||
|
||||
---
|
||||
|
||||
## 5. Fitur Utama
|
||||
|
||||
### 5.1 Fungsi A — Shortcut dari Executable
|
||||
|
||||
Membuat shortcut dari file binary eksekutabel (ELF, AppImage, script dengan shebang).
|
||||
|
||||
**Input:**
|
||||
- File binary (file picker).
|
||||
- Ikon (opsional; auto-detect dari folder yang sama jika kosong).
|
||||
- Nama aplikasi (auto-filled dari nama file).
|
||||
- Kategori (Utility, Network, Development, dll).
|
||||
- **Target lokasi** (lihat §5.3).
|
||||
|
||||
**Proses:**
|
||||
1. Salin binary ke `~/.local/bin/<nama>` (mode `0755`).
|
||||
2. Salin ikon ke `~/.local/share/icons/hicolor/512x512/apps/`.
|
||||
3. Tulis file `.desktop` di `~/.local/share/applications/`.
|
||||
4. Jika "Desktop" dicentang, salin juga ke `~/Desktop/` dan set `chmod +x`.
|
||||
5. Refresh cache: `update-desktop-database` + `kbuildsycoca6 --noincremental`.
|
||||
|
||||
### 5.2 Fungsi B — Shortcut dari PWA (URL)
|
||||
|
||||
Membuat shortcut yang membuka URL di browser dalam mode app.
|
||||
|
||||
**Input:**
|
||||
- URL (contoh: `https://web.whatsapp.com`).
|
||||
- Nama aplikasi.
|
||||
- Browser target (auto-detect: Chrome, Chromium, Brave, Edge, Firefox).
|
||||
- Ikon (opsional; auto-fetch favicon dari URL jika kosong).
|
||||
- **Target lokasi** (lihat §5.3).
|
||||
|
||||
**Proses:**
|
||||
1. Fetch favicon atau pakai ikon default.
|
||||
2. Simpan ikon ke direktori ikon XDG.
|
||||
3. Tulis `.desktop` dengan `Exec=` menyesuaikan browser:
|
||||
- Chromium-based: `<browser> --app=<URL>`
|
||||
- Firefox: `<browser> --new-window <URL>`
|
||||
4. Jika "Desktop" dicentang, salin ke `~/Desktop/` dan set `chmod +x`.
|
||||
5. Refresh cache.
|
||||
|
||||
### 5.3 Kontrol Lokasi Shortcut (Fitur Kunci)
|
||||
|
||||
Setiap kali membuat shortcut, pengguna memilih lokasi via **checkbox** yang fleksibel:
|
||||
|
||||
```
|
||||
┌─ Lokasi Shortcut ─────────────────────┐
|
||||
│ [✓] Menu Aplikasi │
|
||||
│ [ ] Desktop │
|
||||
└───────────────────────────────────────┘
|
||||
```
|
||||
|
||||
| Menu Aplikasi | Desktop | Hasil |
|
||||
|:---:|:---:|---|
|
||||
| ✓ | ✗ | Hanya muncul di menu aplikasi (launcher) |
|
||||
| ✗ | ✓ | Hanya muncul di desktop |
|
||||
| ✓ | ✓ | Muncul di keduanya (rekomendasi default) |
|
||||
| ✗ | ✗ | Diblokir — minimal satu harus dipilih |
|
||||
|
||||
**Detail teknis:**
|
||||
- **Menu Aplikasi**: file `.desktop` ditulis ke `~/.local/share/applications/`.
|
||||
- **Desktop**: file `.desktop` yang sama di-*copy* ke `~/Desktop/` dengan `chmod +x` agar bisa diklik ganda.
|
||||
- Sumber kebenaran tetap di `~/.local/share/applications/`; file di `~/Desktop/` adalah salinan (symlink opsional di v1.1).
|
||||
- Saat uninstall, keduanya dihapus secara sinkron.
|
||||
|
||||
### 5.4 Manajemen Shortcut
|
||||
|
||||
- **List Shortcut**: menampilkan semua shortcut yang dibuat LazyRun.
|
||||
- **Status**: menampilkan ikon/badge jika shortcut ada di Menu, Desktop, atau keduanya.
|
||||
- **Uninstall**: hapus file `.desktop` dari semua lokasi, ikon, dan binary (opsional).
|
||||
- **Open Location**: buka folder binary atau desktop di file manager.
|
||||
|
||||
### 5.5 Update Aplikasi LazyRun
|
||||
|
||||
- Bundle AppImage dengan dukungan **zsync**.
|
||||
- Menu **"Check for Updates"** memanggil `AppImageUpdate`.
|
||||
- Update otomatis dari GitHub Releases.
|
||||
|
||||
---
|
||||
|
||||
## 6. User Interface
|
||||
|
||||
### 6.1 Window Utama
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────────────┐
|
||||
│ LazyRun — lazy shortcut build │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ [+ Executable] [+ Web App] │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ Shortcut Terinstall: │
|
||||
│ ┌────────────────────────────────────────────┐ │
|
||||
│ │ 🟦 Winbox Network [Menu][Desk] │ │
|
||||
│ │ 🌐 WhatsApp Web Web [Menu] │ │
|
||||
│ │ 🟩 my-go-app Utility [Menu][Desk] │ │
|
||||
│ └────────────────────────────────────────────┘ │
|
||||
├──────────────────────────────────────────────────┤
|
||||
│ [Check for Updates] v1.0.0 │
|
||||
└──────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.2 Dialog "New Executable Shortcut"
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ New Executable Shortcut │
|
||||
├──────────────────────────────────────────┤
|
||||
│ Binary: [ Browse... ] │
|
||||
│ Icon: [ Browse... ] (opsional) │
|
||||
│ Nama: [ Winbox ] │
|
||||
│ Kategori: [ Network ▼ ] │
|
||||
│ │
|
||||
│ ── Lokasi Shortcut ── │
|
||||
│ [✓] Menu Aplikasi │
|
||||
│ [✓] Desktop │
|
||||
│ │
|
||||
│ [ Cancel ] [ Install ] │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 6.3 Dialog "New Web App Shortcut"
|
||||
|
||||
```
|
||||
┌──────────────────────────────────────────┐
|
||||
│ New Web App Shortcut │
|
||||
├──────────────────────────────────────────┤
|
||||
│ URL: [ https://... ] │
|
||||
│ Nama: [ WhatsApp Web ] │
|
||||
│ Browser: [ Chrome ▼ ] │
|
||||
│ Icon: [ Auto-fetch ] (opsi) │
|
||||
│ │
|
||||
│ ── Lokasi Shortcut ── │
|
||||
│ [✓] Menu Aplikasi │
|
||||
│ [✓] Desktop │
|
||||
│ │
|
||||
│ [ Cancel ] [ Install ] │
|
||||
└──────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. Arsitektur Teknis
|
||||
|
||||
### 7.1 Stack
|
||||
|
||||
| Komponen | Teknologi |
|
||||
|---|---|
|
||||
| Bahasa | Go 1.21+ |
|
||||
| GUI | Fyne |
|
||||
| Desktop entry | XDG `.desktop` spec |
|
||||
| Cache refresh | `update-desktop-database`, `kbuildsycoca6`/`5` |
|
||||
| Distribusi | AppImage + `appimagetool -u` (zsync) |
|
||||
|
||||
### 7.2 Struktur Direktori Proyek
|
||||
|
||||
```
|
||||
lazyrun/
|
||||
├── cmd/lazyrun/main.go
|
||||
├── internal/
|
||||
│ ├── shortcut/
|
||||
│ │ ├── executable.go # Fungsi A
|
||||
│ │ ├── webapp.go # Fungsi B
|
||||
│ │ ├── desktop.go # Generate .desktop
|
||||
│ │ ├── desktopdir.go # Copy ke ~/Desktop
|
||||
│ │ └── cache.go # Refresh cache
|
||||
│ ├── icon/
|
||||
│ │ ├── favicon.go
|
||||
│ │ └── install.go
|
||||
│ ├── browser/detect.go
|
||||
│ ├── store/store.go # Metadata JSON
|
||||
│ └── ui/
|
||||
│ ├── main_window.go
|
||||
│ ├── tab_executable.go
|
||||
│ └── tab_webapp.go
|
||||
├── assets/
|
||||
│ ├── icon.png
|
||||
│ └── lazyrun.desktop
|
||||
└── build.sh
|
||||
```
|
||||
|
||||
### 7.3 Path XDG
|
||||
|
||||
| Tujuan | Path |
|
||||
|---|---|
|
||||
| Binary | `~/.local/bin/` |
|
||||
| Ikon | `~/.local/share/icons/hicolor/512x512/apps/` |
|
||||
| Menu aplikasi | `~/.local/share/applications/` |
|
||||
| Desktop | `~/Desktop/` (via `xdg-user-dir DESKTOP`) |
|
||||
| Metadata | `~/.config/lazyrun/shortcuts.json` |
|
||||
|
||||
### 7.4 Metadata Internal
|
||||
|
||||
`~/.config/lazyrun/shortcuts.json` menyimpan daftar shortcut LazyRun:
|
||||
|
||||
```json
|
||||
[
|
||||
{
|
||||
"name": "Winbox",
|
||||
"type": "executable",
|
||||
"binary_path": "/home/user/.local/bin/winbox",
|
||||
"icon_path": "/home/user/.local/share/icons/hicolor/512x512/apps/Winbox.png",
|
||||
"desktop_path": "/home/user/.local/share/applications/Winbox.desktop",
|
||||
"desktop_copy_path": "/home/user/Desktop/Winbox.desktop",
|
||||
"targets": {
|
||||
"menu": true,
|
||||
"desktop": true
|
||||
},
|
||||
"installed_at": "2026-10-13T10:00:00Z"
|
||||
}
|
||||
]
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 8. Alur Install & Uninstall
|
||||
|
||||
### 8.1 Install
|
||||
|
||||
```
|
||||
User klik Install
|
||||
│
|
||||
▼
|
||||
Validasi input (min. satu target dicentang)
|
||||
│
|
||||
▼
|
||||
Salin binary + ikon ke XDG paths
|
||||
│
|
||||
▼
|
||||
Tulis .desktop di ~/.local/share/applications/
|
||||
│
|
||||
├── Jika [Menu] ✓ → selesai di sini
|
||||
│
|
||||
└── Jika [Desktop] ✓ → salin .desktop ke ~/Desktop/ + chmod +x
|
||||
│
|
||||
▼
|
||||
Refresh cache (update-desktop-database + kbuildsycoca)
|
||||
│
|
||||
▼
|
||||
Simpan metadata ke shortcuts.json
|
||||
│
|
||||
▼
|
||||
Tampilkan notifikasi sukses
|
||||
```
|
||||
|
||||
### 8.2 Uninstall
|
||||
|
||||
```
|
||||
User klik 🗑 pada shortcut
|
||||
│
|
||||
▼
|
||||
Konfirmasi dialog
|
||||
│
|
||||
▼
|
||||
Hapus .desktop di semua lokasi (menu + desktop)
|
||||
│
|
||||
▼
|
||||
Hapus ikon (opsional)
|
||||
│
|
||||
▼
|
||||
Hapus binary (opsional, checkbox "hapus binary juga")
|
||||
│
|
||||
▼
|
||||
Refresh cache
|
||||
│
|
||||
▼
|
||||
Update shortcuts.json
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 9. Alur Build AppImage
|
||||
|
||||
```bash
|
||||
# 1. Build binary
|
||||
go build -o lazyrun ./cmd/lazyrun
|
||||
|
||||
# 2. Siapkan AppDir
|
||||
mkdir -p lazyrun.AppDir/usr/bin
|
||||
cp lazyrun lazyrun.AppDir/usr/bin/
|
||||
cp assets/icon.png lazyrun.AppDir/lazyrun.png
|
||||
cp assets/lazyrun.desktop lazyrun.AppDir/
|
||||
ln -s usr/bin/lazyrun lazyrun.AppDir/AppRun
|
||||
|
||||
# 3. Build AppImage dengan dukungan update
|
||||
./appimagetool-x86_64.AppImage \
|
||||
-u "gh-releases-zsync|user|lazyrun|latest|lazyrun-*.AppImage.zsync" \
|
||||
lazyrun.AppDir
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. Persyaratan Non-Fungsional
|
||||
|
||||
| Aspek | Target |
|
||||
|---|---|
|
||||
| Ukuran AppImage | < 30 MB |
|
||||
| Startup time | < 2 detik |
|
||||
| Bahasa UI | Indonesia + Inggris |
|
||||
| Kompatibilitas | KDE Plasma 5 & 6, GNOME 42+ |
|
||||
| Hak akses | Tanpa root, semua di `~/.local/` |
|
||||
| Fallback cache | `kbuildsycoca6` → `kbuildsycoca5` → skip |
|
||||
| Desktop path | Auto-detect via `xdg-user-dir DESKTOP` |
|
||||
|
||||
---
|
||||
|
||||
## 11. Roadmap
|
||||
|
||||
### v1.0 (MVP)
|
||||
- [ ] GUI Fyne dengan window utama & 2 dialog.
|
||||
- [ ] Fungsi A: shortcut dari executable.
|
||||
- [ ] Fungsi B: shortcut dari PWA (Chrome/Chromium/Brave).
|
||||
- [ ] Checkbox "Menu Aplikasi" & "Desktop" dengan validasi min. satu.
|
||||
- [ ] Uninstall shortcut.
|
||||
- [ ] Deteksi browser terinstall.
|
||||
- [ ] Bundle AppImage + zsync update.
|
||||
|
||||
### v1.1
|
||||
- [ ] Edit shortcut yang sudah ada.
|
||||
- [ ] Auto-detect binary di `$PATH` (htop, nvim, dll).
|
||||
- [ ] Symlink desktop (bukan copy) sebagai opsi.
|
||||
- [ ] Import/Export daftar shortcut.
|
||||
- [ ] Dark mode otomatis.
|
||||
|
||||
### v1.2
|
||||
- [ ] Dukungan Firefox PWA (via `--kiosk`).
|
||||
- [ ] Dukungan Flatpak & Snap.
|
||||
- [ ] Preview ikon sebelum install.
|
||||
|
||||
---
|
||||
|
||||
## 12. Risiko & Mitigasi
|
||||
|
||||
| Risiko | Mitigasi |
|
||||
|---|---|
|
||||
| `~/Desktop/` bukan bahasa Inggris (mis. `~/Desktop` vs `~/デスクトップ`) | Gunakan `xdg-user-dir DESKTOP` |
|
||||
| KDE versi beda (`kbuildsycoca5`/`6`) | Deteksi otomatis, fallback berurutan |
|
||||
| Firefox tanpa mode PWA penuh | Fallback `--new-window`, atau rekomendasi Chromium |
|
||||
| Favicon gagal di-fetch | Fallback ke ikon default LazyRun |
|
||||
| User hapus file desktop manual | Deteksi saat list, tandai "broken", tawarkan repair |
|
||||
| File `.desktop` di `~/Desktop/` perlu `chmod +x` | Set otomatis saat install |
|
||||
|
||||
---
|
||||
|
||||
## 13. Kriteria Sukses
|
||||
|
||||
- Shortcut dapat dibuat dalam **< 30 detik** tanpa terminal.
|
||||
- 90% pengguna KDE melihat shortcut muncul **tanpa restart**.
|
||||
- Checkbox Menu/Desktop berfungsi konsisten di KDE & GNOME.
|
||||
- Berjalan sebagai **single-file AppImage** di Ubuntu, Fedora, Arch, openSUSE.
|
||||
- Update otomatis berjalan tanpa intervensi manual.
|
||||
|
||||
---
|
||||
|
||||
## 14. Lampiran: Contoh File `.desktop` Hasil LazyRun
|
||||
|
||||
**Executable:**
|
||||
```ini
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=Winbox
|
||||
Exec=/home/user/.local/bin/winbox
|
||||
Icon=/home/user/.local/share/icons/hicolor/512x512/apps/Winbox.png
|
||||
Terminal=false
|
||||
Categories=Network;
|
||||
Comment=Created by LazyRun
|
||||
```
|
||||
|
||||
**PWA:**
|
||||
```ini
|
||||
[Desktop Entry]
|
||||
Type=Application
|
||||
Name=WhatsApp Web
|
||||
Exec=/usr/bin/google-chrome --app=https://web.whatsapp.com
|
||||
Icon=/home/user/.local/share/icons/hicolor/512x512/apps/WhatsApp Web.png
|
||||
Terminal=false
|
||||
Categories=Network;WebBrowser;
|
||||
Comment=Created by LazyRun
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
*PRD ini adalah living document. Perubahan signifikan akan dicatat di changelog versi berikutnya.*
|
||||
Reference in new issue
Block a user