diff --git a/README.md b/README.md index a6164d6..3a7c791 100644 --- a/README.md +++ b/README.md @@ -30,6 +30,10 @@ breathes while the app is open (the koala in `assets/logo.svg` is the mascot).* - **Executable shortcut** — picks an ELF/AppImage/script/Windows `.exe`, copies it to `~/.local/bin/`, installs the icon, writes the `.desktop` entry. +- **Terminal apps just work** — CLI/TUI programs (lazydocker, htop, a script) need + `Terminal=true`, while GUI apps must not open a terminal. LazyRun guesses this from + the libraries the binary links (no GUI library → terminal app), pre-fills the + *Run in a terminal window* checkbox, and keeps it editable. - **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. diff --git a/internal/shortcut/install_test.go b/internal/shortcut/install_test.go index 1929280..b769570 100644 --- a/internal/shortcut/install_test.go +++ b/internal/shortcut/install_test.go @@ -299,3 +299,49 @@ func TestScriptWrapperPrefersOriginalFile(t *testing.T) { t.Errorf("wrapper tidak menyiapkan PATH:\n%s", text) } } + +func TestInstallCLIAppGetsTerminalEntry(t *testing.T) { + testEnv(t) + // pakai binary tes ini sendiri: ELF asli tanpa library GUI + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + src := self + + // binary tanpa library GUI -> dianggap aplikasi terminal + terminal := RunsInTerminal(src) + if !terminal { + t.Fatal("binary CLI harus terdeteksi butuh terminal") + } + + sc, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "TUI Tool", Menu: true, Terminal: terminal}) + if err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(sc.DesktopPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "Terminal=true\n") { + t.Errorf("aplikasi CLI harus Terminal=true:\n%s", content) + } +} + +func TestInstallGUIAppKeepsTerminalFalse(t *testing.T) { + home := testEnv(t) + src := fakeBinary(t, home, "gui-tool") + + // simulasi aplikasi GUI: tidak butuh terminal + sc, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "GUI Tool", Menu: true, Terminal: false}) + if err != nil { + t.Fatal(err) + } + content, err := os.ReadFile(sc.DesktopPath) + if err != nil { + t.Fatal(err) + } + if !strings.Contains(string(content), "Terminal=false\n") { + t.Errorf("aplikasi GUI tidak boleh membuka terminal:\n%s", content) + } +} diff --git a/internal/shortcut/terminal.go b/internal/shortcut/terminal.go new file mode 100644 index 0000000..a78e11d --- /dev/null +++ b/internal/shortcut/terminal.go @@ -0,0 +1,48 @@ +package shortcut + +import ( + "debug/elf" + "strings" +) + +// guiLibs adalah library yang menandakan aplikasi berjendela sendiri. +// Kalau binary tidak memakai satu pun dari ini, biasanya ia program terminal. +var guiLibs = []string{ + "libX11", "libxcb", "libwayland-client", "libgtk", "libgdk", + "libQt5", "libQt6", "libSDL", "libGL", "libEGL", "libglfw", "libglib", +} + +// RunsInTerminal menebak apakah program perlu jendela terminal. +// +// Script dan aplikasi baris perintah butuh Terminal=true di .desktop, +// sedangkan aplikasi GUI tidak (kalau dipaksa, jendela terminal ikut terbuka). +// Tebakannya dari daftar library dinamis: tanpa library GUI dianggap CLI. +func RunsInTerminal(path string) bool { + kind := DetectKind(path) + if kind == KindScript { + return true + } + if kind == KindWindows { + return false // .exe Windows (lewat wine) umumnya GUI + } + + f, err := elf.Open(path) + if err != nil { + return false + } + defer f.Close() + + libs, err := f.DynString(elf.DT_NEEDED) + if err != nil { + // binary statis (Go murni) tanpa dependency dinamis: kemungkinan CLI + return true + } + for _, lib := range libs { + for _, gui := range guiLibs { + if strings.Contains(lib, gui) { + return false + } + } + } + return true +} diff --git a/internal/shortcut/terminal_test.go b/internal/shortcut/terminal_test.go new file mode 100644 index 0000000..86365e1 --- /dev/null +++ b/internal/shortcut/terminal_test.go @@ -0,0 +1,69 @@ +package shortcut + +import ( + "os" + "os/exec" + "path/filepath" + "testing" +) + +func TestRunsInTerminal(t *testing.T) { + dir := t.TempDir() + + script := filepath.Join(dir, "tool.sh") + if err := os.WriteFile(script, []byte("#!/bin/sh\necho hai\n"), 0o755); err != nil { + t.Fatal(err) + } + if !RunsInTerminal(script) { + t.Error("script harus dianggap butuh terminal") + } + + exe := filepath.Join(dir, "win.exe") + if err := os.WriteFile(exe, append([]byte("MZ\x90\x00"), make([]byte, 32)...), 0o755); err != nil { + t.Fatal(err) + } + if RunsInTerminal(exe) { + t.Error("aplikasi Windows (wine) tidak perlu terminal") + } + + // binary CLI tanpa library GUI: pakai binary tes ini sendiri + self, err := os.Executable() + if err != nil { + t.Fatal(err) + } + if !RunsInTerminal(self) { + t.Error("binary CLI (tanpa lib GUI) harus dianggap butuh terminal") + } + + // berkas tidak ada / bukan ELF + if RunsInTerminal(filepath.Join(dir, "tidak-ada")) { + t.Error("berkas tidak ada tidak boleh dianggap butuh terminal") + } +} + +func TestRunsInTerminalOnGUIBinary(t *testing.T) { + // pakai aplikasi GUI nyata kalau ada di mesin ini + candidates := []string{"/opt/helium-browser-bin/helium", "/usr/bin/vivaldi"} + for _, path := range candidates { + if _, err := os.Stat(path); err != nil { + continue + } + if RunsInTerminal(path) { + t.Errorf("%s memakai lib GUI, tidak boleh dianggap butuh terminal", path) + } + return + } + t.Skip("tidak ada binary GUI untuk diuji") +} + +func TestRunsInTerminalOnRealCLITool(t *testing.T) { + // lazydocker (TUI Go) adalah kasus nyata yang dilaporkan user + if _, err := os.Stat("/home/oxpoe/.local/bin/lazydocker"); err != nil { + t.Skip("lazydocker tidak ada di mesin ini") + } + if !RunsInTerminal("/home/oxpoe/.local/bin/lazydocker") { + t.Error("lazydocker adalah TUI, harus butuh terminal") + } +} + +var _ = exec.LookPath diff --git a/internal/ui/dialog_edit.go b/internal/ui/dialog_edit.go index 6891ce4..05b2a91 100644 --- a/internal/ui/dialog_edit.go +++ b/internal/ui/dialog_edit.go @@ -100,9 +100,6 @@ func (ui *app) showEditDialog(sc store.Shortcut) { desktopChk.SetChecked(sc.Targets.Desktop) terminal := widget.NewCheck(T("check_terminal"), nil) terminal.SetChecked(sc.Terminal) - if sc.Kind != shortcut.KindScript { - terminal.Hide() - } browseIcon := newButton(T("browse"), theme.FolderOpenIcon(), T("tip_browse"), widget.MediumImportance, func() { pickFile(ui.win, imageFilter, func(path string) { @@ -123,12 +120,7 @@ func (ui *app) showEditDialog(sc store.Shortcut) { pickFile(ui.win, nil, func(path string) { binPath = path binLabel.SetText(filepath.Base(path) + " · " + detectedKind(path)) - if shortcut.DetectKind(path) == shortcut.KindScript { - terminal.Show() - } else { - terminal.SetChecked(false) - terminal.Hide() - } + terminal.SetChecked(shortcut.RunsInTerminal(path)) }) }) category := widget.NewSelect(categories, nil) @@ -220,6 +212,12 @@ func (ui *app) showEditDialog(sc store.Shortcut) { }) hint := textLine(T("edit_hint"), 12, false, colorNameMuted) + + // beberapa entri lama belum menyalakan Terminal walau aplikasinya CLI/TUI + terminalSuggest := container.NewVBox() + if !sc.Terminal && shortcut.RunsInTerminal(sc.BinaryPath) { + terminalSuggest.Add(textLine(T("terminal_suggest"), 12, false, colorNameChipDesk)) + } content := container.NewVScroll(container.NewVBox( closeRow(func() { d.Hide() }), widget.NewForm(items...), @@ -228,6 +226,7 @@ func (ui *app) showEditDialog(sc store.Shortcut) { menu, desktopChk, terminal, + terminalSuggest, hint, container.NewHBox(logoBox(newLogo(), 20), spacer(), newButton(T("cancel"), nil, T("tip_cancel"), widget.MediumImportance, func() { d.Hide() }), diff --git a/internal/ui/dialog_executable.go b/internal/ui/dialog_executable.go index cdabe90..200c916 100644 --- a/internal/ui/dialog_executable.go +++ b/internal/ui/dialog_executable.go @@ -41,19 +41,14 @@ func (ui *app) showExecutableDialog() { desktop := widget.NewCheck(T("check_desktop"), nil) desktop.SetChecked(true) terminal := widget.NewCheck(T("check_terminal"), nil) - terminal.Hide() // hanya relevan untuk script browseBin := newButton(T("browse"), theme.FolderOpenIcon(), T("tip_browse"), widget.MediumImportance, func() { pickFile(ui.win, nil, func(path string) { binPath = path binLabel.SetText(filepath.Base(path) + " · " + detectedKind(path)) - // terminal hanya perlu untuk script; tampilkan kalau terdeteksi - if shortcut.DetectKind(path) == shortcut.KindScript { - terminal.Show() - } else { - terminal.SetChecked(false) - terminal.Hide() - } + // aplikasi terminal (TUI/CLI/script) perlu Terminal=true; + // tebakannya dari library yang dipakai binary + terminal.SetChecked(shortcut.RunsInTerminal(path)) if strings.TrimSpace(nameEntry.Text) == "" { nameEntry.SetText(appNameFromFile(path)) } diff --git a/internal/ui/strings.go b/internal/ui/strings.go index de3d206..c0bd7f5 100644 --- a/internal/ui/strings.go +++ b/internal/ui/strings.go @@ -49,7 +49,8 @@ var tr = map[string][2]string{ "section_location": {"Shortcut location", "Lokasi shortcut"}, "check_menu": {"Application menu", "Menu aplikasi"}, "check_desktop": {"Desktop", "Desktop"}, - "check_terminal": {"Run in a terminal window (scripts)", "Jalankan di jendela terminal (script)"}, + "check_terminal": {"Run in a terminal window (CLI/TUI apps)", "Jalankan di jendela terminal (aplikasi CLI/TUI)"}, + "terminal_suggest": {"This looks like a terminal app - tick the box above if it closes right away.", "Ini kelihatan aplikasi terminal - centang kotak di atas kalau menutup begitu saja."}, "exec_hint": {"The binary is copied to ~/.local/bin and can be uninstalled from the list.", "Binary disalin ke ~/.local/bin dan bisa dihapus lagi dari daftar."}, "web_hint": {"The icon comes from the site's favicon, or is looked up online if there is none.", "Ikon diambil dari favicon situs, atau dicari otomatis di internet kalau tidak ada."},