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 Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· 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 { | |||
| 181 | 181 | } | |
| 182 | 182 | 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 | + | } | |
| @@ -181,3 +181,33 @@ func TrimLeadingSpacesIfURL(input string) string { | |||||
| 181 | } | 181 | } | ||
| 182 | return input | 182 | 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 { | |||
| 392 | 392 | // RestoreSessionEnvVar is the environment variable used to pass session ID for restoration. | |
| 393 | 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 | 409 | // SpawnWithSession starts a new dumber instance to restore a session. | |
| 396 | 410 | func (s *SessionSpawner) SpawnWithSession(sessionID entity.SessionID) error { | |
| 397 | 411 | 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 ( | |||
| 10 | 10 | "time" | |
| 11 | 11 | ||
| 12 | 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 | 15 | "github.com/bnema/dumber/internal/logging" | |
| 14 | 16 | "github.com/bnema/puregotk-webkit/webkit" | |
| 15 | 17 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| @@ -557,7 +559,8 @@ func (wv *WebView) connectDecidePolicySignal() { | |||
| 557 | 559 | switch decisionType { | |
| 558 | 560 | case webkit.PolicyDecisionTypeResponseValue: | |
| 559 | 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 | 564 | return wv.handleNavigationPolicyDecision(decisionPtr) | |
| 562 | 565 | default: | |
| 563 | 566 | return false | |
| @@ -627,7 +630,7 @@ func (wv *WebView) handleResponsePolicyDecision(decisionPtr uintptr) bool { | |||
| 627 | 630 | 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 | 634 | func (wv *WebView) handleNavigationPolicyDecision(decisionPtr uintptr) bool { | |
| 632 | 635 | navDecision := webkit.NavigationPolicyDecisionNewFromInternalPtr(decisionPtr) | |
| 633 | 636 | if navDecision == nil { | |
| @@ -639,6 +642,55 @@ func (wv *WebView) handleNavigationPolicyDecision(decisionPtr uintptr) bool { | |||
| 639 | 642 | 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 | 694 | if navAction.GetNavigationType() != webkit.NavigationTypeLinkClickedValue { | |
| 643 | 695 | return false | |
| 644 | 696 | } | |
| @@ -652,16 +704,6 @@ func (wv *WebView) handleNavigationPolicyDecision(decisionPtr uintptr) bool { | |||
| 652 | 704 | 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 | 707 | wv.logger.Debug(). | |
| 666 | 708 | Str("uri", linkURI). | |
| 667 | 709 | 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 false | 566 | return false | ||
| @@ -627,7 +630,7 @@ func (wv *WebView) handleResponsePolicyDecision(decisionPtr uintptr) bool { | |||||
| 627 | return true | 630 | 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 false | 642 | 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 false | 695 | return false | ||
| 644 | } | 696 | } | ||
| @@ -652,16 +704,6 @@ func (wv *WebView) handleNavigationPolicyDecision(decisionPtr uintptr) bool { | |||||
| 652 | return false | 704 | 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 ( | |||
| 11 | 11 | "github.com/bnema/dumber/internal/application/port" | |
| 12 | 12 | "github.com/bnema/dumber/internal/application/usecase" | |
| 13 | 13 | "github.com/bnema/dumber/internal/domain/entity" | |
| 14 | + | urlutil "github.com/bnema/dumber/internal/domain/url" | |
| 14 | 15 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 15 | 17 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 16 | 18 | "github.com/bnema/dumber/internal/logging" | |
| 17 | 19 | "github.com/bnema/dumber/internal/ui/adapter" | |
| @@ -1444,9 +1446,31 @@ func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID e | |||
| 1444 | 1446 | c.onProgressChanged(paneID, progress) | |
| 1445 | 1447 | } | |
| 1446 | 1448 | ||
| 1447 | - | // SPA navigation | |
| 1449 | + | // SPA navigation and external scheme handling | |
| 1448 | 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 | 1474 | c.onSPANavigation(ctx, paneID, uri) | |
| 1451 | 1475 | } | |
| 1452 | 1476 | } | |
| @@ -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 navigation | 1449 | + | // 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 | } | ||