Merge pull request #261 from bnema/fix/default-browser-mime-sync
fix(desktop): sync browser MIME handlers
2 files · +108 -0
M internal/infrastructure/desktop/adapter.go +53 -0
| @@ -43,6 +43,7 @@ StartupWMClass=dumber | |||
| 43 | 43 | // Adapter implements port.DesktopIntegration using XDG tools. | |
| 44 | 44 | type Adapter struct { | |
| 45 | 45 | xdgSettingsPath string | |
| 46 | + | xdgMimePath string | |
| 46 | 47 | updateDesktopDB string | |
| 47 | 48 | } | |
| 48 | 49 | ||
| @@ -55,6 +56,11 @@ func New() port.DesktopIntegration { | |||
| 55 | 56 | 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 | 64 | // Detect update-desktop-database (optional) | |
| 59 | 65 | if path, err := exec.LookPath("update-desktop-database"); err == nil { | |
| 60 | 66 | 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 | 175 | log.Debug(). | |
| 166 | 176 | Bool("desktop_installed", status.DesktopFileInstalled). | |
| 167 | 177 | Bool("icon_installed", status.IconInstalled). | |
| @@ -320,12 +330,55 @@ func (a *Adapter) SetAsDefaultBrowser(ctx context.Context) error { | |||
| 320 | 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 | 342 | log.Info().Str("desktop_file", desktopFile).Msg("dumber set as default browser") | |
| 324 | 343 | return nil | |
| 325 | 344 | } | |
| 326 | 345 | ||
| 327 | 346 | // findDesktopFile returns the desktop file name to use for default browser setting. | |
| 328 | 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 | 382 | func (a *Adapter) findDesktopFile(ctx context.Context) string { | |
| 330 | 383 | log := logging.FromContext(ctx) | |
| 331 | 384 | ||
| @@ -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 string | 45 | xdgSettingsPath string | ||
| 46 | + | xdgMimePath string | |||
| 46 | updateDesktopDB string | 47 | updateDesktopDB string | ||
| 47 | } | 48 | } | ||
| 48 | 49 | ||||
| @@ -55,6 +56,11 @@ func New() port.DesktopIntegration { | |||||
| 55 | a.xdgSettingsPath = path | 56 | 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 = path | 66 | 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 nil | 343 | 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 @@ | |||
| 1 | 1 | package desktop | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "errors" | |
| 5 | 6 | "os" | |
| 6 | 7 | "os/exec" | |
| @@ -9,6 +10,9 @@ import ( | |||
| 9 | 10 | "testing" | |
| 10 | 11 | "time" | |
| 11 | 12 | ||
| 13 | + | "github.com/stretchr/testify/assert" | |
| 14 | + | "github.com/stretchr/testify/require" | |
| 15 | + | ||
| 12 | 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 | 274 | func requireNoError(t *testing.T, err error) { | |
| 220 | 275 | t.Helper() | |
| 221 | 276 | if err != nil { | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package desktop | 1 | 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 { | ||