2 Commits
Author SHA1 Message Date
oxpoe 63869e9158 Deteksi aplikasi terminal (CLI/TUI) + perbaikan shortcut lazydocker
- internal/shortcut/terminal.go: RunsInTerminal() menebak dari library dinamis
  (tanpa lib GUI = aplikasi terminal); script selalu true, .exe wine false
- checkbox 'Run in a terminal window' kini muncul untuk semua executable, bukan
  hanya script, dan terisi otomatis saat berkas dipilih
- dialog edit: kalau entri lama belum menyalakan Terminal padahal aplikasinya
  CLI/TUI, muncul catatan kecil sebagai petunjuk
- tes: script/.exe/binary CLI (test binary)/binary GUI (helium)/lazydocker nyata
- kasus nyata: lazydocker & docker-dash terpasang Terminal=false -> TUI gagal
  ('open /dev/tty: no such device or address'); dengan Terminal=true TUI jalan
2026-10-13 10:34:05 +07:00
oxpoe 35e83dba09 Tombol Run menghormati Terminal=true (gio launch) + wrapper pakai script asli
- launch lewat 'gio launch <file .desktop>' kalau tersedia: Terminal=true membuka
  terminal, Path= dihormati, sama seperti menu desktop (script yang butuh sudo
  atau input sekarang bisa jalan dari tombol Run)
- wrapper script memakai berkas script asli kalau masih ada, jadi dirname $0
  menunjuk folder aslinya (laporan/hasil tidak nyasar ke ~/.local/bin);
  salinan .real tetap jadi cadangan
- tes: pemilihan perintah launch, wrapper memakai script asli + PATH
2026-10-13 09:36:02 +07:00
12 changed files with 330 additions and 36 deletions

No files matched your search

