1// Package desktop provides desktop environment integration for Linux (XDG).
2package desktop
3
4import (
5 "context"
6 "fmt"
7 "os"
8 "os/exec"
9 "path/filepath"
10 "strings"
11
12 "github.com/bnema/dumber/internal/application/port"
13 "github.com/bnema/dumber/internal/domain/entity"
14 "github.com/bnema/dumber/internal/infrastructure/process"
15 "github.com/bnema/dumber/internal/logging"
16)
17
18const (
19 appName = "dumber"
20 desktopFileName = "dumber.desktop"
21 packageDesktopFileName = "dev.bnema.Dumber.desktop" // Used by AUR/Flatpak
22 iconFileName = "dumber.svg"
23 filePerm = 0644
24 dirPerm = 0755
25)
26
27// desktopFileTemplate is the freedesktop.org desktop entry format.
28// %s placeholder for executable path.
29const desktopFileTemplate = `[Desktop Entry]
30Version=1.1
31Type=Application
32Name=Dumber
33GenericName=Dumber Browser
34Comment=It's a browser but dumber
35Exec=%s browse %%U
36Icon=dumber
37Terminal=false
38Categories=Network;WebBrowser;
39MimeType=text/html;text/xml;application/xhtml+xml;x-scheme-handler/http;x-scheme-handler/https;
40StartupNotify=true
41StartupWMClass=dumber
42`
43
44// Adapter implements port.DesktopIntegration using XDG tools.
45type Adapter struct {
46 xdgSettingsPath string
47 xdgMimePath string
48 updateDesktopDB string
49}
50
51// New creates a new desktop integration adapter.
52func New() port.DesktopIntegration {
53 a := &Adapter{}
54
55 // Detect xdg-settings
56 if path, err := exec.LookPath("xdg-settings"); err == nil {
57 a.xdgSettingsPath = path
58 }
59
60 // Detect xdg-mime
61 if path, err := exec.LookPath("xdg-mime"); err == nil {
62 a.xdgMimePath = path
63 }
64
65 // Detect update-desktop-database (optional)
66 if path, err := exec.LookPath("update-desktop-database"); err == nil {
67 a.updateDesktopDB = path
68 }
69
70 return a
71}
72
73// getApplicationsDir returns the XDG applications directory.
74func getApplicationsDir() (string, error) {
75 dataHome := os.Getenv("XDG_DATA_HOME")
76 if dataHome == "" {
77 home, err := os.UserHomeDir()
78 if err != nil {
79 return "", fmt.Errorf("get home dir: %w", err)
80 }
81 dataHome = filepath.Join(home, ".local", "share")
82 }
83 return filepath.Join(dataHome, "applications"), nil
84}
85
86// getDesktopFilePath returns the full path to the desktop file.
87func getDesktopFilePath() (string, error) {
88 appDir, err := getApplicationsDir()
89 if err != nil {
90 return "", err
91 }
92 return filepath.Join(appDir, desktopFileName), nil
93}
94
95// getIconFilePath returns the full path to the icon file.
96// Uses hicolor theme scalable apps directory.
97func getIconFilePath() (string, error) {
98 dataHome := os.Getenv("XDG_DATA_HOME")
99 if dataHome == "" {
100 home, err := os.UserHomeDir()
101 if err != nil {
102 return "", fmt.Errorf("get home dir: %w", err)
103 }
104 dataHome = filepath.Join(home, ".local", "share")
105 }
106 return filepath.Join(dataHome, "icons", "hicolor", "scalable", "apps", iconFileName), nil
107}
108
109// getExecutablePath returns the path to the dumber executable.
110func getExecutablePath() (string, error) {
111 // First try the running executable
112 execPath, err := os.Executable()
113 if err == nil {
114 // Resolve symlinks
115 resolved, symlinkErr := filepath.EvalSymlinks(execPath)
116 if symlinkErr == nil {
117 execPath = resolved
118 }
119 return execPath, nil
120 }
121
122 // Fallback to PATH lookup
123 path, err := exec.LookPath(appName)
124 if err != nil {
125 return "", fmt.Errorf("cannot find %s executable: %w", appName, err)
126 }
127 return path, nil
128}
129
130// GetStatus checks the current desktop integration state.
131func (a *Adapter) GetStatus(ctx context.Context) (*port.DesktopIntegrationStatus, error) {
132 log := logging.FromContext(ctx)
133 status := &port.DesktopIntegrationStatus{}
134
135 // Check desktop file
136 desktopPath, err := getDesktopFilePath()
137 if err != nil {
138 return nil, err
139 }
140 status.DesktopFilePath = desktopPath
141
142 if _, statErr := os.Stat(desktopPath); statErr == nil {
143 status.DesktopFileInstalled = true
144 }
145
146 // Check icon file
147 iconPath, err := getIconFilePath()
148 if err != nil {
149 return nil, err
150 }
151 status.IconFilePath = iconPath
152
153 if _, iconStatErr := os.Stat(iconPath); iconStatErr == nil {
154 status.IconInstalled = true
155 }
156
157 // Check executable
158 execPath, err := getExecutablePath()
159 if err == nil {
160 status.ExecutablePath = execPath
161 }
162
163 // Check default browser
164 if a.xdgSettingsPath != "" {
165 out, err := exec.CommandContext(ctx, a.xdgSettingsPath, "get", "default-web-browser").Output()
166 if err == nil {
167 currentDefault := strings.TrimSpace(string(out))
168 status.IsDefaultBrowser = currentDefault == desktopFileName || currentDefault == packageDesktopFileName
169 }
170 }
171
172 if status.IsDefaultBrowser && a.xdgMimePath != "" {
173 status.IsDefaultBrowser = a.hasDefaultBrowserLinkHandlers(ctx)
174 }
175
176 log.Debug().
177 Bool("desktop_installed", status.DesktopFileInstalled).
178 Bool("icon_installed", status.IconInstalled).
179 Bool("default", status.IsDefaultBrowser).
180 Str("desktop_path", status.DesktopFilePath).
181 Str("icon_path", status.IconFilePath).
182 Str("exec_path", status.ExecutablePath).
183 Msg("desktop integration status")
184
185 return status, nil
186}
187
188// InstallDesktopFile writes the desktop file to XDG applications directory.
189func (a *Adapter) InstallDesktopFile(ctx context.Context) (string, error) {
190 log := logging.FromContext(ctx)
191
192 execPath, err := getExecutablePath()
193 if err != nil {
194 return "", err
195 }
196
197 desktopPath, err := getDesktopFilePath()
198 if err != nil {
199 return "", err
200 }
201
202 // Ensure applications directory exists
203 appDir := filepath.Dir(desktopPath)
204 if err := os.MkdirAll(appDir, dirPerm); err != nil {
205 return "", fmt.Errorf("create applications dir: %w", err)
206 }
207
208 // Generate desktop file content
209 content := fmt.Sprintf(desktopFileTemplate, execPath)
210
211 // Write desktop file
212 if err := os.WriteFile(desktopPath, []byte(content), filePerm); err != nil {
213 return "", fmt.Errorf("write desktop file: %w", err)
214 }
215
216 log.Info().Str("path", desktopPath).Msg("desktop file installed")
217
218 // Update desktop database (optional, helps with some DEs)
219 if a.updateDesktopDB != "" {
220 if err := exec.CommandContext(ctx, a.updateDesktopDB, appDir).Run(); err != nil {
221 log.Debug().Err(err).Msg("update-desktop-database failed (non-fatal)")
222 }
223 }
224
225 return desktopPath, nil
226}
227
228// RemoveDesktopFile removes the desktop file from XDG applications directory.
229func (a *Adapter) RemoveDesktopFile(ctx context.Context) error {
230 log := logging.FromContext(ctx)
231
232 desktopPath, err := getDesktopFilePath()
233 if err != nil {
234 return err
235 }
236
237 // Check if exists
238 if _, err := os.Stat(desktopPath); os.IsNotExist(err) {
239 log.Debug().Str("path", desktopPath).Msg("desktop file not found (already removed)")
240 return nil
241 }
242
243 if err := os.Remove(desktopPath); err != nil {
244 return fmt.Errorf("remove desktop file: %w", err)
245 }
246
247 log.Info().Str("path", desktopPath).Msg("desktop file removed")
248
249 // Update desktop database
250 if a.updateDesktopDB != "" {
251 appDir := filepath.Dir(desktopPath)
252 _ = exec.CommandContext(ctx, a.updateDesktopDB, appDir).Run()
253 }
254
255 return nil
256}
257
258// InstallIcon writes the icon file to XDG icons directory.
259func (a *Adapter) InstallIcon(ctx context.Context, svgData []byte) (string, error) {
260 if a == nil {
261 return "", fmt.Errorf("desktop adapter is nil")
262 }
263 log := logging.FromContext(ctx)
264
265 iconPath, err := getIconFilePath()
266 if err != nil {
267 return "", err
268 }
269
270 // Ensure icons directory exists
271 iconDir := filepath.Dir(iconPath)
272 if err := os.MkdirAll(iconDir, dirPerm); err != nil {
273 return "", fmt.Errorf("create icons dir: %w", err)
274 }
275
276 // Write icon file
277 if err := os.WriteFile(iconPath, svgData, filePerm); err != nil {
278 return "", fmt.Errorf("write icon file: %w", err)
279 }
280
281 log.Info().Str("path", iconPath).Msg("icon file installed")
282
283 return iconPath, nil
284}
285
286// RemoveIcon removes the icon file from XDG icons directory.
287func (a *Adapter) RemoveIcon(ctx context.Context) error {
288 if a == nil {
289 return fmt.Errorf("desktop adapter is nil")
290 }
291 log := logging.FromContext(ctx)
292
293 iconPath, err := getIconFilePath()
294 if err != nil {
295 return err
296 }
297
298 // Check if exists
299 if _, err := os.Stat(iconPath); os.IsNotExist(err) {
300 log.Debug().Str("path", iconPath).Msg("icon file not found (already removed)")
301 return nil
302 }
303
304 if err := os.Remove(iconPath); err != nil {
305 return fmt.Errorf("remove icon file: %w", err)
306 }
307
308 log.Info().Str("path", iconPath).Msg("icon file removed")
309
310 return nil
311}
312
313// SetAsDefaultBrowser sets dumber as the default web browser.
314func (a *Adapter) SetAsDefaultBrowser(ctx context.Context) error {
315 log := logging.FromContext(ctx)
316
317 if a.xdgSettingsPath == "" {
318 return fmt.Errorf("xdg-settings not found (install xdg-utils)")
319 }
320
321 // Determine which desktop file to use
322 // Priority: package manager desktop file (AUR/Flatpak) > user-installed
323 desktopFile := a.findDesktopFile(ctx)
324 if desktopFile == "" {
325 return fmt.Errorf("desktop file not installed - run 'dumber setup install' first")
326 }
327
328 // Set default browser
329 cmd := exec.CommandContext(ctx, a.xdgSettingsPath, "set", "default-web-browser", desktopFile)
330 if out, err := cmd.CombinedOutput(); err != nil {
331 return fmt.Errorf("xdg-settings failed: %s", strings.TrimSpace(string(out)))
332 }
333
334 if a.xdgMimePath != "" {
335 mimeArgs := []string{"default", desktopFile}
336 mimeArgs = append(mimeArgs, browserMimeTypes()...)
337 cmd = exec.CommandContext(ctx, a.xdgMimePath, mimeArgs...)
338 if out, err := cmd.CombinedOutput(); err != nil {
339 return fmt.Errorf("xdg-mime failed: %w: %s", err, strings.TrimSpace(string(out)))
340 }
341 }
342
343 log.Info().Str("desktop_file", desktopFile).Msg("dumber set as default browser")
344 return nil
345}
346
347// findDesktopFile returns the desktop file name to use for default browser setting.
348// Checks system paths first (AUR/Flatpak), then user path (manual install).
349func browserMimeTypes() []string {
350 return []string{
351 "x-scheme-handler/http",
352 "x-scheme-handler/https",
353 "text/html",
354 "text/xml",
355 "application/xhtml+xml",
356 }
357}
358
359func browserLinkMimeTypes() []string {
360 return []string{
361 "x-scheme-handler/http",
362 "x-scheme-handler/https",
363 "text/html",
364 }
365}
366
367func (a *Adapter) hasDefaultBrowserLinkHandlers(ctx context.Context) bool {
368 for _, mimeType := range browserLinkMimeTypes() {
369 out, err := exec.CommandContext(ctx, a.xdgMimePath, "query", "default", mimeType).Output()
370 if err != nil {
371 return false
372 }
373
374 currentDefault := strings.TrimSpace(string(out))
375 if currentDefault != desktopFileName && currentDefault != packageDesktopFileName {
376 return false
377 }
378 }
379
380 return true
381}
382
383func (a *Adapter) findDesktopFile(ctx context.Context) string {
384 log := logging.FromContext(ctx)
385
386 // Check system-wide paths for package manager installs
387 systemPaths := []string{
388 "/usr/share/applications/" + packageDesktopFileName,
389 "/app/share/applications/" + packageDesktopFileName, // Flatpak
390 }
391
392 for _, path := range systemPaths {
393 if _, err := os.Stat(path); err == nil {
394 log.Debug().Str("path", path).Msg("found package desktop file")
395 return packageDesktopFileName
396 }
397 }
398
399 // Check user path for manual installs
400 userPath, err := getDesktopFilePath()
401 if err == nil {
402 if _, err := os.Stat(userPath); err == nil {
403 log.Debug().Str("path", userPath).Msg("found user desktop file")
404 return desktopFileName
405 }
406 }
407
408 return ""
409}
410
411// UnsetAsDefaultBrowser resets default browser if dumber is currently default.
412func (a *Adapter) UnsetAsDefaultBrowser(ctx context.Context) error {
413 log := logging.FromContext(ctx)
414
415 if a.xdgSettingsPath == "" {
416 return nil // No xdg-settings, nothing to do
417 }
418
419 // Check current default
420 out, err := exec.CommandContext(ctx, a.xdgSettingsPath, "get", "default-web-browser").Output()
421 if err != nil {
422 return nil // Can't determine, skip
423 }
424
425 currentDefault := strings.TrimSpace(string(out))
426 if currentDefault != desktopFileName {
427 log.Debug().Str("current", currentDefault).Msg("dumber is not default browser, nothing to reset")
428 return nil
429 }
430
431 // xdg-settings doesn't have a "reset" - user must set another browser
432 log.Warn().Msg("dumber was default browser - user should set a new default")
433 return nil
434}
435
436// SessionSpawner implements port.SessionSpawner by launching a new dumber process.
437type SessionSpawner struct {
438 ctx context.Context
439 spawnEnv port.SessionSpawnEnvironment
440}
441
442// NewSessionSpawner creates a new session spawner.
443func NewSessionSpawner(ctx context.Context, spawnEnv port.SessionSpawnEnvironment) *SessionSpawner {
444 return &SessionSpawner{ctx: ctx, spawnEnv: spawnEnv}
445}
446
447// RestoreSessionEnvVar is the environment variable used to pass session ID for restoration.
448const RestoreSessionEnvVar = "DUMBER_RESTORE_SESSION"
449
450// FreshWindowLaunchEnvVar marks a browse child that must open a fresh window
451// instead of applying external-link workspace placement.
452const FreshWindowLaunchEnvVar = "DUMBER_BROWSER_FRESH_WINDOW"
453
454const layerShellPreloadToken = "libgtk4" + "-layer-shell"
455
456// LaunchExternalURL opens a URL with the system's default handler (xdg-open on Linux).
457// Used for external URL schemes like vscode://, spotify://, steam://, etc.
458func LaunchExternalURL(uri string) {
459 cmd := exec.Command("xdg-open", uri)
460 cmd.Env = sanitizedChildEnv(os.Environ())
461 if err := startDetachedProcess(cmd); err != nil {
462 fmt.Fprintf(os.Stderr, "failed to launch external URL %q with xdg-open: %v\n", uri, err)
463 }
464}
465
466// LaunchBrowserURL opens a URL in a new dumber browser window.
467func LaunchBrowserURL(uri string) {
468 if err := launchBrowserBrowseURL(uri, getExecutablePath, startDetachedProcess); err != nil {
469 fmt.Fprintf(os.Stderr, "%v\n", err)
470 }
471}
472
473func launchBrowserBrowseURL(uri string, resolveExecutablePath func() (string, error), spawnDetachedProcess func(*exec.Cmd) error) error {
474 return launchBrowserURL(uri, false, resolveExecutablePath, spawnDetachedProcess)
475}
476
477func launchBrowserFreshWindowURL(
478 uri string,
479 resolveExecutablePath func() (string, error),
480 spawnDetachedProcess func(*exec.Cmd) error,
481) error {
482 return launchBrowserURL(uri, true, resolveExecutablePath, spawnDetachedProcess)
483}
484
485func launchBrowserURL(
486 uri string,
487 freshWindow bool,
488 resolveExecutablePath func() (string, error),
489 spawnDetachedProcess func(*exec.Cmd) error,
490) error {
491 if resolveExecutablePath == nil {
492 resolveExecutablePath = getExecutablePath
493 }
494 if spawnDetachedProcess == nil {
495 spawnDetachedProcess = startDetachedProcess
496 }
497
498 execPath, err := resolveExecutablePath()
499 if err != nil {
500 return fmt.Errorf("failed to resolve dumber executable for requested URL: %w", err)
501 }
502
503 cmd := exec.Command(execPath, "browse", uri)
504 // Prepare the child environment with the shared safety merge after
505 // sanitization so application-spawned browser processes start safe.
506 childEnv, _ := process.MergeRuntimeSafety(sanitizedChildEnv(os.Environ()))
507 cmd.Env = withoutEnvKeys(childEnv, FreshWindowLaunchEnvVar)
508 if freshWindow {
509 cmd.Env = append(cmd.Env, FreshWindowLaunchEnvVar+"=1")
510 }
511 if err := spawnDetachedProcess(cmd); err != nil {
512 return fmt.Errorf("failed to launch dumber browse for requested URL: %w", err)
513 }
514 return nil
515}
516
517func startDetachedProcess(cmd *exec.Cmd) error {
518 if cmd == nil {
519 return nil
520 }
521
522 cmd.Stdin = nil
523 cmd.Stdout = nil
524 cmd.Stderr = nil
525 if err := cmd.Start(); err != nil {
526 return err
527 }
528 if cmd.Process != nil {
529 if err := cmd.Process.Release(); err != nil {
530 logging.FromContext(context.Background()).Warn().
531 Err(err).
532 Str("path", cmd.Path).
533 Msg("startDetachedProcess: cmd.Process.Release failed after start (non-fatal)")
534 }
535 }
536
537 return nil
538}
539
540func sanitizedChildEnv(env []string) []string {
541 cleaned := make([]string, 0, len(env))
542 for _, entry := range env {
543 if !strings.HasPrefix(entry, "LD_PRELOAD=") {
544 cleaned = append(cleaned, entry)
545 continue
546 }
547
548 value := strings.TrimPrefix(entry, "LD_PRELOAD=")
549 parts := strings.FieldsFunc(value, func(r rune) bool {
550 return r == ' ' || r == ':'
551 })
552 kept := make([]string, 0, len(parts))
553 for _, part := range parts {
554 if strings.Contains(part, layerShellPreloadToken) {
555 continue
556 }
557 kept = append(kept, part)
558 }
559 if len(kept) > 0 {
560 cleaned = append(cleaned, "LD_PRELOAD="+strings.Join(kept, " "))
561 }
562 }
563
564 return cleaned
565}
566
567func withoutEnvKeys(env []string, keys ...string) []string {
568 if len(keys) == 0 {
569 return append([]string(nil), env...)
570 }
571
572 blocked := make(map[string]struct{}, len(keys))
573 for _, key := range keys {
574 if key == "" {
575 continue
576 }
577 blocked[key] = struct{}{}
578 }
579
580 filtered := make([]string, 0, len(env))
581 for _, entry := range env {
582 key, _, ok := strings.Cut(entry, "=")
583 if ok {
584 if _, drop := blocked[key]; drop {
585 continue
586 }
587 }
588 filtered = append(filtered, entry)
589 }
590
591 return filtered
592}
593
594// SpawnWithSession starts a new dumber instance to restore a session.
595func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
596 log := logging.FromContext(s.ctx)
597
598 execPath, err := getExecutablePath()
599 if err != nil {
600 return fmt.Errorf("get executable path: %w", err)
601 }
602
603 // Start dumber browse with session ID in environment
604 cmd := exec.Command(execPath, "browse")
605
606 sanitizedEnv, _ := process.MergeRuntimeSafety(sanitizedChildEnv(os.Environ()))
607 sanitizedEnv = withoutEnvKeys(sanitizedEnv, RestoreSessionEnvVar)
608 overrides := []string{RestoreSessionEnvVar + "=" + string(sessionID)}
609 if s.spawnEnv != nil {
610 sanitizedEnv = withoutEnvKeys(sanitizedEnv, s.spawnEnv.RootCacheEnvVar())
611 overrides = append(overrides, s.spawnEnv.RootCacheEnvVar()+"="+s.spawnEnv.SessionRootCachePath(sessionID))
612 }
613 sanitizedEnv = append(sanitizedEnv, overrides...)
614 cmd.Env = sanitizedEnv
615
616 // Detach from current process group so the new process survives.
617 cmd.Stdin = nil
618 cmd.Stdout = nil
619 cmd.Stderr = nil
620
621 if err := cmd.Start(); err != nil {
622 return fmt.Errorf("spawn session: %w", err)
623 }
624
625 // Capture PID before releasing.
626 pid := cmd.Process.Pid
627
628 // Release the process so it continues running after we close.
629 if err := cmd.Process.Release(); err != nil {
630 log.Warn().Err(err).Msg("failed to release spawned process (non-fatal)")
631 }
632
633 log.Info().
634 Str("session_id", string(sessionID)).
635 Int("pid", pid).
636 Msg("spawned dumber with session restoration")
637
638 return nil
639}