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,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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user