+8 -1
View File
@@ -30,13 +30,20 @@ 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.
- **Scripts run properly** — the desktop environment starts programs with your home as
the working directory and the session `PATH`, which breaks scripts that use relative
paths or tools from `~/.local/bin`. LazyRun therefore installs a tiny wrapper that
fixes both (and can open the script in a terminal window when you tick that option).
fixes both, running the original script file when it is still there (so `dirname $0`
keeps pointing at its own folder). *Run* in the list launches through
`gio launch <desktop file>`, so `Terminal=true` and `Path=` behave exactly like the
desktop menu — handy for scripts that ask for input or need `sudo`.
- **Web app shortcut** — opens the URL as an app: `--app=<url>` for
Chromium-based browsers (Chrome, Chromium, Brave, Edge, Vivaldi, Helium,
including Flatpak builds), `--new-window` for Firefox.
+2
View File
@@ -121,6 +121,8 @@ case "$PATH" in *".local/bin"*) echo "path ok";; *) echo "path hilang";; esac
if !strings.Contains(string(out), "path ok") {
t.Errorf("PATH tidak disiapkan wrapper: %s", out)
}
// script asli di folder asalnya masih ada di mesin ini? bukan di sini,
// jadi wrapper memakai salinannya (sudah diuji lewat 'data ok' di atas)
// binary biasa hasil restore juga harus executable
tool := restored["Tool"]
+15 -5
View File
@@ -117,7 +117,7 @@ func installBinary(kind, src, binDst string) error {
if err := copyFile(src, binDst+scriptSuffix, 0o755); err != nil {
return err
}
return writeScriptWrapper(binDst, binDst+scriptSuffix, filepath.Dir(src))
return writeScriptWrapper(binDst, binDst+scriptSuffix, src)
}
if samePath(src, binDst) {
return nil
@@ -137,16 +137,26 @@ func writeFileMode(path string, data []byte, mode os.FileMode) error {
const scriptSuffix = ".real"
// writeScriptWrapper menulis wrapper yang menyiapkan PATH dan folder kerja
// sebelum menjalankan script aslinya.
func writeScriptWrapper(path, real, originDir string) error {
// sebelum menjalankan script.
//
// Script asli di folder asalnya dipakai lebih dulu kalau masih ada (supaya
// `dirname $0` dan berkas di sebelahnya tetap mengarah ke sana); salinan di
// ~/.local/bin jadi cadangan kalau script aslinya sudah pindah/dihapus.
func writeScriptWrapper(path, real, originFile string) error {
body := "#!/bin/sh\n" +
"# dibuat oleh LazyRun: siapkan PATH & folder kerja lalu jalankan script aslinya\n" +
"# dibuat oleh LazyRun: siapkan PATH & folder kerja lalu jalankan script\n" +
`PATH="$HOME/.local/bin:$PATH"` + "\n" +
"export PATH\n"
if originDir != "" && originDir != "." {
originDir := filepath.Dir(originFile)
if originDir != "" && originDir != "." && originDir != "/" {
quoted := shellQuote(originDir)
body += "if [ -d " + quoted + " ]; then cd " + quoted + " || exit 1; fi\n"
}
if originFile != "" && originFile != real {
body += "if [ -f " + shellQuote(originFile) + " ]; then exec " + shellQuote(originFile) + ` "$@"; fi` + "\n"
}
body += "exec " + shellQuote(real) + ` "$@"` + "\n"
return os.WriteFile(path, []byte(body), 0o755)
}
+78
View File
@@ -267,3 +267,81 @@ func TestUninstallKeepsBinaryByDefault(t *testing.T) {
t.Errorf("binary harus tetap ada: %v", err)
}
}
func TestScriptWrapperPrefersOriginalFile(t *testing.T) {
home := testEnv(t)
dir := filepath.Join(home, "proyek")
if err := os.MkdirAll(dir, 0o755); err != nil {
t.Fatal(err)
}
src := filepath.Join(dir, "bench.sh")
if err := os.WriteFile(src, []byte("#!/bin/sh\necho asli \"$(pwd)\"\n"), 0o755); err != nil {
t.Fatal(err)
}
sc, err := InstallExecutable(ExecOptions{BinaryPath: src, Name: "Bench", Menu: true})
if err != nil {
t.Fatal(err)
}
wrapper, err := os.ReadFile(sc.BinaryPath)
if err != nil {
t.Fatal(err)
}
text := string(wrapper)
if !strings.Contains(text, shellQuote(src)) {
t.Errorf("wrapper tidak memakai script asli:\n%s", text)
}
if !strings.Contains(text, shellQuote(dir)) {
t.Errorf("wrapper tidak cd ke folder asli:\n%s", text)
}
if !strings.Contains(text, "PATH=\"$HOME/.local/bin:$PATH\"") {
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)
}
}
+5 -1
View File
@@ -108,7 +108,11 @@ func restoreBinary(restored *store.Shortcut, slug string, binary []byte) error {
if err := os.Rename(binDst, binDst+scriptSuffix); err != nil {
return err
}
if err := writeScriptWrapper(binDst, binDst+scriptSuffix, originDir); err != nil {
originFile := ""
if originDir != "" {
originFile = filepath.Join(originDir, filepath.Base(restored.BinaryPath))
}
if err := writeScriptWrapper(binDst, binDst+scriptSuffix, originFile); err != nil {
return err
}
}
+48
View File
@@ -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
}
+69
View File
@@ -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
+8 -9
View File
@@ -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() }),
+3 -8
View File
@@ -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))
}
+62
View File
@@ -0,0 +1,62 @@
package ui
import (
"os"
"os/exec"
"path/filepath"
"testing"
"lazyrun/internal/store"
)
func TestLaunchCommandPrefersDesktopFile(t *testing.T) {
dir := t.TempDir()
desk := filepath.Join(dir, "tool.desktop")
if err := os.WriteFile(desk, []byte("[Desktop Entry]\n"), 0o644); err != nil {
t.Fatal(err)
}
sc := store.Shortcut{DesktopPath: desk, BinaryPath: "/bin/true", ExecArgs: []string{"/bin/true"}}
args, err := launchCommand(sc)
if err != nil {
t.Fatal(err)
}
if _, err := exec.LookPath("gio"); err != nil {
// tanpa gio: langsung jalankan perintahnya
if len(args) != 1 || args[0] != "/bin/true" {
t.Errorf("tanpa gio harus jalankan binary, dapat %v", args)
}
return
}
if len(args) != 3 || args[1] != "launch" || args[2] != desk {
t.Errorf("harus lewat gio launch supaya Terminal=true dihormati, dapat %v", args)
}
}
func TestLaunchCommandFallsBack(t *testing.T) {
// .desktop tidak ada -> pakai exec args
sc := store.Shortcut{DesktopPath: "/tidak/ada/x.desktop", ExecArgs: []string{"/usr/bin/wine", "/x/winbox"}}
args, err := launchCommand(sc)
if err != nil {
t.Fatal(err)
}
if len(args) != 2 || args[0] != "/usr/bin/wine" {
t.Errorf("fallback exec args salah: %v", args)
}
// web app tanpa exec args
web := store.Shortcut{Type: store.TypeWebApp, URL: "https://example.com"}
args, err = launchCommand(web)
if err != nil {
t.Fatal(err)
}
if len(args) != 2 || args[0] != "xdg-open" {
t.Errorf("fallback web app salah: %v", args)
}
// tanpa apa-apa
if _, err := launchCommand(store.Shortcut{}); err == nil {
t.Error("shortcut kosong harus error, bukan jalan tanpa perintah")
}
}
+30 -11
View File
@@ -1,7 +1,9 @@
package ui
import (
"errors"
"net/url"
"os"
"os/exec"
"fyne.io/fyne/v2"
@@ -27,18 +29,15 @@ func (ui *app) openURL(raw string) {
}
}
// launch menjalankan shortcut: pakai argv hasil install, fallback ke binary/URL.
// launch menjalankan shortcut.
//
// Kalau ada, `gio launch <file .desktop>` dipakai supaya aturannya sama dengan
// menu desktop: Terminal=true membuka jendela terminal (script yang butuh sudo
// atau input bisa jalan) dan Path= ikut dihormati.
func (ui *app) launch(sc store.Shortcut) {
args := sc.ExecArgs
if len(args) == 0 {
switch {
case sc.Type == store.TypeWebApp && sc.URL != "":
args = []string{"xdg-open", sc.URL}
case sc.BinaryPath != "":
args = []string{sc.BinaryPath}
}
}
if len(args) == 0 {
args, err := launchCommand(sc)
if err != nil {
dialog.ShowError(err, ui.win)
return
}
@@ -49,3 +48,23 @@ func (ui *app) launch(sc store.Shortcut) {
}
go cmd.Wait() // supaya proses anak tidak jadi zombie
}
// launchCommand menyusun perintah untuk menjalankan shortcut.
func launchCommand(sc store.Shortcut) ([]string, error) {
if sc.DesktopPath != "" {
if gio, err := exec.LookPath("gio"); err == nil {
if _, err := os.Stat(sc.DesktopPath); err == nil {
return []string{gio, "launch", sc.DesktopPath}, nil
}
}
}
switch {
case len(sc.ExecArgs) > 0:
return sc.ExecArgs, nil
case sc.Type == store.TypeWebApp && sc.URL != "":
return []string{"xdg-open", sc.URL}, nil
case sc.BinaryPath != "":
return []string{sc.BinaryPath}, nil
}
return nil, errors.New("shortcut ini tidak punya perintah yang bisa dijalankan")
}
+2 -1
View File
@@ -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."},