//go:build linux package main import ( "context" "errors" "fmt" "os" "os/exec" "os/user" "path/filepath" "strings" "syscall" "time" ) // dialogTool is the zenity or kdialog binary used for native file dialogs, or // "" when neither is installed (or there is no display) and the UI uses its // built-in file browser. var dialogTool = findDialogTool() var nativeDialogs = dialogTool != "" func findDialogTool() string { if os.Getenv("DISPLAY") == "" && os.Getenv("WAYLAND_DISPLAY") == "" { return "" } order := []string{"zenity", "kdialog"} if strings.Contains(strings.ToUpper(os.Getenv("XDG_CURRENT_DESKTOP")), "KDE") { order = []string{"kdialog", "zenity"} } for _, name := range order { if p, err := exec.LookPath(name); err == nil { return p } } return "" } // hideConsole puts child programs (ffmpeg, dialogs) in their own process group // so Ctrl+C in the terminal reaches only VibEdit, which then cancels them and // cleans up. They are killed if VibEdit itself dies. func hideConsole(cmd *exec.Cmd) { cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true, Pdeathsig: syscall.SIGKILL} } // listRoots offers / plus mounted drives for the built-in file browser. func listRoots() []string { roots := []string{"/"} var dirs []string if u, err := user.Current(); err == nil { dirs = append(dirs, "/run/media/"+u.Username, "/media/"+u.Username) } dirs = append(dirs, "/mnt") for _, d := range dirs { des, _ := os.ReadDir(d) for _, de := range des { if de.IsDir() && !strings.HasPrefix(de.Name(), ".") { roots = append(roots, filepath.Join(d, de.Name())) } } } return roots } // openBrowser prefers a chromeless Chrome/Chromium/Edge/Brave "app" window, // which feels like a normal desktop program, and falls back to the default // browser. func openBrowser(url string, preferDefault bool) error { if !preferDefault { for _, name := range []string{"google-chrome", "google-chrome-stable", "chromium", "chromium-browser", "microsoft-edge", "microsoft-edge-stable", "brave-browser"} { p, err := exec.LookPath(name) if err != nil { continue } if err := startDetached(p, "--app="+url, "--window-size=1440,920"); err == nil { return nil } } } return startDetached("xdg-open", url) } // startDetached starts a desktop program in its own session, so it neither // gets the terminal's Ctrl+C nor is tied to VibEdit's lifetime. func startDetached(name string, args ...string) error { cmd := exec.Command(name, args...) cmd.SysProcAttr = &syscall.SysProcAttr{Setsid: true} if err := cmd.Start(); err != nil { return err } go cmd.Wait() return nil } // revealInFileManager asks the file manager to show the file selected (the // FileManager1 D-Bus interface of Nautilus, Dolphin, Nemo, Caja and Thunar) // and falls back to opening its folder. func revealInFileManager(path string) error { if abs, err := filepath.Abs(path); err == nil { path = abs } ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) defer cancel() err := exec.CommandContext(ctx, "dbus-send", "--session", "--print-reply", "--reply-timeout=4000", "--type=method_call", "--dest=org.freedesktop.FileManager1", "/org/freedesktop/FileManager1", "org.freedesktop.FileManager1.ShowItems", "array:string:"+fileURI(path), "string:").Run() if err == nil { return nil } return startDetached("xdg-open", filepath.Dir(path)) } // fileURI percent-encodes everything except unreserved characters and slashes // (dbus-send would otherwise split the array argument at commas). func fileURI(path string) string { var b strings.Builder b.WriteString("file://") for i := 0; i < len(path); i++ { c := path[i] if 'a' <= c && c <= 'z' || 'A' <= c && c <= 'Z' || '0' <= c && c <= '9' || strings.IndexByte("/-._~", c) >= 0 { b.WriteByte(c) } else { fmt.Fprintf(&b, "%%%02X", c) } } return b.String() } func waitBeforeExit() {} func isKDialog() bool { return filepath.Base(dialogTool) == "kdialog" } // runDialog runs zenity/kdialog and returns the chosen paths, one per output // line. Both exit with status 1 when the user cancels. func runDialog(args ...string) ([]string, error) { if dialogTool == "" { return nil, errors.New("no file dialog program (zenity or kdialog) found") } ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour) defer cancel() out, err := command(ctx, dialogTool, args...).Output() var exit *exec.ExitError if errors.As(err, &exit) && exit.ExitCode() == 1 { return nil, nil } if err != nil { return nil, err } var lines []string for _, l := range strings.Split(string(out), "\n") { if l != "" { lines = append(lines, l) } } return lines, nil } // videoPatterns lists every media extension in lower and upper case, since // GTK file filters are case-sensitive. func videoPatterns() string { var pats []string for _, e := range mediaExts { pats = append(pats, "*."+e, "*."+strings.ToUpper(e)) } return strings.Join(pats, " ") } func nativeOpenDialog(title, dir string) ([]string, error) { if !isDir(dir) { dir, _ = os.UserHomeDir() } if isKDialog() { return runDialog("--title", title, "--multiple", "--separate-output", "--getopenfilename", dir, "Video files ("+videoPatterns()+")\nAll files (*)") } return runDialog("--file-selection", "--multiple", "--separator=\n", "--title="+title, "--filename="+strings.TrimSuffix(dir, "/")+"/", "--file-filter=Video files | "+videoPatterns(), "--file-filter=All files | *") } func nativeSaveDialog(title, path string) (string, error) { ext := strings.TrimPrefix(filepath.Ext(path), ".") var lines []string var err error if isKDialog() { filter := "All files (*)" if ext != "" { filter = strings.ToUpper(ext) + " video (*." + ext + ")\n" + filter } lines, err = runDialog("--title", title, "--getsavefilename", path, filter) } else { args := []string{"--file-selection", "--save", "--title=" + title, "--filename=" + path} if ext != "" { args = append(args, "--file-filter="+strings.ToUpper(ext)+" video | *."+ext) } lines, err = runDialog(append(args, "--file-filter=All files | *")...) } if err != nil || len(lines) == 0 { return "", err } // Like the Windows dialog, add the default extension when none was typed. p := lines[0] if ext != "" && filepath.Ext(p) == "" { p += "." + ext } return p, nil }