v0.29.0 21b3150

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/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}

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