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