v0.23.3 fc8669a

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

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