Label jenis aplikasi dengan auto-detect (Go/AppImage/Electron/Script/Windows/Binary/PWA)

- internal/shortcut/kind.go: deteksi dari isi berkas - build info Go, magic
  AppImage, resources/*.asar untuk Electron, shebang untuk script, MZ untuk .exe
- .exe Windows kini boleh dipasang dan dijalankan lewat wine (Exec=wine <path>);
  kalau wine tidak terpasang, install ditolak dengan pesan jelas
- metadata menyimpan kind; chip jenis ditampilkan di setiap baris shortcut
  (data lama tetap tampil sebagai Executable/PWA)
- dialog menampilkan hasil deteksi setelah berkas dipilih, mis. 'winbox.exe - windows'
- README: bagian label otomatis + screenshot baru
This commit is contained in:
oxpoe committed 2026-10-13 08:21:38 +07:00
1 parent 41ada9a506
commit d6f6c69c5a
13 files changed
+249 -13

No files matched your search

+15 -1
View File
@@ -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,
+4 -2
View File
@@ -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.
+69
View File
@@ -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)
}
+80
View File
@@ -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)
}
}
+1
View File
@@ -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"`
+10 -1
View File
@@ -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 {
+33 -6
View File
@@ -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 {
+29 -2
View File
@@ -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)
}
}
+1
View File
@@ -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"},
+3
View File
@@ -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))
}