detached 61df531

fix: handle external URL schemes (vscode://, spotify://, etc.)

Launch external URL schemes via xdg-open instead of showing WebKit
error pages. This fixes OAuth flows that redirect to app-specific
schemes like vscode-insiders://.

- Add IsExternalScheme() to domain/url for scheme detection
- Add LaunchExternalURL() to desktop adapter for xdg-open launch
- Handle external schemes in navigation policy (decide-policy signal)
- Handle JS-initiated redirects in OnURIChanged callback
commit
61df5316035ce520b3febf8f9cd792e841c6bd43
author
brice · · 2026-01-28 08:49 UTC
committer
brice · · 2026-01-28 08:50 UTC
parent
ef22477
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +124 -14
M internal/domain/url/normalize.go +30 -0
@@ -181,3 +181,33 @@ func TrimLeadingSpacesIfURL(input string) string {
181181 }
182182 return input
183183 }
184+
185+// IsExternalScheme returns true if the URL uses a scheme that should be
186+// launched externally (e.g., vscode://, vscode-insiders://, spotify://, steam://)
187+// rather than handled by the browser.
188+func IsExternalScheme(uri string) bool {
189+ if uri == "" {
190+ return false
191+ }
192+
193+ // Internal schemes we handle
194+ if strings.HasPrefix(uri, "http://") ||
195+ strings.HasPrefix(uri, "https://") ||
196+ strings.HasPrefix(uri, "file://") ||
197+ strings.HasPrefix(uri, "dumb://") ||
198+ strings.HasPrefix(uri, "about:") ||
199+ strings.HasPrefix(uri, "data:") ||
200+ strings.HasPrefix(uri, "blob:") ||
201+ strings.HasPrefix(uri, "javascript:") {
202+ return false
203+ }
204+
205+ // Check if it looks like a valid URL with a scheme
206+ parsed, err := url.Parse(uri)
207+ if err != nil {
208+ return false
209+ }
210+
211+ // Must have a non-empty scheme to be considered external
212+ return parsed.Scheme != ""
213+}
@@ -181,3 +181,33 @@ func TrimLeadingSpacesIfURL(input string) string {
181 }181 }
182 return input182 return input
183 }183 }
184+
185+// IsExternalScheme returns true if the URL uses a scheme that should be
186+// launched externally (e.g., vscode://, vscode-insiders://, spotify://, steam://)
187+// rather than handled by the browser.
188+func IsExternalScheme(uri string) bool {
189+ if uri == "" {
190+ return false
191+ }
192+
193+ // Internal schemes we handle
194+ if strings.HasPrefix(uri, "http://") ||
195+ strings.HasPrefix(uri, "https://") ||
196+ strings.HasPrefix(uri, "file://") ||
197+ strings.HasPrefix(uri, "dumb://") ||
198+ strings.HasPrefix(uri, "about:") ||
199+ strings.HasPrefix(uri, "data:") ||
200+ strings.HasPrefix(uri, "blob:") ||
201+ strings.HasPrefix(uri, "javascript:") {
202+ return false
203+ }
204+
205+ // Check if it looks like a valid URL with a scheme
206+ parsed, err := url.Parse(uri)
207+ if err != nil {
208+ return false
209+ }
210+
211+ // Must have a non-empty scheme to be considered external
212+ return parsed.Scheme != ""
213+}
M internal/infrastructure/desktop/adapter.go +14 -0
@@ -392,6 +392,20 @@ func NewSessionSpawner(ctx context.Context) *SessionSpawner {
392392 // RestoreSessionEnvVar is the environment variable used to pass session ID for restoration.
393393 const RestoreSessionEnvVar = "DUMBER_RESTORE_SESSION"
394394
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.
397+func 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+ _ = cmd.Start()
404+ if cmd.Process != nil {
405+ _ = cmd.Process.Release()
406+ }
407+}
408+
395409 // SpawnWithSession starts a new dumber instance to restore a session.
396410 func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
397411 log := logging.FromContext(s.ctx)
@@ -392,6 +392,20 @@ func NewSessionSpawner(ctx context.Context) *SessionSpawner {
392 // RestoreSessionEnvVar is the environment variable used to pass session ID for restoration.392 // RestoreSessionEnvVar is the environment variable used to pass session ID for restoration.
393 const RestoreSessionEnvVar = "DUMBER_RESTORE_SESSION"393 const RestoreSessionEnvVar = "DUMBER_RESTORE_SESSION"
394 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.
397+func 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+ _ = cmd.Start()
404+ if cmd.Process != nil {
405+ _ = cmd.Process.Release()
406+ }
407+}
408+
395 // SpawnWithSession starts a new dumber instance to restore a session.409 // SpawnWithSession starts a new dumber instance to restore a session.
396 func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {410 func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error {
397 log := logging.FromContext(s.ctx)411 log := logging.FromContext(s.ctx)
M internal/infrastructure/webkit/webview.go +54 -12
@@ -10,6 +10,8 @@ import (
1010 "time"
1111
1212 "github.com/bnema/dumber/internal/application/port"
13+ urlutil "github.com/bnema/dumber/internal/domain/url"
14+ "github.com/bnema/dumber/internal/infrastructure/desktop"
1315 "github.com/bnema/dumber/internal/logging"
1416 "github.com/bnema/puregotk-webkit/webkit"
1517 "github.com/jwijenbergh/puregotk/v4/gdk"
@@ -557,7 +559,8 @@ func (wv *WebView) connectDecidePolicySignal() {
557559 switch decisionType {
558560 case webkit.PolicyDecisionTypeResponseValue:
559561 return wv.handleResponsePolicyDecision(decisionPtr)
560- case webkit.PolicyDecisionTypeNavigationActionValue:
562+ case webkit.PolicyDecisionTypeNavigationActionValue, webkit.PolicyDecisionTypeNewWindowActionValue:
563+ // Both navigation and new window actions use NavigationPolicyDecision
561564 return wv.handleNavigationPolicyDecision(decisionPtr)
562565 default:
563566 return false
@@ -627,7 +630,7 @@ func (wv *WebView) handleResponsePolicyDecision(decisionPtr uintptr) bool {
627630 return true
628631 }
629632
630-// handleNavigationPolicyDecision handles navigation policy decisions (e.g., middle-click).
633+// handleNavigationPolicyDecision handles navigation policy decisions (e.g., middle-click, external schemes).
631634 func (wv *WebView) handleNavigationPolicyDecision(decisionPtr uintptr) bool {
632635 navDecision := webkit.NavigationPolicyDecisionNewFromInternalPtr(decisionPtr)
633636 if navDecision == nil {
@@ -639,6 +642,55 @@ func (wv *WebView) handleNavigationPolicyDecision(decisionPtr uintptr) bool {
639642 return false
640643 }
641644
645+ request := navAction.GetRequest()
646+ if request == nil {
647+ return false
648+ }
649+
650+ linkURI := request.GetUri()
651+ if linkURI == "" {
652+ return false
653+ }
654+
655+ // Debug logging to trace navigation decisions
656+ wv.logger.Debug().
657+ Str("uri", linkURI).
658+ Int("nav_type", int(navAction.GetNavigationType())).
659+ Bool("user_gesture", navAction.IsUserGesture()).
660+ Msg("navigation policy decision")
661+
662+ // Check for external URL schemes (e.g., vscode://, vscode-insiders://, spotify://)
663+ // These need to be launched via xdg-open rather than handled by WebKit
664+ if urlutil.IsExternalScheme(linkURI) {
665+ wv.logger.Info().
666+ Str("uri", linkURI).
667+ Msg("launching external URL scheme via xdg-open")
668+
669+ // Launch asynchronously to avoid blocking WebKit
670+ go desktop.LaunchExternalURL(linkURI)
671+
672+ // Ignore the navigation to prevent WebKit from showing an error
673+ navDecision.Ignore()
674+
675+ // Go back to the previous page to avoid showing WebKit's error page.
676+ // This handles OAuth flows where after successful auth, the callback
677+ // redirects to a custom scheme (vscode://, vscode-insiders://, etc.)
678+ // Schedule on idle to let WebKit process the ignored decision first
679+ cb := glib.SourceFunc(func(_ uintptr) bool {
680+ if wv.inner != nil && !wv.destroyed.Load() {
681+ wv.inner.GoBack()
682+ }
683+ return false // Don't repeat
684+ })
685+ wv.mu.Lock()
686+ wv.asyncCallbacks = append(wv.asyncCallbacks, &cb)
687+ wv.mu.Unlock()
688+ glib.IdleAdd(&cb, 0)
689+
690+ return true
691+ }
692+
693+ // Only handle link clicks for middle-click/ctrl-click (open in new tab)
642694 if navAction.GetNavigationType() != webkit.NavigationTypeLinkClickedValue {
643695 return false
644696 }
@@ -652,16 +704,6 @@ func (wv *WebView) handleNavigationPolicyDecision(decisionPtr uintptr) bool {
652704 return false
653705 }
654706
655- request := navAction.GetRequest()
656- if request == nil {
657- return false
658- }
659-
660- linkURI := request.GetUri()
661- if linkURI == "" {
662- return false
663- }
664-
665707 wv.logger.Debug().
666708 Str("uri", linkURI).
667709 Uint("button", mouseButton).
@@ -10,6 +10,8 @@ import (
10 "time"10 "time"
11 11
12 "github.com/bnema/dumber/internal/application/port"12 "github.com/bnema/dumber/internal/application/port"
13+ urlutil "github.com/bnema/dumber/internal/domain/url"
14+ "github.com/bnema/dumber/internal/infrastructure/desktop"
13 "github.com/bnema/dumber/internal/logging"15 "github.com/bnema/dumber/internal/logging"
14 "github.com/bnema/puregotk-webkit/webkit"16 "github.com/bnema/puregotk-webkit/webkit"
15 "github.com/jwijenbergh/puregotk/v4/gdk"17 "github.com/jwijenbergh/puregotk/v4/gdk"
@@ -557,7 +559,8 @@ func (wv *WebView) connectDecidePolicySignal() {
557 switch decisionType {559 switch decisionType {
558 case webkit.PolicyDecisionTypeResponseValue:560 case webkit.PolicyDecisionTypeResponseValue:
559 return wv.handleResponsePolicyDecision(decisionPtr)561 return wv.handleResponsePolicyDecision(decisionPtr)
560- case webkit.PolicyDecisionTypeNavigationActionValue:562+ case webkit.PolicyDecisionTypeNavigationActionValue, webkit.PolicyDecisionTypeNewWindowActionValue:
563+ // Both navigation and new window actions use NavigationPolicyDecision
561 return wv.handleNavigationPolicyDecision(decisionPtr)564 return wv.handleNavigationPolicyDecision(decisionPtr)
562 default:565 default:
563 return false566 return false
@@ -627,7 +630,7 @@ func (wv *WebView) handleResponsePolicyDecision(decisionPtr uintptr) bool {
627 return true630 return true
628 }631 }
629 632
630-// handleNavigationPolicyDecision handles navigation policy decisions (e.g., middle-click).633+// handleNavigationPolicyDecision handles navigation policy decisions (e.g., middle-click, external schemes).
631 func (wv *WebView) handleNavigationPolicyDecision(decisionPtr uintptr) bool {634 func (wv *WebView) handleNavigationPolicyDecision(decisionPtr uintptr) bool {
632 navDecision := webkit.NavigationPolicyDecisionNewFromInternalPtr(decisionPtr)635 navDecision := webkit.NavigationPolicyDecisionNewFromInternalPtr(decisionPtr)
633 if navDecision == nil {636 if navDecision == nil {
@@ -639,6 +642,55 @@ func (wv *WebView) handleNavigationPolicyDecision(decisionPtr uintptr) bool {
639 return false642 return false
640 }643 }
641 644
645+ request := navAction.GetRequest()
646+ if request == nil {
647+ return false
648+ }
649+
650+ linkURI := request.GetUri()
651+ if linkURI == "" {
652+ return false
653+ }
654+
655+ // Debug logging to trace navigation decisions
656+ wv.logger.Debug().
657+ Str("uri", linkURI).
658+ Int("nav_type", int(navAction.GetNavigationType())).
659+ Bool("user_gesture", navAction.IsUserGesture()).
660+ Msg("navigation policy decision")
661+
662+ // Check for external URL schemes (e.g., vscode://, vscode-insiders://, spotify://)
663+ // These need to be launched via xdg-open rather than handled by WebKit
664+ if urlutil.IsExternalScheme(linkURI) {
665+ wv.logger.Info().
666+ Str("uri", linkURI).
667+ Msg("launching external URL scheme via xdg-open")
668+
669+ // Launch asynchronously to avoid blocking WebKit
670+ go desktop.LaunchExternalURL(linkURI)
671+
672+ // Ignore the navigation to prevent WebKit from showing an error
673+ navDecision.Ignore()
674+
675+ // Go back to the previous page to avoid showing WebKit's error page.
676+ // This handles OAuth flows where after successful auth, the callback
677+ // redirects to a custom scheme (vscode://, vscode-insiders://, etc.)
678+ // Schedule on idle to let WebKit process the ignored decision first
679+ cb := glib.SourceFunc(func(_ uintptr) bool {
680+ if wv.inner != nil && !wv.destroyed.Load() {
681+ wv.inner.GoBack()
682+ }
683+ return false // Don't repeat
684+ })
685+ wv.mu.Lock()
686+ wv.asyncCallbacks = append(wv.asyncCallbacks, &cb)
687+ wv.mu.Unlock()
688+ glib.IdleAdd(&cb, 0)
689+
690+ return true
691+ }
692+
693+ // Only handle link clicks for middle-click/ctrl-click (open in new tab)
642 if navAction.GetNavigationType() != webkit.NavigationTypeLinkClickedValue {694 if navAction.GetNavigationType() != webkit.NavigationTypeLinkClickedValue {
643 return false695 return false
644 }696 }
@@ -652,16 +704,6 @@ func (wv *WebView) handleNavigationPolicyDecision(decisionPtr uintptr) bool {
652 return false704 return false
653 }705 }
654 706
655- request := navAction.GetRequest()
656- if request == nil {
657- return false
658- }
659-
660- linkURI := request.GetUri()
661- if linkURI == "" {
662- return false
663- }
664-
665 wv.logger.Debug().707 wv.logger.Debug().
666 Str("uri", linkURI).708 Str("uri", linkURI).
667 Uint("button", mouseButton).709 Uint("button", mouseButton).
M internal/ui/coordinator/content.go +26 -2
@@ -11,7 +11,9 @@ import (
1111 "github.com/bnema/dumber/internal/application/port"
1212 "github.com/bnema/dumber/internal/application/usecase"
1313 "github.com/bnema/dumber/internal/domain/entity"
14+ urlutil "github.com/bnema/dumber/internal/domain/url"
1415 "github.com/bnema/dumber/internal/infrastructure/config"
16+ "github.com/bnema/dumber/internal/infrastructure/desktop"
1517 "github.com/bnema/dumber/internal/infrastructure/webkit"
1618 "github.com/bnema/dumber/internal/logging"
1719 "github.com/bnema/dumber/internal/ui/adapter"
@@ -1444,9 +1446,31 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
14441446 c.onProgressChanged(paneID, progress)
14451447 }
14461448
1447- // SPA navigation
1449+ // SPA navigation and external scheme handling
14481450 wv.OnURIChanged = func(uri string) {
1449- if !wv.IsLoading() && uri != "" {
1451+ log := logging.FromContext(ctx)
1452+ if uri == "" {
1453+ return
1454+ }
1455+
1456+ // Check for external URL schemes (vscode://, vscode-insiders://, spotify://, etc.)
1457+ // These are typically triggered by JavaScript redirects (window.location)
1458+ isExternal := urlutil.IsExternalScheme(uri)
1459+ log.Debug().Str("pane_id", string(paneID)).Str("uri", uri).Bool("is_external", isExternal).Bool("is_loading", wv.IsLoading()).Msg("OnURIChanged")
1460+
1461+ if isExternal {
1462+ log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme detected, launching externally")
1463+
1464+ // Launch externally
1465+ desktop.LaunchExternalURL(uri)
1466+
1467+ // Stop loading to prevent WebKit from showing an error page
1468+ // The page stays on the previous URL before the JS redirect
1469+ _ = wv.Stop(ctx)
1470+ return
1471+ }
1472+
1473+ if !wv.IsLoading() {
14501474 c.onSPANavigation(ctx, paneID, uri)
14511475 }
14521476 }
@@ -11,7 +11,9 @@ import (
11 "github.com/bnema/dumber/internal/application/port"11 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/application/usecase"12 "github.com/bnema/dumber/internal/application/usecase"
13 "github.com/bnema/dumber/internal/domain/entity"13 "github.com/bnema/dumber/internal/domain/entity"
14+ urlutil "github.com/bnema/dumber/internal/domain/url"
14 "github.com/bnema/dumber/internal/infrastructure/config"15 "github.com/bnema/dumber/internal/infrastructure/config"
16+ "github.com/bnema/dumber/internal/infrastructure/desktop"
15 "github.com/bnema/dumber/internal/infrastructure/webkit"17 "github.com/bnema/dumber/internal/infrastructure/webkit"
16 "github.com/bnema/dumber/internal/logging"18 "github.com/bnema/dumber/internal/logging"
17 "github.com/bnema/dumber/internal/ui/adapter"19 "github.com/bnema/dumber/internal/ui/adapter"
@@ -1444,9 +1446,31 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e
1444 c.onProgressChanged(paneID, progress)1446 c.onProgressChanged(paneID, progress)
1445 }1447 }
1446 1448
1447- // SPA navigation1449+ // SPA navigation and external scheme handling
1448 wv.OnURIChanged = func(uri string) {1450 wv.OnURIChanged = func(uri string) {
1449- if !wv.IsLoading() && uri != "" {1451+ log := logging.FromContext(ctx)
1452+ if uri == "" {
1453+ return
1454+ }
1455+
1456+ // Check for external URL schemes (vscode://, vscode-insiders://, spotify://, etc.)
1457+ // These are typically triggered by JavaScript redirects (window.location)
1458+ isExternal := urlutil.IsExternalScheme(uri)
1459+ log.Debug().Str("pane_id", string(paneID)).Str("uri", uri).Bool("is_external", isExternal).Bool("is_loading", wv.IsLoading()).Msg("OnURIChanged")
1460+
1461+ if isExternal {
1462+ log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme detected, launching externally")
1463+
1464+ // Launch externally
1465+ desktop.LaunchExternalURL(uri)
1466+
1467+ // Stop loading to prevent WebKit from showing an error page
1468+ // The page stays on the previous URL before the JS redirect
1469+ _ = wv.Stop(ctx)
1470+ return
1471+ }
1472+
1473+ if !wv.IsLoading() {
1450 c.onSPANavigation(ctx, paneID, uri)1474 c.onSPANavigation(ctx, paneID, uri)
1451 }1475 }
1452 }1476 }

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