detached 5c538ea

fix(desktop): sync browser MIME handlers

commit
5c538ea8d831f49d943633b909f85accafd7436c
author
brice · · 2026-05-23 09:01 UTC
parent
d94d482
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
2 files · +108 -0
M internal/infrastructure/desktop/adapter.go +53 -0
@@ -43,6 +43,7 @@ StartupWMClass=dumber
4343 // Adapter implements port.DesktopIntegration using XDG tools.
4444 type Adapter struct {
4545 xdgSettingsPath string
46+ xdgMimePath string
4647 updateDesktopDB string
4748 }
4849
@@ -55,6 +56,11 @@ func New() port.DesktopIntegration {
5556 a.xdgSettingsPath = path
5657 }
5758
59+ // Detect xdg-mime
60+ if path, err := exec.LookPath("xdg-mime"); err == nil {
61+ a.xdgMimePath = path
62+ }
63+
5864 // Detect update-desktop-database (optional)
5965 if path, err := exec.LookPath("update-desktop-database"); err == nil {
6066 a.updateDesktopDB = path
@@ -162,6 +168,10 @@ func (a *Adapter) GetStatus(ctx context.Context) (*port.DesktopIntegrationStatus
162168 }
163169 }
164170
171+ if status.IsDefaultBrowser && a.xdgMimePath != "" {
172+ status.IsDefaultBrowser = a.hasDefaultBrowserLinkHandlers(ctx)
173+ }
174+
165175 log.Debug().
166176 Bool("desktop_installed", status.DesktopFileInstalled).
167177 Bool("icon_installed", status.IconInstalled).
@@ -320,12 +330,55 @@ func (a *Adapter) SetAsDefaultBrowser(ctx context.Context) error {
320330 return fmt.Errorf("xdg-settings failed: %s", strings.TrimSpace(string(out)))
321331 }
322332
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+
323342 log.Info().Str("desktop_file", desktopFile).Msg("dumber set as default browser")
324343 return nil
325344 }
326345
327346 // findDesktopFile returns the desktop file name to use for default browser setting.
328347 // Checks system paths first (AUR/Flatpak), then user path (manual install).
348+func 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+
358+func browserLinkMimeTypes() []string {
359+ return []string{
360+ "x-scheme-handler/http",
361+ "x-scheme-handler/https",
362+ "text/html",
363+ }
364+}
365+
366+func (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+
329382 func (a *Adapter) findDesktopFile(ctx context.Context) string {
330383 log := logging.FromContext(ctx)
331384
@@ -43,6 +43,7 @@ StartupWMClass=dumber
43 // Adapter implements port.DesktopIntegration using XDG tools.43 // Adapter implements port.DesktopIntegration using XDG tools.
44 type Adapter struct {44 type Adapter struct {
45 xdgSettingsPath string45 xdgSettingsPath string
46+ xdgMimePath string
46 updateDesktopDB string47 updateDesktopDB string
47 }48 }
48 49
@@ -55,6 +56,11 @@ func New() port.DesktopIntegration {
55 a.xdgSettingsPath = path56 a.xdgSettingsPath = path
56 }57 }
57 58
59+ // Detect xdg-mime
60+ if path, err := exec.LookPath("xdg-mime"); err == nil {
61+ a.xdgMimePath = path
62+ }
63+
58 // Detect update-desktop-database (optional)64 // Detect update-desktop-database (optional)
59 if path, err := exec.LookPath("update-desktop-database"); err == nil {65 if path, err := exec.LookPath("update-desktop-database"); err == nil {
60 a.updateDesktopDB = path66 a.updateDesktopDB = path
@@ -162,6 +168,10 @@ func (a *Adapter) GetStatus(ctx context.Context) (*port.DesktopIntegrationStatus
162 }168 }
163 }169 }
164 170
171+ if status.IsDefaultBrowser && a.xdgMimePath != "" {
172+ status.IsDefaultBrowser = a.hasDefaultBrowserLinkHandlers(ctx)
173+ }
174+
165 log.Debug().175 log.Debug().
166 Bool("desktop_installed", status.DesktopFileInstalled).176 Bool("desktop_installed", status.DesktopFileInstalled).
167 Bool("icon_installed", status.IconInstalled).177 Bool("icon_installed", status.IconInstalled).
@@ -320,12 +330,55 @@ func (a *Adapter) SetAsDefaultBrowser(ctx context.Context) error {
320 return fmt.Errorf("xdg-settings failed: %s", strings.TrimSpace(string(out)))330 return fmt.Errorf("xdg-settings failed: %s", strings.TrimSpace(string(out)))
321 }331 }
322 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+
323 log.Info().Str("desktop_file", desktopFile).Msg("dumber set as default browser")342 log.Info().Str("desktop_file", desktopFile).Msg("dumber set as default browser")
324 return nil343 return nil
325 }344 }
326 345
327 // findDesktopFile returns the desktop file name to use for default browser setting.346 // findDesktopFile returns the desktop file name to use for default browser setting.
328 // Checks system paths first (AUR/Flatpak), then user path (manual install).347 // Checks system paths first (AUR/Flatpak), then user path (manual install).
348+func 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+
358+func browserLinkMimeTypes() []string {
359+ return []string{
360+ "x-scheme-handler/http",
361+ "x-scheme-handler/https",
362+ "text/html",
363+ }
364+}
365+
366+func (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+
329 func (a *Adapter) findDesktopFile(ctx context.Context) string {382 func (a *Adapter) findDesktopFile(ctx context.Context) string {
330 log := logging.FromContext(ctx)383 log := logging.FromContext(ctx)
331 384
M internal/infrastructure/desktop/adapter_test.go +55 -0
@@ -1,6 +1,7 @@
11 package desktop
22
33 import (
4+ "context"
45 "errors"
56 "os"
67 "os/exec"
@@ -9,6 +10,9 @@ import (
910 "testing"
1011 "time"
1112
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+
1216 "github.com/bnema/dumber/internal/domain/entity"
1317 )
1418
@@ -216,6 +220,57 @@ func TestLaunchBrowserBrowseURL_SpawnErrorDoesNotLeakURL(t *testing.T) {
216220 }
217221 }
218222
223+func TestSetAsDefaultBrowserAlsoUpdatesMimeHandlers(t *testing.T) {
224+ t.Setenv("HOME", t.TempDir())
225+ t.Setenv("XDG_DATA_HOME", filepath.Join(os.Getenv("HOME"), ".local", "share"))
226+
227+ desktopPath, err := getDesktopFilePath()
228+ require.NoError(t, err)
229+ require.NoError(t, os.MkdirAll(filepath.Dir(desktopPath), 0o755))
230+ require.NoError(t, os.WriteFile(desktopPath, []byte(desktopFileTemplate), 0o644))
231+
232+ binDir := t.TempDir()
233+ logPath := filepath.Join(binDir, "commands.log")
234+
235+ xdgSettingsPath := writeExecutable(t, filepath.Join(binDir, "xdg-settings"), "#!/bin/sh\necho \"$@\" >> \""+logPath+"\"\n")
236+ xdgMimePath := writeExecutable(t, filepath.Join(binDir, "xdg-mime"), "#!/bin/sh\necho \"$@\" >> \""+logPath+"\"\n")
237+
238+ a := &Adapter{xdgSettingsPath: xdgSettingsPath, xdgMimePath: xdgMimePath}
239+ desktopFile := a.findDesktopFile(context.Background())
240+ require.NotEmpty(t, desktopFile)
241+
242+ err = a.SetAsDefaultBrowser(context.Background())
243+ require.NoError(t, err)
244+
245+ logged, err := os.ReadFile(logPath)
246+ require.NoError(t, err)
247+ commands := strings.Split(strings.TrimSpace(string(logged)), "\n")
248+
249+ assert.Contains(t, commands, "set default-web-browser "+desktopFile)
250+ assert.Contains(t, commands, "default "+desktopFile+" x-scheme-handler/http x-scheme-handler/https text/html text/xml application/xhtml+xml")
251+}
252+
253+func TestGetStatusRequiresBrowserLinkMimeHandlersToMatch(t *testing.T) {
254+ t.Setenv("HOME", t.TempDir())
255+ t.Setenv("XDG_DATA_HOME", filepath.Join(os.Getenv("HOME"), ".local", "share"))
256+
257+ binDir := t.TempDir()
258+ xdgSettingsPath := writeExecutable(t, filepath.Join(binDir, "xdg-settings"), "#!/bin/sh\nif [ \"$1\" = \"get\" ]; then\n echo dumber.desktop\nfi\n")
259+ xdgMimePath := writeExecutable(t, filepath.Join(binDir, "xdg-mime"), "#!/bin/sh\nif [ \"$1\" = \"query\" ]; then\n if [ \"$3\" = \"x-scheme-handler/http\" ]; then\n echo zen.desktop\n else\n echo dumber.desktop\n fi\nfi\n")
260+
261+ a := &Adapter{xdgSettingsPath: xdgSettingsPath, xdgMimePath: xdgMimePath}
262+
263+ status, err := a.GetStatus(context.Background())
264+ require.NoError(t, err)
265+ assert.False(t, status.IsDefaultBrowser)
266+}
267+
268+func writeExecutable(t *testing.T, path, content string) string {
269+ t.Helper()
270+ require.NoError(t, os.WriteFile(path, []byte(content), 0o755))
271+ return path
272+}
273+
219274 func requireNoError(t *testing.T, err error) {
220275 t.Helper()
221276 if err != nil {
@@ -1,6 +1,7 @@
1 package desktop1 package desktop
2 2
3 import (3 import (
4+ "context"
4 "errors"5 "errors"
5 "os"6 "os"
6 "os/exec"7 "os/exec"
@@ -9,6 +10,9 @@ import (
9 "testing"10 "testing"
10 "time"11 "time"
11 12
13+ "github.com/stretchr/testify/assert"
14+ "github.com/stretchr/testify/require"
15+
12 "github.com/bnema/dumber/internal/domain/entity"16 "github.com/bnema/dumber/internal/domain/entity"
13 )17 )
14 18
@@ -216,6 +220,57 @@ func TestLaunchBrowserBrowseURL_SpawnErrorDoesNotLeakURL(t *testing.T) {
216 }220 }
217 }221 }
218 222
223+func TestSetAsDefaultBrowserAlsoUpdatesMimeHandlers(t *testing.T) {
224+ t.Setenv("HOME", t.TempDir())
225+ t.Setenv("XDG_DATA_HOME", filepath.Join(os.Getenv("HOME"), ".local", "share"))
226+
227+ desktopPath, err := getDesktopFilePath()
228+ require.NoError(t, err)
229+ require.NoError(t, os.MkdirAll(filepath.Dir(desktopPath), 0o755))
230+ require.NoError(t, os.WriteFile(desktopPath, []byte(desktopFileTemplate), 0o644))
231+
232+ binDir := t.TempDir()
233+ logPath := filepath.Join(binDir, "commands.log")
234+
235+ xdgSettingsPath := writeExecutable(t, filepath.Join(binDir, "xdg-settings"), "#!/bin/sh\necho \"$@\" >> \""+logPath+"\"\n")
236+ xdgMimePath := writeExecutable(t, filepath.Join(binDir, "xdg-mime"), "#!/bin/sh\necho \"$@\" >> \""+logPath+"\"\n")
237+
238+ a := &Adapter{xdgSettingsPath: xdgSettingsPath, xdgMimePath: xdgMimePath}
239+ desktopFile := a.findDesktopFile(context.Background())
240+ require.NotEmpty(t, desktopFile)
241+
242+ err = a.SetAsDefaultBrowser(context.Background())
243+ require.NoError(t, err)
244+
245+ logged, err := os.ReadFile(logPath)
246+ require.NoError(t, err)
247+ commands := strings.Split(strings.TrimSpace(string(logged)), "\n")
248+
249+ assert.Contains(t, commands, "set default-web-browser "+desktopFile)
250+ assert.Contains(t, commands, "default "+desktopFile+" x-scheme-handler/http x-scheme-handler/https text/html text/xml application/xhtml+xml")
251+}
252+
253+func TestGetStatusRequiresBrowserLinkMimeHandlersToMatch(t *testing.T) {
254+ t.Setenv("HOME", t.TempDir())
255+ t.Setenv("XDG_DATA_HOME", filepath.Join(os.Getenv("HOME"), ".local", "share"))
256+
257+ binDir := t.TempDir()
258+ xdgSettingsPath := writeExecutable(t, filepath.Join(binDir, "xdg-settings"), "#!/bin/sh\nif [ \"$1\" = \"get\" ]; then\n echo dumber.desktop\nfi\n")
259+ xdgMimePath := writeExecutable(t, filepath.Join(binDir, "xdg-mime"), "#!/bin/sh\nif [ \"$1\" = \"query\" ]; then\n if [ \"$3\" = \"x-scheme-handler/http\" ]; then\n echo zen.desktop\n else\n echo dumber.desktop\n fi\nfi\n")
260+
261+ a := &Adapter{xdgSettingsPath: xdgSettingsPath, xdgMimePath: xdgMimePath}
262+
263+ status, err := a.GetStatus(context.Background())
264+ require.NoError(t, err)
265+ assert.False(t, status.IsDefaultBrowser)
266+}
267+
268+func writeExecutable(t *testing.T, path, content string) string {
269+ t.Helper()
270+ require.NoError(t, os.WriteFile(path, []byte(content), 0o755))
271+ return path
272+}
273+
219 func requireNoError(t *testing.T, err error) {274 func requireNoError(t *testing.T, err error) {
220 t.Helper()275 t.Helper()
221 if err != nil {276 if err != nil {

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