v0.27.0 dcb4c51

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

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