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 updateDesktopDB string
47}
48
49// New creates a new desktop integration adapter.
50func New() port.DesktopIntegration {
51 a := &Adapter{}
52
53 // Detect xdg-settings
54 if path, err := exec.LookPath("xdg-settings"); err == nil {
55 a.xdgSettingsPath = path
56 }
57
58 // Detect update-desktop-database (optional)
59 if path, err := exec.LookPath("update-desktop-database"); err == nil {
60 a.updateDesktopDB = path
61 }
62
63 return a
64}
65
66// getApplicationsDir returns the XDG applications directory.
67func getApplicationsDir() (string, error) {
68 dataHome := os.Getenv("XDG_DATA_HOME")
69 if dataHome == "" {
70 home, err := os.UserHomeDir()
71 if err != nil {
72 return "", fmt.Errorf("get home dir: %w", err)
73 }
74 dataHome = filepath.Join(home, ".local", "share")
75 }
76 return filepath.Join(dataHome, "applications"), nil
77}
78
79// getDesktopFilePath returns the full path to the desktop file.
80func getDesktopFilePath() (string, error) {
81 appDir, err := getApplicationsDir()
82 if err != nil {
83 return "", err
84 }
85 return filepath.Join(appDir, desktopFileName), nil
86}
87
88// getIconFilePath returns the full path to the icon file.
89// Uses hicolor theme scalable apps directory.
90func getIconFilePath() (string, error) {
91 dataHome := os.Getenv("XDG_DATA_HOME")
92 if dataHome == "" {
93 home, err := os.UserHomeDir()
94 if err != nil {
95 return "", fmt.Errorf("get home dir: %w", err)
96 }
97 dataHome = filepath.Join(home, ".local", "share")
98 }
99 return filepath.Join(dataHome, "icons", "hicolor", "scalable", "apps", iconFileName), nil
100}
101
102// getExecutablePath returns the path to the dumber executable.
103func getExecutablePath() (string, error) {
104 // First try the running executable
105 execPath, err := os.Executable()
106 if err == nil {
107 // Resolve symlinks
108 resolved, symlinkErr := filepath.EvalSymlinks(execPath)
109 if symlinkErr == nil {
110 execPath = resolved
111 }
112 return execPath, nil
113 }
114
115 // Fallback to PATH lookup
116 path, err := exec.LookPath(appName)
117 if err != nil {
118 return "", fmt.Errorf("cannot find %s executable: %w", appName, err)
119 }
120 return path, nil
121}
122
123// GetStatus checks the current desktop integration state.
124func (a *Adapter) GetStatus(ctx context.Context) (*port.DesktopIntegrationStatus, error) {
125 log := logging.FromContext(ctx)
126 status := &port.DesktopIntegrationStatus{}
127
128 // Check desktop file
129 desktopPath, err := getDesktopFilePath()
130 if err != nil {
131 return nil, err
132 }
133 status.DesktopFilePath = desktopPath
134
135 if _, statErr := os.Stat(desktopPath); statErr == nil {
136 status.DesktopFileInstalled = true
137 }
138
139 // Check icon file
140 iconPath, err := getIconFilePath()
141 if err != nil {
142 return nil, err
143 }
144 status.IconFilePath = iconPath
145
146 if _, iconStatErr := os.Stat(iconPath); iconStatErr == nil {
147 status.IconInstalled = true
148 }
149
150 // Check executable
151 execPath, err := getExecutablePath()
152 if err == nil {
153 status.ExecutablePath = execPath
154 }
155
156 // Check default browser
157 if a.xdgSettingsPath != "" {
158 out, err := exec.CommandContext(ctx, a.xdgSettingsPath, "get", "default-web-browser").Output()
159 if err == nil {
160 currentDefault := strings.TrimSpace(string(out))
161 status.IsDefaultBrowser = currentDefault == desktopFileName || currentDefault == packageDesktopFileName
162 }
163 }
164
165 log.Debug().
166 Bool("desktop_installed", status.DesktopFileInstalled).
167 Bool("icon_installed", status.IconInstalled).
168 Bool("default", status.IsDefaultBrowser).
169 Str("desktop_path", status.DesktopFilePath).
170 Str("icon_path", status.IconFilePath).
171 Str("exec_path", status.ExecutablePath).
172 Msg("desktop integration status")
173
174 return status, nil
175}
176
177// InstallDesktopFile writes the desktop file to XDG applications directory.
178func (a *Adapter) InstallDesktopFile(ctx context.Context) (string, error) {
179 log := logging.FromContext(ctx)
180
181 execPath, err := getExecutablePath()
182 if err != nil {
183 return "", err
184 }
185
186 desktopPath, err := getDesktopFilePath()
187 if err != nil {
188 return "", err
189 }
190
191 // Ensure applications directory exists
192 appDir := filepath.Dir(desktopPath)
193 if err := os.MkdirAll(appDir, dirPerm); err != nil {
194 return "", fmt.Errorf("create applications dir: %w", err)
195 }
196
197 // Generate desktop file content
198 content := fmt.Sprintf(desktopFileTemplate, execPath)
199
200 // Write desktop file
201 if err := os.WriteFile(desktopPath, []byte(content), filePerm); err != nil {
202 return "", fmt.Errorf("write desktop file: %w", err)
203 }
204
205 log.Info().Str("path", desktopPath).Msg("desktop file installed")
206
207 // Update desktop database (optional, helps with some DEs)
208 if a.updateDesktopDB != "" {
209 if err := exec.CommandContext(ctx, a.updateDesktopDB, appDir).Run(); err != nil {
210 log.Debug().Err(err).Msg("update-desktop-database failed (non-fatal)")
211 }
212 }
213
214 return desktopPath, nil
215}
216
217// RemoveDesktopFile removes the desktop file from XDG applications directory.
218func (a *Adapter) RemoveDesktopFile(ctx context.Context) error {
219 log := logging.FromContext(ctx)
220
221 desktopPath, err := getDesktopFilePath()
222 if err != nil {
223 return err
224 }
225
226 // Check if exists
227 if _, err := os.Stat(desktopPath); os.IsNotExist(err) {
228 log.Debug().Str("path", desktopPath).Msg("desktop file not found (already removed)")
229 return nil
230 }
231
232 if err := os.Remove(desktopPath); err != nil {
233 return fmt.Errorf("remove desktop file: %w", err)
234 }
235
236 log.Info().Str("path", desktopPath).Msg("desktop file removed")
237
238 // Update desktop database
239 if a.updateDesktopDB != "" {
240 appDir := filepath.Dir(desktopPath)
241 _ = exec.CommandContext(ctx, a.updateDesktopDB, appDir).Run()
242 }
243
244 return nil
245}
246
247// InstallIcon writes the icon file to XDG icons directory.
248func (a *Adapter) InstallIcon(ctx context.Context, svgData []byte) (string, error) {
249 if a == nil {
250 return "", fmt.Errorf("desktop adapter is nil")
251 }
252 log := logging.FromContext(ctx)
253
254 iconPath, err := getIconFilePath()
255 if err != nil {
256 return "", err
257 }
258
259 // Ensure icons directory exists
260 iconDir := filepath.Dir(iconPath)
261 if err := os.MkdirAll(iconDir, dirPerm); err != nil {
262 return "", fmt.Errorf("create icons dir: %w", err)
263 }
264
265 // Write icon file
266 if err := os.WriteFile(iconPath, svgData, filePerm); err != nil {
267 return "", fmt.Errorf("write icon file: %w", err)
268 }
269
270 log.Info().Str("path", iconPath).Msg("icon file installed")
271
272 return iconPath, nil
273}
274
275// RemoveIcon removes the icon file from XDG icons directory.
276func (a *Adapter) RemoveIcon(ctx context.Context) error {
277 if a == nil {
278 return fmt.Errorf("desktop adapter is nil")
279 }
280 log := logging.FromContext(ctx)
281
282 iconPath, err := getIconFilePath()
283 if err != nil {
284 return err
285 }
286
287 // Check if exists
288 if _, err := os.Stat(iconPath); os.IsNotExist(err) {
289 log.Debug().Str("path", iconPath).Msg("icon file not found (already removed)")
290 return nil
291 }
292
293 if err := os.Remove(iconPath); err != nil {
294 return fmt.Errorf("remove icon file: %w", err)
295 }
296
297 log.Info().Str("path", iconPath).Msg("icon file removed")
298
299 return nil
300}
301
302// SetAsDefaultBrowser sets dumber as the default web browser.
303func (a *Adapter) SetAsDefaultBrowser(ctx context.Context) error {
304 log := logging.FromContext(ctx)
305
306 if a.xdgSettingsPath == "" {
307 return fmt.Errorf("xdg-settings not found (install xdg-utils)")
308 }
309
310 // Determine which desktop file to use
311 // Priority: package manager desktop file (AUR/Flatpak) > user-installed
312 desktopFile := a.findDesktopFile(ctx)
313 if desktopFile == "" {
314 return fmt.Errorf("desktop file not installed - run 'dumber setup install' first")
315 }
316
317 // Set default browser
318 cmd := exec.CommandContext(ctx, a.xdgSettingsPath, "set", "default-web-browser", desktopFile)
319 if out, err := cmd.CombinedOutput(); err != nil {
320 return fmt.Errorf("xdg-settings failed: %s", strings.TrimSpace(string(out)))
321 }
322
323 log.Info().Str("desktop_file", desktopFile).Msg("dumber set as default browser")
324 return nil
325}
326
327// findDesktopFile returns the desktop file name to use for default browser setting.
328// Checks system paths first (AUR/Flatpak), then user path (manual install).
329func (a *Adapter) findDesktopFile(ctx context.Context) string {
330 log := logging.FromContext(ctx)
331
332 // Check system-wide paths for package manager installs
333 systemPaths := []string{
334 "/usr/share/applications/" + packageDesktopFileName,
335 "/app/share/applications/" + packageDesktopFileName, // Flatpak
336 }
337
338 for _, path := range systemPaths {
339 if _, err := os.Stat(path); err == nil {
340 log.Debug().Str("path", path).Msg("found package desktop file")
341 return packageDesktopFileName
342 }
343 }
344
345 // Check user path for manual installs
346 userPath, err := getDesktopFilePath()
347 if err == nil {
348 if _, err := os.Stat(userPath); err == nil {
349 log.Debug().Str("path", userPath).Msg("found user desktop file")
350 return desktopFileName
351 }
352 }
353
354 return ""
355}
356
357// UnsetAsDefaultBrowser resets default browser if dumber is currently default.
358func (a *Adapter) UnsetAsDefaultBrowser(ctx context.Context) error {
359 log := logging.FromContext(ctx)
360
361 if a.xdgSettingsPath == "" {
362 return nil // No xdg-settings, nothing to do
363 }
364
365 // Check current default
366 out, err := exec.CommandContext(ctx, a.xdgSettingsPath, "get", "default-web-browser").Output()
367 if err != nil {
368 return nil // Can't determine, skip
369 }
370
371 currentDefault := strings.TrimSpace(string(out))
372 if currentDefault != desktopFileName {
373 log.Debug().Str("current", currentDefault).Msg("dumber is not default browser, nothing to reset")
374 return nil
375 }
376
377 // xdg-settings doesn't have a "reset" - user must set another browser
378 log.Warn().Msg("dumber was default browser - user should set a new default")
379 return nil
380}
381
382// SessionSpawner implements port.SessionSpawner by launching a new dumber process.
383type SessionSpawner struct {
384 ctx context.Context
385}
386
387// NewSessionSpawner creates a new session spawner.
388func NewSessionSpawner(ctx context.Context) *SessionSpawner {
389 return &SessionSpawner{ctx: ctx}
390}
391
392// RestoreSessionEnvVar is the environment variable used to pass session ID for restoration.
393const RestoreSessionEnvVar = "DUMBER_RESTORE_SESSION"
394
395// LaunchExternalURL opens a URL with the system's default handler (xdg-open on Linux).
396// Used for external URL schemes like vscode://, spotify://, steam://, etc.
397func LaunchExternalURL(uri string) {
398 cmd := exec.Command("xdg-open", uri)
399 // Detach from current process - we don't care about the result
400 cmd.Stdin = nil
401 cmd.Stdout = nil
402 cmd.Stderr = nil
403 if err := cmd.Start(); err != nil {
404 fmt.Fprintf(os.Stderr, "failed to launch external URL %q with xdg-open: %v\n", uri, err)
405 return
406 }
407 if cmd.Process != nil {
408 if err := cmd.Process.Release(); err != nil {
409 fmt.Fprintf(os.Stderr, "failed to release xdg-open process for URL %q: %v\n", uri, err)
410 }
411 }
412}
413
414// SpawnWithSession starts a new dumber instance to restore a session.
415func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
416 log := logging.FromContext(s.ctx)
417
418 execPath, err := getExecutablePath()
419 if err != nil {
420 return fmt.Errorf("get executable path: %w", err)
421 }
422
423 // Start dumber browse with session ID in environment
424 cmd := exec.Command(execPath, "browse")
425
426 // Inherit environment and add restore session ID
427 cmd.Env = append(os.Environ(), RestoreSessionEnvVar+"="+string(sessionID))
428
429 // Detach from current process group so the new process survives
430 cmd.Stdin = nil
431 cmd.Stdout = nil
432 cmd.Stderr = nil
433
434 if err := cmd.Start(); err != nil {
435 return fmt.Errorf("spawn session: %w", err)
436 }
437
438 // Capture PID before releasing
439 pid := cmd.Process.Pid
440
441 // Release the process so it continues running after we close
442 if err := cmd.Process.Release(); err != nil {
443 log.Warn().Err(err).Msg("failed to release spawned process (non-fatal)")
444 }
445
446 log.Info().
447 Str("session_id", string(sessionID)).
448 Int("pid", pid).
449 Msg("spawned dumber with session restoration")
450
451 return nil
452}