Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
320 changes: 310 additions & 10 deletions flake.nix

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions pd/internal/cli/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -86,5 +86,8 @@ func loadDesktopFromState(
if state.OpenboxPid != nil {
d.OpenboxPid = *state.OpenboxPid
}
if state.DockPid != nil {
d.DockPid = *state.DockPid
}
return d, &state, nil
}
7 changes: 7 additions & 0 deletions pd/internal/cli/up.go
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ func newUpCommand(stdout, stderr io.Writer) *cobra.Command {
jsonOutput bool
foreground bool
noOpenbox bool
noDock bool
xvncArgs []string
runtimeDir string
sessionDir string
Expand Down Expand Up @@ -82,6 +83,7 @@ func newUpCommand(stdout, stderr io.Writer) *cobra.Command {
DesktopSizeMode: desktopSizeMode,
XvncArgs: xvncArgs,
Openbox: desktop.BoolPtr(!noOpenbox),
Dock: desktop.BoolPtr(!noDock),
Detached: !foreground,
}

Expand Down Expand Up @@ -112,6 +114,7 @@ func newUpCommand(stdout, stderr io.Writer) *cobra.Command {
// Save state.
xvncPid := d.XvncPid
openboxPid := d.OpenboxPid
dockPid := d.DockPid
state := session.StoredDesktopState{
RuntimeDir: d.RuntimeDir,
Display: d.Display,
Expand All @@ -130,6 +133,9 @@ func newUpCommand(stdout, stderr io.Writer) *cobra.Command {
if openboxPid != 0 {
state.OpenboxPid = &openboxPid
}
if dockPid != 0 {
state.DockPid = &dockPid
}
if err := session.SaveState(stateFile, state); err != nil {
return fmt.Errorf("save state: %w", err)
}
Expand Down Expand Up @@ -167,6 +173,7 @@ func newUpCommand(stdout, stderr io.Writer) *cobra.Command {
cmd.Flags().BoolVar(&jsonOutput, "json", false, "output session info as JSON")
cmd.Flags().BoolVar(&foreground, "foreground", false, "run in foreground; stop on signal")
cmd.Flags().BoolVar(&noOpenbox, "no-openbox", false, "do not start the openbox window manager")
cmd.Flags().BoolVar(&noDock, "no-dock", false, "do not start the application dock")
cmd.Flags().StringSliceVar(&xvncArgs, "xvnc-arg", nil, "extra argument(s) to pass to Xvnc")
cmd.Flags().StringVar(&runtimeDir, "runtime-dir", "", "path to runtime directory (skip embedded unpack)")
cmd.Flags().StringVar(&sessionDir, "session-dir", "", "path to session directory")
Expand Down
304 changes: 304 additions & 0 deletions pd/internal/desktop/appfinder.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,304 @@
package desktop

import (
"bufio"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
)

type DesktopApp struct {
DesktopID string
DesktopFile string
Name string
Exec string
TryExec string
Icon string
Type string
Terminal bool
NoDisplay bool
Hidden bool
OnlyShowIn []string
NotShowIn []string
}

var curatedDesktopIDs = []string{
"google-chrome.desktop",
"firefox.desktop",
"chromium.desktop",
"org.chromium.Chromium.desktop",
"org.gnome.Terminal.desktop",
"gnome-terminal.desktop",
"org.kde.konsole.desktop",
"konsole.desktop",
"xterm.desktop",
}

const maxAutoLaunchers = 8

// FindApplications returns a bounded, de-duplicated set of desktop launchers.
func FindApplications() ([]DesktopApp, error) {
entries, err := scanDesktopApplications()
if err != nil {
return nil, err
}

filtered := make([]DesktopApp, 0, len(entries))
for _, app := range entries {
if !isEligibleDesktopApp(app) {
continue
}
filtered = append(filtered, app)
}

sortDesktopApps(filtered)
filtered = dedupeDesktopApps(filtered)
if len(filtered) > maxAutoLaunchers {
filtered = filtered[:maxAutoLaunchers]
}
return filtered, nil
}

func scanDesktopApplications() ([]DesktopApp, error) {
dirs := xdgApplicationDirs()
seenPaths := make(map[string]struct{}, 64)
apps := make([]DesktopApp, 0, 64)
for _, dir := range dirs {
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
continue
}
return nil, fmt.Errorf("read applications dir %q: %w", dir, err)
}
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".desktop") {
continue
}
path := filepath.Join(dir, entry.Name())
if _, ok := seenPaths[path]; ok {
continue
}
seenPaths[path] = struct{}{}

app, err := parseDesktopEntry(path)
if err != nil {
continue
}
apps = append(apps, app)
}
}
return apps, nil
}

func parseDesktopEntry(path string) (DesktopApp, error) {
file, err := os.Open(path)
if err != nil {
return DesktopApp{}, fmt.Errorf("open desktop file: %w", err)
}
defer file.Close()

app := DesktopApp{
DesktopID: filepath.Base(path),
DesktopFile: path,
}

scanner := bufio.NewScanner(file)
inDesktopEntry := false
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
inDesktopEntry = line == "[Desktop Entry]"
continue
}
if !inDesktopEntry {
continue
}

key, value, ok := strings.Cut(line, "=")
if !ok {
continue
}
key = strings.TrimSpace(key)
value = strings.TrimSpace(value)
switch key {
case "Name":
if app.Name == "" {
app.Name = value
}
case "Exec":
app.Exec = value
case "TryExec":
app.TryExec = value
case "Icon":
app.Icon = value
case "Type":
app.Type = value
case "Terminal":
app.Terminal = parseDesktopBool(value)
case "NoDisplay":
app.NoDisplay = parseDesktopBool(value)
case "Hidden":
app.Hidden = parseDesktopBool(value)
case "OnlyShowIn":
app.OnlyShowIn = parseDesktopList(value)
case "NotShowIn":
app.NotShowIn = parseDesktopList(value)
}
}
if err := scanner.Err(); err != nil {
return DesktopApp{}, fmt.Errorf("scan desktop file: %w", err)
}
return app, nil
}

