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
449const layerShellPreloadToken = "libgtk4" + "-layer-shell"
450
451// LaunchExternalURL opens a URL with the system's default handler (xdg-open on Linux).
452// Used for external URL schemes like vscode://, spotify://, steam://, etc.
453func LaunchExternalURL(uri string) {
454 cmd := exec.Command("xdg-open", uri)
455 cmd.Env = sanitizedChildEnv(os.Environ())
456 if err := startDetachedProcess(cmd); err != nil {
457 fmt.Fprintf(os.Stderr, "failed to launch external URL %q with xdg-open: %v\n", uri, err)
458 }
459}
460
461// LaunchBrowserURL opens a URL in a new dumber browser window.
462func LaunchBrowserURL(uri string) {
463 if err := launchBrowserBrowseURL(uri, getExecutablePath, startDetachedProcess); err != nil {
464 fmt.Fprintf(os.Stderr, "%v\n", err)
465 }
466}
467
468func launchBrowserBrowseURL(uri string, resolveExecutablePath func() (string, error), spawnDetachedProcess func(*exec.Cmd) error) error {
469 if resolveExecutablePath == nil {
470 resolveExecutablePath = getExecutablePath
471 }
472 if spawnDetachedProcess == nil {
473 spawnDetachedProcess = startDetachedProcess
474 }
475
476 execPath, err := resolveExecutablePath()
477 if err != nil {
478 return fmt.Errorf("failed to resolve dumber executable for requested URL: %w", err)
479 }
480
481 cmd := exec.Command(execPath, "browse", uri)
482 cmd.Env = sanitizedChildEnv(os.Environ())
483 if err := spawnDetachedProcess(cmd); err != nil {
484 return fmt.Errorf("failed to launch dumber browse for requested URL: %w", err)
485 }
486 return nil
487}
488
489func startDetachedProcess(cmd *exec.Cmd) error {
490 if cmd == nil {
491 return nil
492 }
493
494 cmd.Stdin = nil
495 cmd.Stdout = nil
496 cmd.Stderr = nil
497 if err := cmd.Start(); err != nil {
498 return err
499 }
500 if cmd.Process != nil {
501 if err := cmd.Process.Release(); err != nil {
502 logging.FromContext(context.Background()).Warn().
503 Err(err).
504 Str("path", cmd.Path).
505 Msg("startDetachedProcess: cmd.Process.Release failed after start (non-fatal)")
506 }
507 }
508
509 return nil
510}
511
512func sanitizedChildEnv(env []string) []string {
513 cleaned := make([]string, 0, len(env))
514 for _, entry := range env {
515 if !strings.HasPrefix(entry, "LD_PRELOAD=") {
516 cleaned = append(cleaned, entry)
517 continue
518 }
519
520 value := strings.TrimPrefix(entry, "LD_PRELOAD=")
521 parts := strings.FieldsFunc(value, func(r rune) bool {
522 return r == ' ' || r == ':'
523 })
524 kept := make([]string, 0, len(parts))
525 for _, part := range parts {
526 if strings.Contains(part, layerShellPreloadToken) {
527 continue
528 }
529 kept = append(kept, part)
530 }
531 if len(kept) > 0 {
532 cleaned = append(cleaned, "LD_PRELOAD="+strings.Join(kept, " "))
533 }
534 }
535
536 return cleaned
537}
538
539func withoutEnvKeys(env []string, keys ...string) []string {
540 if len(keys) == 0 {
541 return append([]string(nil), env...)
542 }
543
544 blocked := make(map[string]struct{}, len(keys))
545 for _, key := range keys {
546 if key == "" {
547 continue
548 }
549 blocked[key] = struct{}{}
550 }
551
552 filtered := make([]string, 0, len(env))
553 for _, entry := range env {
554 key, _, ok := strings.Cut(entry, "=")
555 if ok {
556 if _, drop := blocked[key]; drop {
557 continue
558 }
559 }
560 filtered = append(filtered, entry)
561 }
562
563 return filtered
564}
565
566// SpawnWithSession starts a new dumber instance to restore a session.
567func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
568 log := logging.FromContext(s.ctx)
569
570 execPath, err := getExecutablePath()
571 if err != nil {
572 return fmt.Errorf("get executable path: %w", err)
573 }
574
575 // Start dumber browse with session ID in environment
576 cmd := exec.Command(execPath, "browse")
577
578 sanitizedEnv := withoutEnvKeys(sanitizedChildEnv(os.Environ()), RestoreSessionEnvVar)
579 overrides := []string{RestoreSessionEnvVar + "=" + string(sessionID)}
580 if s.spawnEnv != nil {
581 sanitizedEnv = withoutEnvKeys(sanitizedEnv, s.spawnEnv.RootCacheEnvVar())
582 overrides = append(overrides, s.spawnEnv.RootCacheEnvVar()+"="+s.spawnEnv.SessionRootCachePath(sessionID))
583 }
584 sanitizedEnv = append(sanitizedEnv, overrides...)
585 cmd.Env = sanitizedEnv
586
587 // Detach from current process group so the new process survives.
588 cmd.Stdin = nil
589 cmd.Stdout = nil
590 cmd.Stderr = nil
591
592 if err := cmd.Start(); err != nil {
593 return fmt.Errorf("spawn session: %w", err)
594 }
595
596 // Capture PID before releasing.
597 pid := cmd.Process.Pid
598
599 // Release the process so it continues running after we close.
600 if err := cmd.Process.Release(); err != nil {
601 log.Warn().Err(err).Msg("failed to release spawned process (non-fatal)")
602 }
603
604 log.Info().
605 Str("session_id", string(sessionID)).
606 Int("pid", pid).
607 Msg("spawned dumber with session restoration")
608
609 return nil
610}