// workspace_popup.go - Popup window handling and related pane management package browser import ( "fmt" "strings" "time" "github.com/bnema/dumber/internal/config" "github.com/bnema/dumber/internal/logging" "github.com/bnema/dumber/pkg/webkit" webkitgtk "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6" ) const ( // Default popup window dimensions - sized for OAuth flows defaultPopupMinWidth = 500 // Minimum width for OAuth popups defaultPopupMinHeight = 600 // Minimum height for OAuth popups ) // HandlePopup is DEPRECATED and removed. // Popup lifecycle now uses WebKit's native create/ready-to-show/close signals. // See setupPopupHandling(), handlePopupReadyToShow(), and handlePopupClose() instead. // registerOAuthAutoClose sets up OAuth auto-close functionality for popups // Note: OAuth detection is now handled by the main-world.js injection script func (wm *WorkspaceManager) registerOAuthAutoClose(view *webkit.WebView, url string) { logging.Info(fmt.Sprintf("[workspace] OAuth auto-close enabled for popup with URL: %s", url)) logging.Info(fmt.Sprintf("[workspace] OAuth detection will be handled by main-world.js injection script")) } // insertIndependentPane inserts a new independent pane next to the source func (wm *WorkspaceManager) insertIndependentPane(sourceNode *paneNode, webView *webkit.WebView, url string) error { newPane, err := wm.createPane(webView) if err != nil { return err } direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) if direction == "" { direction = DirectionRight } // Use splitNode with existing pane instead of insertPopupPane if _, err := wm.splitNode(sourceNode, direction, newPane); err != nil { return err } node := wm.viewToNode[webView] if node != nil { node.windowType = webkit.WindowTypeTab node.isRelated = false } if url != "" { _ = webView.LoadURL(url) } return nil } // configureRelatedPopup creates a related view and inserts it func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView *webkit.WebView, url string, feat *webkit.WindowFeatures) { // Use the WebView that was already created and returned to WebKit related := webView newPane, err := wm.createPane(related) if err != nil { logging.Error(fmt.Sprintf("[workspace] failed to create related popup pane: %v", err)) return } direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) if direction == "" { direction = DirectionRight } // Use splitNode with existing pane instead of insertPopupPane if _, err := wm.splitNode(sourceNode, direction, newPane); err != nil { logging.Error(fmt.Sprintf("[workspace] failed to insert related popup pane: %v", err)) return } node := wm.viewToNode[related] popupWebViewID := related.IDString() if node != nil { node.windowType = webkit.WindowTypePopup node.windowFeatures = feat node.isRelated = true node.parentPane = sourceNode node.isPopup = true // Heuristic + config for auto-close intent node.autoClose = wm.shouldAutoClose(url) // Apply minimum size constraints to prevent compression var width, height int if feat != nil { width = feat.Width height = feat.Height } wm.applyPopupSizeConstraints(related, width, height) } // Track this popup in the parent's activePopupChildren // This is critical to prevent OAuth flows from hijacking the parent pane via window.opener if sourceNode != nil && popupWebViewID != "" { if sourceNode.activePopupChildren == nil { sourceNode.activePopupChildren = make([]string, 0) } sourceNode.activePopupChildren = append(sourceNode.activePopupChildren, popupWebViewID) logging.Debug(fmt.Sprintf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)", popupWebViewID, len(sourceNode.activePopupChildren))) // Register navigation policy handler for parent to block script-initiated navigations // when it has active popup children (prevents OAuth from hijacking parent pane) if sourceNode.pane != nil && sourceNode.pane.WebView() != nil { sourceNode.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool { // Always allow user-initiated navigations if isUserGesture { return true } // Block script-initiated navigations if this pane has active popup children if len(sourceNode.activePopupChildren) > 0 { logging.Info(fmt.Sprintf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s", len(sourceNode.activePopupChildren), url)) return false } // Allow if no active popups return true }) logging.Debug(fmt.Sprintf("[workspace] Registered navigation policy handler for parent pane")) } } // Pipe into existing auto-close flow only for popups (confirmed by detection) related.RegisterCloseHandler(func() { logging.Info(fmt.Sprintf("[workspace] Popup requesting close via window.close()")) // Remove this popup from parent's activePopupChildren if sourceNode != nil && popupWebViewID != "" { for i, childID := range sourceNode.activePopupChildren { if childID == popupWebViewID { sourceNode.activePopupChildren = append(sourceNode.activePopupChildren[:i], sourceNode.activePopupChildren[i+1:]...) logging.Debug(fmt.Sprintf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(sourceNode.activePopupChildren))) break } } } if n := wm.viewToNode[related]; n != nil && n.isPopup { time.AfterFunc(200*time.Millisecond, func() { wm.scheduleIdleGuarded(func() bool { if n == nil || !n.widgetValid { return false } if err := wm.ClosePane(n); err != nil { logging.Error(fmt.Sprintf("[workspace] Failed to close popup pane: %v", err)) } return false }, n) }) } }) // URL-based fallback: if providers don't call window.close(), auto-close on OAuth callback URLs if node != nil && node.isPopup && node.autoClose { wm.registerOAuthAutoClose(related, url) } if url != "" { _ = related.LoadURL(url) } } // applyPopupSizeConstraints applies minimum size constraints to prevent OAuth popup compression func (wm *WorkspaceManager) applyPopupSizeConstraints(view *webkit.WebView, width, height int) { minWidth := defaultPopupMinWidth minHeight := defaultPopupMinHeight // Use provided dimensions if valid if width > 0 { minWidth = width } if height > 0 { minHeight = height } // Apply to container if container := view.RootWidget(); container != nil { webkit.WidgetSetSizeRequest(container, minWidth, minHeight) webkit.WidgetQueueResize(container) logging.Debug(fmt.Sprintf("[workspace] Applied minimum size %dx%d to popup container=%p", minWidth, minHeight, container)) } // Apply to WebView widget if webViewWidget := view.Widget(); webViewWidget != nil { webkit.WidgetSetSizeRequest(webViewWidget, minWidth, minHeight) webkit.WidgetQueueResize(webViewWidget) logging.Debug(fmt.Sprintf("[workspace] Applied minimum size %dx%d to popup webview=%p", minWidth, minHeight, webViewWidget)) } } // shouldAutoClose checks simple OAuth-like URL patterns and config flag func (wm *WorkspaceManager) shouldAutoClose(url string) bool { logging.Info(fmt.Sprintf("[workspace] shouldAutoClose called for URL: %s", url)) if wm == nil || wm.app == nil || wm.app.config == nil { logging.Error(fmt.Sprintf("[workspace] shouldAutoClose: missing config, returning true")) return true } if !wm.app.config.Workspace.Popups.OAuthAutoClose { logging.Info(fmt.Sprintf("[workspace] shouldAutoClose: OAuthAutoClose disabled in config, returning false")) return false } u := strings.ToLower(url) logging.Debug(fmt.Sprintf("[workspace] shouldAutoClose: checking lowercase URL: %s", u)) // RFC 6749 compliant OAuth 2.0 URL patterns oauthPatterns := []string{ // Standard OAuth endpoints "oauth", "authorize", "authorization", // Standard callback/redirect patterns "callback", "redirect", "auth/callback", // OpenID Connect patterns "oidc", "openid", // Common OAuth parameter indicators "response_type=", "client_id=", "redirect_uri=", "scope=", "state=", // Standard OAuth response parameters "code=", "access_token=", "id_token=", "token_type=", // Error response parameters "error=", "error_description=", } // Check for OAuth patterns in URL for _, pattern := range oauthPatterns { if strings.Contains(u, pattern) { logging.Info(fmt.Sprintf("[workspace] shouldAutoClose: MATCHED pattern '%s' in URL, returning true", pattern)) return true } } logging.Info(fmt.Sprintf("[workspace] shouldAutoClose: no OAuth patterns matched, returning false")) return false } // RunOnUI schedules a function; here simply executes inline as GTK main loop is single-threaded func (wm *WorkspaceManager) RunOnUI(fn func()) { if fn != nil { fn() } } // handleDetectedWindowType handles window type detection from smart detection path func (wm *WorkspaceManager) handleDetectedWindowType(sourceNode *paneNode, webView *webkit.WebView, url string, windowType webkit.WindowType, features *webkit.WindowFeatures) { if wm.viewToNode[webView] != nil { return // Already placed } logging.Info(fmt.Sprintf("[workspace] Smart detection result: type=%d url=%s", windowType, url)) switch windowType { case webkit.WindowTypeTab: // For tabs, create a NEW independent WebView (can't use the related one) webkitCfg, err := wm.app.buildWebkitConfig() if err != nil { logging.Error(fmt.Sprintf("[workspace] failed to build webkit config for tab: %v", err)) return } // Tabs are embedded in workspace paned containers - no separate window needed webkitCfg.CreateWindow = false // Create independent WebView matching our standard tab insertion path independentView, err := webkit.NewWebView(webkitCfg) if err != nil { logging.Error(fmt.Sprintf("[workspace] failed to create independent tab WebView: %v", err)) return } // Register with content blocking service for network/cosmetic filtering wm.app.RegisterWebViewForFiltering(independentView) // The related webView was just for detection - we don't use it for tabs // Insert the new independent view as a tab if err := wm.insertIndependentPane(sourceNode, independentView, url); err != nil { logging.Error(fmt.Sprintf("[workspace] Failed to insert independent pane: %v", err)) } case webkit.WindowTypePopup: // For popups, use the related WebView we already created wm.configureRelatedPopup(sourceNode, webView, url, features) default: // Fallback to popup behavior wm.configureRelatedPopup(sourceNode, webView, url, features) } } // setupPopupHandling connects WebView's create signal for popup lifecycle management // This implements the WebKit-native popup lifecycle as described in POPUP_REFACTORING_PLAN.md func (wm *WorkspaceManager) setupPopupHandling(webView *webkit.WebView, parentNode *paneNode) { if webView == nil || parentNode == nil { return } webView.RegisterPopupCreateHandler(func(navAction *webkitgtk.NavigationAction) *webkit.WebView { // Get URL from navigation action req := navAction.Request() if req == nil { logging.Info(fmt.Sprintf("[workspace] create signal: no request in navigation action")) return nil } url := req.URI() logging.Info(fmt.Sprintf("[workspace] create signal received for URL: %s", url)) // Check if popups are enabled in config if wm.app.config != nil && !wm.app.config.Workspace.Popups.OpenInNewPane { logging.Info(fmt.Sprintf("[workspace] Popup creation disabled in config - blocking popup")) return nil } // CRITICAL: Create a BARE gotk4 WebView as RELATED to parent (no wrapping, no initialization) // WebKit requires the related-view property to be set during construction for proper lifecycle // This ensures session/cookie sharing and proper WindowFeatures initialization // All initialization (wrapping, container, scripts) happens in ready-to-show logging.Debug(fmt.Sprintf("[workspace] Step 1: Creating bare gotk4 WebView with related-view property")) barePopupView := webkit.NewBareRelatedWebView(webView.GtkWebView()) if barePopupView == nil { logging.Error(fmt.Sprintf("[workspace] Failed to create bare related popup WebView - blocking popup")) return nil } logging.Debug(fmt.Sprintf("[workspace] Step 1 complete: bare related gotk4 WebView created=%p (parent=%p)", barePopupView, webView.GtkWebView())) // Wrap the bare view immediately to get a proper ID // BUT don't initialize it yet - that happens in ready-to-show logging.Info(fmt.Sprintf("[workspace] Step 2: Wrapping bare view (no initialization)")) wrappedView := webkit.WrapBareWebView(barePopupView) if wrappedView == nil { logging.Error(fmt.Sprintf("[workspace] Failed to wrap bare popup WebView - blocking popup")) return nil } // Use the wrapper's ID for tracking popupID := wrappedView.ID() logging.Debug(fmt.Sprintf("[workspace] Step 2 complete: wrapped view created with ID=%d", popupID)) // Store wrapped view (not initialized) for initialization in ready-to-show logging.Info(fmt.Sprintf("[workspace] Step 3: Storing in pendingPopups map")) wm.pendingPopups[popupID] = &pendingPopup{ wrappedView: wrappedView, parentView: webView, parentNode: parentNode, url: url, } logging.Info(fmt.Sprintf("[workspace] Step 3 complete: stored in pendingPopups")) // Connect signals immediately BEFORE returning to WebKit // This is safe because we're only connecting signal handlers, not accessing properties // WebKit will configure WindowFeatures AFTER this callback returns, which is fine logging.Info(fmt.Sprintf("[workspace] Step 4: Connecting signals immediately before returning to WebKit")) // Connect ready-to-show signal barePopupView.ConnectReadyToShow(func() { logging.Info(fmt.Sprintf("[workspace] ready-to-show signal received for popup ID=%d", popupID)) wm.handlePopupReadyToShow(popupID) }) logging.Debug(fmt.Sprintf("[workspace] Connected ready-to-show signal for popup ID=%d", popupID)) // Connect close handler barePopupView.ConnectClose(func() { logging.Info(fmt.Sprintf("[workspace] close signal received for popup ID=%d", popupID)) wm.handlePopupClose(popupID) }) logging.Debug(fmt.Sprintf("[workspace] Connected close signal for popup ID=%d", popupID)) logging.Debug(fmt.Sprintf("[workspace] Step 4 complete: signals connected")) logging.Debug(fmt.Sprintf("[workspace] Step 5: Returning wrapped view to WebKit (will extract bare view in ConnectCreate)")) // Return the wrapped view - WebKit will extract the underlying gotk4 view and configure it return wrappedView }) } // handlePopupReadyToShow inserts popup pane when WebKit says it's ready func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) { info, ok := wm.pendingPopups[popupID] if !ok { logging.Warn(fmt.Sprintf("[workspace] ready-to-show for unknown popup ID: %d", popupID)) return } delete(wm.pendingPopups, popupID) logging.Info(fmt.Sprintf("[workspace] ready-to-show received for popup ID=%d, starting full initialization", popupID)) // NOW it's safe to initialize (WebKit has configured WindowProperties) // We already have the wrapped view from setupPopupHandling wrappedView := info.wrappedView // Build webkit config for initialization webkitCfg, err := wm.app.buildWebkitConfig() if err != nil { logging.Error(fmt.Sprintf("[workspace] Failed to build webkit config for popup initialization: %v", err)) return } webkitCfg.CreateWindow = false // Embedded in workspace // Initialize the wrapped view with full configuration if err := wrappedView.InitializeFromBare(webkitCfg); err != nil { logging.Error(fmt.Sprintf("[workspace] Failed to initialize popup WebView: %v", err)) return } // Register with content blocking service for network/cosmetic filtering wm.app.RegisterWebViewForFiltering(wrappedView) logging.Debug(fmt.Sprintf("[workspace] Successfully initialized popup WebView ID %d", wrappedView.ID())) // Create pane for the fully initialized WebView popupPane, err := wm.createPane(wrappedView) if err != nil { logging.Error(fmt.Sprintf("[workspace] Failed to create popup pane: %v", err)) return } logging.Info(fmt.Sprintf("[workspace] Created popup pane, now inserting into workspace")) // Determine window type from node (set during setupPopupHandling) node := wm.viewToNode[wrappedView] windowType := webkit.WindowTypePopup if node != nil { windowType = node.windowType } // Select behavior based on window type: // - WindowTypeTab (_blank links): Use BlankTargetBehavior config // - WindowTypePopup (JavaScript popups): Use global Behavior config var behaviorStr string if windowType == webkit.WindowTypeTab { behaviorStr = wm.app.config.Workspace.Popups.BlankTargetBehavior logging.Debug(fmt.Sprintf("[workspace] WindowTypeTab (_blank link) detected, using BlankTargetBehavior: %s", behaviorStr)) } else { behaviorStr = string(wm.app.config.Workspace.Popups.Behavior) logging.Debug(fmt.Sprintf("[workspace] WindowTypePopup detected, using Behavior: %s", behaviorStr)) } // Map string to PopupBehavior enum var behavior config.PopupBehavior switch behaviorStr { case "split": behavior = config.PopupBehaviorSplit case "stacked": behavior = config.PopupBehaviorStacked case "tabbed": behavior = config.PopupBehaviorTabbed default: behavior = config.PopupBehaviorSplit // Fallback } logging.Info(fmt.Sprintf("[workspace] Final behavior: %s", behavior)) switch behavior { case config.PopupBehaviorSplit: // Split pane behavior (default) - use splitNode with existing popup pane direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) if direction == "" { direction = DirectionRight } if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil { logging.Error(fmt.Sprintf("[workspace] Failed to insert split popup pane: %v", err)) return } case config.PopupBehaviorStacked: // Stacked pane behavior _, err := wm.stackedPaneManager.StackPane(info.parentNode) if err != nil { logging.Error(fmt.Sprintf("[workspace] Failed to stack existing pane: %v", err)) return } // Insert the popup pane in the same container - use splitNode with existing popup pane direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) if direction == "" { direction = DirectionRight } if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil { logging.Error(fmt.Sprintf("[workspace] Failed to insert stacked popup pane: %v", err)) return } case config.PopupBehaviorTabbed: // Tabbed behavior - TODO: implement proper tabbed interface logging.Warn(fmt.Sprintf("[workspace] WARNING: Tabbed popup behavior not yet implemented, falling back to split")) direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) if direction == "" { direction = DirectionRight } if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil { logging.Error(fmt.Sprintf("[workspace] Failed to insert tabbed popup pane: %v", err)) return } case config.PopupBehaviorWindowed: // Windowed behavior - opens in new workspace/window // TODO: implement new window creation with GTK logging.Warn(fmt.Sprintf("[workspace] WARNING: Windowed popup behavior not yet implemented, falling back to split")) direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) if direction == "" { direction = DirectionRight } if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil { logging.Error(fmt.Sprintf("[workspace] Failed to insert windowed popup pane: %v", err)) return } default: // Fallback to split if unknown behavior logging.Warn(fmt.Sprintf("[workspace] Unknown popup behavior '%s', falling back to split", behavior)) direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement) if direction == "" { direction = DirectionRight } if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil { logging.Error(fmt.Sprintf("[workspace] Failed to insert popup pane: %v", err)) return } } // Configure the popup node // Note: windowType should already be set correctly from setupPopupHandling // Don't overwrite it here - preserve WindowTypeTab for _blank links node = wm.viewToNode[wrappedView] if node != nil { node.isRelated = true node.parentPane = info.parentNode node.isPopup = true // Mark as popup to prevent browser exit when parent closes node.autoClose = wm.shouldAutoClose(info.url) node.popupID = popupID // Store popupID for close signal lookup // Track this popup in the parent's activePopupChildren if info.parentNode != nil { popupWebViewID := wrappedView.IDString() if info.parentNode.activePopupChildren == nil { info.parentNode.activePopupChildren = make([]string, 0) } info.parentNode.activePopupChildren = append(info.parentNode.activePopupChildren, popupWebViewID) logging.Debug(fmt.Sprintf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)", popupWebViewID, len(info.parentNode.activePopupChildren))) // Register navigation policy handler for parent if info.parentNode.pane != nil && info.parentNode.pane.WebView() != nil { info.parentNode.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool { if isUserGesture { return true } if len(info.parentNode.activePopupChildren) > 0 { logging.Info(fmt.Sprintf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s", len(info.parentNode.activePopupChildren), url)) return false } return true }) } } // URL-based auto-close for OAuth popups if node.autoClose { wm.registerOAuthAutoClose(wrappedView, info.url) } } wm.ensureGUIInPane(popupPane) // Widgets are already shown by splitNode - no need to show again // GTK4 handles visibility automatically during reparenting logging.Info(fmt.Sprintf("[workspace] Popup pane inserted and visible for URL: %s", info.url)) } // handlePopupClose responds to WebKit's close signal func (wm *WorkspaceManager) handlePopupClose(popupID uint64) { logging.Info(fmt.Sprintf("[workspace] close signal received for popup native=%d", popupID)) // Check if it's in pending (not yet shown) if info, ok := wm.pendingPopups[popupID]; ok { delete(wm.pendingPopups, popupID) logging.Info(fmt.Sprintf("[workspace] Popup closed before ready-to-show, cleaning up")) // Just remove from pending, WebKit will handle cleanup _ = info // avoid unused variable return } // Find pane by popupID (stored in node during ready-to-show) var targetNode *paneNode for _, node := range wm.viewToNode { if node != nil && node.popupID == popupID { targetNode = node break } } if targetNode == nil { logging.Warn(fmt.Sprintf("[workspace] close signal for unknown popup ID=%d, already closed?", popupID)) return } // Remove from parent's activePopupChildren // Find the wrapped WebView for this node to get its IDString var wrappedViewID string for webView, node := range wm.viewToNode { if node == targetNode { wrappedViewID = webView.IDString() break } } if targetNode.parentPane != nil && wrappedViewID != "" { for i, childID := range targetNode.parentPane.activePopupChildren { if childID == wrappedViewID { targetNode.parentPane.activePopupChildren = append( targetNode.parentPane.activePopupChildren[:i], targetNode.parentPane.activePopupChildren[i+1:]...) logging.Debug(fmt.Sprintf("[workspace] Removed popup from parent's activePopupChildren (remaining: %d)", len(targetNode.parentPane.activePopupChildren))) break } } } // Clean close via existing ClosePane (WebKit already stopped) logging.Info(fmt.Sprintf("[workspace] Closing popup pane via ClosePane")) if err := wm.ClosePane(targetNode); err != nil { logging.Error(fmt.Sprintf("[workspace] Error closing popup pane: %v", err)) } }