// Package desktop provides desktop environment integration for Linux (XDG). package desktop import ( "context" "fmt" "os" "os/exec" "path/filepath" "strings" "github.com/bnema/dumber/internal/application/port" "github.com/bnema/dumber/internal/domain/entity" "github.com/bnema/dumber/internal/infrastructure/process" "github.com/bnema/dumber/internal/logging" ) const ( appName = "dumber" desktopFileName = "dumber.desktop" packageDesktopFileName = "dev.bnema.Dumber.desktop" // Used by AUR/Flatpak iconFileName = "dumber.svg" filePerm = 0644 dirPerm = 0755 ) // desktopFileTemplate is the freedesktop.org desktop entry format. // %s placeholder for executable path. const desktopFileTemplate = `[Desktop Entry] Version=1.1 Type=Application Name=Dumber GenericName=Dumber Browser Comment=It's a browser but dumber Exec=%s browse %%U Icon=dumber Terminal=false Categories=Network;WebBrowser; MimeType=text/html;text/xml;application/xhtml+xml;x-scheme-handler/http;x-scheme-handler/https; StartupNotify=true StartupWMClass=dumber ` // Adapter implements port.DesktopIntegration using XDG tools. type Adapter struct { xdgSettingsPath string xdgMimePath string updateDesktopDB string } // New creates a new desktop integration adapter. func New() port.DesktopIntegration { a := &Adapter{} // Detect xdg-settings if path, err := exec.LookPath("xdg-settings"); err == nil { a.xdgSettingsPath = path } // Detect xdg-mime if path, err := exec.LookPath("xdg-mime"); err == nil { a.xdgMimePath = path } // Detect update-desktop-database (optional) if path, err := exec.LookPath("update-desktop-database"); err == nil { a.updateDesktopDB = path } return a } // getApplicationsDir returns the XDG applications directory. func getApplicationsDir() (string, error) { dataHome := os.Getenv("XDG_DATA_HOME") if dataHome == "" { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("get home dir: %w", err) } dataHome = filepath.Join(home, ".local", "share") } return filepath.Join(dataHome, "applications"), nil } // getDesktopFilePath returns the full path to the desktop file. func getDesktopFilePath() (string, error) { appDir, err := getApplicationsDir() if err != nil { return "", err } return filepath.Join(appDir, desktopFileName), nil } // getIconFilePath returns the full path to the icon file. // Uses hicolor theme scalable apps directory. func getIconFilePath() (string, error) { dataHome := os.Getenv("XDG_DATA_HOME") if dataHome == "" { home, err := os.UserHomeDir() if err != nil { return "", fmt.Errorf("get home dir: %w", err) } dataHome = filepath.Join(home, ".local", "share") } return filepath.Join(dataHome, "icons", "hicolor", "scalable", "apps", iconFileName), nil } // getExecutablePath returns the path to the dumber executable. func getExecutablePath() (string, error) { // First try the running executable execPath, err := os.Executable() if err == nil { // Resolve symlinks resolved, symlinkErr := filepath.EvalSymlinks(execPath) if symlinkErr == nil { execPath = resolved } return execPath, nil } // Fallback to PATH lookup path, err := exec.LookPath(appName) if err != nil { return "", fmt.Errorf("cannot find %s executable: %w", appName, err) } return path, nil } // GetStatus checks the current desktop integration state. func (a *Adapter) GetStatus(ctx context.Context) (*port.DesktopIntegrationStatus, error) { log := logging.FromContext(ctx) status := &port.DesktopIntegrationStatus{} // Check desktop file desktopPath, err := getDesktopFilePath() if err != nil { return nil, err } status.DesktopFilePath = desktopPath if _, statErr := os.Stat(desktopPath); statErr == nil { status.DesktopFileInstalled = true } // Check icon file iconPath, err := getIconFilePath() if err != nil { return nil, err } status.IconFilePath = iconPath if _, iconStatErr := os.Stat(iconPath); iconStatErr == nil { status.IconInstalled = true } // Check executable execPath, err := getExecutablePath() if err == nil { status.ExecutablePath = execPath } // Check default browser if a.xdgSettingsPath != "" { out, err := exec.CommandContext(ctx, a.xdgSettingsPath, "get", "default-web-browser").Output() if err == nil { currentDefault := strings.TrimSpace(string(out)) status.IsDefaultBrowser = currentDefault == desktopFileName || currentDefault == packageDesktopFileName } } if status.IsDefaultBrowser && a.xdgMimePath != "" { status.IsDefaultBrowser = a.hasDefaultBrowserLinkHandlers(ctx) } log.Debug(). Bool("desktop_installed", status.DesktopFileInstalled). Bool("icon_installed", status.IconInstalled). Bool("default", status.IsDefaultBrowser). Str("desktop_path", status.DesktopFilePath). Str("icon_path", status.IconFilePath). Str("exec_path", status.ExecutablePath). Msg("desktop integration status") return status, nil } // InstallDesktopFile writes the desktop file to XDG applications directory. func (a *Adapter) InstallDesktopFile(ctx context.Context) (string, error) { log := logging.FromContext(ctx) execPath, err := getExecutablePath() if err != nil { return "", err } desktopPath, err := getDesktopFilePath() if err != nil { return "", err } // Ensure applications directory exists appDir := filepath.Dir(desktopPath) if err := os.MkdirAll(appDir, dirPerm); err != nil { return "", fmt.Errorf("create applications dir: %w", err) } // Generate desktop file content content := fmt.Sprintf(desktopFileTemplate, execPath) // Write desktop file if err := os.WriteFile(desktopPath, []byte(content), filePerm); err != nil { return "", fmt.Errorf("write desktop file: %w", err) } log.Info().Str("path", desktopPath).Msg("desktop file installed") // Update desktop database (optional, helps with some DEs) if a.updateDesktopDB != "" { if err := exec.CommandContext(ctx, a.updateDesktopDB, appDir).Run(); err != nil { log.Debug().Err(err).Msg("update-desktop-database failed (non-fatal)") } } return desktopPath, nil } // RemoveDesktopFile removes the desktop file from XDG applications directory. func (a *Adapter) RemoveDesktopFile(ctx context.Context) error { log := logging.FromContext(ctx) desktopPath, err := getDesktopFilePath() if err != nil { return err } // Check if exists if _, err := os.Stat(desktopPath); os.IsNotExist(err) { log.Debug().Str("path", desktopPath).Msg("desktop file not found (already removed)") return nil } if err := os.Remove(desktopPath); err != nil { return fmt.Errorf("remove desktop file: %w", err) } log.Info().Str("path", desktopPath).Msg("desktop file removed") // Update desktop database if a.updateDesktopDB != "" { appDir := filepath.Dir(desktopPath) _ = exec.CommandContext(ctx, a.updateDesktopDB, appDir).Run() } return nil } // InstallIcon writes the icon file to XDG icons directory. func (a *Adapter) InstallIcon(ctx context.Context, svgData []byte) (string, error) { if a == nil { return "", fmt.Errorf("desktop adapter is nil") } log := logging.FromContext(ctx) iconPath, err := getIconFilePath() if err != nil { return "", err } // Ensure icons directory exists iconDir := filepath.Dir(iconPath) if err := os.MkdirAll(iconDir, dirPerm); err != nil { return "", fmt.Errorf("create icons dir: %w", err) } // Write icon file if err := os.WriteFile(iconPath, svgData, filePerm); err != nil { return "", fmt.Errorf("write icon file: %w", err) } log.Info().Str("path", iconPath).Msg("icon file installed") return iconPath, nil } // RemoveIcon removes the icon file from XDG icons directory. func (a *Adapter) RemoveIcon(ctx context.Context) error { if a == nil { return fmt.Errorf("desktop adapter is nil") } log := logging.FromContext(ctx) iconPath, err := getIconFilePath() if err != nil { return err } // Check if exists if _, err := os.Stat(iconPath); os.IsNotExist(err) { log.Debug().Str("path", iconPath).Msg("icon file not found (already removed)") return nil } if err := os.Remove(iconPath); err != nil { return fmt.Errorf("remove icon file: %w", err) } log.Info().Str("path", iconPath).Msg("icon file removed") return nil } // SetAsDefaultBrowser sets dumber as the default web browser. func (a *Adapter) SetAsDefaultBrowser(ctx context.Context) error { log := logging.FromContext(ctx) if a.xdgSettingsPath == "" { return fmt.Errorf("xdg-settings not found (install xdg-utils)") } // Determine which desktop file to use // Priority: package manager desktop file (AUR/Flatpak) > user-installed desktopFile := a.findDesktopFile(ctx) if desktopFile == "" { return fmt.Errorf("desktop file not installed - run 'dumber setup install' first") } // Set default browser cmd := exec.CommandContext(ctx, a.xdgSettingsPath, "set", "default-web-browser", desktopFile) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("xdg-settings failed: %s", strings.TrimSpace(string(out))) } if a.xdgMimePath != "" { mimeArgs := []string{"default", desktopFile} mimeArgs = append(mimeArgs, browserMimeTypes()...) cmd = exec.CommandContext(ctx, a.xdgMimePath, mimeArgs...) if out, err := cmd.CombinedOutput(); err != nil { return fmt.Errorf("xdg-mime failed: %w: %s", err, strings.TrimSpace(string(out))) } } log.Info().Str("desktop_file", desktopFile).Msg("dumber set as default browser") return nil } // findDesktopFile returns the desktop file name to use for default browser setting. // Checks system paths first (AUR/Flatpak), then user path (manual install). func browserMimeTypes() []string { return []string{ "x-scheme-handler/http", "x-scheme-handler/https", "text/html", "text/xml", "application/xhtml+xml", } } func browserLinkMimeTypes() []string { return []string{ "x-scheme-handler/http", "x-scheme-handler/https", "text/html", } } func (a *Adapter) hasDefaultBrowserLinkHandlers(ctx context.Context) bool { for _, mimeType := range browserLinkMimeTypes() { out, err := exec.CommandContext(ctx, a.xdgMimePath, "query", "default", mimeType).Output() if err != nil { return false } currentDefault := strings.TrimSpace(string(out)) if currentDefault != desktopFileName && currentDefault != packageDesktopFileName { return false } } return true } func (a *Adapter) findDesktopFile(ctx context.Context) string { log := logging.FromContext(ctx) // Check system-wide paths for package manager installs systemPaths := []string{ "/usr/share/applications/" + packageDesktopFileName, "/app/share/applications/" + packageDesktopFileName, // Flatpak } for _, path := range systemPaths { if _, err := os.Stat(path); err == nil { log.Debug().Str("path", path).Msg("found package desktop file") return packageDesktopFileName } } // Check user path for manual installs userPath, err := getDesktopFilePath() if err == nil { if _, err := os.Stat(userPath); err == nil { log.Debug().Str("path", userPath).Msg("found user desktop file") return desktopFileName } } return "" } // UnsetAsDefaultBrowser resets default browser if dumber is currently default. func (a *Adapter) UnsetAsDefaultBrowser(ctx context.Context) error { log := logging.FromContext(ctx) if a.xdgSettingsPath == "" { return nil // No xdg-settings, nothing to do } // Check current default out, err := exec.CommandContext(ctx, a.xdgSettingsPath, "get", "default-web-browser").Output() if err != nil { return nil // Can't determine, skip } currentDefault := strings.TrimSpace(string(out)) if currentDefault != desktopFileName { log.Debug().Str("current", currentDefault).Msg("dumber is not default browser, nothing to reset") return nil } // xdg-settings doesn't have a "reset" - user must set another browser log.Warn().Msg("dumber was default browser - user should set a new default") return nil } // SessionSpawner implements port.SessionSpawner by launching a new dumber process. type SessionSpawner struct { ctx context.Context spawnEnv port.SessionSpawnEnvironment } // NewSessionSpawner creates a new session spawner. func NewSessionSpawner(ctx context.Context, spawnEnv port.SessionSpawnEnvironment) *SessionSpawner { return &SessionSpawner{ctx: ctx, spawnEnv: spawnEnv} } // RestoreSessionEnvVar is the environment variable used to pass session ID for restoration. const RestoreSessionEnvVar = "DUMBER_RESTORE_SESSION" // FreshWindowLaunchEnvVar marks a browse child that must open a fresh window // instead of applying external-link workspace placement. const FreshWindowLaunchEnvVar = "DUMBER_BROWSER_FRESH_WINDOW" const layerShellPreloadToken = "libgtk4" + "-layer-shell" // LaunchExternalURL opens a URL with the system's default handler (xdg-open on Linux). // Used for external URL schemes like vscode://, spotify://, steam://, etc. func LaunchExternalURL(uri string) { cmd := exec.Command("xdg-open", uri) cmd.Env = sanitizedChildEnv(os.Environ()) if err := startDetachedProcess(cmd); err != nil { fmt.Fprintf(os.Stderr, "failed to launch external URL %q with xdg-open: %v\n", uri, err) } } // LaunchBrowserURL opens a URL in a new dumber browser window. func LaunchBrowserURL(uri string) { if err := launchBrowserBrowseURL(uri, getExecutablePath, startDetachedProcess); err != nil { fmt.Fprintf(os.Stderr, "%v\n", err) } } func launchBrowserBrowseURL(uri string, resolveExecutablePath func() (string, error), spawnDetachedProcess func(*exec.Cmd) error) error { return launchBrowserURL(uri, false, resolveExecutablePath, spawnDetachedProcess) } func launchBrowserFreshWindowURL( uri string, resolveExecutablePath func() (string, error), spawnDetachedProcess func(*exec.Cmd) error, ) error { return launchBrowserURL(uri, true, resolveExecutablePath, spawnDetachedProcess) } func launchBrowserURL( uri string, freshWindow bool, resolveExecutablePath func() (string, error), spawnDetachedProcess func(*exec.Cmd) error, ) error { if resolveExecutablePath == nil { resolveExecutablePath = getExecutablePath } if spawnDetachedProcess == nil { spawnDetachedProcess = startDetachedProcess } execPath, err := resolveExecutablePath() if err != nil { return fmt.Errorf("failed to resolve dumber executable for requested URL: %w", err) } cmd := exec.Command(execPath, "browse", uri) // Prepare the child environment with the shared safety merge after // sanitization so application-spawned browser processes start safe. childEnv, _ := process.MergeRuntimeSafety(sanitizedChildEnv(os.Environ())) cmd.Env = withoutEnvKeys(childEnv, FreshWindowLaunchEnvVar) if freshWindow { cmd.Env = append(cmd.Env, FreshWindowLaunchEnvVar+"=1") } if err := spawnDetachedProcess(cmd); err != nil { return fmt.Errorf("failed to launch dumber browse for requested URL: %w", err) } return nil } func startDetachedProcess(cmd *exec.Cmd) error { if cmd == nil { return nil } cmd.Stdin = nil cmd.Stdout = nil cmd.Stderr = nil if err := cmd.Start(); err != nil { return err } if cmd.Process != nil { if err := cmd.Process.Release(); err != nil { logging.FromContext(context.Background()).Warn(). Err(err). Str("path", cmd.Path). Msg("startDetachedProcess: cmd.Process.Release failed after start (non-fatal)") } } return nil } func sanitizedChildEnv(env []string) []string { cleaned := make([]string, 0, len(env)) for _, entry := range env { if !strings.HasPrefix(entry, "LD_PRELOAD=") { cleaned = append(cleaned, entry) continue } value := strings.TrimPrefix(entry, "LD_PRELOAD=") parts := strings.FieldsFunc(value, func(r rune) bool { return r == ' ' || r == ':' }) kept := make([]string, 0, len(parts)) for _, part := range parts { if strings.Contains(part, layerShellPreloadToken) { continue } kept = append(kept, part) } if len(kept) > 0 { cleaned = append(cleaned, "LD_PRELOAD="+strings.Join(kept, " ")) } } return cleaned } func withoutEnvKeys(env []string, keys ...string) []string { if len(keys) == 0 { return append([]string(nil), env...) } blocked := make(map[string]struct{}, len(keys)) for _, key := range keys { if key == "" { continue } blocked[key] = struct{}{} } filtered := make([]string, 0, len(env)) for _, entry := range env { key, _, ok := strings.Cut(entry, "=") if ok { if _, drop := blocked[key]; drop { continue } } filtered = append(filtered, entry) } return filtered } // SpawnWithSession starts a new dumber instance to restore a session. func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error { log := logging.FromContext(s.ctx) execPath, err := getExecutablePath() if err != nil { return fmt.Errorf("get executable path: %w", err) } // Start dumber browse with session ID in environment cmd := exec.Command(execPath, "browse") sanitizedEnv, _ := process.MergeRuntimeSafety(sanitizedChildEnv(os.Environ())) sanitizedEnv = withoutEnvKeys(sanitizedEnv, RestoreSessionEnvVar) overrides := []string{RestoreSessionEnvVar + "=" + string(sessionID)} if s.spawnEnv != nil { sanitizedEnv = withoutEnvKeys(sanitizedEnv, s.spawnEnv.RootCacheEnvVar()) overrides = append(overrides, s.spawnEnv.RootCacheEnvVar()+"="+s.spawnEnv.SessionRootCachePath(sessionID)) } sanitizedEnv = append(sanitizedEnv, overrides...) cmd.Env = sanitizedEnv // Detach from current process group so the new process survives. cmd.Stdin = nil cmd.Stdout = nil cmd.Stderr = nil if err := cmd.Start(); err != nil { return fmt.Errorf("spawn session: %w", err) } // Capture PID before releasing. pid := cmd.Process.Pid // Release the process so it continues running after we close. if err := cmd.Process.Release(); err != nil { log.Warn().Err(err).Msg("failed to release spawned process (non-fatal)") } log.Info(). Str("session_id", string(sessionID)). Int("pid", pid). Msg("spawned dumber with session restoration") return nil }