diff --git a/README.md b/README.md index fe7e337..2f85171 100644 --- a/README.md +++ b/README.md @@ -20,8 +20,11 @@ AppImage, no terminal needed. - **Repo:** - **Donate:** -- **Executable shortcut** — picks an ELF/AppImage/script, copies it to +- **Executable shortcut** — picks an ELF/AppImage/script/Windows `.exe`, copies it to `~/.local/bin/`, installs the icon, writes the `.desktop` entry. +- **Automatic type label** — every shortcut is tagged with what it actually is, + detected from the file: `Go`, `AppImage`, `Electron`, `Script`, `Windows`, `Binary` + (`.exe` files are launched through `wine`), or `PWA` for web apps. - **Web app shortcut** — opens the URL as an app: `--app=` for Chromium-based browsers (Chrome, Chromium, Brave, Edge, Vivaldi, Helium, including Flatpak builds), `--new-window` for Firefox. diff --git a/docs/main-dark.png b/docs/main-dark.png index e7a85fc..8c6679f 100644 Binary files a/docs/main-dark.png and b/docs/main-dark.png differ diff --git a/docs/main-light.png b/docs/main-light.png index eda3542..b98774e 100644 Binary files a/docs/main-light.png and b/docs/main-light.png differ diff --git a/internal/shortcut/executable.go b/internal/shortcut/executable.go index f2a3bb3..3dcf4a7 100644 --- a/internal/shortcut/executable.go +++ b/internal/shortcut/executable.go @@ -5,6 +5,7 @@ import ( "fmt" "net/url" "os" + "os/exec" "path/filepath" "strings" "time" @@ -65,9 +66,20 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) { return sc, err } + // jenis aplikasi dideteksi otomatis; .exe Windows dijalankan lewat wine + kind := DetectKind(o.BinaryPath) + execArgs := []string{binDst} + if kind == KindWindows { + wine, err := exec.LookPath("wine") + if err != nil { + return sc, errors.New("this is a Windows .exe; install wine first so the shortcut can run it") + } + execArgs = []string{wine, binDst} + } + entry := Entry{ Name: name, - ExecArgs: []string{binDst}, + ExecArgs: execArgs, Icon: iconPath, Categories: category(o.Category, "Utility;"), NoDisplay: !o.Menu, @@ -80,6 +92,7 @@ func InstallExecutable(o ExecOptions) (store.Shortcut, error) { Slug: sl, Name: name, Type: store.TypeExecutable, + Kind: kind, ExecArgs: entry.ExecArgs, BinaryPath: binDst, IconPath: iconPath, @@ -174,6 +187,7 @@ func InstallWebApp(o WebOptions) (store.Shortcut, error) { Slug: sl, Name: name, Type: store.TypeWebApp, + Kind: KindPWA, URL: pageURL, Browser: strings.Join(o.Browser.Exec, " "), ExecArgs: entry.ExecArgs, diff --git a/internal/shortcut/install.go b/internal/shortcut/install.go index 1ac32ca..2a7b26f 100644 --- a/internal/shortcut/install.go +++ b/internal/shortcut/install.go @@ -46,10 +46,12 @@ func checkRunnable(path string) error { 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("#!")) { + if bytes.HasPrefix(head, []byte{0x7f, 'E', 'L', 'F'}) || + bytes.HasPrefix(head, []byte("#!")) || + bytes.HasPrefix(head, []byte("MZ")) { // .exe Windows (dijalankan lewat wine) return nil } - return errors.New("not an executable binary (ELF, AppImage, or script only)") + return errors.New("not an executable (ELF, AppImage, script, or Windows .exe)") } // copyFile menyalin src ke dst lewat file sementara supaya dst tidak pernah setengah tertulis. diff --git a/internal/shortcut/kind.go b/internal/shortcut/kind.go new file mode 100644 index 0000000..d356dc3 --- /dev/null +++ b/internal/shortcut/kind.go @@ -0,0 +1,69 @@ +package shortcut + +import ( + "bytes" + "debug/buildinfo" + "os" + "path/filepath" +) + +// Jenis aplikasi yang bisa dikenali LazyRun. +const ( + KindGo = "go" + KindAppImage = "appimage" + KindElectron = "electron" + KindScript = "script" + KindWindows = "windows" + KindBinary = "binary" + KindPWA = "pwa" +) + +// DetectKind menebak jenis berkas yang dipilih user: +// binary Go, AppImage, aplikasi Electron, script, .exe Windows, atau binary biasa. +func DetectKind(path string) string { + f, err := os.Open(path) + if err != nil { + return "" + } + head := make([]byte, 12) + n, _ := f.Read(head) + f.Close() + head = head[:n] + + switch { + case bytes.HasPrefix(head, []byte("MZ")): + return KindWindows + case bytes.HasPrefix(head, []byte("#!")): + return KindScript + case isAppImageHead(head): + return KindAppImage + case bytes.HasPrefix(head, []byte{0x7f, 'E', 'L', 'F'}): + if _, err := buildinfo.ReadFile(path); err == nil { + return KindGo + } + if hasElectronResources(path) { + return KindElectron + } + return KindBinary + } + return "" +} + +// hasElectronResources mencari berkas penanda Electron di sebelah binary. +func hasElectronResources(path string) bool { + resources := filepath.Join(filepath.Dir(path), "resources") + for _, name := range []string{"app.asar", "default_app.asar", "app"} { + if _, err := os.Stat(filepath.Join(resources, name)); err == nil { + return true + } + } + return false +} + +// isAppImageHead memeriksa magic AppImage (ELF + "AI" pada offset 8). +// Sengaja terpisah dari internal/update supaya paket ini tidak tergantung ke sana. +func isAppImageHead(head []byte) bool { + return len(head) >= 11 && + head[0] == 0x7f && head[1] == 'E' && head[2] == 'L' && head[3] == 'F' && + head[8] == 'A' && head[9] == 'I' && (head[10] == 0x01 || head[10] == 0x02) +} diff --git a/internal/shortcut/kind_test.go b/internal/shortcut/kind_test.go new file mode 100644 index 0000000..afc1a85 --- /dev/null +++ b/internal/shortcut/kind_test.go @@ -0,0 +1,80 @@ +package shortcut + +import ( + "os" + "path/filepath" + "testing" +) + +func TestDetectKind(t *testing.T) { + dir := t.TempDir() + + write := func(name string, head []byte) string { + path := filepath.Join(dir, name) + if err := os.WriteFile(path, append(head, make([]byte, 64)...), 0o755); err != nil { + t.Fatal(err) + } + return path + } + + elf := []byte{0x7f, 'E', 'L', 'F', 0x02, 0x01, 0x01, 0x00} + + cases := []struct { + name string + path string + want string + }{ + {"windows exe", write("winbox.exe", []byte("MZ\x90\x00")), KindWindows}, + {"script", write("tool.sh", []byte("#!/bin/sh\n")), KindScript}, + {"appimage", write("app.AppImage", append(append([]byte{}, elf...), 'A', 'I', 0x02, 0x00)), KindAppImage}, + {"binary biasa", write("binary", elf), KindBinary}, + } + + for _, c := range cases { + t.Run(c.name, func(t *testing.T) { + if got := DetectKind(c.path); got != c.want { + t.Errorf("DetectKind() = %q, mau %q", got, c.want) + } + }) + } + + // binary Go: test ini sendiri hasil build Go, jadi harus terdeteksi "go" + exe, err := os.Executable() + if err != nil { + t.Fatal(err) + } + if got := DetectKind(exe); got != KindGo { + t.Errorf("DetectKind(test binary) = %q, mau %q", got, KindGo) + } +} + +func TestDetectKindElectron(t *testing.T) { + dir := t.TempDir() + app := filepath.Join(dir, "myapp") + if err := os.WriteFile(app, append([]byte{0x7f, 'E', 'L', 'F', 0x02, 0x01, 0x01, 0x00}, make([]byte, 32)...), 0o755); err != nil { + t.Fatal(err) + } + if err := os.MkdirAll(filepath.Join(dir, "resources"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "resources", "app.asar"), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + if got := DetectKind(app); got != KindElectron { + t.Errorf("DetectKind(electron) = %q", got) + } +} + +func TestDetectKindUnknownFile(t *testing.T) { + path := filepath.Join(t.TempDir(), "catatan.txt") + if err := os.WriteFile(path, []byte("halo"), 0o644); err != nil { + t.Fatal(err) + } + if got := DetectKind(path); got != "" { + t.Errorf("teks biasa tidak punya kind, dapat %q", got) + } + if got := DetectKind(filepath.Join(t.TempDir(), "tidak-ada")); got != "" { + t.Errorf("berkas tidak ada harus kosong, dapat %q", got) + } +} diff --git a/internal/store/store.go b/internal/store/store.go index f6517f9..e38e037 100644 --- a/internal/store/store.go +++ b/internal/store/store.go @@ -25,6 +25,7 @@ type Shortcut struct { Slug string `json:"slug"` Name string `json:"name"` Type string `json:"type"` + Kind string `json:"kind,omitempty"` // hasil auto-detect: go, appimage, electron, script, windows, binary, pwa URL string `json:"url,omitempty"` Browser string `json:"browser,omitempty"` ExecArgs []string `json:"exec_args,omitempty"` diff --git a/internal/ui/dialog_executable.go b/internal/ui/dialog_executable.go index 703f54c..0ed16fa 100644 --- a/internal/ui/dialog_executable.go +++ b/internal/ui/dialog_executable.go @@ -44,7 +44,7 @@ func (ui *app) showExecutableDialog() { browseBin := widget.NewButtonWithIcon(T("browse"), theme.FolderOpenIcon(), func() { pickFile(ui.win, nil, func(path string) { binPath = path - binLabel.SetText(filepath.Base(path)) + binLabel.SetText(filepath.Base(path) + " · " + detectedKind(path)) if strings.TrimSpace(nameEntry.Text) == "" { nameEntry.SetText(appNameFromFile(path)) } @@ -136,6 +136,15 @@ func pickFile(win fyne.Window, filter storage.FileFilter, onPick func(path strin fd.Show() } +// detectedKind menampilkan hasil auto-detect jenis berkas di dialog. +func detectedKind(path string) string { + kind := shortcut.DetectKind(path) + if kind == "" { + return T("kind_unknown") + } + return kind +} + func appNameFromFile(path string) string { base := filepath.Base(path) for _, ext := range stripExts { diff --git a/internal/ui/main_window.go b/internal/ui/main_window.go index af45de4..dd4eb68 100644 --- a/internal/ui/main_window.go +++ b/internal/ui/main_window.go @@ -129,7 +129,8 @@ func (ui *app) row(sc store.Shortcut) *hoverCard { caption(metaText(sc)), ) - chips := make([]fyne.CanvasObject, 0, 3) + chips := make([]fyne.CanvasObject, 0, 4) + chips = append(chips, chip(kindLabel(sc), colorNameChipKind)) if sc.Targets.Menu { chips = append(chips, chip(T("chip_menu"), colorNameChipMenu)) } @@ -157,18 +158,44 @@ func (ui *app) row(sc store.Shortcut) *hoverCard { actions)) } -// metaText menampilkan tipe shortcut dan penanda singkatnya. +// kindLabel adalah label jenis aplikasi hasil auto-detect. +// Istilahnya teknis (nama teknologi), jadi tidak diterjemahkan. +func kindLabel(sc store.Shortcut) string { + switch sc.Kind { + case shortcut.KindGo: + return "Go" + case shortcut.KindAppImage: + return "AppImage" + case shortcut.KindElectron: + return "Electron" + case shortcut.KindScript: + return "Script" + case shortcut.KindWindows: + return "Windows" + case shortcut.KindBinary: + return "Binary" + case shortcut.KindPWA: + return "PWA" + } + // shortcut lama (sebelum ada auto-detect) + if sc.Type == store.TypeWebApp { + return "PWA" + } + return "Executable" +} + +// metaText menampilkan penanda singkat: nama berkas binary atau host web app. func metaText(sc store.Shortcut) string { if sc.Type == store.TypeWebApp { if host := hostOf(sc.URL); host != "" { - return "Web · " + host + return host } - return "Web" + return sc.URL } if sc.BinaryPath != "" { - return "Executable · " + filepath.Base(sc.BinaryPath) + return filepath.Base(sc.BinaryPath) } - return "Executable" + return sc.Name } func fallbackIcon(sc store.Shortcut) fyne.Resource { diff --git a/internal/ui/main_window_test.go b/internal/ui/main_window_test.go index b233ed1..c4fc168 100644 --- a/internal/ui/main_window_test.go +++ b/internal/ui/main_window_test.go @@ -5,6 +5,7 @@ import ( "fyne.io/fyne/v2/test" + "lazyrun/internal/shortcut" "lazyrun/internal/store" ) @@ -28,11 +29,37 @@ func TestBuildMainWindow(t *testing.T) { func TestMetaText(t *testing.T) { sc := store.Shortcut{Type: store.TypeWebApp, URL: "https://example.com/x", Targets: store.Targets{Menu: true}} - if got := metaText(sc); got != "Web · example.com" { + if got := metaText(sc); got != "example.com" { t.Errorf("metaText() = %q", got) } exe := store.Shortcut{Type: store.TypeExecutable, BinaryPath: "/home/u/.local/bin/winbox"} - if got := metaText(exe); got != "Executable · winbox" { + if got := metaText(exe); got != "winbox" { t.Errorf("metaText() = %q", got) } } + +func TestKindLabel(t *testing.T) { + cases := map[string]string{ + shortcut.KindGo: "Go", + shortcut.KindAppImage: "AppImage", + shortcut.KindElectron: "Electron", + shortcut.KindScript: "Script", + shortcut.KindWindows: "Windows", + shortcut.KindBinary: "Binary", + shortcut.KindPWA: "PWA", + } + for kind, want := range cases { + sc := store.Shortcut{Kind: kind, Type: store.TypeExecutable} + if got := kindLabel(sc); got != want { + t.Errorf("kindLabel(%q) = %q, mau %q", kind, got, want) + } + } + + // data lama tanpa kind + if got := kindLabel(store.Shortcut{Type: store.TypeExecutable}); got != "Executable" { + t.Errorf("kindLabel(lama, executable) = %q", got) + } + if got := kindLabel(store.Shortcut{Type: store.TypeWebApp}); got != "PWA" { + t.Errorf("kindLabel(lama, webapp) = %q", got) + } +} diff --git a/internal/ui/strings.go b/internal/ui/strings.go index 1dc996b..67472d0 100644 --- a/internal/ui/strings.go +++ b/internal/ui/strings.go @@ -18,6 +18,7 @@ var tr = map[string][2]string{ "chip_desktop": {"Desktop", "Desktop"}, "chip_broken": {"file missing", "file hilang"}, "none": {"not selected", "belum dipilih"}, + "kind_unknown": {"unknown type", "jenis tidak dikenal"}, "icon_auto": {"automatic", "otomatis"}, "online": {"Online", "Online"}, "search": {"Search", "Cari"}, diff --git a/internal/ui/theme.go b/internal/ui/theme.go index 339d460..4bbb0a3 100644 --- a/internal/ui/theme.go +++ b/internal/ui/theme.go @@ -21,6 +21,7 @@ const ( colorNameChipMenu fyne.ThemeColorName = "lazyrunChipMenu" colorNameChipDesk fyne.ThemeColorName = "lazyrunChipDesktop" colorNameChipBroken fyne.ThemeColorName = "lazyrunChipBroken" + colorNameChipKind fyne.ThemeColorName = "lazyrunChipKind" ) // brand menimpa sebagian warna/ukuran tema bawaan; terang/gelap tetap ikut sistem. @@ -51,6 +52,8 @@ func (b brand) Color(name fyne.ThemeColorName, variant fyne.ThemeVariant) color. return withAlpha(brandIndigo, pick[uint8](dark, 0x44, 0x26)) case colorNameChipDesk: return withAlpha(brandCyan, pick[uint8](dark, 0x3D, 0x2C)) + case colorNameChipKind: + return pick(dark, color.NRGBA{R: 0xFF, G: 0xFF, B: 0xFF, A: 0x1C}, color.NRGBA{R: 0x00, G: 0x00, B: 0x00, A: 0x12}) case colorNameChipBroken: return withAlpha(color.NRGBA{R: 0xE0, G: 0x4F, B: 0x4F, A: 0xFF}, pick[uint8](dark, 0x4A, 0x2E)) }