LazyRun v1.0.0: shortcut maker untuk executable dan PWA

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

No files matched your search

+115
View File
@@ -0,0 +1,115 @@
// Package browser mendeteksi browser yang terinstall untuk dipakai shortcut PWA.
package browser
import (
"os"
"os/exec"
"strings"
)
// Kind menentukan cara membuka URL dalam mode aplikasi.
type Kind int
const (
// Chromium membuka PWA dengan --app=<url>.
Chromium Kind = iota
// Firefox tidak punya mode app; dipakai --new-window.
Firefox
)
// Browser adalah browser siap dipanggil.
type Browser struct {
Name string
Exec []string // argv lengkap, sudah termasuk argumen flatpak
Kind Kind
}
// AppArgs adalah argumen yang membuka url sebagai aplikasi.
func (b Browser) AppArgs(url string) []string {
if b.Kind == Firefox {
return []string{"--new-window", url}
}
return []string{"--app=" + url}
}
type candidate struct {
name string
kind Kind
bin []string // nama di $PATH (tanpa "/") atau path absolut
}
var candidates = []candidate{
{"Google Chrome", Chromium, []string{"google-chrome", "google-chrome-stable", "/opt/google/chrome/chrome"}},
{"Chromium", Chromium, []string{"chromium", "chromium-browser"}},
{"Brave", Chromium, []string{"brave-browser", "brave", "/opt/brave.com/brave/brave"}},
{"Microsoft Edge", Chromium, []string{"microsoft-edge", "microsoft-edge-stable"}},
{"Vivaldi", Chromium, []string{"vivaldi", "vivaldi-stable", "/opt/vivaldi/vivaldi"}},
{"Helium", Chromium, []string{"helium", "/opt/helium-browser-bin/helium"}},
{"Firefox", Firefox, []string{"firefox", "firefox-esr", "/usr/lib/firefox/firefox"}},
}
var flatpakApps = []struct {
id, name string
kind Kind
}{
{"com.google.Chrome", "Google Chrome (Flatpak)", Chromium},
{"org.chromium.Chromium", "Chromium (Flatpak)", Chromium},
{"com.brave.Browser", "Brave (Flatpak)", Chromium},
{"com.microsoft.Edge", "Microsoft Edge (Flatpak)", Chromium},
{"org.mozilla.firefox", "Firefox (Flatpak)", Firefox},
}
// Detect mengembalikan browser yang ada di sistem, native dulu lalu flatpak.
func Detect() []Browser {
var out []Browser
seen := map[string]bool{}
add := func(b Browser) {
if !seen[b.Name] {
seen[b.Name] = true
out = append(out, b)
}
}
for _, c := range candidates {
for _, bin := range c.bin {
path := bin
if !strings.Contains(bin, "/") {
p, err := exec.LookPath(bin)
if err != nil {
continue
}
path = p
} else if st, err := os.Stat(bin); err != nil || st.IsDir() {
continue
}
add(Browser{Name: c.name, Exec: []string{path}, Kind: c.kind})
break
}
}
installed := flatpakList()
for _, f := range flatpakApps {
if installed[f.id] {
add(Browser{Name: f.name, Exec: []string{"flatpak", "run", f.id}, Kind: f.kind})
}
}
return out
}
func flatpakList() map[string]bool {
out := map[string]bool{}
if _, err := exec.LookPath("flatpak"); err != nil {
return out
}
data, err := exec.Command("flatpak", "list", "--app", "--columns=application").Output()
if err != nil {
return out
}
for _, line := range strings.Split(string(data), "\n") {
if id := strings.TrimSpace(line); id != "" {
out[id] = true
}
}
return out
}
+175
View File
@@ -0,0 +1,175 @@
// Package icon: favicon.go mengambil ikon dari halaman web untuk shortcut PWA.
package icon
import (
"bytes"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"regexp"
"sort"
"strings"
"time"
)
var client = &http.Client{Timeout: 8 * time.Second}
// shortcut: parsing HTML pakai regex, bukan parser penuh; cukup untuk tag <link rel=icon>.
var (
linkTag = regexp.MustCompile(`(?is)<link\b[^>]*>`)
relAttr = regexp.MustCompile(`(?is)\brel\s*=\s*["']?([^"'>]+)`)
hrefAttr = regexp.MustCompile(`(?is)\bhref\s*=\s*["']?([^"'\s>]+)`)
typeAttr = regexp.MustCompile(`(?is)\btype\s*=\s*["']?([^"'\s>]+)`)
sizeAttr = regexp.MustCompile(`(?is)\bsizes\s*=\s*["']?(\d+)`)
)
// Fetch mengambil favicon dari halaman. Return data + ekstensi file (".png", ".jpg", ".svg").
func Fetch(pageURL string) ([]byte, string, error) {
base, err := url.Parse(pageURL)
if err != nil {
return nil, "", err
}
var lastErr error
for _, candidate := range iconCandidates(base) {
data, err := get(candidate)
if err != nil {
lastErr = err
continue
}
if ext := sniffExt(data); ext != "" {
return data, ext, nil
}
if png := largestPNGFromICO(data); png != nil {
return png, ".png", nil
}
lastErr = errors.New("unsupported icon format at " + candidate)
}
if lastErr == nil {
lastErr = errors.New("no favicon found")
}
return nil, "", lastErr
}
// iconCandidate adalah kandidat URL ikon dengan skor prioritas.
type iconCandidate struct {
url string
score int
}
// iconCandidates mengumpulkan URL ikon dari HTML, ikon besar didahulukan, favicon.ico terakhir.
func iconCandidates(base *url.URL) []string {
var found []iconCandidate
if page, err := get(base.String()); err == nil {
for _, tag := range linkTag.FindAllString(string(page), -1) {
rel := strings.ToLower(attr(relAttr, tag))
if !strings.Contains(rel, "icon") || strings.Contains(rel, "mask-icon") {
continue
}
href := attr(hrefAttr, tag)
if href == "" {
continue
}
ref, err := url.Parse(href)
if err != nil {
continue
}
score := 1
if strings.Contains(strings.ToLower(attr(typeAttr, tag)), "png") {
score += 10
}
if size := attr(sizeAttr, tag); size != "" {
fmt.Sscanf(size, "%d", &score)
score += 10
}
found = append(found, iconCandidate{base.ResolveReference(ref).String(), score})
}
}
sort.SliceStable(found, func(i, j int) bool { return found[i].score > found[j].score })
out := make([]string, 0, len(found)+1)
seen := map[string]bool{}
for _, c := range found {
if !seen[c.url] {
seen[c.url] = true
out = append(out, c.url)
}
}
fallback := base.ResolveReference(&url.URL{Path: "/favicon.ico"}).String()
if !seen[fallback] {
out = append(out, fallback)
}
return out
}
func attr(re *regexp.Regexp, tag string) string {
if m := re.FindStringSubmatch(tag); m != nil {
return m[1]
}
return ""
}
func get(rawURL string) ([]byte, error) {
req, err := http.NewRequest(http.MethodGet, rawURL, nil)
if err != nil {
return nil, err
}
req.Header.Set("User-Agent", "Mozilla/5.0 (X11; Linux x86_64) LazyRun")
req.Header.Set("Accept", "text/html,image/*;q=0.9,*/*;q=0.8")
resp, err := client.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("%s: %s", rawURL, resp.Status)
}
return io.ReadAll(io.LimitReader(resp.Body, 2<<20))
}
func sniffExt(data []byte) string {
head := data
if len(head) > 512 {
head = head[:512]
}
switch {
case bytes.HasPrefix(data, []byte("\x89PNG\r\n\x1a\n")):
return ".png"
case bytes.HasPrefix(data, []byte{0xff, 0xd8, 0xff}):
return ".jpg"
case bytes.Contains(bytes.ToLower(head), []byte("<svg")):
return ".svg"
}
return ""
}
// largestPNGFromICO mengambil PNG terbesar di dalam file .ico.
// ICO dari BMP lama tidak didukung (langka untuk favicon).
func largestPNGFromICO(data []byte) []byte {
if len(data) < 22 || data[0] != 0 || data[1] != 0 || data[2] != 1 || data[3] != 0 {
return nil
}
count := int(data[4]) | int(data[5])<<8
var best []byte
for i := 0; i < count; i++ {
off := 6 + i*16
if off+16 > len(data) {
break
}
size := int(data[off+8]) | int(data[off+9])<<8 | int(data[off+10])<<16 | int(data[off+11])<<24
start := int(data[off+12]) | int(data[off+13])<<8 | int(data[off+14])<<16 | int(data[off+15])<<24
if size <= 0 || start < 0 || start+size > len(data) {
continue
}
blob := data[start : start+size]
if bytes.HasPrefix(blob, []byte("\x89PNG\r\n\x1a\n")) && len(blob) > len(best) {
best = blob
}
}
return best
}
+112
View File
@@ -0,0 +1,112 @@
package icon
import (
"io"
"net/http"
"net/http/httptest"
"testing"
)
func icoWithPNG(png []byte) []byte {
ico := make([]byte, 22+len(png))
ico[2], ico[3] = 1, 0 // type = icon
ico[4], ico[5] = 1, 0 // jumlah gambar
put32 := func(off, v int) {
ico[off] = byte(v)
ico[off+1] = byte(v >> 8)
ico[off+2] = byte(v >> 16)
ico[off+3] = byte(v >> 24)
}
put32(14, len(png)) // bytesInRes
put32(18, 22) // imageOffset
copy(ico[22:], png)
return ico
}
var png = append([]byte("\x89PNG\r\n\x1a\n"), []byte("payload")...)
func TestFetchUsesLinkedIcon(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/":
io.WriteString(w, `<html><head>`+
`<link rel="apple-touch-icon" href="/small.ico">`+
`<link rel="icon" type="image/png" sizes="192x192" href="/big.png">`+
`</head></html>`)
case "/big.png":
w.Write(png)
default:
w.Write(icoWithPNG(png))
}
}))
defer srv.Close()
data, ext, err := Fetch(srv.URL)
if err != nil {
t.Fatal(err)
}
if ext != ".png" || string(data) != string(png) {
t.Errorf("ikon terpilih salah: ext=%s data=%q", ext, data)
}
}
func TestFetchFallsBackToFaviconICO(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path == "/" {
io.WriteString(w, `<html><head><title>no icon</title></head></html>`)
return
}
w.Write(icoWithPNG(png))
}))
defer srv.Close()
data, ext, err := Fetch(srv.URL)
if err != nil {
t.Fatal(err)
}
if ext != ".png" || string(data) != string(png) {
t.Errorf("PNG di dalam .ico harus dipakai: ext=%s data=%q", ext, data)
}
}
func TestFetchFailsWithoutIcon(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.NotFound(w, r)
}))
defer srv.Close()
if _, _, err := Fetch(srv.URL); err == nil {
t.Error("tanpa favicon harus error supaya UI pakai ikon default")
}
}
func TestLargestPNGFromICO(t *testing.T) {
if got := largestPNGFromICO(icoWithPNG(png)); string(got) != string(png) {
t.Errorf("PNG di dalam .ico tidak terbaca: %q", got)
}
if got := largestPNGFromICO([]byte("bukan ico")); got != nil {
t.Errorf("data bukan ICO harus menghasilkan nil, dapat %q", got)
}
// ICO berisi BMP lama (bukan PNG) tidak didukung.
bmp := make([]byte, 40)
bmp[0], bmp[1], bmp[2], bmp[3] = 0, 0, 1, 0
bmp[4], bmp[5] = 1, 0
if got := largestPNGFromICO(bmp); got != nil {
t.Errorf("ICO BMP harus mengembalikan nil, dapat %q", got)
}
}
func TestSniffExt(t *testing.T) {
if ext := sniffExt(png); ext != ".png" {
t.Errorf("sniffExt(png) = %s", ext)
}
if ext := sniffExt([]byte(`<?xml version="1.0"?><svg xmlns="..."><path/></svg>`)); ext != ".svg" {
t.Errorf("sniffExt(svg) = %s", ext)
}
if ext := sniffExt([]byte("\xff\xd8\xff\xe0jpeg")); ext != ".jpg" {
t.Errorf("sniffExt(jpg) = %s", ext)
}
if ext := sniffExt([]byte("plain text")); ext != "" {
t.Errorf("sniffExt(text) = %s", ext)
}
}
+82
View File
@@ -0,0 +1,82 @@
// Package icon menyimpan ikon shortcut ke direktori ikon XDG.
package icon
import (
"errors"
"os"
"path/filepath"
"strings"
"lazyrun/assets"
"lazyrun/internal/xdg"
)
var exts = []string{".png", ".svg", ".jpg", ".jpeg", ".xpm"}
var nearbyNames = []string{"icon", "logo"}
// Save memasang ikon untuk shortcut dan mengembalikan path absolutnya.
// Urutan pilihan: ikon dari user, ikon di folder binary, lalu ikon default LazyRun.
func Save(userIcon, nearbyDir, nearbyBase, slug string) (string, error) {
if userIcon != "" {
return fromFile(userIcon, slug)
}
for _, name := range append([]string{nearbyBase}, nearbyNames...) {
if name == "" {
continue
}
if p := findNearby(nearbyDir, name); p != "" {
return fromFile(p, slug)
}
}
return Default(slug)
}
// Default memasang ikon bawaan LazyRun.
func Default(slug string) (string, error) {
return SaveBytes(assets.Icon, ".png", slug)
}
func findNearby(dir, base string) string {
base = strings.ToLower(base)
for _, ext := range exts {
p := filepath.Join(dir, base+ext)
if st, err := os.Stat(p); err == nil && !st.IsDir() {
return p
}
}
return ""
}
func fromFile(src, slug string) (string, error) {
data, err := os.ReadFile(src)
if err != nil {
return "", err
}
ext := strings.ToLower(filepath.Ext(src))
// ICO tidak dipakai desktop theme; pakai PNG di dalamnya kalau ada.
if ext == ".ico" {
png := largestPNGFromICO(data)
if png == nil {
return "", errors.New("this .ico has no PNG inside it, pick another icon")
}
return SaveBytes(png, ".png", slug)
}
return SaveBytes(data, ext, slug)
}
// SaveBytes menulis data ikon ke direktori ikon XDG.
func SaveBytes(data []byte, ext, slug string) (string, error) {
if ext == "" {
ext = ".png"
}
if err := os.MkdirAll(xdg.IconsDir(), 0o755); err != nil {
return "", err
}
// shortcut: ikon disimpan apa adanya di hicolor/512x512 tanpa resize;
// desktop environment men-skalanya sendiri. Tambah resize kalau ikon kecil terlihat pecah.
dest := filepath.Join(xdg.IconsDir(), slug+ext)
return dest, os.WriteFile(dest, data, 0o644)
}
// IconBytes adalah ikon default LazyRun.
func IconBytes() []byte { return assets.Icon }
+26
View File
@@ -0,0 +1,26 @@
package shortcut
import (
"context"
"os/exec"
"time"
"lazyrun/internal/xdg"
)
// RefreshCache memberi tahu desktop environment supaya .desktop baru langsung terbaca.
// Best-effort: kalau tool tidak ada atau gagal, menu tetap refresh saat login berikutnya.
func RefreshCache() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if p, err := exec.LookPath("update-desktop-database"); err == nil {
_ = exec.CommandContext(ctx, p, xdg.ApplicationsDir()).Run()
}
for _, tool := range []string{"kbuildsycoca6", "kbuildsycoca5"} {
if p, err := exec.LookPath(tool); err == nil {
_ = exec.CommandContext(ctx, p, "--noincremental").Run()
return
}
}
}
+89
View File
@@ -0,0 +1,89 @@
package shortcut
import (
"fmt"
"os"
"path/filepath"
"strings"
"unicode"
)
// Entry adalah isi file .desktop yang dihasilkan LazyRun.
type Entry struct {
Name string
ExecArgs []string // argv lengkap; tiap arg di-quote sesuai spec Exec
Icon string // path absolut atau nama ikon
Categories string // sudah diakhiri ";" atau kosong
// NoDisplay menyembunyikan entry dari menu aplikasi. Dipakai kalau user
// hanya memilih Desktop: file tetap ada di applications/ sebagai sumber kebenaran.
NoDisplay bool
}
// Render menghasilkan isi file .desktop.
func (e Entry) Render() string {
var b strings.Builder
b.WriteString("[Desktop Entry]\n")
b.WriteString("Type=Application\n")
fmt.Fprintf(&b, "Name=%s\n", escapeValue(e.Name))
fmt.Fprintf(&b, "Exec=%s\n", execArgs(e.ExecArgs))
if e.Icon != "" {
fmt.Fprintf(&b, "Icon=%s\n", escapeValue(e.Icon))
}
b.WriteString("Terminal=false\n")
if e.Categories != "" {
fmt.Fprintf(&b, "Categories=%s\n", escapeValue(e.Categories))
}
if e.NoDisplay {
b.WriteString("NoDisplay=true\n")
}
b.WriteString("Comment=Created by LazyRun\n")
return b.String()
}
// WriteDesktop menulis entry .desktop ke path.
func WriteDesktop(path string, e Entry) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, []byte(e.Render()), 0o644)
}
func execArgs(args []string) string {
quoted := make([]string, len(args))
for i, a := range args {
quoted[i] = execQuote(a)
}
return strings.Join(quoted, " ")
}
// execQuote mengutip satu argumen sesuai aturan field Exec (spec Desktop Entry).
func execQuote(s string) string {
if s != "" && !strings.ContainsAny(s, " \t\n\"'\\><~|&;$*?#()`") {
return s
}
r := strings.NewReplacer("\\", "\\\\", "\"", "\\\"", "`", "\\`", "$", "\\$", "\n", "\\n")
return "\"" + r.Replace(s) + "\""
}
// escapeValue meng-escape nilai string biasa (Name, Icon, Categories).
func escapeValue(s string) string {
r := strings.NewReplacer("\\", "\\\\", "\n", "\\n", "\r", "\\r", "\t", "\\t")
return r.Replace(s)
}
// slugify membuat nama file yang aman dari nama aplikasi.
func slugify(name string) string {
var b strings.Builder
dash := false
for _, r := range strings.ToLower(name) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', unicode.IsLetter(r), unicode.IsDigit(r):
b.WriteRune(r)
dash = false
case b.Len() > 0 && !dash:
b.WriteByte('-')
dash = true
}
}
return strings.Trim(b.String(), "-")
}
+89
View File
@@ -0,0 +1,89 @@
package shortcut
import (
"strings"
"testing"
)
func TestRender(t *testing.T) {
e := Entry{
Name: "Winbox",
ExecArgs: []string{"/home/user/.local/bin/winbox"},
Icon: "/home/user/.local/share/icons/hicolor/512x512/apps/winbox.png",
Categories: "Network;",
}
want := `[Desktop Entry]
Type=Application
Name=Winbox
Exec=/home/user/.local/bin/winbox
Icon=/home/user/.local/share/icons/hicolor/512x512/apps/winbox.png
Terminal=false
Categories=Network;
Comment=Created by LazyRun
`
if got := e.Render(); got != want {
t.Errorf("Render()\ngot:\n%s\nwant:\n%s", got, want)
}
}
func TestRenderNoDisplayWhenDesktopOnly(t *testing.T) {
e := Entry{Name: "X", ExecArgs: []string{"/x"}, NoDisplay: true}
if !strings.Contains(e.Render(), "NoDisplay=true\n") {
t.Errorf("entry desktop-only harus NoDisplay=true:\n%s", e.Render())
}
}
func TestExecQuote(t *testing.T) {
tests := []struct {
in, want string
}{
{"/usr/bin/winbox", "/usr/bin/winbox"},
{"/home/user/my app/app", `"/home/user/my app/app"`},
{"/opt/a$b", `"/opt/a\$b"`},
{"back\\slash path", `"back\\slash path"`},
{`say "hi"`, `"say \"hi\""`},
{"", `""`},
{"--app=https://web.whatsapp.com", "--app=https://web.whatsapp.com"},
}
for _, tc := range tests {
if got := execQuote(tc.in); got != tc.want {
t.Errorf("execQuote(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestExecArgsJoinsArgs(t *testing.T) {
got := execArgs([]string{"/usr/bin/chromium", "--app=https://web.whatsapp.com"})
want := "/usr/bin/chromium --app=https://web.whatsapp.com"
if got != want {
t.Errorf("execArgs() = %q, want %q", got, want)
}
}
func TestSlugify(t *testing.T) {
tests := map[string]string{
"WhatsApp Web": "whatsapp-web",
"Winbox": "winbox",
"my-go-app": "my-go-app",
" spaced ": "spaced",
"Café #1": "café-1",
"!!": "",
}
for in, want := range tests {
if got := slugify(in); got != want {
t.Errorf("slugify(%q) = %q, want %q", in, got, want)
}
}
}
func TestNormalizeURL(t *testing.T) {
if got, err := normalizeURL("web.whatsapp.com"); err != nil || got != "https://web.whatsapp.com" {
t.Errorf("normalizeURL tanpa skema = %q, %v", got, err)
}
if _, err := normalizeURL(""); err == nil {
t.Error("URL kosong harus error")
}
if _, err := normalizeURL("ftp://example.com"); err == nil {
t.Error("skema selain http(s) harus error")
}
}
+213
View File
@@ -0,0 +1,213 @@
package shortcut
import (
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"lazyrun/internal/icon"
"lazyrun/internal/store"
"lazyrun/internal/xdg"
)
// ExecOptions adalah input Fungsi A: shortcut dari file executable.
type ExecOptions struct {
BinaryPath string
IconPath string // opsional
Name string
Category string
Menu bool
Desktop bool
}
// InstallExecutable menyalin binary + ikon, menulis .desktop, lalu refresh cache.
func InstallExecutable(o ExecOptions) (store.Shortcut, error) {
var sc store.Shortcut
name := strings.TrimSpace(o.Name)
if err := validate(name, o.Menu, o.Desktop); err != nil {
return sc, err
}
if err := checkRunnable(o.BinaryPath); err != nil {
return sc, fmt.Errorf("%s: %w", filepath.Base(o.BinaryPath), err)
}
sl := slugify(name)
binDst := filepath.Join(xdg.BinDir(), sl)
deskDst := filepath.Join(xdg.ApplicationsDir(), sl+".desktop")
if _, err := os.Stat(deskDst); err == nil {
return sc, fmt.Errorf("shortcut %q already exists, uninstall it first", name)
}
if st, err := os.Stat(binDst); err == nil && !st.IsDir() && !samePath(o.BinaryPath, binDst) {
return sc, fmt.Errorf("%s already exists, rename the app or remove that file first", binDst)
}
if !samePath(o.BinaryPath, binDst) {
if err := copyFile(o.BinaryPath, binDst, 0o755); err != nil {
return sc, err
}
}
iconPath, err := icon.Save(o.IconPath, filepath.Dir(o.BinaryPath), strings.TrimSuffix(filepath.Base(o.BinaryPath), filepath.Ext(o.BinaryPath)), sl)
if err != nil {
return sc, err
}
entry := Entry{
Name: name,
ExecArgs: []string{binDst},
Icon: iconPath,
Categories: category(o.Category, "Utility;"),
NoDisplay: !o.Menu,
}
if err := WriteDesktop(deskDst, entry); err != nil {
return sc, err
}
sc = store.Shortcut{
Slug: sl,
Name: name,
Type: store.TypeExecutable,
BinaryPath: binDst,
IconPath: iconPath,
DesktopPath: deskDst,
Targets: store.Targets{Menu: o.Menu, Desktop: o.Desktop},
InstalledAt: time.Now().UTC(),
}
if o.Desktop {
cp, err := desktopCopy(deskDst, sl)
if err != nil {
return sc, err
}
sc.DesktopCopyPath = cp
}
go RefreshCache()
return sc, nil
}
// WebOptions adalah input Fungsi B: shortcut dari URL (PWA).
type WebOptions struct {
URL string
Name string
Browser BrowserArg
IconPath string // opsional; kosong = auto-fetch favicon
Menu bool
Desktop bool
}
// BrowserArg adalah browser target yang dipilih di UI.
type BrowserArg struct {
Name string
Exec []string
// Firefox membuka dengan --new-window, sisanya (Chromium-based) dengan --app=.
Firefox bool
}
// InstallWebApp menulis .desktop yang membuka URL sebagai aplikasi.
func InstallWebApp(o WebOptions) (store.Shortcut, error) {
var sc store.Shortcut
name := strings.TrimSpace(o.Name)
if err := validate(name, o.Menu, o.Desktop); err != nil {
return sc, err
}
if len(o.Browser.Exec) == 0 {
return sc, errors.New("no browser selected")
}
pageURL, err := normalizeURL(o.URL)
if err != nil {
return sc, err
}
sl := slugify(name)
deskDst := filepath.Join(xdg.ApplicationsDir(), sl+".desktop")
if _, err := os.Stat(deskDst); err == nil {
return sc, fmt.Errorf("shortcut %q already exists, uninstall it first", name)
}
iconPath, err := installWebIcon(o.IconPath, pageURL, sl)
if err != nil {
return sc, err
}
appArgs := o.Browser.Exec[1:]
if o.Browser.Firefox {
appArgs = append(appArgs, "--new-window", pageURL)
} else {
appArgs = append(appArgs, "--app="+pageURL)
}
entry := Entry{
Name: name,
ExecArgs: append([]string{o.Browser.Exec[0]}, appArgs...),
Icon: iconPath,
Categories: "Network;WebBrowser;",
NoDisplay: !o.Menu,
}
if err := WriteDesktop(deskDst, entry); err != nil {
return sc, err
}
sc = store.Shortcut{
Slug: sl,
Name: name,
Type: store.TypeWebApp,
URL: pageURL,
Browser: strings.Join(o.Browser.Exec, " "),
IconPath: iconPath,
DesktopPath: deskDst,
Targets: store.Targets{Menu: o.Menu, Desktop: o.Desktop},
InstalledAt: time.Now().UTC(),
}
if o.Desktop {
cp, err := desktopCopy(deskDst, sl)
if err != nil {
return sc, err
}
sc.DesktopCopyPath = cp
}
go RefreshCache()
return sc, nil
}
func installWebIcon(userIcon, pageURL, slug string) (string, error) {
if userIcon != "" {
return icon.Save(userIcon, "", "", slug)
}
if data, ext, err := icon.Fetch(pageURL); err == nil {
return icon.SaveBytes(data, ext, slug)
}
return icon.Default(slug)
}
func category(c, fallback string) string {
c = strings.TrimSpace(c)
if c == "" {
return fallback
}
return strings.TrimSuffix(c, ";") + ";"
}
// normalizeURL menambah skema https kalau user menulis domain saja.
func normalizeURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", errors.New("URL is required")
}
if !strings.Contains(raw, "://") {
raw = "https://" + raw
}
u, err := url.Parse(raw)
if err != nil {
return "", err
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", errors.New("only http/https URLs are supported")
}
if u.Host == "" {
return "", errors.New("URL has no host")
}
return u.String(), nil
}
+105
View File
@@ -0,0 +1,105 @@
package shortcut
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"strings"
"lazyrun/internal/xdg"
)
func validate(name string, menu, desktop bool) error {
if name == "" {
return errors.New("name is required")
}
if strings.ContainsAny(name, "\n\r") {
return errors.New("name must not contain line breaks")
}
if !menu && !desktop {
return errors.New("pick at least one location (menu or desktop)")
}
if slugify(name) == "" {
return errors.New("name must contain at least one letter or digit")
}
return nil
}
// checkRunnable memastikan file adalah ELF (binary/AppImage) atau script dengan shebang.
func checkRunnable(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
st, err := f.Stat()
if err != nil {
return err
}
if st.IsDir() {
return errors.New("this is a directory, not a file")
}
head := make([]byte, 4)
n, _ := io.ReadFull(f, head)
head = head[:n]
if bytes.HasPrefix(head, []byte{0x7f, 'E', 'L', 'F'}) || bytes.HasPrefix(head, []byte("#!")) {
return nil
}
return errors.New("not an executable binary (ELF, AppImage, or script only)")
}
// copyFile menyalin src ke dst lewat file sementara supaya dst tidak pernah setengah tertulis.
func copyFile(src, dst string, mode os.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(dst), ".lazyrun-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := io.Copy(tmp, in); err != nil {
tmp.Close()
return err
}
if err := tmp.Chmod(mode); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), dst)
}
func samePath(a, b string) bool {
sa, err := os.Stat(a)
if err != nil {
return false
}
sb, err := os.Stat(b)
if err != nil {
return false
}
return os.SameFile(sa, sb)
}
// desktopCopy menyalin .desktop ke ~/Desktop agar bisa diklik ganda.
func desktopCopy(desktopPath, slug string) (string, error) {
dst := filepath.Join(xdg.DesktopDir(), slug+".desktop")
if _, err := os.Stat(dst); err == nil && !samePath(dst, desktopPath) {
return "", errors.New(dst + " already exists")
}
return dst, copyFile(desktopPath, dst, 0o755)
}
+250
View File
@@ -0,0 +1,250 @@
package shortcut
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"lazyrun/internal/store"
)
var pngMagic = []byte("\x89PNG\r\n\x1a\n")
// testEnv mengarahkan semua path XDG ke tempdir dan mematikan tool eksternal,
// supaya tes tidak menyentuh ~/.local atau ~/Desktop milik user.
func testEnv(t *testing.T) string {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_DATA_HOME", "")
t.Setenv("XDG_CONFIG_HOME", "")
t.Setenv("PATH", "")
return home
}
func fakeBinary(t *testing.T, dir, name string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, append([]byte{0x7f, 'E', 'L', 'F'}, []byte("fake")...), 0o755); err != nil {
t.Fatal(err)
}
return path
}
func TestInstallExecutable(t *testing.T) {
home := testEnv(t)
src := fakeBinary(t, home, "winbox")
sc, err := InstallExecutable(ExecOptions{
BinaryPath: src,
Name: "Winbox",
Category: "Network",
Menu: true,
Desktop: true,
})
if err != nil {
t.Fatal(err)
}
if sc.BinaryPath != filepath.Join(home, ".local", "bin", "winbox") {
t.Errorf("binary tersalin ke %s", sc.BinaryPath)
}
if st, err := os.Stat(sc.BinaryPath); err != nil || st.Mode().Perm()&0o111 == 0 {
t.Errorf("binary harus executable: %v %v", st, err)
}
content, err := os.ReadFile(sc.DesktopPath)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"Name=Winbox\n",
"Exec=" + sc.BinaryPath + "\n",
"Categories=Network;\n",
"Icon=" + sc.IconPath + "\n",
} {
if !strings.Contains(string(content), want) {
t.Errorf("isi .desktop tidak memuat %q:\n%s", want, content)
}
}
if strings.Contains(string(content), "NoDisplay") {
t.Errorf("menu tercentang tidak boleh NoDisplay:\n%s", content)
}
if sc.DesktopCopyPath != filepath.Join(home, "Desktop", "winbox.desktop") {
t.Errorf("salinan desktop di %s", sc.DesktopCopyPath)
}
if st, err := os.Stat(sc.DesktopCopyPath); err != nil || st.Mode().Perm()&0o111 == 0 {
t.Errorf("file di Desktop harus executable: %v %v", st, err)
}
if _, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Winbox", Menu: true, Desktop: true}); err == nil {
t.Error("install kedua dengan nama sama harus ditolak")
}
}
func TestInstallExecutableDesktopOnly(t *testing.T) {
home := testEnv(t)
src := fakeBinary(t, home, "tool")
sc, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Tool", Menu: false, Desktop: true})
if err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(sc.DesktopPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "NoDisplay=true") {
t.Errorf("desktop-only harus disembunyikan dari menu:\n%s", content)
}
}
func TestInstallExecutableRejectsNonBinary(t *testing.T) {
home := testEnv(t)
path := filepath.Join(home, "notes.txt")
if err := os.WriteFile(path, []byte("halo"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := InstallExecutable(ExecOptions{BinaryPath: path, Name: "Notes", Menu: true}); err == nil {
t.Error("file teks biasa harus ditolak")
}
}
func TestInstallExecutableRequiresTarget(t *testing.T) {
home := testEnv(t)
src := fakeBinary(t, home, "tool")
if _, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Tool"}); err == nil {
t.Error("tanpa target harus ditolak")
}
}
func TestInstallWebApp(t *testing.T) {
testEnv(t)
page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/":
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(`<html><head><link rel="icon" type="image/png" sizes="128x128" href="/fav.png"></head></html>`))
case "/fav.png":
_, _ = w.Write(append(pngMagic, []byte("fake")...))
default:
http.NotFound(w, r)
}
}))
defer page.Close()
sc, err := InstallWebApp(WebOptions{
URL: page.URL,
Name: "WhatsApp Web",
Browser: BrowserArg{Name: "Chromium", Exec: []string{"/usr/bin/chromium"}},
Menu: true,
Desktop: true,
})
if err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(sc.DesktopPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "Exec=/usr/bin/chromium --app="+page.URL+"\n") {
t.Errorf("Exec PWA salah:\n%s", content)
}
if filepath.Ext(sc.IconPath) != ".png" {
t.Errorf("favicon harus tersimpan sebagai .png, dapat %s", sc.IconPath)
}
if st, err := os.Stat(sc.IconPath); err != nil || st.Size() == 0 {
t.Errorf("ikon favicon tidak tertulis: %v %v", st, err)
}
if sc.Type != store.TypeWebApp {
t.Errorf("type = %s", sc.Type)
}
if sc.URL != page.URL {
t.Errorf("url = %s", sc.URL)
}
}
func TestInstallWebAppFirefoxUsesNewWindow(t *testing.T) {
testEnv(t)
sc, err := InstallWebApp(WebOptions{
URL: "https://example.com",
Name: "Example",
Browser: BrowserArg{Name: "Firefox", Exec: []string{"/usr/bin/firefox"}, Firefox: true},
Menu: true,
})
if err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(sc.DesktopPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "Exec=/usr/bin/firefox --new-window https://example.com\n") {
t.Errorf("Exec Firefox salah:\n%s", content)
}
}
func TestInstallWebAppFlatpakBrowser(t *testing.T) {
testEnv(t)
sc, err := InstallWebApp(WebOptions{
URL: "https://example.com",
Name: "Example",
Browser: BrowserArg{Name: "Chromium (Flatpak)", Exec: []string{"flatpak", "run", "org.chromium.Chromium"}},
Menu: true,
})
if err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(sc.DesktopPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "Exec=flatpak run org.chromium.Chromium --app=https://example.com\n") {
t.Errorf("Exec flatpak salah:\n%s", content)
}
}
func TestUninstall(t *testing.T) {
home := testEnv(t)
src := fakeBinary(t, home, "gone")
sc, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Gone", Menu: true, Desktop: true})
if err != nil {
t.Fatal(err)
}
if err := Uninstall(sc, RemoveOptions{Binary: true}); err != nil {
t.Fatal(err)
}
for _, path := range []string{sc.DesktopPath, sc.DesktopCopyPath, sc.IconPath, sc.BinaryPath} {
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("%s masih ada setelah uninstall", path)
}
}
// uninstall kedua harus aman (file sudah tidak ada)
if err := Uninstall(sc, RemoveOptions{Binary: true}); err != nil {
t.Errorf("uninstall ulang: %v", err)
}
}
func TestUninstallKeepsBinaryByDefault(t *testing.T) {
home := testEnv(t)
src := fakeBinary(t, home, "keep")
sc, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Keep", Menu: true})
if err != nil {
t.Fatal(err)
}
if err := Uninstall(sc, RemoveOptions{}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(sc.BinaryPath); err != nil {
t.Errorf("binary harus tetap ada: %v", err)
}
}
+41
View File
@@ -0,0 +1,41 @@
package shortcut
import (
"errors"
"os"
"lazyrun/internal/store"
)
// RemoveOptions mengatur apa saja yang ikut dihapus saat uninstall.
type RemoveOptions struct {
// Binary true = binary hasil salinan di ~/.local/bin juga dihapus.
Binary bool
}
// Uninstall menghapus .desktop (menu + desktop), ikon, dan opsional binary.
func Uninstall(sc store.Shortcut, o RemoveOptions) error {
var errs []error
for _, path := range []string{sc.DesktopCopyPath, sc.DesktopPath} {
if path == "" {
continue
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
errs = append(errs, err)
}
}
if sc.IconPath != "" {
if err := os.Remove(sc.IconPath); err != nil && !os.IsNotExist(err) {
errs = append(errs, err)
}
}
if o.Binary && sc.Type == store.TypeExecutable && sc.BinaryPath != "" {
if err := os.Remove(sc.BinaryPath); err != nil && !os.IsNotExist(err) {
errs = append(errs, err)
}
}
go RefreshCache()
return errors.Join(errs...)
}
+123
View File
@@ -0,0 +1,123 @@
// Package store menyimpan daftar shortcut milik LazyRun dalam satu file JSON.
package store
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"time"
)
const (
TypeExecutable = "executable"
TypeWebApp = "webapp"
)
// Targets adalah lokasi shortcut yang dipilih user.
type Targets struct {
Menu bool `json:"menu"`
Desktop bool `json:"desktop"`
}
// Shortcut adalah satu baris metadata di shortcuts.json.
type Shortcut struct {
Slug string `json:"slug"`
Name string `json:"name"`
Type string `json:"type"`
URL string `json:"url,omitempty"`
Browser string `json:"browser,omitempty"`
BinaryPath string `json:"binary_path,omitempty"`
IconPath string `json:"icon_path,omitempty"`
DesktopPath string `json:"desktop_path"`
DesktopCopyPath string `json:"desktop_copy_path,omitempty"`
Targets Targets `json:"targets"`
InstalledAt time.Time `json:"installed_at"`
}
// Broken berarti file .desktop sudah hilang (dihapus manual oleh user).
func (s Shortcut) Broken() bool {
_, err := os.Stat(s.DesktopPath)
return err != nil
}
// Store adalah isi shortcuts.json (array di level atas).
type Store struct {
Path string
Items []Shortcut
}
// Load membaca store dari path. File yang belum ada bukan error.
// File yang rusak di-rename ke *.bak supaya tidak menimpa data lama.
func Load(path string) (*Store, error) {
s := &Store{Path: path}
data, err := os.ReadFile(path)
if os.IsNotExist(err) {
return s, nil
}
if err != nil {
return s, err
}
if err := json.Unmarshal(data, &s.Items); err != nil {
bak := path + ".bak"
_ = os.Rename(path, bak)
return s, fmt.Errorf("shortcuts.json rusak, dipindahkan ke %s: %w", bak, err)
}
return s, nil
}
// Save menulis store secara atomik supaya file tidak pernah setengah tertulis.
func (s *Store) Save() error {
if err := os.MkdirAll(filepath.Dir(s.Path), 0o755); err != nil {
return err
}
data, err := json.MarshalIndent(s.Items, "", " ")
if err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(s.Path), ".shortcuts-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := tmp.Write(append(data, '\n')); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
if err := os.Chmod(tmp.Name(), 0o644); err != nil {
return err
}
return os.Rename(tmp.Name(), s.Path)
}
// Add menambahkan shortcut; slug yang sama menimpa entry lama.
func (s *Store) Add(sc Shortcut) {
if i, ok := s.Get(sc.Slug); ok {
s.Items[i] = sc
return
}
s.Items = append(s.Items, sc)
}
// Get mencari index shortcut berdasarkan slug.
func (s *Store) Get(slug string) (int, bool) {
for i, sc := range s.Items {
if sc.Slug == slug {
return i, true
}
}
return 0, false
}
// Remove menghapus shortcut berdasarkan slug.
func (s *Store) Remove(slug string) bool {
i, ok := s.Get(slug)
if !ok {
return false
}
s.Items = append(s.Items[:i], s.Items[i+1:]...)
return true
}
+60
View File
@@ -0,0 +1,60 @@
package store
import (
"os"
"path/filepath"
"testing"
)
func TestLoadMissingFileIsEmpty(t *testing.T) {
s, err := Load(filepath.Join(t.TempDir(), "shortcuts.json"))
if err != nil {
t.Fatal(err)
}
if len(s.Items) != 0 {
t.Errorf("store baru harus kosong, dapat %d item", len(s.Items))
}
}
func TestSaveLoadRoundtrip(t *testing.T) {
path := filepath.Join(t.TempDir(), "nested", "shortcuts.json")
s, err := Load(path)
if err != nil {
t.Fatal(err)
}
s.Add(Shortcut{Slug: "winbox", Name: "Winbox", Type: TypeExecutable, Targets: Targets{Menu: true}})
s.Add(Shortcut{Slug: "wa", Name: "WhatsApp Web", Type: TypeWebApp, Targets: Targets{Menu: true, Desktop: true}})
if err := s.Save(); err != nil {
t.Fatal(err)
}
got, err := Load(path)
if err != nil {
t.Fatal(err)
}
if len(got.Items) != 2 || got.Items[1].Name != "WhatsApp Web" {
t.Fatalf("hasil load salah: %+v", got.Items)
}
if _, ok := got.Get("wa"); !ok {
t.Error("Get by slug gagal")
}
if !got.Remove("wa") || len(got.Items) != 1 {
t.Error("Remove gagal")
}
}
func TestLoadCorruptFileIsBackedUp(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "shortcuts.json")
if err := os.WriteFile(path, []byte("{bukan json"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := Load(path); err == nil {
t.Fatal("file rusak harus dilaporkan sebagai error")
}
if _, err := os.Stat(path + ".bak"); err != nil {
t.Errorf("file rusak harus di-rename ke .bak: %v", err)
}
}
+131
View File
@@ -0,0 +1,131 @@
package ui
import (
"path/filepath"
"strings"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/storage"
"fyne.io/fyne/v2/widget"
"lazyrun/internal/shortcut"
"lazyrun/internal/store"
)
var categories = []string{
"Utility", "Network", "Development", "Graphics",
"AudioVideo", "Office", "Game", "Education", "System", "Settings",
}
var (
imageFilter = storage.NewExtensionFileFilter([]string{".png", ".svg", ".jpg", ".jpeg", ".xpm", ".ico"})
stripExts = []string{".AppImage", ".appimage", ".sh", ".bin", ".run", ".desktop"}
)
func (ui *app) showExecutableDialog() {
var binPath, iconPath string
binLabel := widget.NewLabel(T("none"))
iconLabel := widget.NewLabel(T("icon_auto"))
nameEntry := widget.NewEntry()
category := widget.NewSelect(categories, nil)
category.SetSelected("Utility")
menu := widget.NewCheck(T("check_menu"), nil)
menu.SetChecked(true)
desktop := widget.NewCheck(T("check_desktop"), nil)
desktop.SetChecked(true)
browseBin := widget.NewButton(T("browse"), func() {
pickFile(ui.win, nil, func(path string) {
binPath = path
binLabel.SetText(filepath.Base(path))
if strings.TrimSpace(nameEntry.Text) == "" {
nameEntry.SetText(appNameFromFile(path))
}
})
})
browseIcon := widget.NewButton(T("browse"), func() {
pickFile(ui.win, imageFilter, func(path string) {
iconPath = path
iconLabel.SetText(filepath.Base(path))
})
})
var d *dialog.CustomDialog
install := widget.NewButton(T("install"), func() {
switch {
case binPath == "":
dialog.ShowInformation(T("err_title"), T("need_binary"), ui.win)
return
case strings.TrimSpace(nameEntry.Text) == "":
dialog.ShowInformation(T("err_title"), T("need_name"), ui.win)
return
case !menu.Checked && !desktop.Checked:
dialog.ShowInformation(T("err_title"), T("need_target"), ui.win)
return
}
opts := shortcut.ExecOptions{
BinaryPath: binPath,
IconPath: iconPath,
Name: nameEntry.Text,
Category: category.Selected,
Menu: menu.Checked,
Desktop: desktop.Checked,
}
ui.install(
func() (store.Shortcut, error) { return shortcut.InstallExecutable(opts) },
func(sc store.Shortcut, err error) { ui.added(sc, err, d.Hide) },
)
})
install.Importance = widget.HighImportance
form := widget.NewForm(
widget.NewFormItem(T("field_binary"), container.NewBorder(nil, nil, nil, browseBin, binLabel)),
widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil, browseIcon, iconLabel)),
widget.NewFormItem(T("field_name"), nameEntry),
widget.NewFormItem(T("field_category"), category),
)
content := container.NewVBox(
form,
widget.NewSeparator(),
heading(T("section_location")),
menu,
desktop,
container.NewHBox(spacer(), widget.NewButton(T("cancel"), func() { d.Hide() }), install),
)
d = dialog.NewCustom(T("new_exec_title"), "", content, ui.win)
d.Resize(fyne.NewSize(520, 460))
d.Show()
}
// pickFile membuka file picker dan mengirim path absolut ke onPick.
func pickFile(win fyne.Window, filter storage.FileFilter, onPick func(path string)) {
fd := dialog.NewFileOpen(func(r fyne.URIReadCloser, err error) {
if err != nil {
dialog.ShowError(err, win)
return
}
if r == nil {
return
}
defer r.Close()
onPick(r.URI().Path())
}, win)
if filter != nil {
fd.SetFilter(filter)
}
fd.Show()
}
func appNameFromFile(path string) string {
base := filepath.Base(path)
for _, ext := range stripExts {
if strings.HasSuffix(base, ext) {
return strings.TrimSuffix(base, ext)
}
}
return base
}
+141
View File
@@ -0,0 +1,141 @@
package ui
import (
"net/url"
"path/filepath"
"strings"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/widget"
"lazyrun/internal/browser"
"lazyrun/internal/shortcut"
"lazyrun/internal/store"
)
func (ui *app) showWebAppDialog() {
if len(ui.browsers) == 0 {
dialog.ShowInformation(T("err_title"), T("no_browser"), ui.win)
return
}
var iconPath string
var nameTouched, filling bool
urlEntry := widget.NewEntry()
urlEntry.SetPlaceHolder("https://web.whatsapp.com")
nameEntry := widget.NewEntry()
iconLabel := widget.NewLabel(T("icon_auto"))
picker := widget.NewSelect(browserNames(ui.browsers), nil)
picker.SetSelected(ui.browsers[0].Name)
menu := widget.NewCheck(T("check_menu"), nil)
menu.SetChecked(true)
desktop := widget.NewCheck(T("check_desktop"), nil)
desktop.SetChecked(true)
nameEntry.OnChanged = func(string) {
if !filling {
nameTouched = true
}
}
urlEntry.OnChanged = func(value string) {
if nameTouched {
return
}
if host := hostOf(value); host != "" {
filling = true
nameEntry.SetText(host)
filling = false
}
}
browseIcon := widget.NewButton(T("browse"), func() {
pickFile(ui.win, imageFilter, func(path string) {
iconPath = path
iconLabel.SetText(filepath.Base(path))
})
})
var d *dialog.CustomDialog
install := widget.NewButton(T("install"), func() {
switch {
case strings.TrimSpace(urlEntry.Text) == "":
dialog.ShowInformation(T("err_title"), T("need_url"), ui.win)
return
case strings.TrimSpace(nameEntry.Text) == "":
dialog.ShowInformation(T("err_title"), T("need_name"), ui.win)
return
case !menu.Checked && !desktop.Checked:
dialog.ShowInformation(T("err_title"), T("need_target"), ui.win)
return
}
b := ui.browsers[indexOf(ui.browsers, picker.Selected)]
opts := shortcut.WebOptions{
URL: urlEntry.Text,
Name: nameEntry.Text,
Browser: shortcut.BrowserArg{Name: b.Name, Exec: b.Exec, Firefox: b.Kind == browser.Firefox},
IconPath: iconPath,
Menu: menu.Checked,
Desktop: desktop.Checked,
}
ui.install(
func() (store.Shortcut, error) { return shortcut.InstallWebApp(opts) },
func(sc store.Shortcut, err error) { ui.added(sc, err, d.Hide) },
)
})
install.Importance = widget.HighImportance
form := widget.NewForm(
widget.NewFormItem(T("field_url"), urlEntry),
widget.NewFormItem(T("field_name"), nameEntry),
widget.NewFormItem(T("field_browser"), picker),
widget.NewFormItem(T("field_icon"), container.NewBorder(nil, nil, nil, browseIcon, iconLabel)),
)
content := container.NewVBox(
form,
widget.NewSeparator(),
heading(T("section_location")),
menu,
desktop,
container.NewHBox(spacer(), widget.NewButton(T("cancel"), func() { d.Hide() }), install),
)
d = dialog.NewCustom(T("new_web_title"), "", content, ui.win)
d.Resize(fyne.NewSize(520, 440))
d.Show()
}
func browserNames(list []browser.Browser) []string {
names := make([]string, len(list))
for i, b := range list {
names[i] = b.Name
}
return names
}
func indexOf(list []browser.Browser, name string) int {
for i, b := range list {
if b.Name == name {
return i
}
}
return 0
}
// hostOf mengambil hostname dari URL yang mungkin ditulis tanpa skema.
func hostOf(raw string) string {
raw = strings.TrimSpace(raw)
if raw == "" {
return ""
}
if !strings.Contains(raw, "://") {
raw = "https://" + raw
}
u, err := url.Parse(raw)
if err != nil {
return ""
}
return u.Hostname()
}
+180
View File
@@ -0,0 +1,180 @@
// Package ui berisi window utama dan dialog LazyRun.
package ui
import (
"fmt"
"os/exec"
"path/filepath"
"strings"
"fyne.io/fyne/v2"
"fyne.io/fyne/v2/container"
"fyne.io/fyne/v2/dialog"
"fyne.io/fyne/v2/layout"
"fyne.io/fyne/v2/widget"
"lazyrun/assets"
"lazyrun/internal/browser"
"lazyrun/internal/shortcut"
"lazyrun/internal/store"
"lazyrun/internal/xdg"
)
type app struct {
win fyne.Window
store *store.Store
browsers []browser.Browser
rows *fyne.Container
version string
}
// Run menampilkan window utama LazyRun sampai user menutupnya.
func Run(a fyne.App, version string) {
ui, loadErr := build(a, version)
ui.win.Show()
if loadErr != nil {
dialog.ShowError(loadErr, ui.win)
}
a.Run()
}
// build menyiapkan window utama tanpa menampilkannya (dipakai Run dan tes headless).
func build(a fyne.App, version string) (*app, error) {
ui := &app{version: version}
a.SetIcon(fyne.NewStaticResource("lazyrun.png", assets.Icon))
ui.browsers = browser.Detect()
ui.rows = container.NewVBox()
ui.win = a.NewWindow(T("app_title"))
ui.win.Resize(fyne.NewSize(760, 540))
top := container.NewHBox(
widget.NewButton(T("btn_executable"), ui.showExecutableDialog),
widget.NewButton(T("btn_webapp"), ui.showWebAppDialog),
)
bottom := container.NewBorder(nil, nil,
widget.NewButton(T("check_updates"), ui.checkUpdates),
widget.NewLabel("v"+version))
body := container.NewBorder(heading(T("installed")), nil, nil, nil, container.NewVScroll(ui.rows))
ui.win.SetContent(container.NewBorder(top, bottom, nil, nil, body))
st, err := store.Load(xdg.MetadataPath())
ui.store = st
ui.reload()
return ui, err
}
func (ui *app) reload() {
if st, err := store.Load(ui.store.Path); err == nil {
ui.store = st
}
ui.rows.RemoveAll()
if len(ui.store.Items) == 0 {
ui.rows.Add(widget.NewLabel(T("empty")))
}
for _, sc := range ui.store.Items {
ui.rows.Add(ui.row(sc))
}
}
func (ui *app) row(sc store.Shortcut) fyne.CanvasObject {
title := widget.NewLabelWithStyle(sc.Name, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
info := widget.NewLabel(describe(sc))
open := widget.NewButton(T("folder"), func() {
if err := exec.Command("xdg-open", filepath.Dir(sc.DesktopPath)).Start(); err != nil {
dialog.ShowError(err, ui.win)
}
})
remove := widget.NewButton(T("delete"), func() { ui.confirmDelete(sc) })
return container.NewBorder(nil, widget.NewSeparator(), nil,
container.NewHBox(open, remove),
container.NewVBox(title, info))
}
func describe(sc store.Shortcut) string {
targets := make([]string, 0, 2)
if sc.Targets.Menu {
targets = append(targets, T("check_menu"))
}
if sc.Targets.Desktop {
targets = append(targets, T("check_desktop"))
}
loc := strings.Join(targets, " + ")
kind := "Executable"
if sc.Type == store.TypeWebApp {
kind = "Web"
}
line := kind + " · " + loc
if sc.Broken() {
line += " · " + T("broken")
}
return line
}
func (ui *app) confirmDelete(sc store.Shortcut) {
content := container.NewVBox(widget.NewLabel(fmt.Sprintf(T("confirm_delete_msg"), sc.Name)))
var removeBinary *widget.Check
if sc.Type == store.TypeExecutable && sc.BinaryPath != "" {
removeBinary = widget.NewCheck(T("also_binary"), nil)
content.Add(removeBinary)
}
d := dialog.NewCustomConfirm(T("confirm_delete_title"), T("delete"), T("cancel"), content, func(ok bool) {
if !ok {
return
}
opts := shortcut.RemoveOptions{}
if removeBinary != nil {
opts.Binary = removeBinary.Checked
}
if err := shortcut.Uninstall(sc, opts); err != nil {
dialog.ShowError(err, ui.win)
}
ui.store.Remove(sc.Slug)
if err := ui.store.Save(); err != nil {
dialog.ShowError(err, ui.win)
}
ui.reload()
}, ui.win)
d.Show()
}
// install menjalankan pekerjaan berat (salin file besar / fetch favicon) di luar UI thread.
func (ui *app) install(work func() (store.Shortcut, error), done func(store.Shortcut, error)) {
progress := dialog.NewProgressInfinite(T("install"), T("working"), ui.win)
progress.Show()
go func() {
sc, err := work()
fyne.Do(func() {
progress.Hide()
done(sc, err)
})
}()
}
// added menyimpan metadata shortcut baru; dipanggil dari callback install.
func (ui *app) added(sc store.Shortcut, err error, closeDialog func()) {
if err != nil {
dialog.ShowError(err, ui.win)
return
}
ui.store.Add(sc)
if err := ui.store.Save(); err != nil {
ui.store.Remove(sc.Slug)
dialog.ShowError(err, ui.win)
return
}
closeDialog()
ui.reload()
dialog.ShowInformation(T("success_title"), fmt.Sprintf(T("success_msg"), sc.Name), ui.win)
}
func heading(text string) *widget.Label {
return widget.NewLabelWithStyle(text, fyne.TextAlignLeading, fyne.TextStyle{Bold: true})
}
func spacer() fyne.CanvasObject { return layout.NewSpacer() }
+35
View File
@@ -0,0 +1,35 @@
package ui
import (
"testing"
"fyne.io/fyne/v2/test"
"lazyrun/internal/store"
)
func TestBuildMainWindow(t *testing.T) {
a := test.NewApp()
defer a.Quit()
ui, err := build(a, "test")
if err != nil {
t.Fatalf("build(): %v", err)
}
if ui.win.Content() == nil {
t.Fatal("window tanpa content")
}
// dialog harus bisa dibuka tanpa panic
ui.showExecutableDialog()
ui.showWebAppDialog()
ui.checkUpdates()
}
func TestDescribe(t *testing.T) {
sc := store.Shortcut{Type: store.TypeWebApp, Targets: store.Targets{Menu: true}, DesktopPath: "/nonexistent/x.desktop"}
got := describe(sc)
if got != "Web · Application menu · missing" && got != "Web · Menu aplikasi · hilang" {
t.Errorf("describe() = %q", got)
}
}
+83
View File
@@ -0,0 +1,83 @@
package ui
import (
"os"
"strings"
)
// tr menyimpan teks UI: index 0 = Inggris, 1 = Indonesia.
var tr = map[string][2]string{
"app_title": {"LazyRun — lazy shortcut build", "LazyRun — lazy shortcut build"},
"btn_executable": {"+ Executable", "+ Executable"},
"btn_webapp": {"+ Web App", "+ Aplikasi Web"},
"installed": {"Installed shortcuts", "Shortcut terinstall"},
"empty": {"No shortcuts yet. Add one with the buttons above.", "Belum ada shortcut. Tambahkan lewat tombol di atas."},
"none": {"not selected", "belum dipilih"},
"icon_auto": {"automatic", "otomatis"},
"browse": {"Browse...", "Pilih..."},
"install": {"Install", "Install"},
"cancel": {"Cancel", "Batal"},
"delete": {"Delete", "Hapus"},
"folder": {"Folder", "Folder"},
"working": {"Working...", "Sedang diproses..."},
"new_exec_title": {"New Executable Shortcut", "Shortcut Executable Baru"},
"new_web_title": {"New Web App Shortcut", "Shortcut Web App Baru"},
"field_binary": {"Binary", "Binary"},
"field_icon": {"Icon (optional)", "Ikon (opsional)"},
"field_name": {"Name", "Nama"},
"field_category": {"Category", "Kategori"},
"field_url": {"URL", "URL"},
"field_browser": {"Browser", "Browser"},
"section_location": {"Shortcut location", "Lokasi shortcut"},
"check_menu": {"Application menu", "Menu aplikasi"},
"check_desktop": {"Desktop", "Desktop"},
"confirm_delete_title": {"Uninstall shortcut", "Hapus shortcut"},
"confirm_delete_msg": {"Remove %s?", "Hapus %s?"},
"also_binary": {"Also delete the copied binary", "Hapus binary hasil salinan juga"},
"success_title": {"Done", "Selesai"},
"success_msg": {"%s is ready.", "%s sudah siap."},
"err_title": {"Cannot continue", "Tidak bisa lanjut"},
"need_binary": {"Choose a binary first.", "Pilih binary dulu."},
"need_name": {"Enter a name first.", "Masukkan nama dulu."},
"need_url": {"Enter a URL first.", "Masukkan URL dulu."},
"need_target": {"Pick at least one location: menu or desktop.", "Pilih minimal satu lokasi: menu atau desktop."},
"no_browser": {"No supported browser found. Install Chrome, Chromium, Brave, Edge, Vivaldi, or Firefox first.", "Browser yang didukung tidak ditemukan. Install Chrome, Chromium, Brave, Edge, Vivaldi, atau Firefox dulu."},
"broken": {"missing", "hilang"},
"check_updates": {"Check for Updates", "Cek Update"},
"not_appimage": {"This build is not an AppImage (version %s). Update from where you got it.", "Build ini bukan AppImage (versi %s). Update dari sumber kamu mendapatkannya."},
"no_appimageupdate": {"AppImageUpdate is not installed. Install it to update LazyRun automatically.", "AppImageUpdate tidak terpasang. Install dulu supaya LazyRun bisa update otomatis."},
}
var lang = detectLang()
// T mengembalikan teks UI sesuai $LANG: "id*" → Indonesia, selain itu Inggris.
func T(key string) string {
s, ok := tr[key]
if !ok {
return key
}
if lang == "id" {
return s[1]
}
return s[0]
}
func detectLang() string {
v := os.Getenv("LC_ALL")
if v == "" {
v = os.Getenv("LC_MESSAGES")
}
if v == "" {
v = os.Getenv("LANG")
}
if strings.HasPrefix(strings.ToLower(v), "id") {
return "id"
}
return "en"
}
+26
View File
@@ -0,0 +1,26 @@
package ui
import (
"fmt"
"os"
"os/exec"
"fyne.io/fyne/v2/dialog"
)
// checkUpdates menjalankan AppImageUpdate kalau LazyRun berjalan sebagai AppImage.
func (ui *app) checkUpdates() {
image := os.Getenv("APPIMAGE")
if image == "" {
dialog.ShowInformation(T("check_updates"), fmt.Sprintf(T("not_appimage"), ui.version), ui.win)
return
}
update, err := exec.LookPath("AppImageUpdate")
if err != nil {
dialog.ShowInformation(T("check_updates"), T("no_appimageupdate"), ui.win)
return
}
if err := exec.Command(update, image).Start(); err != nil {
dialog.ShowError(err, ui.win)
}
}
+54
View File
@@ -0,0 +1,54 @@
// Package xdg menghitung path XDG yang dipakai LazyRun.
package xdg
import (
"os"
"os/exec"
"path/filepath"
"strings"
)
func home() string {
h, _ := os.UserHomeDir()
return h
}
func dataHome() string {
if v := os.Getenv("XDG_DATA_HOME"); v != "" {
return v
}
return filepath.Join(home(), ".local", "share")
}
func configHome() string {
if v := os.Getenv("XDG_CONFIG_HOME"); v != "" {
return v
}
return filepath.Join(home(), ".config")
}
// BinDir adalah lokasi binary target setelah di-install.
func BinDir() string { return filepath.Join(home(), ".local", "bin") }
// ApplicationsDir menampung file .desktop untuk menu aplikasi.
func ApplicationsDir() string { return filepath.Join(dataHome(), "applications") }
// IconsDir adalah direktori ikon aplikasi XDG.
func IconsDir() string {
return filepath.Join(dataHome(), "icons", "hicolor", "512x512", "apps")
}
// DesktopDir mengikuti xdg-user-dir, fallback ke ~/Desktop.
func DesktopDir() string {
if out, err := exec.Command("xdg-user-dir", "DESKTOP").Output(); err == nil {
if p := strings.TrimSpace(string(out)); p != "" {
return p
}
}
return filepath.Join(home(), "Desktop")
}
// MetadataPath adalah file daftar shortcut milik LazyRun.
func MetadataPath() string {
return filepath.Join(configHome(), "lazyrun", "shortcuts.json")
}
+39
View File
@@ -0,0 +1,39 @@
package xdg
import (
"path/filepath"
"testing"
)
func TestDataHomeOverride(t *testing.T) {
t.Setenv("HOME", "/home/tester")
t.Setenv("XDG_DATA_HOME", "/data")
if got := ApplicationsDir(); got != "/data/applications" {
t.Errorf("ApplicationsDir() = %s", got)
}
if got := IconsDir(); got != filepath.Join("/data", "icons/hicolor/512x512/apps") {
t.Errorf("IconsDir() = %s", got)
}
}
func TestFallbacks(t *testing.T) {
t.Setenv("HOME", "/home/tester")
t.Setenv("XDG_DATA_HOME", "")
t.Setenv("XDG_CONFIG_HOME", "")
if got := ApplicationsDir(); got != "/home/tester/.local/share/applications" {
t.Errorf("ApplicationsDir() = %s", got)
}
if got := BinDir(); got != "/home/tester/.local/bin" {
t.Errorf("BinDir() = %s", got)
}
if got := MetadataPath(); got != "/home/tester/.config/lazyrun/shortcuts.json" {
t.Errorf("MetadataPath() = %s", got)
}
// xdg-user-dir tidak ada di $PATH → fallback ke ~/Desktop.
t.Setenv("PATH", "")
if got := DesktopDir(); got != "/home/tester/Desktop" {
t.Errorf("DesktopDir() = %s", got)
}
}