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 iconFileName = "dumber.svg"
21 filePerm = 0644
22 dirPerm = 0755
23)
24
25// desktopFileTemplate is the freedesktop.org desktop entry format.
26// %s placeholder for executable path.
27const desktopFileTemplate = `[Desktop Entry]
28Version=1.1
29Type=Application
30Name=Dumber
31GenericName=Dumber Browser
32Comment=It's a browser but dumber
33Exec=%s browse %%U
34Icon=dumber
35Terminal=false
36Categories=Network;WebBrowser;
37MimeType=text/html;text/xml;application/xhtml+xml;x-scheme-handler/http;x-scheme-handler/https;
38StartupNotify=true
39StartupWMClass=dumber
40`
41
42// Adapter implements port.DesktopIntegration using XDG tools.
43type Adapter struct {
44 xdgSettingsPath string
45 updateDesktopDB string
46}
47
48// New creates a new desktop integration adapter.
49func New() port.DesktopIntegration {
50 a := &Adapter{}
51
52 // Detect xdg-settings
53 if path, err := exec.LookPath("xdg-settings"); err == nil {
54 a.xdgSettingsPath = path
55 }
56
57 // Detect update-desktop-database (optional)
58 if path, err := exec.LookPath("update-desktop-database"); err == nil {
59 a.updateDesktopDB = path
60 }
61
62 return a
63}
64
65// getApplicationsDir returns the XDG applications directory.
66func getApplicationsDir() (string, error) {
67 dataHome := os.Getenv("XDG_DATA_HOME")
68 if dataHome == "" {
69 home, err := os.UserHomeDir()
70 if err != nil {
71 return "", fmt.Errorf("get home dir: %w", err)
72 }
73 dataHome = filepath.Join(home, ".local", "share")
74 }
75 return filepath.Join(dataHome, "applications"), nil
76}
77
78// getDesktopFilePath returns the full path to the desktop file.
79func getDesktopFilePath() (string, error) {
80 appDir, err := getApplicationsDir()
81 if err != nil {
82 return "", err
83 }
84 return filepath.Join(appDir, desktopFileName), nil
85}
86
87// getIconFilePath returns the full path to the icon file.
88// Uses hicolor theme scalable apps directory.
89func getIconFilePath() (string, error) {
90 dataHome := os.Getenv("XDG_DATA_HOME")
91 if dataHome == "" {
92 home, err := os.UserHomeDir()
93 if err != nil {
94 return "", fmt.Errorf("get home dir: %w", err)
95 }
96 dataHome = filepath.Join(home, ".local", "share")
97 }
98 return filepath.Join(dataHome, "icons", "hicolor", "scalable", "apps", iconFileName), nil
99}
100
101// getExecutablePath returns the path to the dumber executable.
102func getExecutablePath() (string, error) {
103 // First try the running executable
104 execPath, err := os.Executable()
105 if err == nil {
106 // Resolve symlinks
107 resolved, symlinkErr := filepath.EvalSymlinks(execPath)
108 if symlinkErr == nil {
109 execPath = resolved
110 }
111 return execPath, nil
112 }
113
114 // Fallback to PATH lookup
115 path, err := exec.LookPath(appName)
116 if err != nil {
117 return "", fmt.Errorf("cannot find %s executable: %w", appName, err)
118 }
119 return path, nil
120}
121
122// GetStatus checks the current desktop integration state.
123func (a *Adapter) GetStatus(ctx context.Context) (*port.DesktopIntegrationStatus, error) {
124 log := logging.FromContext(ctx)
125 status := &port.DesktopIntegrationStatus{}
126
127 // Check desktop file
128 desktopPath, err := getDesktopFilePath()
129 if err != nil {
130 return nil, err
131 }
132 status.DesktopFilePath = desktopPath
133
134 if _, statErr := os.Stat(desktopPath); statErr == nil {
135 status.DesktopFileInstalled = true
136 }
137
138 // Check icon file
139 iconPath, err := getIconFilePath()
140 if err != nil {
141 return nil, err
142 }
143 status.IconFilePath = iconPath
144
145 if _, iconStatErr := os.Stat(iconPath); iconStatErr == nil {
146 status.IconInstalled = true
147 }
148
149 // Check executable
150 execPath, err := getExecutablePath()
151 if err == nil {
152 status.ExecutablePath = execPath
153 }
154
155 // Check default browser
156 if a.xdgSettingsPath != "" {
157 out, err := exec.CommandContext(ctx, a.xdgSettingsPath, "get", "default-web-browser").Output()
158 if err == nil {
159 currentDefault := strings.TrimSpace(string(out))
160 status.IsDefaultBrowser = currentDefault == desktopFileName
161 }
162 }
163
164 log.Debug().
165 Bool("desktop_installed", status.DesktopFileInstalled).
166 Bool("icon_installed", status.IconInstalled).
167 Bool("default", status.IsDefaultBrowser).
168 Str("desktop_path", status.DesktopFilePath).
169 Str("icon_path", status.IconFilePath).
170 Str("exec_path", status.ExecutablePath).
171 Msg("desktop integration status")
172
173 return status, nil
174}
175
176// InstallDesktopFile writes the desktop file to XDG applications directory.
177func (a *Adapter) InstallDesktopFile(ctx context.Context) (string, error) {
178 log := logging.FromContext(ctx)
179
180 execPath, err := getExecutablePath()
181 if err != nil {
182 return "", err
183 }
184
185 desktopPath, err := getDesktopFilePath()
186 if err != nil {
187 return "", err
188 }
189
190 // Ensure applications directory exists
191 appDir := filepath.Dir(desktopPath)
192 if err := os.MkdirAll(appDir, dirPerm); err != nil {
193 return "", fmt.Errorf("create applications dir: %w", err)
194 }
195
196 // Generate desktop file content
197 content := fmt.Sprintf(desktopFileTemplate, execPath)
198
199 // Write desktop file
200 if err := os.WriteFile(desktopPath, []byte(content), filePerm); err != nil {
201 return "", fmt.Errorf("write desktop file: %w", err)
202 }
203
204 log.Info().Str("path", desktopPath).Msg("desktop file installed")
205
206 // Update desktop database (optional, helps with some DEs)
207 if a.updateDesktopDB != "" {
208 if err := exec.CommandContext(ctx, a.updateDesktopDB, appDir).Run(); err != nil {
209 log.Debug().Err(err).Msg("update-desktop-database failed (non-fatal)")
210 }
211 }
212
213 return desktopPath, nil
214}
215
216// RemoveDesktopFile removes the desktop file from XDG applications directory.
217func (a *Adapter) RemoveDesktopFile(ctx context.Context) error {
218 log := logging.FromContext(ctx)
219
220 desktopPath, err := getDesktopFilePath()
221 if err != nil {
222 return err
223 }
224
225 // Check if exists
226 if _, err := os.Stat(desktopPath); os.IsNotExist(err) {
227 log.Debug().Str("path", desktopPath).Msg("desktop file not found (already removed)")
228 return nil
229 }
230
231 if err := os.Remove(desktopPath); err != nil {
232 return fmt.Errorf("remove desktop file: %w", err)
233 }
234
235 log.Info().Str("path", desktopPath).Msg("desktop file removed")
236
237 // Update desktop database
238 if a.updateDesktopDB != "" {
239 appDir := filepath.Dir(desktopPath)
240 _ = exec.CommandContext(ctx, a.updateDesktopDB, appDir).Run()
241 }
242
243 return nil
244}
245
246// InstallIcon writes the icon file to XDG icons directory.
247func (a *Adapter) InstallIcon(ctx context.Context, svgData []byte) (string, error) {
248 if a == nil {
249 return "", fmt.Errorf("desktop adapter is nil")
250 }
251 log := logging.FromContext(ctx)
252
253 iconPath, err := getIconFilePath()
254 if err != nil {
255 return "", err
256 }
257
258 // Ensure icons directory exists
259 iconDir := filepath.Dir(iconPath)
260 if err := os.MkdirAll(iconDir, dirPerm); err != nil {
261 return "", fmt.Errorf("create icons dir: %w", err)
262 }
263
264 // Write icon file
265 if err := os.WriteFile(iconPath, svgData, filePerm); err != nil {
266 return "", fmt.Errorf("write icon file: %w", err)
267 }
268
269 log.Info().Str("path", iconPath).Msg("icon file installed")
270
271 return iconPath, nil
272}
273
274// RemoveIcon removes the icon file from XDG icons directory.
275func (a *Adapter) RemoveIcon(ctx context.Context) error {
276 if a == nil {
277 return fmt.Errorf("desktop adapter is nil")
278 }
279 log := logging.FromContext(ctx)
280
281 iconPath, err := getIconFilePath()
282 if err != nil {
283 return err
284 }
285
286 // Check if exists
287 if _, err := os.Stat(iconPath); os.IsNotExist(err) {
288 log.Debug().Str("path", iconPath).Msg("icon file not found (already removed)")
289 return nil
290 }
291
292 if err := os.Remove(iconPath); err != nil {
293 return fmt.Errorf("remove icon file: %w", err)
294 }
295
296 log.Info().Str("path", iconPath).Msg("icon file removed")
297
298 return nil
299}
300
301// SetAsDefaultBrowser sets dumber as the default web browser.
302func (a *Adapter) SetAsDefaultBrowser(ctx context.Context) error {
303 log := logging.FromContext(ctx)
304
305 if a.xdgSettingsPath == "" {
306 return fmt.Errorf("xdg-settings not found (install xdg-utils)")
307 }
308
309 // Check if desktop file is installed
310 desktopPath, err := getDesktopFilePath()
311 if err != nil {
312 return err
313 }
314 if _, err := os.Stat(desktopPath); os.IsNotExist(err) {
315 return fmt.Errorf("desktop file not installed - run 'dumber setup install' first")
316 }
317
318 // Set default browser
319 cmd := exec.CommandContext(ctx, a.xdgSettingsPath, "set", "default-web-browser", desktopFileName)
320 if out, err := cmd.CombinedOutput(); err != nil {
321 return fmt.Errorf("xdg-settings failed: %s", strings.TrimSpace(string(out)))
322 }
323
324 log.Info().Msg("dumber set as default browser")
325 return nil
326}
327
328// UnsetAsDefaultBrowser resets default browser if dumber is currently default.
329func (a *Adapter) UnsetAsDefaultBrowser(ctx context.Context) error {
330 log := logging.FromContext(ctx)
331
332 if a.xdgSettingsPath == "" {
333 return nil // No xdg-settings, nothing to do
334 }
335
336 // Check current default
337 out, err := exec.CommandContext(ctx, a.xdgSettingsPath, "get", "default-web-browser").Output()
338 if err != nil {
339 return nil // Can't determine, skip
340 }
341
342 currentDefault := strings.TrimSpace(string(out))
343 if currentDefault != desktopFileName {
344 log.Debug().Str("current", currentDefault).Msg("dumber is not default browser, nothing to reset")
345 return nil
346 }
347
348 // xdg-settings doesn't have a "reset" - user must set another browser
349 log.Warn().Msg("dumber was default browser - user should set a new default")
350 return nil
351}
352
353// SessionSpawner implements port.SessionSpawner by launching a new dumber process.
354type SessionSpawner struct {
355 ctx context.Context
356}
357
358// NewSessionSpawner creates a new session spawner.
359func NewSessionSpawner(ctx context.Context) *SessionSpawner {
360 return &SessionSpawner{ctx: ctx}
361}
362
363// RestoreSessionEnvVar is the environment variable used to pass session ID for restoration.
364const RestoreSessionEnvVar = "DUMBER_RESTORE_SESSION"
365
366// SpawnWithSession starts a new dumber instance to restore a session.
367func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
368 log := logging.FromContext(s.ctx)
369
370 execPath, err := getExecutablePath()
371 if err != nil {
372 return fmt.Errorf("get executable path: %w", err)
373 }
374
375 // Start dumber browse with session ID in environment
376 cmd := exec.Command(execPath, "browse")
377
378 // Inherit environment and add restore session ID
379 cmd.Env = append(os.Environ(), RestoreSessionEnvVar+"="+string(sessionID))
380
381 // Detach from current process group so the new process survives
382 cmd.Stdin = nil
383 cmd.Stdout = nil
384 cmd.Stderr = nil
385
386 if err := cmd.Start(); err != nil {
387 return fmt.Errorf("spawn session: %w", err)
388 }
389
390 // Capture PID before releasing
391 pid := cmd.Process.Pid
392
393 // Release the process so it continues running after we close
394 if err := cmd.Process.Release(); err != nil {
395 log.Warn().Err(err).Msg("failed to release spawned process (non-fatal)")
396 }
397
398 log.Info().
399 Str("session_id", string(sessionID)).
400 Int("pid", pid).
401 Msg("spawned dumber with session restoration")
402
403 return nil
404}