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