v0.33.2 57f21d6

adapter.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close