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