LazyRun v1.0.0: shortcut maker untuk executable dan PWA

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

No files matched your search

+26
View File
@@ -0,0 +1,26 @@
package shortcut
import (
"context"
"os/exec"
"time"
"lazyrun/internal/xdg"
)
// RefreshCache memberi tahu desktop environment supaya .desktop baru langsung terbaca.
// Best-effort: kalau tool tidak ada atau gagal, menu tetap refresh saat login berikutnya.
func RefreshCache() {
ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if p, err := exec.LookPath("update-desktop-database"); err == nil {
_ = exec.CommandContext(ctx, p, xdg.ApplicationsDir()).Run()
}
for _, tool := range []string{"kbuildsycoca6", "kbuildsycoca5"} {
if p, err := exec.LookPath(tool); err == nil {
_ = exec.CommandContext(ctx, p, "--noincremental").Run()
return
}
}
}
+89
View File
@@ -0,0 +1,89 @@
package shortcut
import (
"fmt"
"os"
"path/filepath"
"strings"
"unicode"
)
// Entry adalah isi file .desktop yang dihasilkan LazyRun.
type Entry struct {
Name string
ExecArgs []string // argv lengkap; tiap arg di-quote sesuai spec Exec
Icon string // path absolut atau nama ikon
Categories string // sudah diakhiri ";" atau kosong
// NoDisplay menyembunyikan entry dari menu aplikasi. Dipakai kalau user
// hanya memilih Desktop: file tetap ada di applications/ sebagai sumber kebenaran.
NoDisplay bool
}
// Render menghasilkan isi file .desktop.
func (e Entry) Render() string {
var b strings.Builder
b.WriteString("[Desktop Entry]\n")
b.WriteString("Type=Application\n")
fmt.Fprintf(&b, "Name=%s\n", escapeValue(e.Name))
fmt.Fprintf(&b, "Exec=%s\n", execArgs(e.ExecArgs))
if e.Icon != "" {
fmt.Fprintf(&b, "Icon=%s\n", escapeValue(e.Icon))
}
b.WriteString("Terminal=false\n")
if e.Categories != "" {
fmt.Fprintf(&b, "Categories=%s\n", escapeValue(e.Categories))
}
if e.NoDisplay {
b.WriteString("NoDisplay=true\n")
}
b.WriteString("Comment=Created by LazyRun\n")
return b.String()
}
// WriteDesktop menulis entry .desktop ke path.
func WriteDesktop(path string, e Entry) error {
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, []byte(e.Render()), 0o644)
}
func execArgs(args []string) string {
quoted := make([]string, len(args))
for i, a := range args {
quoted[i] = execQuote(a)
}
return strings.Join(quoted, " ")
}
// execQuote mengutip satu argumen sesuai aturan field Exec (spec Desktop Entry).
func execQuote(s string) string {
if s != "" && !strings.ContainsAny(s, " \t\n\"'\\><~|&;$*?#()`") {
return s
}
r := strings.NewReplacer("\\", "\\\\", "\"", "\\\"", "`", "\\`", "$", "\\$", "\n", "\\n")
return "\"" + r.Replace(s) + "\""
}
// escapeValue meng-escape nilai string biasa (Name, Icon, Categories).
func escapeValue(s string) string {
r := strings.NewReplacer("\\", "\\\\", "\n", "\\n", "\r", "\\r", "\t", "\\t")
return r.Replace(s)
}
// slugify membuat nama file yang aman dari nama aplikasi.
func slugify(name string) string {
var b strings.Builder
dash := false
for _, r := range strings.ToLower(name) {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9', unicode.IsLetter(r), unicode.IsDigit(r):
b.WriteRune(r)
dash = false
case b.Len() > 0 && !dash:
b.WriteByte('-')
dash = true
}
}
return strings.Trim(b.String(), "-")
}
+89
View File
@@ -0,0 +1,89 @@
package shortcut
import (
"strings"
"testing"
)
func TestRender(t *testing.T) {
e := Entry{
Name: "Winbox",
ExecArgs: []string{"/home/user/.local/bin/winbox"},
Icon: "/home/user/.local/share/icons/hicolor/512x512/apps/winbox.png",
Categories: "Network;",
}
want := `[Desktop Entry]
Type=Application
Name=Winbox
Exec=/home/user/.local/bin/winbox
Icon=/home/user/.local/share/icons/hicolor/512x512/apps/winbox.png
Terminal=false
Categories=Network;
Comment=Created by LazyRun
`
if got := e.Render(); got != want {
t.Errorf("Render()\ngot:\n%s\nwant:\n%s", got, want)
}
}
func TestRenderNoDisplayWhenDesktopOnly(t *testing.T) {
e := Entry{Name: "X", ExecArgs: []string{"/x"}, NoDisplay: true}
if !strings.Contains(e.Render(), "NoDisplay=true\n") {
t.Errorf("entry desktop-only harus NoDisplay=true:\n%s", e.Render())
}
}
func TestExecQuote(t *testing.T) {
tests := []struct {
in, want string
}{
{"/usr/bin/winbox", "/usr/bin/winbox"},
{"/home/user/my app/app", `"/home/user/my app/app"`},
{"/opt/a$b", `"/opt/a\$b"`},
{"back\\slash path", `"back\\slash path"`},
{`say "hi"`, `"say \"hi\""`},
{"", `""`},
{"--app=https://web.whatsapp.com", "--app=https://web.whatsapp.com"},
}
for _, tc := range tests {
if got := execQuote(tc.in); got != tc.want {
t.Errorf("execQuote(%q) = %q, want %q", tc.in, got, tc.want)
}
}
}
func TestExecArgsJoinsArgs(t *testing.T) {
got := execArgs([]string{"/usr/bin/chromium", "--app=https://web.whatsapp.com"})
want := "/usr/bin/chromium --app=https://web.whatsapp.com"
if got != want {
t.Errorf("execArgs() = %q, want %q", got, want)
}
}
func TestSlugify(t *testing.T) {
tests := map[string]string{
"WhatsApp Web": "whatsapp-web",
"Winbox": "winbox",
"my-go-app": "my-go-app",
" spaced ": "spaced",
"Café #1": "café-1",
"!!": "",
}
for in, want := range tests {
if got := slugify(in); got != want {
t.Errorf("slugify(%q) = %q, want %q", in, got, want)
}
}
}
func TestNormalizeURL(t *testing.T) {
if got, err := normalizeURL("web.whatsapp.com"); err != nil || got != "https://web.whatsapp.com" {
t.Errorf("normalizeURL tanpa skema = %q, %v", got, err)
}
if _, err := normalizeURL(""); err == nil {
t.Error("URL kosong harus error")
}
if _, err := normalizeURL("ftp://example.com"); err == nil {
t.Error("skema selain http(s) harus error")
}
}
+213
View File
@@ -0,0 +1,213 @@
package shortcut
import (
"errors"
"fmt"
"net/url"
"os"
"path/filepath"
"strings"
"time"
"lazyrun/internal/icon"
"lazyrun/internal/store"
"lazyrun/internal/xdg"
)
// ExecOptions adalah input Fungsi A: shortcut dari file executable.
type ExecOptions struct {
BinaryPath string
IconPath string // opsional
Name string
Category string
Menu bool
Desktop bool
}
// InstallExecutable menyalin binary + ikon, menulis .desktop, lalu refresh cache.
func InstallExecutable(o ExecOptions) (store.Shortcut, error) {
var sc store.Shortcut
name := strings.TrimSpace(o.Name)
if err := validate(name, o.Menu, o.Desktop); err != nil {
return sc, err
}
if err := checkRunnable(o.BinaryPath); err != nil {
return sc, fmt.Errorf("%s: %w", filepath.Base(o.BinaryPath), err)
}
sl := slugify(name)
binDst := filepath.Join(xdg.BinDir(), sl)
deskDst := filepath.Join(xdg.ApplicationsDir(), sl+".desktop")
if _, err := os.Stat(deskDst); err == nil {
return sc, fmt.Errorf("shortcut %q already exists, uninstall it first", name)
}
if st, err := os.Stat(binDst); err == nil && !st.IsDir() && !samePath(o.BinaryPath, binDst) {
return sc, fmt.Errorf("%s already exists, rename the app or remove that file first", binDst)
}
if !samePath(o.BinaryPath, binDst) {
if err := copyFile(o.BinaryPath, binDst, 0o755); err != nil {
return sc, err
}
}
iconPath, err := icon.Save(o.IconPath, filepath.Dir(o.BinaryPath), strings.TrimSuffix(filepath.Base(o.BinaryPath), filepath.Ext(o.BinaryPath)), sl)
if err != nil {
return sc, err
}
entry := Entry{
Name: name,
ExecArgs: []string{binDst},
Icon: iconPath,
Categories: category(o.Category, "Utility;"),
NoDisplay: !o.Menu,
}
if err := WriteDesktop(deskDst, entry); err != nil {
return sc, err
}
sc = store.Shortcut{
Slug: sl,
Name: name,
Type: store.TypeExecutable,
BinaryPath: binDst,
IconPath: iconPath,
DesktopPath: deskDst,
Targets: store.Targets{Menu: o.Menu, Desktop: o.Desktop},
InstalledAt: time.Now().UTC(),
}
if o.Desktop {
cp, err := desktopCopy(deskDst, sl)
if err != nil {
return sc, err
}
sc.DesktopCopyPath = cp
}
go RefreshCache()
return sc, nil
}
// WebOptions adalah input Fungsi B: shortcut dari URL (PWA).
type WebOptions struct {
URL string
Name string
Browser BrowserArg
IconPath string // opsional; kosong = auto-fetch favicon
Menu bool
Desktop bool
}
// BrowserArg adalah browser target yang dipilih di UI.
type BrowserArg struct {
Name string
Exec []string
// Firefox membuka dengan --new-window, sisanya (Chromium-based) dengan --app=.
Firefox bool
}
// InstallWebApp menulis .desktop yang membuka URL sebagai aplikasi.
func InstallWebApp(o WebOptions) (store.Shortcut, error) {
var sc store.Shortcut
name := strings.TrimSpace(o.Name)
if err := validate(name, o.Menu, o.Desktop); err != nil {
return sc, err
}
if len(o.Browser.Exec) == 0 {
return sc, errors.New("no browser selected")
}
pageURL, err := normalizeURL(o.URL)
if err != nil {
return sc, err
}
sl := slugify(name)
deskDst := filepath.Join(xdg.ApplicationsDir(), sl+".desktop")
if _, err := os.Stat(deskDst); err == nil {
return sc, fmt.Errorf("shortcut %q already exists, uninstall it first", name)
}
iconPath, err := installWebIcon(o.IconPath, pageURL, sl)
if err != nil {
return sc, err
}
appArgs := o.Browser.Exec[1:]
if o.Browser.Firefox {
appArgs = append(appArgs, "--new-window", pageURL)
} else {
appArgs = append(appArgs, "--app="+pageURL)
}
entry := Entry{
Name: name,
ExecArgs: append([]string{o.Browser.Exec[0]}, appArgs...),
Icon: iconPath,
Categories: "Network;WebBrowser;",
NoDisplay: !o.Menu,
}
if err := WriteDesktop(deskDst, entry); err != nil {
return sc, err
}
sc = store.Shortcut{
Slug: sl,
Name: name,
Type: store.TypeWebApp,
URL: pageURL,
Browser: strings.Join(o.Browser.Exec, " "),
IconPath: iconPath,
DesktopPath: deskDst,
Targets: store.Targets{Menu: o.Menu, Desktop: o.Desktop},
InstalledAt: time.Now().UTC(),
}
if o.Desktop {
cp, err := desktopCopy(deskDst, sl)
if err != nil {
return sc, err
}
sc.DesktopCopyPath = cp
}
go RefreshCache()
return sc, nil
}
func installWebIcon(userIcon, pageURL, slug string) (string, error) {
if userIcon != "" {
return icon.Save(userIcon, "", "", slug)
}
if data, ext, err := icon.Fetch(pageURL); err == nil {
return icon.SaveBytes(data, ext, slug)
}
return icon.Default(slug)
}
func category(c, fallback string) string {
c = strings.TrimSpace(c)
if c == "" {
return fallback
}
return strings.TrimSuffix(c, ";") + ";"
}
// normalizeURL menambah skema https kalau user menulis domain saja.
func normalizeURL(raw string) (string, error) {
raw = strings.TrimSpace(raw)
if raw == "" {
return "", errors.New("URL is required")
}
if !strings.Contains(raw, "://") {
raw = "https://" + raw
}
u, err := url.Parse(raw)
if err != nil {
return "", err
}
if u.Scheme != "http" && u.Scheme != "https" {
return "", errors.New("only http/https URLs are supported")
}
if u.Host == "" {
return "", errors.New("URL has no host")
}
return u.String(), nil
}
+105
View File
@@ -0,0 +1,105 @@
package shortcut
import (
"bytes"
"errors"
"io"
"os"
"path/filepath"
"strings"
"lazyrun/internal/xdg"
)
func validate(name string, menu, desktop bool) error {
if name == "" {
return errors.New("name is required")
}
if strings.ContainsAny(name, "\n\r") {
return errors.New("name must not contain line breaks")
}
if !menu && !desktop {
return errors.New("pick at least one location (menu or desktop)")
}
if slugify(name) == "" {
return errors.New("name must contain at least one letter or digit")
}
return nil
}
// checkRunnable memastikan file adalah ELF (binary/AppImage) atau script dengan shebang.
func checkRunnable(path string) error {
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
st, err := f.Stat()
if err != nil {
return err
}
if st.IsDir() {
return errors.New("this is a directory, not a file")
}
head := make([]byte, 4)
n, _ := io.ReadFull(f, head)
head = head[:n]
if bytes.HasPrefix(head, []byte{0x7f, 'E', 'L', 'F'}) || bytes.HasPrefix(head, []byte("#!")) {
return nil
}
return errors.New("not an executable binary (ELF, AppImage, or script only)")
}
// copyFile menyalin src ke dst lewat file sementara supaya dst tidak pernah setengah tertulis.
func copyFile(src, dst string, mode os.FileMode) error {
in, err := os.Open(src)
if err != nil {
return err
}
defer in.Close()
if err := os.MkdirAll(filepath.Dir(dst), 0o755); err != nil {
return err
}
tmp, err := os.CreateTemp(filepath.Dir(dst), ".lazyrun-*")
if err != nil {
return err
}
defer os.Remove(tmp.Name())
if _, err := io.Copy(tmp, in); err != nil {
tmp.Close()
return err
}
if err := tmp.Chmod(mode); err != nil {
tmp.Close()
return err
}
if err := tmp.Close(); err != nil {
return err
}
return os.Rename(tmp.Name(), dst)
}
func samePath(a, b string) bool {
sa, err := os.Stat(a)
if err != nil {
return false
}
sb, err := os.Stat(b)
if err != nil {
return false
}
return os.SameFile(sa, sb)
}
// desktopCopy menyalin .desktop ke ~/Desktop agar bisa diklik ganda.
func desktopCopy(desktopPath, slug string) (string, error) {
dst := filepath.Join(xdg.DesktopDir(), slug+".desktop")
if _, err := os.Stat(dst); err == nil && !samePath(dst, desktopPath) {
return "", errors.New(dst + " already exists")
}
return dst, copyFile(desktopPath, dst, 0o755)
}
+250
View File
@@ -0,0 +1,250 @@
package shortcut
import (
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"testing"
"lazyrun/internal/store"
)
var pngMagic = []byte("\x89PNG\r\n\x1a\n")
// testEnv mengarahkan semua path XDG ke tempdir dan mematikan tool eksternal,
// supaya tes tidak menyentuh ~/.local atau ~/Desktop milik user.
func testEnv(t *testing.T) string {
t.Helper()
home := t.TempDir()
t.Setenv("HOME", home)
t.Setenv("XDG_DATA_HOME", "")
t.Setenv("XDG_CONFIG_HOME", "")
t.Setenv("PATH", "")
return home
}
func fakeBinary(t *testing.T, dir, name string) string {
t.Helper()
path := filepath.Join(dir, name)
if err := os.WriteFile(path, append([]byte{0x7f, 'E', 'L', 'F'}, []byte("fake")...), 0o755); err != nil {
t.Fatal(err)
}
return path
}
func TestInstallExecutable(t *testing.T) {
home := testEnv(t)
src := fakeBinary(t, home, "winbox")
sc, err := InstallExecutable(ExecOptions{
BinaryPath: src,
Name: "Winbox",
Category: "Network",
Menu: true,
Desktop: true,
})
if err != nil {
t.Fatal(err)
}
if sc.BinaryPath != filepath.Join(home, ".local", "bin", "winbox") {
t.Errorf("binary tersalin ke %s", sc.BinaryPath)
}
if st, err := os.Stat(sc.BinaryPath); err != nil || st.Mode().Perm()&0o111 == 0 {
t.Errorf("binary harus executable: %v %v", st, err)
}
content, err := os.ReadFile(sc.DesktopPath)
if err != nil {
t.Fatal(err)
}
for _, want := range []string{
"Name=Winbox\n",
"Exec=" + sc.BinaryPath + "\n",
"Categories=Network;\n",
"Icon=" + sc.IconPath + "\n",
} {
if !strings.Contains(string(content), want) {
t.Errorf("isi .desktop tidak memuat %q:\n%s", want, content)
}
}
if strings.Contains(string(content), "NoDisplay") {
t.Errorf("menu tercentang tidak boleh NoDisplay:\n%s", content)
}
if sc.DesktopCopyPath != filepath.Join(home, "Desktop", "winbox.desktop") {
t.Errorf("salinan desktop di %s", sc.DesktopCopyPath)
}
if st, err := os.Stat(sc.DesktopCopyPath); err != nil || st.Mode().Perm()&0o111 == 0 {
t.Errorf("file di Desktop harus executable: %v %v", st, err)
}
if _, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Winbox", Menu: true, Desktop: true}); err == nil {
t.Error("install kedua dengan nama sama harus ditolak")
}
}
func TestInstallExecutableDesktopOnly(t *testing.T) {
home := testEnv(t)
src := fakeBinary(t, home, "tool")
sc, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Tool", Menu: false, Desktop: true})
if err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(sc.DesktopPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "NoDisplay=true") {
t.Errorf("desktop-only harus disembunyikan dari menu:\n%s", content)
}
}
func TestInstallExecutableRejectsNonBinary(t *testing.T) {
home := testEnv(t)
path := filepath.Join(home, "notes.txt")
if err := os.WriteFile(path, []byte("halo"), 0o644); err != nil {
t.Fatal(err)
}
if _, err := InstallExecutable(ExecOptions{BinaryPath: path, Name: "Notes", Menu: true}); err == nil {
t.Error("file teks biasa harus ditolak")
}
}
func TestInstallExecutableRequiresTarget(t *testing.T) {
home := testEnv(t)
src := fakeBinary(t, home, "tool")
if _, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Tool"}); err == nil {
t.Error("tanpa target harus ditolak")
}
}
func TestInstallWebApp(t *testing.T) {
testEnv(t)
page := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/":
w.Header().Set("Content-Type", "text/html")
_, _ = w.Write([]byte(`<html><head><link rel="icon" type="image/png" sizes="128x128" href="/fav.png"></head></html>`))
case "/fav.png":
_, _ = w.Write(append(pngMagic, []byte("fake")...))
default:
http.NotFound(w, r)
}
}))
defer page.Close()
sc, err := InstallWebApp(WebOptions{
URL: page.URL,
Name: "WhatsApp Web",
Browser: BrowserArg{Name: "Chromium", Exec: []string{"/usr/bin/chromium"}},
Menu: true,
Desktop: true,
})
if err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(sc.DesktopPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "Exec=/usr/bin/chromium --app="+page.URL+"\n") {
t.Errorf("Exec PWA salah:\n%s", content)
}
if filepath.Ext(sc.IconPath) != ".png" {
t.Errorf("favicon harus tersimpan sebagai .png, dapat %s", sc.IconPath)
}
if st, err := os.Stat(sc.IconPath); err != nil || st.Size() == 0 {
t.Errorf("ikon favicon tidak tertulis: %v %v", st, err)
}
if sc.Type != store.TypeWebApp {
t.Errorf("type = %s", sc.Type)
}
if sc.URL != page.URL {
t.Errorf("url = %s", sc.URL)
}
}
func TestInstallWebAppFirefoxUsesNewWindow(t *testing.T) {
testEnv(t)
sc, err := InstallWebApp(WebOptions{
URL: "https://example.com",
Name: "Example",
Browser: BrowserArg{Name: "Firefox", Exec: []string{"/usr/bin/firefox"}, Firefox: true},
Menu: true,
})
if err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(sc.DesktopPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "Exec=/usr/bin/firefox --new-window https://example.com\n") {
t.Errorf("Exec Firefox salah:\n%s", content)
}
}
func TestInstallWebAppFlatpakBrowser(t *testing.T) {
testEnv(t)
sc, err := InstallWebApp(WebOptions{
URL: "https://example.com",
Name: "Example",
Browser: BrowserArg{Name: "Chromium (Flatpak)", Exec: []string{"flatpak", "run", "org.chromium.Chromium"}},
Menu: true,
})
if err != nil {
t.Fatal(err)
}
content, err := os.ReadFile(sc.DesktopPath)
if err != nil {
t.Fatal(err)
}
if !strings.Contains(string(content), "Exec=flatpak run org.chromium.Chromium --app=https://example.com\n") {
t.Errorf("Exec flatpak salah:\n%s", content)
}
}
func TestUninstall(t *testing.T) {
home := testEnv(t)
src := fakeBinary(t, home, "gone")
sc, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Gone", Menu: true, Desktop: true})
if err != nil {
t.Fatal(err)
}
if err := Uninstall(sc, RemoveOptions{Binary: true}); err != nil {
t.Fatal(err)
}
for _, path := range []string{sc.DesktopPath, sc.DesktopCopyPath, sc.IconPath, sc.BinaryPath} {
if _, err := os.Stat(path); !os.IsNotExist(err) {
t.Errorf("%s masih ada setelah uninstall", path)
}
}
// uninstall kedua harus aman (file sudah tidak ada)
if err := Uninstall(sc, RemoveOptions{Binary: true}); err != nil {
t.Errorf("uninstall ulang: %v", err)
}
}
func TestUninstallKeepsBinaryByDefault(t *testing.T) {
home := testEnv(t)
src := fakeBinary(t, home, "keep")
sc, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Keep", Menu: true})
if err != nil {
t.Fatal(err)
}
if err := Uninstall(sc, RemoveOptions{}); err != nil {
t.Fatal(err)
}
if _, err := os.Stat(sc.BinaryPath); err != nil {
t.Errorf("binary harus tetap ada: %v", err)
}
}
+41
View File
@@ -0,0 +1,41 @@
package shortcut
import (
"errors"
"os"
"lazyrun/internal/store"
)
// RemoveOptions mengatur apa saja yang ikut dihapus saat uninstall.
type RemoveOptions struct {
// Binary true = binary hasil salinan di ~/.local/bin juga dihapus.
Binary bool
}
// Uninstall menghapus .desktop (menu + desktop), ikon, dan opsional binary.
func Uninstall(sc store.Shortcut, o RemoveOptions) error {
var errs []error
for _, path := range []string{sc.DesktopCopyPath, sc.DesktopPath} {
if path == "" {
continue
}
if err := os.Remove(path); err != nil && !os.IsNotExist(err) {
errs = append(errs, err)
}
}
if sc.IconPath != "" {
if err := os.Remove(sc.IconPath); err != nil && !os.IsNotExist(err) {
errs = append(errs, err)
}
}
if o.Binary && sc.Type == store.TypeExecutable && sc.BinaryPath != "" {
if err := os.Remove(sc.BinaryPath); err != nil && !os.IsNotExist(err) {
errs = append(errs, err)
}
}
go RefreshCache()
return errors.Join(errs...)
}