func isEligibleDesktopApp(app DesktopApp) bool {
if app.Type != "Application" {
return false
}
if app.Hidden || app.NoDisplay || app.Terminal {
return false
}
if app.Name == "" || app.Exec == "" {
return false
}
if !isVisibleInDesktopEnvironment(app) {
return false
}
if app.TryExec != "" && !tryExecExists(app.TryExec) {
return false
}
return true
}

func isVisibleInDesktopEnvironment(app DesktopApp) bool {
const desktopEnv = "OPENBOX"
if len(app.OnlyShowIn) > 0 && !containsStringFold(app.OnlyShowIn, desktopEnv) {
return false
}
if containsStringFold(app.NotShowIn, desktopEnv) {
return false
}
return true
}

func tryExecExists(value string) bool {
cmd := strings.TrimSpace(value)
if cmd == "" {
return false
}
binary := desktopExecBinary(cmd)
if binary == "" {
return false
}
if strings.ContainsRune(binary, filepath.Separator) {
_, err := os.Stat(binary)
return err == nil
}
_, err := exec.LookPath(binary)
return err == nil
}

func desktopExecBinary(value string) string {
fields := strings.Fields(value)
if len(fields) == 0 {
return ""
}
return fields[0]
}

func dedupeDesktopApps(apps []DesktopApp) []DesktopApp {
seen := make(map[string]struct{}, len(apps))
out := make([]DesktopApp, 0, len(apps))
for _, app := range apps {
key := dedupeKey(app)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, app)
}
return out
}

func dedupeKey(app DesktopApp) string {
if execBinary := desktopExecBinary(app.Exec); execBinary != "" {
return "exec:" + strings.ToLower(execBinary)
}
if app.DesktopID != "" {
return "id:" + strings.ToLower(app.DesktopID)
}
return "path:" + strings.ToLower(app.DesktopFile)
}

func sortDesktopApps(apps []DesktopApp) {
priority := make(map[string]int, len(curatedDesktopIDs))
for i, desktopID := range curatedDesktopIDs {
priority[strings.ToLower(desktopID)] = i
}

sort.SliceStable(apps, func(i, j int) bool {
pi, iok := priority[strings.ToLower(apps[i].DesktopID)]
pj, jok := priority[strings.ToLower(apps[j].DesktopID)]
if iok && jok && pi != pj {
return pi < pj
}
if iok != jok {
return iok
}
if !strings.EqualFold(apps[i].Name, apps[j].Name) {
return strings.ToLower(apps[i].Name) < strings.ToLower(apps[j].Name)
}
return strings.ToLower(apps[i].DesktopID) < strings.ToLower(apps[j].DesktopID)
})
}

func xdgApplicationDirs() []string {
var dirs []string
if dataHome := strings.TrimSpace(os.Getenv("XDG_DATA_HOME")); dataHome != "" {
dirs = append(dirs, filepath.Join(dataHome, "applications"))
} else if home := strings.TrimSpace(os.Getenv("HOME")); home != "" {
dirs = append(dirs, filepath.Join(home, ".local", "share", "applications"))
}

dataDirs := os.Getenv("XDG_DATA_DIRS")
if strings.TrimSpace(dataDirs) == "" {
dataDirs = "/usr/local/share:/usr/share"
}
for _, dir := range splitPathList(dataDirs) {
dirs = append(dirs, filepath.Join(dir, "applications"))
}
return uniqueStrings(dirs)
}

func parseDesktopBool(value string) bool {
return strings.EqualFold(strings.TrimSpace(value), "true")
}

func parseDesktopList(value string) []string {
parts := strings.Split(value, ";")
out := make([]string, 0, len(parts))
for _, part := range parts {
part = strings.TrimSpace(part)
if part == "" {
continue
}
out = append(out, part)
}
return out
}

func containsStringFold(values []string, want string) bool {
for _, value := range values {
if strings.EqualFold(value, want) {
return true
}
}
return false
}
Loading
Loading