feat(coordinator): implement popup handling with middle-click and window.open support
Add comprehensive popup handling in ContentCoordinator including: - PopupType enumeration to distinguish tab-like (_blank) vs JS popups - PendingPopup struct to track popup lifecycle (create → ready-to-show → insert) - handlePopupCreate/handlePopupReadyToShow/handlePopupClose methods - handleLinkMiddleClick handler for middle-click/Ctrl+click link detection - OAuth auto-close detection with pattern matching - WebViewFactory for creating regular and related (popup) WebViews This enables popups and middle-click links to be inserted into the workspace with proper session sharing via related WebViews.
- commit
- 24f586c31e8525f613cf995d077a5239f5c5c875
- author
- brice · · 2025-12-17 19:09 UTC
- committer
- brice · · 2025-12-17 22:43 UTC
- parent
- 884f77a
- signature
- unsigned
4 files · +869 -0
A internal/infrastructure/webkit/factory.go +129 -0
| @@ -0,0 +1,129 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "fmt" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | + | "github.com/bnema/dumber/internal/logging" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // WebViewFactory creates WebView instances for the application. | |
| 12 | + | // It supports creating regular WebViews (optionally via pool) and | |
| 13 | + | // related WebViews that share session/cookies with a parent (for popups). | |
| 14 | + | type WebViewFactory struct { | |
| 15 | + | wkCtx *WebKitContext | |
| 16 | + | settings *SettingsManager | |
| 17 | + | pool *WebViewPool | |
| 18 | + | injector *ContentInjector | |
| 19 | + | router *MessageRouter | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | // NewWebViewFactory creates a new WebViewFactory. | |
| 23 | + | // The pool parameter is optional; if nil, WebViews are created directly. | |
| 24 | + | func NewWebViewFactory( | |
| 25 | + | wkCtx *WebKitContext, | |
| 26 | + | settings *SettingsManager, | |
| 27 | + | pool *WebViewPool, | |
| 28 | + | injector *ContentInjector, | |
| 29 | + | router *MessageRouter, | |
| 30 | + | ) *WebViewFactory { | |
| 31 | + | return &WebViewFactory{ | |
| 32 | + | wkCtx: wkCtx, | |
| 33 | + | settings: settings, | |
| 34 | + | pool: pool, | |
| 35 | + | injector: injector, | |
| 36 | + | router: router, | |
| 37 | + | } | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | // Create creates a new WebView instance. | |
| 41 | + | // If a pool is configured, it will try to acquire from the pool first. | |
| 42 | + | func (f *WebViewFactory) Create(ctx context.Context) (*WebView, error) { | |
| 43 | + | log := logging.FromContext(ctx) | |
| 44 | + | ||
| 45 | + | // Try pool first if available | |
| 46 | + | if f.pool != nil { | |
| 47 | + | wv, err := f.pool.Acquire(ctx) | |
| 48 | + | if err == nil && wv != nil { | |
| 49 | + | log.Debug().Uint64("id", uint64(wv.ID())).Msg("acquired webview from pool") | |
| 50 | + | return wv, nil | |
| 51 | + | } | |
| 52 | + | log.Debug().Err(err).Msg("pool acquire failed, creating directly") | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // Create directly | |
| 56 | + | return f.createDirect(ctx) | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | // CreateRelated creates a WebView that shares session/cookies with the parent. | |
| 60 | + | // This is required for popup windows to maintain authentication state (OAuth). | |
| 61 | + | // Related WebViews bypass the pool since they must be linked to a specific parent. | |
| 62 | + | func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebViewID) (*WebView, error) { | |
| 63 | + | log := logging.FromContext(ctx) | |
| 64 | + | ||
| 65 | + | // Look up parent WebView | |
| 66 | + | parent := LookupWebView(WebViewID(parentID)) | |
| 67 | + | if parent == nil { | |
| 68 | + | return nil, fmt.Errorf("parent webview %d not found", parentID) | |
| 69 | + | } | |
| 70 | + | if parent.IsDestroyed() { | |
| 71 | + | return nil, fmt.Errorf("parent webview %d is destroyed", parentID) | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | // Create related WebView | |
| 75 | + | wv, err := NewWebViewWithRelated(ctx, parent, f.settings) | |
| 76 | + | if err != nil { | |
| 77 | + | return nil, fmt.Errorf("create related webview: %w", err) | |
| 78 | + | } | |
| 79 | + | ||
| 80 | + | // Ensure visible | |
| 81 | + | wv.inner.SetVisible(true) | |
| 82 | + | ||
| 83 | + | // Attach frontend (scripts, message handler) | |
| 84 | + | if err := wv.AttachFrontend(ctx, f.injector, f.router); err != nil { | |
| 85 | + | log.Warn().Err(err).Uint64("id", uint64(wv.ID())).Msg("failed to attach frontend to related webview") | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | log.Debug(). | |
| 89 | + | Uint64("id", uint64(wv.ID())). | |
| 90 | + | Uint64("parent_id", uint64(parentID)). | |
| 91 | + | Msg("created related webview for popup") | |
| 92 | + | ||
| 93 | + | return wv, nil | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // createDirect creates a WebView without using the pool. | |
| 97 | + | func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) { | |
| 98 | + | log := logging.FromContext(ctx) | |
| 99 | + | ||
| 100 | + | wv, err := NewWebView(ctx, f.wkCtx, f.settings) | |
| 101 | + | if err != nil { | |
| 102 | + | return nil, err | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // Ensure visible | |
| 106 | + | wv.inner.SetVisible(true) | |
| 107 | + | ||
| 108 | + | // Attach frontend | |
| 109 | + | if err := wv.AttachFrontend(ctx, f.injector, f.router); err != nil { | |
| 110 | + | log.Warn().Err(err).Uint64("id", uint64(wv.ID())).Msg("failed to attach frontend to webview") | |
| 111 | + | } | |
| 112 | + | ||
| 113 | + | log.Debug().Uint64("id", uint64(wv.ID())).Msg("created webview directly") | |
| 114 | + | return wv, nil | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | // Release returns a WebView to the pool if available, otherwise destroys it. | |
| 118 | + | // Related WebViews (popups) should be destroyed directly, not released to pool. | |
| 119 | + | func (f *WebViewFactory) Release(ctx context.Context, wv *WebView) { | |
| 120 | + | if wv == nil || wv.IsDestroyed() { | |
| 121 | + | return | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | if f.pool != nil { | |
| 125 | + | f.pool.Release(ctx, wv) | |
| 126 | + | } else { | |
| 127 | + | wv.Destroy() | |
| 128 | + | } | |
| 129 | + | } | |
| @@ -0,0 +1,129 @@ | |||||
| 1 | + | package webkit | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 8 | + | "github.com/bnema/dumber/internal/logging" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | // WebViewFactory creates WebView instances for the application. | |||
| 12 | + | // It supports creating regular WebViews (optionally via pool) and | |||
| 13 | + | // related WebViews that share session/cookies with a parent (for popups). | |||
| 14 | + | type WebViewFactory struct { | |||
| 15 | + | wkCtx *WebKitContext | |||
| 16 | + | settings *SettingsManager | |||
| 17 | + | pool *WebViewPool | |||
| 18 | + | injector *ContentInjector | |||
| 19 | + | router *MessageRouter | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | // NewWebViewFactory creates a new WebViewFactory. | |||
| 23 | + | // The pool parameter is optional; if nil, WebViews are created directly. | |||
| 24 | + | func NewWebViewFactory( | |||
| 25 | + | wkCtx *WebKitContext, | |||
| 26 | + | settings *SettingsManager, | |||
| 27 | + | pool *WebViewPool, | |||
| 28 | + | injector *ContentInjector, | |||
| 29 | + | router *MessageRouter, | |||
| 30 | + | ) *WebViewFactory { | |||
| 31 | + | return &WebViewFactory{ | |||
| 32 | + | wkCtx: wkCtx, | |||
| 33 | + | settings: settings, | |||
| 34 | + | pool: pool, | |||
| 35 | + | injector: injector, | |||
| 36 | + | router: router, | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | // Create creates a new WebView instance. | |||
| 41 | + | // If a pool is configured, it will try to acquire from the pool first. | |||
| 42 | + | func (f *WebViewFactory) Create(ctx context.Context) (*WebView, error) { | |||
| 43 | + | log := logging.FromContext(ctx) | |||
| 44 | + | ||||
| 45 | + | // Try pool first if available | |||
| 46 | + | if f.pool != nil { | |||
| 47 | + | wv, err := f.pool.Acquire(ctx) | |||
| 48 | + | if err == nil && wv != nil { | |||
| 49 | + | log.Debug().Uint64("id", uint64(wv.ID())).Msg("acquired webview from pool") | |||
| 50 | + | return wv, nil | |||
| 51 | + | } | |||
| 52 | + | log.Debug().Err(err).Msg("pool acquire failed, creating directly") | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | // Create directly | |||
| 56 | + | return f.createDirect(ctx) | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | // CreateRelated creates a WebView that shares session/cookies with the parent. | |||
| 60 | + | // This is required for popup windows to maintain authentication state (OAuth). | |||
| 61 | + | // Related WebViews bypass the pool since they must be linked to a specific parent. | |||
| 62 | + | func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebViewID) (*WebView, error) { | |||
| 63 | + | log := logging.FromContext(ctx) | |||
| 64 | + | ||||
| 65 | + | // Look up parent WebView | |||
| 66 | + | parent := LookupWebView(WebViewID(parentID)) | |||
| 67 | + | if parent == nil { | |||
| 68 | + | return nil, fmt.Errorf("parent webview %d not found", parentID) | |||
| 69 | + | } | |||
| 70 | + | if parent.IsDestroyed() { | |||
| 71 | + | return nil, fmt.Errorf("parent webview %d is destroyed", parentID) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | // Create related WebView | |||
| 75 | + | wv, err := NewWebViewWithRelated(ctx, parent, f.settings) | |||
| 76 | + | if err != nil { | |||
| 77 | + | return nil, fmt.Errorf("create related webview: %w", err) | |||
| 78 | + | } | |||
| 79 | + | ||||
| 80 | + | // Ensure visible | |||
| 81 | + | wv.inner.SetVisible(true) | |||
| 82 | + | ||||
| 83 | + | // Attach frontend (scripts, message handler) | |||
| 84 | + | if err := wv.AttachFrontend(ctx, f.injector, f.router); err != nil { | |||
| 85 | + | log.Warn().Err(err).Uint64("id", uint64(wv.ID())).Msg("failed to attach frontend to related webview") | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | log.Debug(). | |||
| 89 | + | Uint64("id", uint64(wv.ID())). | |||
| 90 | + | Uint64("parent_id", uint64(parentID)). | |||
| 91 | + | Msg("created related webview for popup") | |||
| 92 | + | ||||
| 93 | + | return wv, nil | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // createDirect creates a WebView without using the pool. | |||
| 97 | + | func (f *WebViewFactory) createDirect(ctx context.Context) (*WebView, error) { | |||
| 98 | + | log := logging.FromContext(ctx) | |||
| 99 | + | ||||
| 100 | + | wv, err := NewWebView(ctx, f.wkCtx, f.settings) | |||
| 101 | + | if err != nil { | |||
| 102 | + | return nil, err | |||
| 103 | + | } | |||
| 104 | + | ||||
| 105 | + | // Ensure visible | |||
| 106 | + | wv.inner.SetVisible(true) | |||
| 107 | + | ||||
| 108 | + | // Attach frontend | |||
| 109 | + | if err := wv.AttachFrontend(ctx, f.injector, f.router); err != nil { | |||
| 110 | + | log.Warn().Err(err).Uint64("id", uint64(wv.ID())).Msg("failed to attach frontend to webview") | |||
| 111 | + | } | |||
| 112 | + | ||||
| 113 | + | log.Debug().Uint64("id", uint64(wv.ID())).Msg("created webview directly") | |||
| 114 | + | return wv, nil | |||
| 115 | + | } | |||
| 116 | + | ||||
| 117 | + | // Release returns a WebView to the pool if available, otherwise destroys it. | |||
| 118 | + | // Related WebViews (popups) should be destroyed directly, not released to pool. | |||
| 119 | + | func (f *WebViewFactory) Release(ctx context.Context, wv *WebView) { | |||
| 120 | + | if wv == nil || wv.IsDestroyed() { | |||
| 121 | + | return | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | if f.pool != nil { | |||
| 125 | + | f.pool.Release(ctx, wv) | |||
| 126 | + | } else { | |||
| 127 | + | wv.Destroy() | |||
| 128 | + | } | |||
| 129 | + | } | |||
M internal/ui/coordinator/content.go +471 -0
| @@ -4,9 +4,12 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | "sync" | |
| 7 | + | "time" | |
| 7 | 8 | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 10 | "github.com/bnema/dumber/internal/application/usecase" | |
| 9 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 10 | 13 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 11 | 14 | "github.com/bnema/dumber/internal/logging" | |
| 12 | 15 | "github.com/bnema/dumber/internal/ui/cache" | |
| @@ -43,6 +46,21 @@ type ContentCoordinator struct { | |||
| 43 | 46 | ||
| 44 | 47 | // Gesture action handler for mouse button navigation | |
| 45 | 48 | gestureActionHandler input.ActionHandler | |
| 49 | + | ||
| 50 | + | // Popup handling | |
| 51 | + | factory *webkit.WebViewFactory | |
| 52 | + | popupConfig *config.PopupBehaviorConfig | |
| 53 | + | pendingPopups map[port.WebViewID]*PendingPopup | |
| 54 | + | popupMu sync.RWMutex | |
| 55 | + | ||
| 56 | + | // Callback to insert popup into workspace (avoids circular dependency) | |
| 57 | + | onInsertPopup func(ctx context.Context, input InsertPopupInput) error | |
| 58 | + | ||
| 59 | + | // Callback to close a pane when popup closes | |
| 60 | + | onClosePane func(ctx context.Context, paneID entity.PaneID) error | |
| 61 | + | ||
| 62 | + | // ID generator for popup panes | |
| 63 | + | generateID func() string | |
| 46 | 64 | } | |
| 47 | 65 | ||
| 48 | 66 | // NewContentCoordinator creates a new ContentCoordinator. | |
| @@ -66,6 +84,7 @@ func NewContentCoordinator( | |||
| 66 | 84 | paneTitles: make(map[entity.PaneID]string), | |
| 67 | 85 | navOrigins: make(map[entity.PaneID]string), | |
| 68 | 86 | getActiveWS: getActiveWS, | |
| 87 | + | pendingPopups: make(map[port.WebViewID]*PendingPopup), | |
| 69 | 88 | } | |
| 70 | 89 | } | |
| 71 | 90 | ||
| @@ -140,6 +159,14 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa | |||
| 140 | 159 | } | |
| 141 | 160 | } | |
| 142 | 161 | ||
| 162 | + | // Set up middle-click / Ctrl+click handler for opening links in new pane | |
| 163 | + | wv.OnLinkMiddleClick = func(uri string) bool { | |
| 164 | + | return c.handleLinkMiddleClick(ctx, paneID, uri) | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // Set up popup handling for this WebView | |
| 168 | + | c.SetupPopupHandling(ctx, paneID, wv) | |
| 169 | + | ||
| 143 | 170 | log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane") | |
| 144 | 171 | return wv, nil | |
| 145 | 172 | } | |
| @@ -262,6 +289,14 @@ func (c *ContentCoordinator) GetWebView(paneID entity.PaneID) *webkit.WebView { | |||
| 262 | 289 | return c.webViews[paneID] | |
| 263 | 290 | } | |
| 264 | 291 | ||
| 292 | + | // RegisterPopupWebView registers a popup WebView that was created externally. | |
| 293 | + | // This is used when popup tabs are created and the WebView needs to be tracked. | |
| 294 | + | func (c *ContentCoordinator) RegisterPopupWebView(paneID entity.PaneID, wv *webkit.WebView) { | |
| 295 | + | if wv != nil && paneID != "" { | |
| 296 | + | c.webViews[paneID] = wv | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | ||
| 265 | 300 | // GetTitle returns the current title for a pane. | |
| 266 | 301 | func (c *ContentCoordinator) GetTitle(paneID entity.PaneID) string { | |
| 267 | 302 | c.titleMu.RLock() | |
| @@ -528,3 +563,439 @@ func (c *ContentCoordinator) onProgressChanged(ctx context.Context, paneID entit | |||
| 528 | 563 | paneView.SetLoadProgress(progress) | |
| 529 | 564 | } | |
| 530 | 565 | } | |
| 566 | + | ||
| 567 | + | // --- Popup Handling --- | |
| 568 | + | ||
| 569 | + | // SetPopupConfig configures popup handling. | |
| 570 | + | func (c *ContentCoordinator) SetPopupConfig( | |
| 571 | + | factory *webkit.WebViewFactory, | |
| 572 | + | popupConfig *config.PopupBehaviorConfig, | |
| 573 | + | generateID func() string, | |
| 574 | + | ) { | |
| 575 | + | c.factory = factory | |
| 576 | + | c.popupConfig = popupConfig | |
| 577 | + | c.generateID = generateID | |
| 578 | + | } | |
| 579 | + | ||
| 580 | + | // SetOnInsertPopup sets the callback to insert popups into the workspace. | |
| 581 | + | func (c *ContentCoordinator) SetOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) { | |
| 582 | + | c.onInsertPopup = fn | |
| 583 | + | } | |
| 584 | + | ||
| 585 | + | // SetOnClosePane sets the callback to close a pane when its popup closes. | |
| 586 | + | func (c *ContentCoordinator) SetOnClosePane(fn func(ctx context.Context, paneID entity.PaneID) error) { | |
| 587 | + | c.onClosePane = fn | |
| 588 | + | } | |
| 589 | + | ||
| 590 | + | // SetupPopupHandling wires the popup create signal for a WebView. | |
| 591 | + | // This should be called after acquiring a WebView for a pane. | |
| 592 | + | func (c *ContentCoordinator) SetupPopupHandling(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) { | |
| 593 | + | log := logging.FromContext(ctx) | |
| 594 | + | ||
| 595 | + | if wv == nil { | |
| 596 | + | return | |
| 597 | + | } | |
| 598 | + | ||
| 599 | + | // Wire the OnCreate callback for popup requests | |
| 600 | + | wv.OnCreate = func(req webkit.PopupRequest) *webkit.WebView { | |
| 601 | + | return c.handlePopupCreate(ctx, paneID, wv, req) | |
| 602 | + | } | |
| 603 | + | ||
| 604 | + | log.Debug().Str("pane_id", string(paneID)).Msg("popup handling configured for webview") | |
| 605 | + | } | |
| 606 | + | ||
| 607 | + | // handlePopupCreate handles the WebKit "create" signal for popup windows. | |
| 608 | + | // Returns a new related WebView if popup is allowed, nil to block. | |
| 609 | + | func (c *ContentCoordinator) handlePopupCreate( | |
| 610 | + | ctx context.Context, | |
| 611 | + | parentPaneID entity.PaneID, | |
| 612 | + | parentWV *webkit.WebView, | |
| 613 | + | req webkit.PopupRequest, | |
| 614 | + | ) *webkit.WebView { | |
| 615 | + | log := logging.FromContext(ctx) | |
| 616 | + | ||
| 617 | + | log.Debug(). | |
| 618 | + | Str("parent_pane", string(parentPaneID)). | |
| 619 | + | Str("target_uri", req.TargetURI). | |
| 620 | + | Str("frame_name", req.FrameName). | |
| 621 | + | Bool("user_gesture", req.IsUserGesture). | |
| 622 | + | Msg("popup create request") | |
| 623 | + | ||
| 624 | + | // Check if popups are enabled | |
| 625 | + | if c.popupConfig != nil && !c.popupConfig.OpenInNewPane { | |
| 626 | + | log.Debug().Msg("popups disabled by config, blocking") | |
| 627 | + | return nil | |
| 628 | + | } | |
| 629 | + | ||
| 630 | + | // Check if factory is available | |
| 631 | + | if c.factory == nil { | |
| 632 | + | log.Warn().Msg("no webview factory, cannot create popup") | |
| 633 | + | return nil | |
| 634 | + | } | |
| 635 | + | ||
| 636 | + | // Create related WebView for session sharing | |
| 637 | + | parentID := port.WebViewID(parentWV.ID()) | |
| 638 | + | popupWV, err := c.factory.CreateRelated(ctx, parentID) | |
| 639 | + | if err != nil { | |
| 640 | + | log.Error().Err(err).Msg("failed to create related webview for popup") | |
| 641 | + | return nil | |
| 642 | + | } | |
| 643 | + | ||
| 644 | + | // Detect popup type | |
| 645 | + | popupType := DetectPopupType(req.FrameName) | |
| 646 | + | ||
| 647 | + | // Store pending popup for ready-to-show handling | |
| 648 | + | popupID := port.WebViewID(popupWV.ID()) | |
| 649 | + | pending := &PendingPopup{ | |
| 650 | + | WebView: popupWV, | |
| 651 | + | ParentPaneID: parentPaneID, | |
| 652 | + | ParentWebViewID: parentID, | |
| 653 | + | TargetURI: req.TargetURI, | |
| 654 | + | FrameName: req.FrameName, | |
| 655 | + | IsUserGesture: req.IsUserGesture, | |
| 656 | + | PopupType: popupType, | |
| 657 | + | CreatedAt: time.Now(), | |
| 658 | + | } | |
| 659 | + | ||
| 660 | + | c.popupMu.Lock() | |
| 661 | + | c.pendingPopups[popupID] = pending | |
| 662 | + | c.popupMu.Unlock() | |
| 663 | + | ||
| 664 | + | // Wire ready-to-show and close signals | |
| 665 | + | popupWV.OnReadyToShow = func() { | |
| 666 | + | c.handlePopupReadyToShow(ctx, popupID) | |
| 667 | + | } | |
| 668 | + | popupWV.OnClose = func() { | |
| 669 | + | c.handlePopupClose(ctx, popupID) | |
| 670 | + | } | |
| 671 | + | ||
| 672 | + | log.Info(). | |
| 673 | + | Uint64("popup_id", uint64(popupID)). | |
| 674 | + | Str("popup_type", popupType.String()). | |
| 675 | + | Str("target_uri", req.TargetURI). | |
| 676 | + | Msg("popup created, awaiting ready-to-show") | |
| 677 | + | ||
| 678 | + | return popupWV | |
| 679 | + | } | |
| 680 | + | ||
| 681 | + | // handlePopupReadyToShow handles the WebKit "ready-to-show" signal. | |
| 682 | + | // This is called when the popup is configured and ready to be displayed. | |
| 683 | + | func (c *ContentCoordinator) handlePopupReadyToShow(ctx context.Context, popupID port.WebViewID) { | |
| 684 | + | log := logging.FromContext(ctx) | |
| 685 | + | ||
| 686 | + | // Get pending popup | |
| 687 | + | c.popupMu.Lock() | |
| 688 | + | pending, ok := c.pendingPopups[popupID] | |
| 689 | + | if ok { | |
| 690 | + | delete(c.pendingPopups, popupID) | |
| 691 | + | } | |
| 692 | + | c.popupMu.Unlock() | |
| 693 | + | ||
| 694 | + | if !ok || pending == nil { | |
| 695 | + | log.Warn().Uint64("popup_id", uint64(popupID)).Msg("ready-to-show for unknown popup") | |
| 696 | + | return | |
| 697 | + | } | |
| 698 | + | ||
| 699 | + | log.Debug(). | |
| 700 | + | Uint64("popup_id", uint64(popupID)). | |
| 701 | + | Str("popup_type", pending.PopupType.String()). | |
| 702 | + | Msg("popup ready to show") | |
| 703 | + | ||
| 704 | + | // Determine behavior from config | |
| 705 | + | behavior := GetBehavior(pending.PopupType, c.popupConfig) | |
| 706 | + | placement := "right" | |
| 707 | + | if c.popupConfig != nil { | |
| 708 | + | placement = c.popupConfig.Placement | |
| 709 | + | } | |
| 710 | + | ||
| 711 | + | // Create popup pane entity | |
| 712 | + | var paneID entity.PaneID | |
| 713 | + | if c.generateID != nil { | |
| 714 | + | paneID = entity.PaneID(c.generateID()) | |
| 715 | + | } else { | |
| 716 | + | paneID = entity.PaneID(fmt.Sprintf("popup_%d", popupID)) | |
| 717 | + | } | |
| 718 | + | ||
| 719 | + | popupPane := entity.NewPane(paneID) | |
| 720 | + | popupPane.WindowType = entity.WindowPopup | |
| 721 | + | popupPane.IsRelated = true | |
| 722 | + | popupPane.ParentPaneID = &pending.ParentPaneID | |
| 723 | + | popupPane.URI = pending.TargetURI | |
| 724 | + | ||
| 725 | + | // Enable OAuth auto-close if configured | |
| 726 | + | if c.popupConfig != nil && c.popupConfig.OAuthAutoClose && IsOAuthURL(pending.TargetURI) { | |
| 727 | + | popupPane.AutoClose = true | |
| 728 | + | c.setupOAuthAutoClose(ctx, paneID, pending.WebView) | |
| 729 | + | } | |
| 730 | + | ||
| 731 | + | // Register WebView in our map | |
| 732 | + | c.webViews[paneID] = pending.WebView | |
| 733 | + | ||
| 734 | + | // Setup standard callbacks | |
| 735 | + | c.setupWebViewCallbacks(ctx, paneID, pending.WebView) | |
| 736 | + | ||
| 737 | + | // Request insertion into workspace | |
| 738 | + | if c.onInsertPopup != nil { | |
| 739 | + | input := InsertPopupInput{ | |
| 740 | + | ParentPaneID: pending.ParentPaneID, | |
| 741 | + | PopupPane: popupPane, | |
| 742 | + | WebView: pending.WebView, | |
| 743 | + | Behavior: behavior, | |
| 744 | + | Placement: placement, | |
| 745 | + | PopupType: pending.PopupType, | |
| 746 | + | TargetURI: pending.TargetURI, | |
| 747 | + | } | |
| 748 | + | ||
| 749 | + | if err := c.onInsertPopup(ctx, input); err != nil { | |
| 750 | + | log.Error().Err(err).Msg("failed to insert popup into workspace") | |
| 751 | + | // Clean up on failure | |
| 752 | + | delete(c.webViews, paneID) | |
| 753 | + | pending.WebView.Destroy() | |
| 754 | + | return | |
| 755 | + | } | |
| 756 | + | } | |
| 757 | + | ||
| 758 | + | log.Info(). | |
| 759 | + | Str("pane_id", string(paneID)). | |
| 760 | + | Str("behavior", string(behavior)). | |
| 761 | + | Str("placement", placement). | |
| 762 | + | Msg("popup inserted into workspace") | |
| 763 | + | } | |
| 764 | + | ||
| 765 | + | // handlePopupClose handles the WebKit "close" signal for popup windows. | |
| 766 | + | func (c *ContentCoordinator) handlePopupClose(ctx context.Context, popupID port.WebViewID) { | |
| 767 | + | log := logging.FromContext(ctx) | |
| 768 | + | log.Debug().Uint64("popup_id", uint64(popupID)).Msg("popup close signal received") | |
| 769 | + | ||
| 770 | + | // Check if still pending (never shown) | |
| 771 | + | c.popupMu.Lock() | |
| 772 | + | pending, wasPending := c.pendingPopups[popupID] | |
| 773 | + | if wasPending { | |
| 774 | + | delete(c.pendingPopups, popupID) | |
| 775 | + | } | |
| 776 | + | c.popupMu.Unlock() | |
| 777 | + | ||
| 778 | + | if wasPending && pending != nil { | |
| 779 | + | // Was never shown, just destroy | |
| 780 | + | pending.WebView.Destroy() | |
| 781 | + | log.Debug().Msg("destroyed pending popup that was never shown") | |
| 782 | + | return | |
| 783 | + | } | |
| 784 | + | ||
| 785 | + | // Find pane by WebView ID | |
| 786 | + | var paneID entity.PaneID | |
| 787 | + | for pid, wv := range c.webViews { | |
| 788 | + | if wv != nil && port.WebViewID(wv.ID()) == popupID { | |
| 789 | + | paneID = pid | |
| 790 | + | break | |
| 791 | + | } | |
| 792 | + | } | |
| 793 | + | ||
| 794 | + | if paneID == "" { | |
| 795 | + | log.Warn().Msg("popup close: could not find pane for webview") | |
| 796 | + | return | |
| 797 | + | } | |
| 798 | + | ||
| 799 | + | // Close the pane in workspace (this removes the UI element) | |
| 800 | + | if c.onClosePane != nil { | |
| 801 | + | if err := c.onClosePane(ctx, paneID); err != nil { | |
| 802 | + | log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to close popup pane") | |
| 803 | + | } | |
| 804 | + | } | |
| 805 | + | ||
| 806 | + | // Release the WebView (this will clean up tracking) | |
| 807 | + | c.ReleaseWebView(ctx, paneID) | |
| 808 | + | ||
| 809 | + | log.Info().Str("pane_id", string(paneID)).Msg("popup closed") | |
| 810 | + | } | |
| 811 | + | ||
| 812 | + | // setupWebViewCallbacks configures standard callbacks for a WebView. | |
| 813 | + | func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) { | |
| 814 | + | // Title changes | |
| 815 | + | wv.OnTitleChanged = func(title string) { | |
| 816 | + | c.onTitleChanged(ctx, paneID, title) | |
| 817 | + | } | |
| 818 | + | ||
| 819 | + | // Favicon changes | |
| 820 | + | wv.OnFaviconChanged = func(favicon *gdk.Texture) { | |
| 821 | + | c.onFaviconChanged(ctx, paneID, favicon) | |
| 822 | + | } | |
| 823 | + | ||
| 824 | + | // Load events | |
| 825 | + | wv.OnLoadChanged = func(event webkit.LoadEvent) { | |
| 826 | + | switch event { | |
| 827 | + | case webkit.LoadStarted: | |
| 828 | + | c.onLoadStarted(ctx, paneID) | |
| 829 | + | case webkit.LoadCommitted: | |
| 830 | + | c.onLoadCommitted(ctx, paneID, wv) | |
| 831 | + | case webkit.LoadFinished: | |
| 832 | + | c.onLoadFinished(ctx, paneID) | |
| 833 | + | } | |
| 834 | + | } | |
| 835 | + | ||
| 836 | + | // Progress | |
| 837 | + | wv.OnProgressChanged = func(progress float64) { | |
| 838 | + | c.onProgressChanged(ctx, paneID, progress) | |
| 839 | + | } | |
| 840 | + | ||
| 841 | + | // SPA navigation | |
| 842 | + | wv.OnURIChanged = func(uri string) { | |
| 843 | + | if !wv.IsLoading() && uri != "" { | |
| 844 | + | c.onSPANavigation(ctx, paneID, uri) | |
| 845 | + | } | |
| 846 | + | } | |
| 847 | + | ||
| 848 | + | // Middle-click / Ctrl+click handler for opening links in new pane | |
| 849 | + | wv.OnLinkMiddleClick = func(uri string) bool { | |
| 850 | + | return c.handleLinkMiddleClick(ctx, paneID, uri) | |
| 851 | + | } | |
| 852 | + | } | |
| 853 | + | ||
| 854 | + | // setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes. | |
| 855 | + | func (c *ContentCoordinator) setupOAuthAutoClose(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) { | |
| 856 | + | log := logging.FromContext(ctx) | |
| 857 | + | ||
| 858 | + | // Wrap the existing OnURIChanged to also check for OAuth callbacks | |
| 859 | + | originalURIChanged := wv.OnURIChanged | |
| 860 | + | wv.OnURIChanged = func(uri string) { | |
| 861 | + | // Call original handler first | |
| 862 | + | if originalURIChanged != nil { | |
| 863 | + | originalURIChanged(uri) | |
| 864 | + | } | |
| 865 | + | ||
| 866 | + | // Check for OAuth callback | |
| 867 | + | if ShouldAutoClose(uri) { | |
| 868 | + | log.Info(). | |
| 869 | + | Str("pane_id", string(paneID)). | |
| 870 | + | Str("uri", uri). | |
| 871 | + | Msg("OAuth callback detected, auto-closing popup") | |
| 872 | + | ||
| 873 | + | // Trigger close after a short delay to let the page process | |
| 874 | + | go func() { | |
| 875 | + | time.Sleep(500 * time.Millisecond) | |
| 876 | + | if wv != nil && !wv.IsDestroyed() { | |
| 877 | + | // This will trigger handlePopupClose | |
| 878 | + | wv.Close() | |
| 879 | + | } | |
| 880 | + | }() | |
| 881 | + | } | |
| 882 | + | } | |
| 883 | + | ||
| 884 | + | // Also check on load committed for immediate redirects | |
| 885 | + | originalLoadChanged := wv.OnLoadChanged | |
| 886 | + | wv.OnLoadChanged = func(event webkit.LoadEvent) { | |
| 887 | + | if originalLoadChanged != nil { | |
| 888 | + | originalLoadChanged(event) | |
| 889 | + | } | |
| 890 | + | ||
| 891 | + | if event == webkit.LoadCommitted { | |
| 892 | + | uri := wv.URI() | |
| 893 | + | if ShouldAutoClose(uri) { | |
| 894 | + | log.Info(). | |
| 895 | + | Str("pane_id", string(paneID)). | |
| 896 | + | Str("uri", uri). | |
| 897 | + | Msg("OAuth callback detected on load, auto-closing popup") | |
| 898 | + | ||
| 899 | + | go func() { | |
| 900 | + | time.Sleep(500 * time.Millisecond) | |
| 901 | + | if wv != nil && !wv.IsDestroyed() { | |
| 902 | + | wv.Close() | |
| 903 | + | } | |
| 904 | + | }() | |
| 905 | + | } | |
| 906 | + | } | |
| 907 | + | } | |
| 908 | + | } | |
| 909 | + | ||
| 910 | + | // handleLinkMiddleClick handles middle-click / Ctrl+click on links. | |
| 911 | + | // Opens the link in a new pane using blank_target_behavior config. | |
| 912 | + | func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID entity.PaneID, uri string) bool { | |
| 913 | + | log := logging.FromContext(ctx) | |
| 914 | + | ||
| 915 | + | log.Info(). | |
| 916 | + | Str("parent_pane", string(parentPaneID)). | |
| 917 | + | Str("uri", uri). | |
| 918 | + | Msg("middle-click/ctrl+click on link") | |
| 919 | + | ||
| 920 | + | // Check if popups are enabled | |
| 921 | + | if c.popupConfig != nil && !c.popupConfig.OpenInNewPane { | |
| 922 | + | log.Debug().Msg("popups disabled by config, ignoring middle-click") | |
| 923 | + | return false | |
| 924 | + | } | |
| 925 | + | ||
| 926 | + | // Check if factory is available | |
| 927 | + | if c.factory == nil { | |
| 928 | + | log.Warn().Msg("no webview factory, cannot handle middle-click") | |
| 929 | + | return false | |
| 930 | + | } | |
| 931 | + | ||
| 932 | + | // Create a new WebView (regular, not related - just opening a link) | |
| 933 | + | newWV, err := c.factory.Create(ctx) | |
| 934 | + | if err != nil { | |
| 935 | + | log.Error().Err(err).Msg("failed to create webview for middle-click") | |
| 936 | + | return false | |
| 937 | + | } | |
| 938 | + | ||
| 939 | + | // Generate pane ID | |
| 940 | + | var paneID entity.PaneID | |
| 941 | + | if c.generateID != nil { | |
| 942 | + | paneID = entity.PaneID(c.generateID()) | |
| 943 | + | } else { | |
| 944 | + | paneID = entity.PaneID(fmt.Sprintf("link_%d", newWV.ID())) | |
| 945 | + | } | |
| 946 | + | ||
| 947 | + | // Create pane entity - uses blank_target_behavior | |
| 948 | + | newPane := entity.NewPane(paneID) | |
| 949 | + | newPane.WindowType = entity.WindowPopup | |
| 950 | + | newPane.URI = uri | |
| 951 | + | ||
| 952 | + | // Register WebView | |
| 953 | + | c.webViews[paneID] = newWV | |
| 954 | + | ||
| 955 | + | // Setup callbacks | |
| 956 | + | c.setupWebViewCallbacks(ctx, paneID, newWV) | |
| 957 | + | ||
| 958 | + | // Get behavior from config (same as _blank links) | |
| 959 | + | behavior := config.PopupBehaviorStacked // default | |
| 960 | + | if c.popupConfig != nil && c.popupConfig.BlankTargetBehavior != "" { | |
| 961 | + | behavior = config.PopupBehavior(c.popupConfig.BlankTargetBehavior) | |
| 962 | + | } | |
| 963 | + | placement := "right" | |
| 964 | + | if c.popupConfig != nil { | |
| 965 | + | placement = c.popupConfig.Placement | |
| 966 | + | } | |
| 967 | + | ||
| 968 | + | // Request insertion into workspace | |
| 969 | + | if c.onInsertPopup != nil { | |
| 970 | + | input := InsertPopupInput{ | |
| 971 | + | ParentPaneID: parentPaneID, | |
| 972 | + | PopupPane: newPane, | |
| 973 | + | WebView: newWV, | |
| 974 | + | Behavior: behavior, | |
| 975 | + | Placement: placement, | |
| 976 | + | PopupType: PopupTypeTab, // Treat like _blank | |
| 977 | + | TargetURI: uri, | |
| 978 | + | } | |
| 979 | + | ||
| 980 | + | if err := c.onInsertPopup(ctx, input); err != nil { | |
| 981 | + | log.Error().Err(err).Msg("failed to insert middle-click pane into workspace") | |
| 982 | + | // Clean up on failure | |
| 983 | + | delete(c.webViews, paneID) | |
| 984 | + | newWV.Destroy() | |
| 985 | + | return false | |
| 986 | + | } | |
| 987 | + | } | |
| 988 | + | ||
| 989 | + | // Load the URI after insertion | |
| 990 | + | if err := newWV.LoadURI(ctx, uri); err != nil { | |
| 991 | + | log.Error().Err(err).Str("uri", uri).Msg("failed to load URI in new pane") | |
| 992 | + | } | |
| 993 | + | ||
| 994 | + | log.Info(). | |
| 995 | + | Str("pane_id", string(paneID)). | |
| 996 | + | Str("behavior", string(behavior)). | |
| 997 | + | Str("uri", uri). | |
| 998 | + | Msg("middle-click link opened in new pane") | |
| 999 | + | ||
| 1000 | + | return true | |
| 1001 | + | } | |
| @@ -4,9 +4,12 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "fmt" | 5 | "fmt" | ||
| 6 | "sync" | 6 | "sync" | ||
| 7 | + | "time" | |||
| 7 | 8 | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 8 | "github.com/bnema/dumber/internal/application/usecase" | 10 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 9 | "github.com/bnema/dumber/internal/domain/entity" | 11 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 12 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 10 | "github.com/bnema/dumber/internal/infrastructure/webkit" | 13 | "github.com/bnema/dumber/internal/infrastructure/webkit" | ||
| 11 | "github.com/bnema/dumber/internal/logging" | 14 | "github.com/bnema/dumber/internal/logging" | ||
| 12 | "github.com/bnema/dumber/internal/ui/cache" | 15 | "github.com/bnema/dumber/internal/ui/cache" | ||
| @@ -43,6 +46,21 @@ type ContentCoordinator struct { | |||||
| 43 | 46 | ||||
| 44 | // Gesture action handler for mouse button navigation | 47 | // Gesture action handler for mouse button navigation | ||
| 45 | gestureActionHandler input.ActionHandler | 48 | gestureActionHandler input.ActionHandler | ||
| 49 | + | ||||
| 50 | + | // Popup handling | |||
| 51 | + | factory *webkit.WebViewFactory | |||
| 52 | + | popupConfig *config.PopupBehaviorConfig | |||
| 53 | + | pendingPopups map[port.WebViewID]*PendingPopup | |||
| 54 | + | popupMu sync.RWMutex | |||
| 55 | + | ||||
| 56 | + | // Callback to insert popup into workspace (avoids circular dependency) | |||
| 57 | + | onInsertPopup func(ctx context.Context, input InsertPopupInput) error | |||
| 58 | + | ||||
| 59 | + | // Callback to close a pane when popup closes | |||
| 60 | + | onClosePane func(ctx context.Context, paneID entity.PaneID) error | |||
| 61 | + | ||||
| 62 | + | // ID generator for popup panes | |||
| 63 | + | generateID func() string | |||
| 46 | } | 64 | } | ||
| 47 | 65 | ||||
| 48 | // NewContentCoordinator creates a new ContentCoordinator. | 66 | // NewContentCoordinator creates a new ContentCoordinator. | ||
| @@ -66,6 +84,7 @@ func NewContentCoordinator( | |||||
| 66 | paneTitles: make(map[entity.PaneID]string), | 84 | paneTitles: make(map[entity.PaneID]string), | ||
| 67 | navOrigins: make(map[entity.PaneID]string), | 85 | navOrigins: make(map[entity.PaneID]string), | ||
| 68 | getActiveWS: getActiveWS, | 86 | getActiveWS: getActiveWS, | ||
| 87 | + | pendingPopups: make(map[port.WebViewID]*PendingPopup), | |||
| 69 | } | 88 | } | ||
| 70 | } | 89 | } | ||
| 71 | 90 | ||||
| @@ -140,6 +159,14 @@ func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.Pa | |||||
| 140 | } | 159 | } | ||
| 141 | } | 160 | } | ||
| 142 | 161 | ||||
| 162 | + | // Set up middle-click / Ctrl+click handler for opening links in new pane | |||
| 163 | + | wv.OnLinkMiddleClick = func(uri string) bool { | |||
| 164 | + | return c.handleLinkMiddleClick(ctx, paneID, uri) | |||
| 165 | + | } | |||
| 166 | + | ||||
| 167 | + | // Set up popup handling for this WebView | |||
| 168 | + | c.SetupPopupHandling(ctx, paneID, wv) | |||
| 169 | + | ||||
| 143 | log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane") | 170 | log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane") | ||
| 144 | return wv, nil | 171 | return wv, nil | ||
| 145 | } | 172 | } | ||
| @@ -262,6 +289,14 @@ func (c *ContentCoordinator) GetWebView(paneID entity.PaneID) *webkit.WebView { | |||||
| 262 | return c.webViews[paneID] | 289 | return c.webViews[paneID] | ||
| 263 | } | 290 | } | ||
| 264 | 291 | ||||
| 292 | + | // RegisterPopupWebView registers a popup WebView that was created externally. | |||
| 293 | + | // This is used when popup tabs are created and the WebView needs to be tracked. | |||
| 294 | + | func (c *ContentCoordinator) RegisterPopupWebView(paneID entity.PaneID, wv *webkit.WebView) { | |||
| 295 | + | if wv != nil && paneID != "" { | |||
| 296 | + | c.webViews[paneID] = wv | |||
| 297 | + | } | |||
| 298 | + | } | |||
| 299 | + | ||||
| 265 | // GetTitle returns the current title for a pane. | 300 | // GetTitle returns the current title for a pane. | ||
| 266 | func (c *ContentCoordinator) GetTitle(paneID entity.PaneID) string { | 301 | func (c *ContentCoordinator) GetTitle(paneID entity.PaneID) string { | ||
| 267 | c.titleMu.RLock() | 302 | c.titleMu.RLock() | ||
| @@ -528,3 +563,439 @@ func (c *ContentCoordinator) onProgressChanged(ctx context.Context, paneID entit | |||||
| 528 | paneView.SetLoadProgress(progress) | 563 | paneView.SetLoadProgress(progress) | ||
| 529 | } | 564 | } | ||
| 530 | } | 565 | } | ||
| 566 | + | ||||
| 567 | + | // --- Popup Handling --- | |||
| 568 | + | ||||
| 569 | + | // SetPopupConfig configures popup handling. | |||
| 570 | + | func (c *ContentCoordinator) SetPopupConfig( | |||
| 571 | + | factory *webkit.WebViewFactory, | |||
| 572 | + | popupConfig *config.PopupBehaviorConfig, | |||
| 573 | + | generateID func() string, | |||
| 574 | + | ) { | |||
| 575 | + | c.factory = factory | |||
| 576 | + | c.popupConfig = popupConfig | |||
| 577 | + | c.generateID = generateID | |||
| 578 | + | } | |||
| 579 | + | ||||
| 580 | + | // SetOnInsertPopup sets the callback to insert popups into the workspace. | |||
| 581 | + | func (c *ContentCoordinator) SetOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) { | |||
| 582 | + | c.onInsertPopup = fn | |||
| 583 | + | } | |||
| 584 | + | ||||
| 585 | + | // SetOnClosePane sets the callback to close a pane when its popup closes. | |||
| 586 | + | func (c *ContentCoordinator) SetOnClosePane(fn func(ctx context.Context, paneID entity.PaneID) error) { | |||
| 587 | + | c.onClosePane = fn | |||
| 588 | + | } | |||
| 589 | + | ||||
| 590 | + | // SetupPopupHandling wires the popup create signal for a WebView. | |||
| 591 | + | // This should be called after acquiring a WebView for a pane. | |||
| 592 | + | func (c *ContentCoordinator) SetupPopupHandling(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) { | |||
| 593 | + | log := logging.FromContext(ctx) | |||
| 594 | + | ||||
| 595 | + | if wv == nil { | |||
| 596 | + | return | |||
| 597 | + | } | |||
| 598 | + | ||||
| 599 | + | // Wire the OnCreate callback for popup requests | |||
| 600 | + | wv.OnCreate = func(req webkit.PopupRequest) *webkit.WebView { | |||
| 601 | + | return c.handlePopupCreate(ctx, paneID, wv, req) | |||
| 602 | + | } | |||
| 603 | + | ||||
| 604 | + | log.Debug().Str("pane_id", string(paneID)).Msg("popup handling configured for webview") | |||
| 605 | + | } | |||
| 606 | + | ||||
| 607 | + | // handlePopupCreate handles the WebKit "create" signal for popup windows. | |||
| 608 | + | // Returns a new related WebView if popup is allowed, nil to block. | |||
| 609 | + | func (c *ContentCoordinator) handlePopupCreate( | |||
| 610 | + | ctx context.Context, | |||
| 611 | + | parentPaneID entity.PaneID, | |||
| 612 | + | parentWV *webkit.WebView, | |||
| 613 | + | req webkit.PopupRequest, | |||
| 614 | + | ) *webkit.WebView { | |||
| 615 | + | log := logging.FromContext(ctx) | |||
| 616 | + | ||||
| 617 | + | log.Debug(). | |||
| 618 | + | Str("parent_pane", string(parentPaneID)). | |||
| 619 | + | Str("target_uri", req.TargetURI). | |||
| 620 | + | Str("frame_name", req.FrameName). | |||
| 621 | + | Bool("user_gesture", req.IsUserGesture). | |||
| 622 | + | Msg("popup create request") | |||
| 623 | + | ||||
| 624 | + | // Check if popups are enabled | |||
| 625 | + | if c.popupConfig != nil && !c.popupConfig.OpenInNewPane { | |||
| 626 | + | log.Debug().Msg("popups disabled by config, blocking") | |||
| 627 | + | return nil | |||
| 628 | + | } | |||
| 629 | + | ||||
| 630 | + | // Check if factory is available | |||
| 631 | + | if c.factory == nil { | |||
| 632 | + | log.Warn().Msg("no webview factory, cannot create popup") | |||
| 633 | + | return nil | |||
| 634 | + | } | |||
| 635 | + | ||||
| 636 | + | // Create related WebView for session sharing | |||
| 637 | + | parentID := port.WebViewID(parentWV.ID()) | |||
| 638 | + | popupWV, err := c.factory.CreateRelated(ctx, parentID) | |||
| 639 | + | if err != nil { | |||
| 640 | + | log.Error().Err(err).Msg("failed to create related webview for popup") | |||
| 641 | + | return nil | |||
| 642 | + | } | |||
| 643 | + | ||||
| 644 | + | // Detect popup type | |||
| 645 | + | popupType := DetectPopupType(req.FrameName) | |||
| 646 | + | ||||
| 647 | + | // Store pending popup for ready-to-show handling | |||
| 648 | + | popupID := port.WebViewID(popupWV.ID()) | |||
| 649 | + | pending := &PendingPopup{ | |||
| 650 | + | WebView: popupWV, | |||
| 651 | + | ParentPaneID: parentPaneID, | |||
| 652 | + | ParentWebViewID: parentID, | |||
| 653 | + | TargetURI: req.TargetURI, | |||
| 654 | + | FrameName: req.FrameName, | |||
| 655 | + | IsUserGesture: req.IsUserGesture, | |||
| 656 | + | PopupType: popupType, | |||
| 657 | + | CreatedAt: time.Now(), | |||
| 658 | + | } | |||
| 659 | + | ||||
| 660 | + | c.popupMu.Lock() | |||
| 661 | + | c.pendingPopups[popupID] = pending | |||
| 662 | + | c.popupMu.Unlock() | |||
| 663 | + | ||||
| 664 | + | // Wire ready-to-show and close signals | |||
| 665 | + | popupWV.OnReadyToShow = func() { | |||
| 666 | + | c.handlePopupReadyToShow(ctx, popupID) | |||
| 667 | + | } | |||
| 668 | + | popupWV.OnClose = func() { | |||
| 669 | + | c.handlePopupClose(ctx, popupID) | |||
| 670 | + | } | |||
| 671 | + | ||||
| 672 | + | log.Info(). | |||
| 673 | + | Uint64("popup_id", uint64(popupID)). | |||
| 674 | + | Str("popup_type", popupType.String()). | |||
| 675 | + | Str("target_uri", req.TargetURI). | |||
| 676 | + | Msg("popup created, awaiting ready-to-show") | |||
| 677 | + | ||||
| 678 | + | return popupWV | |||
| 679 | + | } | |||
| 680 | + | ||||
| 681 | + | // handlePopupReadyToShow handles the WebKit "ready-to-show" signal. | |||
| 682 | + | // This is called when the popup is configured and ready to be displayed. | |||
| 683 | + | func (c *ContentCoordinator) handlePopupReadyToShow(ctx context.Context, popupID port.WebViewID) { | |||
| 684 | + | log := logging.FromContext(ctx) | |||
| 685 | + | ||||
| 686 | + | // Get pending popup | |||
| 687 | + | c.popupMu.Lock() | |||
| 688 | + | pending, ok := c.pendingPopups[popupID] | |||
| 689 | + | if ok { | |||
| 690 | + | delete(c.pendingPopups, popupID) | |||
| 691 | + | } | |||
| 692 | + | c.popupMu.Unlock() | |||
| 693 | + | ||||
| 694 | + | if !ok || pending == nil { | |||
| 695 | + | log.Warn().Uint64("popup_id", uint64(popupID)).Msg("ready-to-show for unknown popup") | |||
| 696 | + | return | |||
| 697 | + | } | |||
| 698 | + | ||||
| 699 | + | log.Debug(). | |||
| 700 | + | Uint64("popup_id", uint64(popupID)). | |||
| 701 | + | Str("popup_type", pending.PopupType.String()). | |||
| 702 | + | Msg("popup ready to show") | |||
| 703 | + | ||||
| 704 | + | // Determine behavior from config | |||
| 705 | + | behavior := GetBehavior(pending.PopupType, c.popupConfig) | |||
| 706 | + | placement := "right" | |||
| 707 | + | if c.popupConfig != nil { | |||
| 708 | + | placement = c.popupConfig.Placement | |||
| 709 | + | } | |||
| 710 | + | ||||
| 711 | + | // Create popup pane entity | |||
| 712 | + | var paneID entity.PaneID | |||
| 713 | + | if c.generateID != nil { | |||
| 714 | + | paneID = entity.PaneID(c.generateID()) | |||
| 715 | + | } else { | |||
| 716 | + | paneID = entity.PaneID(fmt.Sprintf("popup_%d", popupID)) | |||
| 717 | + | } | |||
| 718 | + | ||||
| 719 | + | popupPane := entity.NewPane(paneID) | |||
| 720 | + | popupPane.WindowType = entity.WindowPopup | |||
| 721 | + | popupPane.IsRelated = true | |||
| 722 | + | popupPane.ParentPaneID = &pending.ParentPaneID | |||
| 723 | + | popupPane.URI = pending.TargetURI | |||
| 724 | + | ||||
| 725 | + | // Enable OAuth auto-close if configured | |||
| 726 | + | if c.popupConfig != nil && c.popupConfig.OAuthAutoClose && IsOAuthURL(pending.TargetURI) { | |||
| 727 | + | popupPane.AutoClose = true | |||
| 728 | + | c.setupOAuthAutoClose(ctx, paneID, pending.WebView) | |||
| 729 | + | } | |||
| 730 | + | ||||
| 731 | + | // Register WebView in our map | |||
| 732 | + | c.webViews[paneID] = pending.WebView | |||
| 733 | + | ||||
| 734 | + | // Setup standard callbacks | |||
| 735 | + | c.setupWebViewCallbacks(ctx, paneID, pending.WebView) | |||
| 736 | + | ||||
| 737 | + | // Request insertion into workspace | |||
| 738 | + | if c.onInsertPopup != nil { | |||
| 739 | + | input := InsertPopupInput{ | |||
| 740 | + | ParentPaneID: pending.ParentPaneID, | |||
| 741 | + | PopupPane: popupPane, | |||
| 742 | + | WebView: pending.WebView, | |||
| 743 | + | Behavior: behavior, | |||
| 744 | + | Placement: placement, | |||
| 745 | + | PopupType: pending.PopupType, | |||
| 746 | + | TargetURI: pending.TargetURI, | |||
| 747 | + | } | |||
| 748 | + | ||||
| 749 | + | if err := c.onInsertPopup(ctx, input); err != nil { | |||
| 750 | + | log.Error().Err(err).Msg("failed to insert popup into workspace") | |||
| 751 | + | // Clean up on failure | |||
| 752 | + | delete(c.webViews, paneID) | |||
| 753 | + | pending.WebView.Destroy() | |||
| 754 | + | return | |||
| 755 | + | } | |||
| 756 | + | } | |||
| 757 | + | ||||
| 758 | + | log.Info(). | |||
| 759 | + | Str("pane_id", string(paneID)). | |||
| 760 | + | Str("behavior", string(behavior)). | |||
| 761 | + | Str("placement", placement). | |||
| 762 | + | Msg("popup inserted into workspace") | |||
| 763 | + | } | |||
| 764 | + | ||||
| 765 | + | // handlePopupClose handles the WebKit "close" signal for popup windows. | |||
| 766 | + | func (c *ContentCoordinator) handlePopupClose(ctx context.Context, popupID port.WebViewID) { | |||
| 767 | + | log := logging.FromContext(ctx) | |||
| 768 | + | log.Debug().Uint64("popup_id", uint64(popupID)).Msg("popup close signal received") | |||
| 769 | + | ||||
| 770 | + | // Check if still pending (never shown) | |||
| 771 | + | c.popupMu.Lock() | |||
| 772 | + | pending, wasPending := c.pendingPopups[popupID] | |||
| 773 | + | if wasPending { | |||
| 774 | + | delete(c.pendingPopups, popupID) | |||
| 775 | + | } | |||
| 776 | + | c.popupMu.Unlock() | |||
| 777 | + | ||||
| 778 | + | if wasPending && pending != nil { | |||
| 779 | + | // Was never shown, just destroy | |||
| 780 | + | pending.WebView.Destroy() | |||
| 781 | + | log.Debug().Msg("destroyed pending popup that was never shown") | |||
| 782 | + | return | |||
| 783 | + | } | |||
| 784 | + | ||||
| 785 | + | // Find pane by WebView ID | |||
| 786 | + | var paneID entity.PaneID | |||
| 787 | + | for pid, wv := range c.webViews { | |||
| 788 | + | if wv != nil && port.WebViewID(wv.ID()) == popupID { | |||
| 789 | + | paneID = pid | |||
| 790 | + | break | |||
| 791 | + | } | |||
| 792 | + | } | |||
| 793 | + | ||||
| 794 | + | if paneID == "" { | |||
| 795 | + | log.Warn().Msg("popup close: could not find pane for webview") | |||
| 796 | + | return | |||
| 797 | + | } | |||
| 798 | + | ||||
| 799 | + | // Close the pane in workspace (this removes the UI element) | |||
| 800 | + | if c.onClosePane != nil { | |||
| 801 | + | if err := c.onClosePane(ctx, paneID); err != nil { | |||
| 802 | + | log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to close popup pane") | |||
| 803 | + | } | |||
| 804 | + | } | |||
| 805 | + | ||||
| 806 | + | // Release the WebView (this will clean up tracking) | |||
| 807 | + | c.ReleaseWebView(ctx, paneID) | |||
| 808 | + | ||||
| 809 | + | log.Info().Str("pane_id", string(paneID)).Msg("popup closed") | |||
| 810 | + | } | |||
| 811 | + | ||||
| 812 | + | // setupWebViewCallbacks configures standard callbacks for a WebView. | |||
| 813 | + | func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) { | |||
| 814 | + | // Title changes | |||
| 815 | + | wv.OnTitleChanged = func(title string) { | |||
| 816 | + | c.onTitleChanged(ctx, paneID, title) | |||
| 817 | + | } | |||
| 818 | + | ||||
| 819 | + | // Favicon changes | |||
| 820 | + | wv.OnFaviconChanged = func(favicon *gdk.Texture) { | |||
| 821 | + | c.onFaviconChanged(ctx, paneID, favicon) | |||
| 822 | + | } | |||
| 823 | + | ||||
| 824 | + | // Load events | |||
| 825 | + | wv.OnLoadChanged = func(event webkit.LoadEvent) { | |||
| 826 | + | switch event { | |||
| 827 | + | case webkit.LoadStarted: | |||
| 828 | + | c.onLoadStarted(ctx, paneID) | |||
| 829 | + | case webkit.LoadCommitted: | |||
| 830 | + | c.onLoadCommitted(ctx, paneID, wv) | |||
| 831 | + | case webkit.LoadFinished: | |||
| 832 | + | c.onLoadFinished(ctx, paneID) | |||
| 833 | + | } | |||
| 834 | + | } | |||
| 835 | + | ||||
| 836 | + | // Progress | |||
| 837 | + | wv.OnProgressChanged = func(progress float64) { | |||
| 838 | + | c.onProgressChanged(ctx, paneID, progress) | |||
| 839 | + | } | |||
| 840 | + | ||||
| 841 | + | // SPA navigation | |||
| 842 | + | wv.OnURIChanged = func(uri string) { | |||
| 843 | + | if !wv.IsLoading() && uri != "" { | |||
| 844 | + | c.onSPANavigation(ctx, paneID, uri) | |||
| 845 | + | } | |||
| 846 | + | } | |||
| 847 | + | ||||
| 848 | + | // Middle-click / Ctrl+click handler for opening links in new pane | |||
| 849 | + | wv.OnLinkMiddleClick = func(uri string) bool { | |||
| 850 | + | return c.handleLinkMiddleClick(ctx, paneID, uri) | |||
| 851 | + | } | |||
| 852 | + | } | |||
| 853 | + | ||||
| 854 | + | // setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes. | |||
| 855 | + | func (c *ContentCoordinator) setupOAuthAutoClose(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) { | |||
| 856 | + | log := logging.FromContext(ctx) | |||
| 857 | + | ||||
| 858 | + | // Wrap the existing OnURIChanged to also check for OAuth callbacks | |||
| 859 | + | originalURIChanged := wv.OnURIChanged | |||
| 860 | + | wv.OnURIChanged = func(uri string) { | |||
| 861 | + | // Call original handler first | |||
| 862 | + | if originalURIChanged != nil { | |||
| 863 | + | originalURIChanged(uri) | |||
| 864 | + | } | |||
| 865 | + | ||||
| 866 | + | // Check for OAuth callback | |||
| 867 | + | if ShouldAutoClose(uri) { | |||
| 868 | + | log.Info(). | |||
| 869 | + | Str("pane_id", string(paneID)). | |||
| 870 | + | Str("uri", uri). | |||
| 871 | + | Msg("OAuth callback detected, auto-closing popup") | |||
| 872 | + | ||||
| 873 | + | // Trigger close after a short delay to let the page process | |||
| 874 | + | go func() { | |||
| 875 | + | time.Sleep(500 * time.Millisecond) | |||
| 876 | + | if wv != nil && !wv.IsDestroyed() { | |||
| 877 | + | // This will trigger handlePopupClose | |||
| 878 | + | wv.Close() | |||
| 879 | + | } | |||
| 880 | + | }() | |||
| 881 | + | } | |||
| 882 | + | } | |||
| 883 | + | ||||
| 884 | + | // Also check on load committed for immediate redirects | |||
| 885 | + | originalLoadChanged := wv.OnLoadChanged | |||
| 886 | + | wv.OnLoadChanged = func(event webkit.LoadEvent) { | |||
| 887 | + | if originalLoadChanged != nil { | |||
| 888 | + | originalLoadChanged(event) | |||
| 889 | + | } | |||
| 890 | + | ||||
| 891 | + | if event == webkit.LoadCommitted { | |||
| 892 | + | uri := wv.URI() | |||
| 893 | + | if ShouldAutoClose(uri) { | |||
| 894 | + | log.Info(). | |||
| 895 | + | Str("pane_id", string(paneID)). | |||
| 896 | + | Str("uri", uri). | |||
| 897 | + | Msg("OAuth callback detected on load, auto-closing popup") | |||
| 898 | + | ||||
| 899 | + | go func() { | |||
| 900 | + | time.Sleep(500 * time.Millisecond) | |||
| 901 | + | if wv != nil && !wv.IsDestroyed() { | |||
| 902 | + | wv.Close() | |||
| 903 | + | } | |||
| 904 | + | }() | |||
| 905 | + | } | |||
| 906 | + | } | |||
| 907 | + | } | |||
| 908 | + | } | |||
| 909 | + | ||||
| 910 | + | // handleLinkMiddleClick handles middle-click / Ctrl+click on links. | |||
| 911 | + | // Opens the link in a new pane using blank_target_behavior config. | |||
| 912 | + | func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID entity.PaneID, uri string) bool { | |||
| 913 | + | log := logging.FromContext(ctx) | |||
| 914 | + | ||||
| 915 | + | log.Info(). | |||
| 916 | + | Str("parent_pane", string(parentPaneID)). | |||
| 917 | + | Str("uri", uri). | |||
| 918 | + | Msg("middle-click/ctrl+click on link") | |||
| 919 | + | ||||
| 920 | + | // Check if popups are enabled | |||
| 921 | + | if c.popupConfig != nil && !c.popupConfig.OpenInNewPane { | |||
| 922 | + | log.Debug().Msg("popups disabled by config, ignoring middle-click") | |||
| 923 | + | return false | |||
| 924 | + | } | |||
| 925 | + | ||||
| 926 | + | // Check if factory is available | |||
| 927 | + | if c.factory == nil { | |||
| 928 | + | log.Warn().Msg("no webview factory, cannot handle middle-click") | |||
| 929 | + | return false | |||
| 930 | + | } | |||
| 931 | + | ||||
| 932 | + | // Create a new WebView (regular, not related - just opening a link) | |||
| 933 | + | newWV, err := c.factory.Create(ctx) | |||
| 934 | + | if err != nil { | |||
| 935 | + | log.Error().Err(err).Msg("failed to create webview for middle-click") | |||
| 936 | + | return false | |||
| 937 | + | } | |||
| 938 | + | ||||
| 939 | + | // Generate pane ID | |||
| 940 | + | var paneID entity.PaneID | |||
| 941 | + | if c.generateID != nil { | |||
| 942 | + | paneID = entity.PaneID(c.generateID()) | |||
| 943 | + | } else { | |||
| 944 | + | paneID = entity.PaneID(fmt.Sprintf("link_%d", newWV.ID())) | |||
| 945 | + | } | |||
| 946 | + | ||||
| 947 | + | // Create pane entity - uses blank_target_behavior | |||
| 948 | + | newPane := entity.NewPane(paneID) | |||
| 949 | + | newPane.WindowType = entity.WindowPopup | |||
| 950 | + | newPane.URI = uri | |||
| 951 | + | ||||
| 952 | + | // Register WebView | |||
| 953 | + | c.webViews[paneID] = newWV | |||
| 954 | + | ||||
| 955 | + | // Setup callbacks | |||
| 956 | + | c.setupWebViewCallbacks(ctx, paneID, newWV) | |||
| 957 | + | ||||
| 958 | + | // Get behavior from config (same as _blank links) | |||
| 959 | + | behavior := config.PopupBehaviorStacked // default | |||
| 960 | + | if c.popupConfig != nil && c.popupConfig.BlankTargetBehavior != "" { | |||
| 961 | + | behavior = config.PopupBehavior(c.popupConfig.BlankTargetBehavior) | |||
| 962 | + | } | |||
| 963 | + | placement := "right" | |||
| 964 | + | if c.popupConfig != nil { | |||
| 965 | + | placement = c.popupConfig.Placement | |||
| 966 | + | } | |||
| 967 | + | ||||
| 968 | + | // Request insertion into workspace | |||
| 969 | + | if c.onInsertPopup != nil { | |||
| 970 | + | input := InsertPopupInput{ | |||
| 971 | + | ParentPaneID: parentPaneID, | |||
| 972 | + | PopupPane: newPane, | |||
| 973 | + | WebView: newWV, | |||
| 974 | + | Behavior: behavior, | |||
| 975 | + | Placement: placement, | |||
| 976 | + | PopupType: PopupTypeTab, // Treat like _blank | |||
| 977 | + | TargetURI: uri, | |||
| 978 | + | } | |||
| 979 | + | ||||
| 980 | + | if err := c.onInsertPopup(ctx, input); err != nil { | |||
| 981 | + | log.Error().Err(err).Msg("failed to insert middle-click pane into workspace") | |||
| 982 | + | // Clean up on failure | |||
| 983 | + | delete(c.webViews, paneID) | |||
| 984 | + | newWV.Destroy() | |||
| 985 | + | return false | |||
| 986 | + | } | |||
| 987 | + | } | |||
| 988 | + | ||||
| 989 | + | // Load the URI after insertion | |||
| 990 | + | if err := newWV.LoadURI(ctx, uri); err != nil { | |||
| 991 | + | log.Error().Err(err).Str("uri", uri).Msg("failed to load URI in new pane") | |||
| 992 | + | } | |||
| 993 | + | ||||
| 994 | + | log.Info(). | |||
| 995 | + | Str("pane_id", string(paneID)). | |||
| 996 | + | Str("behavior", string(behavior)). | |||
| 997 | + | Str("uri", uri). | |||
| 998 | + | Msg("middle-click link opened in new pane") | |||
| 999 | + | ||||
| 1000 | + | return true | |||
| 1001 | + | } | |||
A internal/ui/coordinator/oauth.go +141 -0
| @@ -0,0 +1,141 @@ | |||
| 1 | + | package coordinator | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "strings" | |
| 5 | + | ) | |
| 6 | + | ||
| 7 | + | // oauthFlowPatterns are URL patterns that indicate an OAuth flow is in progress. | |
| 8 | + | // These appear in authorization URLs and callback redirects. | |
| 9 | + | var oauthFlowPatterns = []string{ | |
| 10 | + | // OAuth flow indicators | |
| 11 | + | "oauth", | |
| 12 | + | "authorize", | |
| 13 | + | "authorization", | |
| 14 | + | "auth/", | |
| 15 | + | "/auth", | |
| 16 | + | "login", | |
| 17 | + | "signin", | |
| 18 | + | "sign-in", | |
| 19 | + | // OpenID Connect | |
| 20 | + | "oidc", | |
| 21 | + | "openid", | |
| 22 | + | // Callback/redirect endpoints | |
| 23 | + | "callback", | |
| 24 | + | "redirect", | |
| 25 | + | "/cb", | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // oauthCallbackPatterns are URL query parameters that indicate an OAuth callback. | |
| 29 | + | // These appear when the OAuth provider redirects back to the application. | |
| 30 | + | var oauthCallbackPatterns = []string{ | |
| 31 | + | // Success parameters | |
| 32 | + | "code=", | |
| 33 | + | "access_token=", | |
| 34 | + | "id_token=", | |
| 35 | + | "token_type=", | |
| 36 | + | "refresh_token=", | |
| 37 | + | // Error parameters | |
| 38 | + | "error=", | |
| 39 | + | "error_description=", | |
| 40 | + | "error_uri=", | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // oauthRequestPatterns are query parameters found in OAuth authorization requests. | |
| 44 | + | var oauthRequestPatterns = []string{ | |
| 45 | + | "response_type=", | |
| 46 | + | "client_id=", | |
| 47 | + | "redirect_uri=", | |
| 48 | + | "scope=", | |
| 49 | + | "state=", | |
| 50 | + | "nonce=", | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | // IsOAuthURL checks if the URL is related to an OAuth flow. | |
| 54 | + | // This includes authorization endpoints, login pages, and callback URLs. | |
| 55 | + | func IsOAuthURL(url string) bool { | |
| 56 | + | if url == "" { | |
| 57 | + | return false | |
| 58 | + | } | |
| 59 | + | lower := strings.ToLower(url) | |
| 60 | + | ||
| 61 | + | // Check for OAuth flow patterns in URL path | |
| 62 | + | for _, pattern := range oauthFlowPatterns { | |
| 63 | + | if strings.Contains(lower, pattern) { | |
| 64 | + | return true | |
| 65 | + | } | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // Check for OAuth request parameters | |
| 69 | + | for _, pattern := range oauthRequestPatterns { | |
| 70 | + | if strings.Contains(lower, pattern) { | |
| 71 | + | return true | |
| 72 | + | } | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | return false | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // IsOAuthCallback checks if the URL is an OAuth callback with response parameters. | |
| 79 | + | // This indicates the OAuth flow has completed (successfully or with error). | |
| 80 | + | func IsOAuthCallback(url string) bool { | |
| 81 | + | if url == "" { | |
| 82 | + | return false | |
| 83 | + | } | |
| 84 | + | lower := strings.ToLower(url) | |
| 85 | + | ||
| 86 | + | // Must contain callback patterns (code=, access_token=, error=, etc.) | |
| 87 | + | for _, pattern := range oauthCallbackPatterns { | |
| 88 | + | if strings.Contains(lower, pattern) { | |
| 89 | + | return true | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | return false | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // ShouldAutoClose determines if a popup at this URL should auto-close. | |
| 97 | + | // Returns true for OAuth callbacks that indicate flow completion. | |
| 98 | + | // | |
| 99 | + | // This is comprehensive detection that handles: | |
| 100 | + | // - Success: code=, access_token=, id_token= | |
| 101 | + | // - Errors: error=, error_description= | |
| 102 | + | // - Various OAuth providers (Google, GitHub, Auth0, etc.) | |
| 103 | + | func ShouldAutoClose(url string) bool { | |
| 104 | + | // Only auto-close on callback URLs that have OAuth response params | |
| 105 | + | return IsOAuthCallback(url) | |
| 106 | + | } | |
| 107 | + | ||
| 108 | + | // IsOAuthSuccess checks if the callback indicates successful authentication. | |
| 109 | + | func IsOAuthSuccess(url string) bool { | |
| 110 | + | if url == "" { | |
| 111 | + | return false | |
| 112 | + | } | |
| 113 | + | lower := strings.ToLower(url) | |
| 114 | + | ||
| 115 | + | // Check for success indicators | |
| 116 | + | successPatterns := []string{ | |
| 117 | + | "code=", | |
| 118 | + | "access_token=", | |
| 119 | + | "id_token=", | |
| 120 | + | } | |
| 121 | + | ||
| 122 | + | for _, pattern := range successPatterns { | |
| 123 | + | if strings.Contains(lower, pattern) { | |
| 124 | + | // Make sure it's not an error response | |
| 125 | + | if !strings.Contains(lower, "error=") { | |
| 126 | + | return true | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | return false | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // IsOAuthError checks if the callback indicates an authentication error. | |
| 135 | + | func IsOAuthError(url string) bool { | |
| 136 | + | if url == "" { | |
| 137 | + | return false | |
| 138 | + | } | |
| 139 | + | lower := strings.ToLower(url) | |
| 140 | + | return strings.Contains(lower, "error=") | |
| 141 | + | } | |
| @@ -0,0 +1,141 @@ | |||||
| 1 | + | package coordinator | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "strings" | |||
| 5 | + | ) | |||
| 6 | + | ||||
| 7 | + | // oauthFlowPatterns are URL patterns that indicate an OAuth flow is in progress. | |||
| 8 | + | // These appear in authorization URLs and callback redirects. | |||
| 9 | + | var oauthFlowPatterns = []string{ | |||
| 10 | + | // OAuth flow indicators | |||
| 11 | + | "oauth", | |||
| 12 | + | "authorize", | |||
| 13 | + | "authorization", | |||
| 14 | + | "auth/", | |||
| 15 | + | "/auth", | |||
| 16 | + | "login", | |||
| 17 | + | "signin", | |||
| 18 | + | "sign-in", | |||
| 19 | + | // OpenID Connect | |||
| 20 | + | "oidc", | |||
| 21 | + | "openid", | |||
| 22 | + | // Callback/redirect endpoints | |||
| 23 | + | "callback", | |||
| 24 | + | "redirect", | |||
| 25 | + | "/cb", | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | // oauthCallbackPatterns are URL query parameters that indicate an OAuth callback. | |||
| 29 | + | // These appear when the OAuth provider redirects back to the application. | |||
| 30 | + | var oauthCallbackPatterns = []string{ | |||
| 31 | + | // Success parameters | |||
| 32 | + | "code=", | |||
| 33 | + | "access_token=", | |||
| 34 | + | "id_token=", | |||
| 35 | + | "token_type=", | |||
| 36 | + | "refresh_token=", | |||
| 37 | + | // Error parameters | |||
| 38 | + | "error=", | |||
| 39 | + | "error_description=", | |||
| 40 | + | "error_uri=", | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | // oauthRequestPatterns are query parameters found in OAuth authorization requests. | |||
| 44 | + | var oauthRequestPatterns = []string{ | |||
| 45 | + | "response_type=", | |||
| 46 | + | "client_id=", | |||
| 47 | + | "redirect_uri=", | |||
| 48 | + | "scope=", | |||
| 49 | + | "state=", | |||
| 50 | + | "nonce=", | |||
| 51 | + | } | |||
| 52 | + | ||||
| 53 | + | // IsOAuthURL checks if the URL is related to an OAuth flow. | |||
| 54 | + | // This includes authorization endpoints, login pages, and callback URLs. | |||
| 55 | + | func IsOAuthURL(url string) bool { | |||
| 56 | + | if url == "" { | |||
| 57 | + | return false | |||
| 58 | + | } | |||
| 59 | + | lower := strings.ToLower(url) | |||
| 60 | + | ||||
| 61 | + | // Check for OAuth flow patterns in URL path | |||
| 62 | + | for _, pattern := range oauthFlowPatterns { | |||
| 63 | + | if strings.Contains(lower, pattern) { | |||
| 64 | + | return true | |||
| 65 | + | } | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // Check for OAuth request parameters | |||
| 69 | + | for _, pattern := range oauthRequestPatterns { | |||
| 70 | + | if strings.Contains(lower, pattern) { | |||
| 71 | + | return true | |||
| 72 | + | } | |||
| 73 | + | } | |||
| 74 | + | ||||
| 75 | + | return false | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // IsOAuthCallback checks if the URL is an OAuth callback with response parameters. | |||
| 79 | + | // This indicates the OAuth flow has completed (successfully or with error). | |||
| 80 | + | func IsOAuthCallback(url string) bool { | |||
| 81 | + | if url == "" { | |||
| 82 | + | return false | |||
| 83 | + | } | |||
| 84 | + | lower := strings.ToLower(url) | |||
| 85 | + | ||||
| 86 | + | // Must contain callback patterns (code=, access_token=, error=, etc.) | |||
| 87 | + | for _, pattern := range oauthCallbackPatterns { | |||
| 88 | + | if strings.Contains(lower, pattern) { | |||
| 89 | + | return true | |||
| 90 | + | } | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | return false | |||
| 94 | + | } | |||
| 95 | + | ||||
| 96 | + | // ShouldAutoClose determines if a popup at this URL should auto-close. | |||
| 97 | + | // Returns true for OAuth callbacks that indicate flow completion. | |||
| 98 | + | // | |||
| 99 | + | // This is comprehensive detection that handles: | |||
| 100 | + | // - Success: code=, access_token=, id_token= | |||
| 101 | + | // - Errors: error=, error_description= | |||
| 102 | + | // - Various OAuth providers (Google, GitHub, Auth0, etc.) | |||
| 103 | + | func ShouldAutoClose(url string) bool { | |||
| 104 | + | // Only auto-close on callback URLs that have OAuth response params | |||
| 105 | + | return IsOAuthCallback(url) | |||
| 106 | + | } | |||
| 107 | + | ||||
| 108 | + | // IsOAuthSuccess checks if the callback indicates successful authentication. | |||
| 109 | + | func IsOAuthSuccess(url string) bool { | |||
| 110 | + | if url == "" { | |||
| 111 | + | return false | |||
| 112 | + | } | |||
| 113 | + | lower := strings.ToLower(url) | |||
| 114 | + | ||||
| 115 | + | // Check for success indicators | |||
| 116 | + | successPatterns := []string{ | |||
| 117 | + | "code=", | |||
| 118 | + | "access_token=", | |||
| 119 | + | "id_token=", | |||
| 120 | + | } | |||
| 121 | + | ||||
| 122 | + | for _, pattern := range successPatterns { | |||
| 123 | + | if strings.Contains(lower, pattern) { | |||
| 124 | + | // Make sure it's not an error response | |||
| 125 | + | if !strings.Contains(lower, "error=") { | |||
| 126 | + | return true | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | } | |||
| 130 | + | ||||
| 131 | + | return false | |||
| 132 | + | } | |||
| 133 | + | ||||
| 134 | + | // IsOAuthError checks if the callback indicates an authentication error. | |||
| 135 | + | func IsOAuthError(url string) bool { | |||
| 136 | + | if url == "" { | |||
| 137 | + | return false | |||
| 138 | + | } | |||
| 139 | + | lower := strings.ToLower(url) | |||
| 140 | + | return strings.Contains(lower, "error=") | |||
| 141 | + | } | |||
A internal/ui/coordinator/popup_types.go +128 -0
| @@ -0,0 +1,128 @@ | |||
| 1 | + | package coordinator | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "time" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // PopupType indicates whether the popup was triggered by a link or JavaScript. | |
| 13 | + | type PopupType int | |
| 14 | + | ||
| 15 | + | const ( | |
| 16 | + | // PopupTypeTab represents a popup from target="_blank" links. | |
| 17 | + | // These are typically user-initiated and open like new tabs. | |
| 18 | + | PopupTypeTab PopupType = iota | |
| 19 | + | // PopupTypePopup represents a popup from window.open() JavaScript calls. | |
| 20 | + | // These may have different placement behavior than tab-like popups. | |
| 21 | + | PopupTypePopup | |
| 22 | + | ) | |
| 23 | + | ||
| 24 | + | // String returns a human-readable name for the popup type. | |
| 25 | + | func (t PopupType) String() string { | |
| 26 | + | switch t { | |
| 27 | + | case PopupTypeTab: | |
| 28 | + | return "tab" | |
| 29 | + | case PopupTypePopup: | |
| 30 | + | return "popup" | |
| 31 | + | default: | |
| 32 | + | return "unknown" | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // PendingPopup tracks a popup WebView that has been created but not yet | |
| 37 | + | // inserted into the workspace. This is used during the three-phase popup | |
| 38 | + | // lifecycle: create → ready-to-show → (insert into workspace). | |
| 39 | + | type PendingPopup struct { | |
| 40 | + | // WebView is the related WebView created in the "create" phase. | |
| 41 | + | // It shares cookies/session with the parent for OAuth support. | |
| 42 | + | WebView *webkit.WebView | |
| 43 | + | ||
| 44 | + | // ParentPaneID is the pane that spawned this popup. | |
| 45 | + | ParentPaneID entity.PaneID | |
| 46 | + | ||
| 47 | + | // ParentWebViewID is the WebView ID of the parent (for lookup). | |
| 48 | + | ParentWebViewID port.WebViewID | |
| 49 | + | ||
| 50 | + | // TargetURI is the initial URL the popup will load. | |
| 51 | + | TargetURI string | |
| 52 | + | ||
| 53 | + | // FrameName is the target frame name from the navigation action. | |
| 54 | + | // "_blank" indicates a tab-like popup, other values indicate JS popups. | |
| 55 | + | FrameName string | |
| 56 | + | ||
| 57 | + | // IsUserGesture indicates if the popup was triggered by user action | |
| 58 | + | // (click) vs script-initiated (e.g., popup after timeout). | |
| 59 | + | IsUserGesture bool | |
| 60 | + | ||
| 61 | + | // PopupType categorizes this as a tab-like or JS popup. | |
| 62 | + | PopupType PopupType | |
| 63 | + | ||
| 64 | + | // CreatedAt is when the popup was created (for timeout handling). | |
| 65 | + | CreatedAt time.Time | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // DetectPopupType determines if this is a tab-like or JS popup based on frame name. | |
| 69 | + | func DetectPopupType(frameName string) PopupType { | |
| 70 | + | // _blank is the standard indicator for "open in new tab/window" | |
| 71 | + | if frameName == "_blank" { | |
| 72 | + | return PopupTypeTab | |
| 73 | + | } | |
| 74 | + | // Empty or custom frame names indicate JS window.open() | |
| 75 | + | return PopupTypePopup | |
| 76 | + | } | |
| 77 | + | ||
| 78 | + | // InsertPopupInput contains the data needed to insert a popup into the workspace. | |
| 79 | + | type InsertPopupInput struct { | |
| 80 | + | // ParentPaneID is the pane that spawned this popup. | |
| 81 | + | ParentPaneID entity.PaneID | |
| 82 | + | ||
| 83 | + | // PopupPane is the pre-created pane entity for the popup. | |
| 84 | + | // It should have IsRelated=true and ParentPaneID set. | |
| 85 | + | PopupPane *entity.Pane | |
| 86 | + | ||
| 87 | + | // WebView is the related WebView for the popup. | |
| 88 | + | WebView *webkit.WebView | |
| 89 | + | ||
| 90 | + | // Behavior determines how the popup is inserted (split/stacked/tabbed). | |
| 91 | + | Behavior config.PopupBehavior | |
| 92 | + | ||
| 93 | + | // Placement specifies direction for split behavior (right/left/top/bottom). | |
| 94 | + | Placement string | |
| 95 | + | ||
| 96 | + | // PopupType indicates if this is a tab-like or JS popup. | |
| 97 | + | PopupType PopupType | |
| 98 | + | ||
| 99 | + | // TargetURI is the URL to load in the popup. | |
| 100 | + | TargetURI string | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // GetBehavior returns the appropriate behavior based on popup type and config. | |
| 104 | + | func GetBehavior(popupType PopupType, cfg *config.PopupBehaviorConfig) config.PopupBehavior { | |
| 105 | + | if cfg == nil { | |
| 106 | + | return config.PopupBehaviorSplit // Default | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | switch popupType { | |
| 110 | + | case PopupTypeTab: | |
| 111 | + | // Tab-like popups (_blank) use blank_target_behavior | |
| 112 | + | switch cfg.BlankTargetBehavior { | |
| 113 | + | case "split": | |
| 114 | + | return config.PopupBehaviorSplit | |
| 115 | + | case "stacked": | |
| 116 | + | return config.PopupBehaviorStacked | |
| 117 | + | case "tabbed": | |
| 118 | + | return config.PopupBehaviorTabbed | |
| 119 | + | default: | |
| 120 | + | return config.PopupBehaviorStacked // Default for _blank | |
| 121 | + | } | |
| 122 | + | case PopupTypePopup: | |
| 123 | + | // JS popups use the main behavior setting | |
| 124 | + | return cfg.Behavior | |
| 125 | + | default: | |
| 126 | + | return cfg.Behavior | |
| 127 | + | } | |
| 128 | + | } | |
| @@ -0,0 +1,128 @@ | |||||
| 1 | + | package coordinator | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "time" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/webkit" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | // PopupType indicates whether the popup was triggered by a link or JavaScript. | |||
| 13 | + | type PopupType int | |||
| 14 | + | ||||
| 15 | + | const ( | |||
| 16 | + | // PopupTypeTab represents a popup from target="_blank" links. | |||
| 17 | + | // These are typically user-initiated and open like new tabs. | |||
| 18 | + | PopupTypeTab PopupType = iota | |||
| 19 | + | // PopupTypePopup represents a popup from window.open() JavaScript calls. | |||
| 20 | + | // These may have different placement behavior than tab-like popups. | |||
| 21 | + | PopupTypePopup | |||
| 22 | + | ) | |||
| 23 | + | ||||
| 24 | + | // String returns a human-readable name for the popup type. | |||
| 25 | + | func (t PopupType) String() string { | |||
| 26 | + | switch t { | |||
| 27 | + | case PopupTypeTab: | |||
| 28 | + | return "tab" | |||
| 29 | + | case PopupTypePopup: | |||
| 30 | + | return "popup" | |||
| 31 | + | default: | |||
| 32 | + | return "unknown" | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | // PendingPopup tracks a popup WebView that has been created but not yet | |||
| 37 | + | // inserted into the workspace. This is used during the three-phase popup | |||
| 38 | + | // lifecycle: create → ready-to-show → (insert into workspace). | |||
| 39 | + | type PendingPopup struct { | |||
| 40 | + | // WebView is the related WebView created in the "create" phase. | |||
| 41 | + | // It shares cookies/session with the parent for OAuth support. | |||
| 42 | + | WebView *webkit.WebView | |||
| 43 | + | ||||
| 44 | + | // ParentPaneID is the pane that spawned this popup. | |||
| 45 | + | ParentPaneID entity.PaneID | |||
| 46 | + | ||||
| 47 | + | // ParentWebViewID is the WebView ID of the parent (for lookup). | |||
| 48 | + | ParentWebViewID port.WebViewID | |||
| 49 | + | ||||
| 50 | + | // TargetURI is the initial URL the popup will load. | |||
| 51 | + | TargetURI string | |||
| 52 | + | ||||
| 53 | + | // FrameName is the target frame name from the navigation action. | |||
| 54 | + | // "_blank" indicates a tab-like popup, other values indicate JS popups. | |||
| 55 | + | FrameName string | |||
| 56 | + | ||||
| 57 | + | // IsUserGesture indicates if the popup was triggered by user action | |||
| 58 | + | // (click) vs script-initiated (e.g., popup after timeout). | |||
| 59 | + | IsUserGesture bool | |||
| 60 | + | ||||
| 61 | + | // PopupType categorizes this as a tab-like or JS popup. | |||
| 62 | + | PopupType PopupType | |||
| 63 | + | ||||
| 64 | + | // CreatedAt is when the popup was created (for timeout handling). | |||
| 65 | + | CreatedAt time.Time | |||
| 66 | + | } | |||
| 67 | + | ||||
| 68 | + | // DetectPopupType determines if this is a tab-like or JS popup based on frame name. | |||
| 69 | + | func DetectPopupType(frameName string) PopupType { | |||
| 70 | + | // _blank is the standard indicator for "open in new tab/window" | |||
| 71 | + | if frameName == "_blank" { | |||
| 72 | + | return PopupTypeTab | |||
| 73 | + | } | |||
| 74 | + | // Empty or custom frame names indicate JS window.open() | |||
| 75 | + | return PopupTypePopup | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | // InsertPopupInput contains the data needed to insert a popup into the workspace. | |||
| 79 | + | type InsertPopupInput struct { | |||
| 80 | + | // ParentPaneID is the pane that spawned this popup. | |||
| 81 | + | ParentPaneID entity.PaneID | |||
| 82 | + | ||||
| 83 | + | // PopupPane is the pre-created pane entity for the popup. | |||
| 84 | + | // It should have IsRelated=true and ParentPaneID set. | |||
| 85 | + | PopupPane *entity.Pane | |||
| 86 | + | ||||
| 87 | + | // WebView is the related WebView for the popup. | |||
| 88 | + | WebView *webkit.WebView | |||
| 89 | + | ||||
| 90 | + | // Behavior determines how the popup is inserted (split/stacked/tabbed). | |||
| 91 | + | Behavior config.PopupBehavior | |||
| 92 | + | ||||
| 93 | + | // Placement specifies direction for split behavior (right/left/top/bottom). | |||
| 94 | + | Placement string | |||
| 95 | + | ||||
| 96 | + | // PopupType indicates if this is a tab-like or JS popup. | |||
| 97 | + | PopupType PopupType | |||
| 98 | + | ||||
| 99 | + | // TargetURI is the URL to load in the popup. | |||
| 100 | + | TargetURI string | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // GetBehavior returns the appropriate behavior based on popup type and config. | |||
| 104 | + | func GetBehavior(popupType PopupType, cfg *config.PopupBehaviorConfig) config.PopupBehavior { | |||
| 105 | + | if cfg == nil { | |||
| 106 | + | return config.PopupBehaviorSplit // Default | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | switch popupType { | |||
| 110 | + | case PopupTypeTab: | |||
| 111 | + | // Tab-like popups (_blank) use blank_target_behavior | |||
| 112 | + | switch cfg.BlankTargetBehavior { | |||
| 113 | + | case "split": | |||
| 114 | + | return config.PopupBehaviorSplit | |||
| 115 | + | case "stacked": | |||
| 116 | + | return config.PopupBehaviorStacked | |||
| 117 | + | case "tabbed": | |||
| 118 | + | return config.PopupBehaviorTabbed | |||
| 119 | + | default: | |||
| 120 | + | return config.PopupBehaviorStacked // Default for _blank | |||
| 121 | + | } | |||
| 122 | + | case PopupTypePopup: | |||
| 123 | + | // JS popups use the main behavior setting | |||
| 124 | + | return cfg.Behavior | |||
| 125 | + | default: | |||
| 126 | + | return cfg.Behavior | |||
| 127 | + | } | |||
| 128 | + | } | |||