1// workspace_popup.go - Popup window handling and related pane management
2package browser
3
4import (
5 "log"
6 "strings"
7 "time"
8
9 "github.com/bnema/dumber/internal/config"
10 "github.com/bnema/dumber/pkg/webkit"
11 webkitgtk "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
12 "github.com/diamondburned/gotk4/pkg/glib/v2"
13)
14
15const (
16 // Default popup window dimensions - sized for OAuth flows
17 defaultPopupMinWidth = 500 // Minimum width for OAuth popups
18 defaultPopupMinHeight = 600 // Minimum height for OAuth popups
19)
20
21// HandlePopup is DEPRECATED and removed.
22// Popup lifecycle now uses WebKit's native create/ready-to-show/close signals.
23// See setupPopupHandling(), handlePopupReadyToShow(), and handlePopupClose() instead.
24
25// registerOAuthAutoClose sets up OAuth auto-close functionality for popups
26// Note: OAuth detection is now handled by the main-world.js injection script
27func (wm *WorkspaceManager) registerOAuthAutoClose(view *webkit.WebView, url string) {
28 log.Printf("[workspace] OAuth auto-close enabled for popup with URL: %s", url)
29 log.Printf("[workspace] OAuth detection will be handled by main-world.js injection script")
30}
31
32// insertIndependentPane inserts a new independent pane next to the source
33func (wm *WorkspaceManager) insertIndependentPane(sourceNode *paneNode, webView *webkit.WebView, url string) error {
34 newPane, err := wm.createPane(webView)
35 if err != nil {
36 return err
37 }
38 direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
39 if direction == "" {
40 direction = DirectionRight
41 }
42 // Use splitNode with existing pane instead of insertPopupPane
43 if _, err := wm.splitNode(sourceNode, direction, newPane); err != nil {
44 return err
45 }
46 node := wm.viewToNode[webView]
47 if node != nil {
48 node.windowType = webkit.WindowTypeTab
49 node.isRelated = false
50 }
51 if url != "" {
52 _ = webView.LoadURL(url)
53 }
54 return nil
55}
56
57// configureRelatedPopup creates a related view and inserts it
58func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView *webkit.WebView, url string, feat *webkit.WindowFeatures) {
59 // Use the WebView that was already created and returned to WebKit
60 related := webView
61 newPane, err := wm.createPane(related)
62 if err != nil {
63 log.Printf("[workspace] failed to create related popup pane: %v", err)
64 return
65 }
66 direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
67 if direction == "" {
68 direction = DirectionRight
69 }
70 // Use splitNode with existing pane instead of insertPopupPane
71 if _, err := wm.splitNode(sourceNode, direction, newPane); err != nil {
72 log.Printf("[workspace] failed to insert related popup pane: %v", err)
73 return
74 }
75 node := wm.viewToNode[related]
76 popupWebViewID := related.IDString()
77
78 if node != nil {
79 node.windowType = webkit.WindowTypePopup
80 node.windowFeatures = feat
81 node.isRelated = true
82 node.parentPane = sourceNode
83 node.isPopup = true
84 // Heuristic + config for auto-close intent
85 node.autoClose = wm.shouldAutoClose(url)
86
87 // Apply minimum size constraints to prevent compression
88 var width, height int
89 if feat != nil {
90 width = feat.Width
91 height = feat.Height
92 }
93 wm.applyPopupSizeConstraints(related, width, height)
94 }
95
96 // Track this popup in the parent's activePopupChildren
97 // This is critical to prevent OAuth flows from hijacking the parent pane via window.opener
98 if sourceNode != nil && popupWebViewID != "" {
99 if sourceNode.activePopupChildren == nil {
100 sourceNode.activePopupChildren = make([]string, 0)
101 }
102 sourceNode.activePopupChildren = append(sourceNode.activePopupChildren, popupWebViewID)
103 log.Printf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)", popupWebViewID, len(sourceNode.activePopupChildren))
104
105 // Register navigation policy handler for parent to block script-initiated navigations
106 // when it has active popup children (prevents OAuth from hijacking parent pane)
107 if sourceNode.pane != nil && sourceNode.pane.WebView() != nil {
108 sourceNode.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
109 // Always allow user-initiated navigations
110 if isUserGesture {
111 return true
112 }
113
114 // Block script-initiated navigations if this pane has active popup children
115 if len(sourceNode.activePopupChildren) > 0 {
116 log.Printf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s", len(sourceNode.activePopupChildren), url)
117 return false
118 }
119
120 // Allow if no active popups
121 return true
122 })
123 log.Printf("[workspace] Registered navigation policy handler for parent pane")
124 }
125 }
126
127 // Pipe into existing auto-close flow only for popups (confirmed by detection)
128 related.RegisterCloseHandler(func() {
129 log.Printf("[workspace] Popup requesting close via window.close()")
130
131 // Remove this popup from parent's activePopupChildren
132 if sourceNode != nil && popupWebViewID != "" {
133 for i, childID := range sourceNode.activePopupChildren {
134 if childID == popupWebViewID {
135 sourceNode.activePopupChildren = append(sourceNode.activePopupChildren[:i], sourceNode.activePopupChildren[i+1:]...)
136 log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(sourceNode.activePopupChildren))
137 break
138 }
139 }
140 }
141
142 if n := wm.viewToNode[related]; n != nil && n.isPopup {
143 time.AfterFunc(200*time.Millisecond, func() {
144 wm.scheduleIdleGuarded(func() bool {
145 if n == nil || !n.widgetValid {
146 return false
147 }
148 if err := wm.ClosePane(n); err != nil {
149 log.Printf("[workspace] Failed to close popup pane: %v", err)
150 }
151 return false
152 }, n)
153 })
154 }
155 })
156
157 // URL-based fallback: if providers don't call window.close(), auto-close on OAuth callback URLs
158 if node != nil && node.isPopup && node.autoClose {
159 wm.registerOAuthAutoClose(related, url)
160 }
161 if url != "" {
162 _ = related.LoadURL(url)
163 }
164}
165
166// applyPopupSizeConstraints applies minimum size constraints to prevent OAuth popup compression
167func (wm *WorkspaceManager) applyPopupSizeConstraints(view *webkit.WebView, width, height int) {
168 minWidth := defaultPopupMinWidth
169 minHeight := defaultPopupMinHeight
170
171 // Use provided dimensions if valid
172 if width > 0 {
173 minWidth = width
174 }
175 if height > 0 {
176 minHeight = height
177 }
178
179 // Apply to container
180 if container := view.RootWidget(); container != nil {
181 webkit.WidgetSetSizeRequest(container, minWidth, minHeight)
182 webkit.WidgetQueueResize(container)
183 log.Printf("[workspace] Applied minimum size %dx%d to popup container=%p", minWidth, minHeight, container)
184 }
185
186 // Apply to WebView widget
187 if webViewWidget := view.Widget(); webViewWidget != nil {
188 webkit.WidgetSetSizeRequest(webViewWidget, minWidth, minHeight)
189 webkit.WidgetQueueResize(webViewWidget)
190 log.Printf("[workspace] Applied minimum size %dx%d to popup webview=%p", minWidth, minHeight, webViewWidget)
191 }
192}
193
194// shouldAutoClose checks simple OAuth-like URL patterns and config flag
195func (wm *WorkspaceManager) shouldAutoClose(url string) bool {
196 log.Printf("[workspace] shouldAutoClose called for URL: %s", url)
197
198 if wm == nil || wm.app == nil || wm.app.config == nil {
199 log.Printf("[workspace] shouldAutoClose: missing config, returning true")
200 return true
201 }
202 if !wm.app.config.Workspace.Popups.OAuthAutoClose {
203 log.Printf("[workspace] shouldAutoClose: OAuthAutoClose disabled in config, returning false")
204 return false
205 }
206
207 u := strings.ToLower(url)
208 log.Printf("[workspace] shouldAutoClose: checking lowercase URL: %s", u)
209
210 // RFC 6749 compliant OAuth 2.0 URL patterns
211 oauthPatterns := []string{
212 // Standard OAuth endpoints
213 "oauth", "authorize", "authorization",
214 // Standard callback/redirect patterns
215 "callback", "redirect", "auth/callback",
216 // OpenID Connect patterns
217 "oidc", "openid",
218 // Common OAuth parameter indicators
219 "response_type=", "client_id=", "redirect_uri=", "scope=", "state=",
220 // Standard OAuth response parameters
221 "code=", "access_token=", "id_token=", "token_type=",
222 // Error response parameters
223 "error=", "error_description=",
224 }
225
226 // Check for OAuth patterns in URL
227 for _, pattern := range oauthPatterns {
228 if strings.Contains(u, pattern) {
229 log.Printf("[workspace] shouldAutoClose: MATCHED pattern '%s' in URL, returning true", pattern)
230 return true
231 }
232 }
233
234 log.Printf("[workspace] shouldAutoClose: no OAuth patterns matched, returning false")
235 return false
236}
237
238// RunOnUI schedules a function; here simply executes inline as GTK main loop is single-threaded
239func (wm *WorkspaceManager) RunOnUI(fn func()) {
240 if fn != nil {
241 fn()
242 }
243}
244
245// handleDetectedWindowType handles window type detection from smart detection path
246func (wm *WorkspaceManager) handleDetectedWindowType(sourceNode *paneNode, webView *webkit.WebView, url string, windowType webkit.WindowType, features *webkit.WindowFeatures) {
247 if wm.viewToNode[webView] != nil {
248 return // Already placed
249 }
250
251 log.Printf("[workspace] Smart detection result: type=%d url=%s", windowType, url)
252
253 switch windowType {
254 case webkit.WindowTypeTab:
255 // For tabs, create a NEW independent WebView (can't use the related one)
256 webkitCfg, err := wm.app.buildWebkitConfig()
257 if err != nil {
258 log.Printf("[workspace] failed to build webkit config for tab: %v", err)
259 return
260 }
261 // Tabs are embedded in workspace paned containers - no separate window needed
262 webkitCfg.CreateWindow = false
263
264 // Create independent WebView matching our standard tab insertion path
265 independentView, err := webkit.NewWebView(webkitCfg)
266 if err != nil {
267 log.Printf("[workspace] failed to create independent tab WebView: %v", err)
268 return
269 }
270
271 // The related webView was just for detection - we don't use it for tabs
272 // Insert the new independent view as a tab
273 if err := wm.insertIndependentPane(sourceNode, independentView, url); err != nil {
274 log.Printf("[workspace] Failed to insert independent pane: %v", err)
275 }
276
277 case webkit.WindowTypePopup:
278 // For popups, use the related WebView we already created
279 wm.configureRelatedPopup(sourceNode, webView, url, features)
280 default:
281 // Fallback to popup behavior
282 wm.configureRelatedPopup(sourceNode, webView, url, features)
283 }
284}
285
286// setupPopupHandling connects WebView's create signal for popup lifecycle management
287// This implements the WebKit-native popup lifecycle as described in POPUP_REFACTORING_PLAN.md
288func (wm *WorkspaceManager) setupPopupHandling(webView *webkit.WebView, parentNode *paneNode) {
289 if webView == nil || parentNode == nil {
290 return
291 }
292
293 webView.RegisterPopupCreateHandler(func(navAction *webkitgtk.NavigationAction) *webkit.WebView {
294 // Get URL from navigation action
295 req := navAction.Request()
296 if req == nil {
297 log.Printf("[workspace] create signal: no request in navigation action")
298 return nil
299 }
300 url := req.URI()
301
302 log.Printf("[workspace] create signal received for URL: %s", url)
303
304 // Check if popups are enabled in config
305 if wm.app.config != nil && !wm.app.config.Workspace.Popups.OpenInNewPane {
306 log.Printf("[workspace] Popup creation disabled in config - blocking popup")
307 return nil
308 }
309
310 // CRITICAL: Create a BARE gotk4 WebView as RELATED to parent (no wrapping, no initialization)
311 // WebKit requires the related-view property to be set during construction for proper lifecycle
312 // This ensures session/cookie sharing and proper WindowFeatures initialization
313 // All initialization (wrapping, container, scripts) happens in ready-to-show
314 log.Printf("[workspace] Step 1: Creating bare gotk4 WebView with related-view property")
315 barePopupView := webkit.NewBareRelatedWebView(webView.GtkWebView())
316 if barePopupView == nil {
317 log.Printf("[workspace] Failed to create bare related popup WebView - blocking popup")
318 return nil
319 }
320 log.Printf("[workspace] Step 1 complete: bare related gotk4 WebView created=%p (parent=%p)", barePopupView, webView.GtkWebView())
321
322 // Wrap the bare view immediately to get a proper ID
323 // BUT don't initialize it yet - that happens in ready-to-show
324 log.Printf("[workspace] Step 2: Wrapping bare view (no initialization)")
325 wrappedView := webkit.WrapBareWebView(barePopupView)
326 if wrappedView == nil {
327 log.Printf("[workspace] Failed to wrap bare popup WebView - blocking popup")
328 return nil
329 }
330
331 // Use the wrapper's ID for tracking
332 popupID := wrappedView.ID()
333 log.Printf("[workspace] Step 2 complete: wrapped view created with ID=%d", popupID)
334
335 // Store wrapped view (not initialized) for initialization in ready-to-show
336 log.Printf("[workspace] Step 3: Storing in pendingPopups map")
337 wm.pendingPopups[popupID] = &pendingPopup{
338 wrappedView: wrappedView,
339 parentView: webView,
340 parentNode: parentNode,
341 url: url,
342 }
343 log.Printf("[workspace] Step 3 complete: stored in pendingPopups")
344
345 // CRITICAL: Do NOT connect signals before returning to WebKit
346 // Connecting signals might trigger WebKit to access WindowFeatures before it's set
347 // Instead, we'll connect them after WebKit has processed the return
348 log.Printf("[workspace] Step 4: Scheduling signal connections for next main loop iteration (AFTER WebKit configures the view)")
349
350 // Use GLib idle_add to connect signals AFTER this callback returns
351 // This ensures WebKit has finished configuring WindowFeatures before we touch the view
352 glib.IdleAdd(func() bool {
353 log.Printf("[workspace] Idle callback: Now connecting signals for popup ID=%d", popupID)
354
355 // Connect ready-to-show signal NOW (after WebKit has configured the view)
356 barePopupView.ConnectReadyToShow(func() {
357 log.Printf("[workspace] ready-to-show signal received for popup ID=%d", popupID)
358 wm.handlePopupReadyToShow(popupID)
359 })
360 log.Printf("[workspace] Idle callback: ready-to-show connected")
361
362 // Connect close handler
363 barePopupView.ConnectClose(func() {
364 log.Printf("[workspace] close signal received for popup ID=%d", popupID)
365 wm.handlePopupClose(popupID)
366 })
367 log.Printf("[workspace] Idle callback: close signal connected")
368
369 return false // Don't repeat
370 })
371
372 log.Printf("[workspace] Step 4 complete: signal connections scheduled")
373
374 log.Printf("[workspace] Step 5: Returning wrapped view to WebKit (will extract bare view in ConnectCreate)")
375
376 // Return the wrapped view - WebKit will extract the underlying gotk4 view and configure it
377 return wrappedView
378 })
379}
380
381// handlePopupReadyToShow inserts popup pane when WebKit says it's ready
382func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) {
383 info, ok := wm.pendingPopups[popupID]
384 if !ok {
385 log.Printf("[workspace] ready-to-show for unknown popup ID: %d", popupID)
386 return
387 }
388 delete(wm.pendingPopups, popupID)
389
390 log.Printf("[workspace] ready-to-show received for popup ID=%d, starting full initialization", popupID)
391
392 // NOW it's safe to initialize (WebKit has configured WindowProperties)
393 // We already have the wrapped view from setupPopupHandling
394 wrappedView := info.wrappedView
395
396 // Build webkit config for initialization
397 webkitCfg, err := wm.app.buildWebkitConfig()
398 if err != nil {
399 log.Printf("[workspace] Failed to build webkit config for popup initialization: %v", err)
400 return
401 }
402 webkitCfg.CreateWindow = false // Embedded in workspace
403
404 // Initialize the wrapped view with full configuration
405 if err := wrappedView.InitializeFromBare(webkitCfg); err != nil {
406 log.Printf("[workspace] Failed to initialize popup WebView: %v", err)
407 return
408 }
409
410 log.Printf("[workspace] Successfully initialized popup WebView ID %d", wrappedView.ID())
411
412 // Create pane for the fully initialized WebView
413 popupPane, err := wm.createPane(wrappedView)
414 if err != nil {
415 log.Printf("[workspace] Failed to create popup pane: %v", err)
416 return
417 }
418
419 log.Printf("[workspace] Created popup pane, now inserting into workspace")
420
421 // NOW it's safe to insert into workspace based on configured behavior
422 behavior := wm.app.config.Workspace.Popups.Behavior
423 log.Printf("[workspace] Popup behavior: %s", behavior)
424
425 switch behavior {
426 case config.PopupBehaviorSplit:
427 // Split pane behavior (default) - use splitNode with existing popup pane
428 direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
429 if direction == "" {
430 direction = DirectionRight
431 }
432 if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
433 log.Printf("[workspace] Failed to insert split popup pane: %v", err)
434 return
435 }
436
437 case config.PopupBehaviorStacked:
438 // Stacked pane behavior
439 _, err := wm.stackedPaneManager.StackPane(info.parentNode)
440 if err != nil {
441 log.Printf("[workspace] Failed to stack existing pane: %v", err)
442 return
443 }
444 // Insert the popup pane in the same container - use splitNode with existing popup pane
445 direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
446 if direction == "" {
447 direction = DirectionRight
448 }
449 if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
450 log.Printf("[workspace] Failed to insert stacked popup pane: %v", err)
451 return
452 }
453
454 case config.PopupBehaviorTabbed:
455 // Tabbed behavior - TODO: implement proper tabbed interface
456 log.Printf("[workspace] WARNING: Tabbed popup behavior not yet implemented, falling back to split")
457 direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
458 if direction == "" {
459 direction = DirectionRight
460 }
461 if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
462 log.Printf("[workspace] Failed to insert tabbed popup pane: %v", err)
463 return
464 }
465
466 case config.PopupBehaviorWindowed:
467 // Windowed behavior - opens in new workspace/window
468 // TODO: implement new window creation with GTK
469 log.Printf("[workspace] WARNING: Windowed popup behavior not yet implemented, falling back to split")
470 direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
471 if direction == "" {
472 direction = DirectionRight
473 }
474 if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
475 log.Printf("[workspace] Failed to insert windowed popup pane: %v", err)
476 return
477 }
478
479 default:
480 // Fallback to split if unknown behavior
481 log.Printf("[workspace] Unknown popup behavior '%s', falling back to split", behavior)
482 direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
483 if direction == "" {
484 direction = DirectionRight
485 }
486 if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
487 log.Printf("[workspace] Failed to insert popup pane: %v", err)
488 return
489 }
490 }
491
492 // Configure the popup node
493 node := wm.viewToNode[wrappedView]
494 if node != nil {
495 node.windowType = webkit.WindowTypePopup
496 node.isRelated = true
497 node.parentPane = info.parentNode
498 node.isPopup = true
499 node.autoClose = wm.shouldAutoClose(info.url)
500 node.popupID = popupID // Store popupID for close signal lookup
501
502 // Track this popup in the parent's activePopupChildren
503 if info.parentNode != nil {
504 popupWebViewID := wrappedView.IDString()
505 if info.parentNode.activePopupChildren == nil {
506 info.parentNode.activePopupChildren = make([]string, 0)
507 }
508 info.parentNode.activePopupChildren = append(info.parentNode.activePopupChildren, popupWebViewID)
509 log.Printf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)",
510 popupWebViewID, len(info.parentNode.activePopupChildren))
511
512 // Register navigation policy handler for parent
513 if info.parentNode.pane != nil && info.parentNode.pane.WebView() != nil {
514 info.parentNode.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
515 if isUserGesture {
516 return true
517 }
518 if len(info.parentNode.activePopupChildren) > 0 {
519 log.Printf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s",
520 len(info.parentNode.activePopupChildren), url)
521 return false
522 }
523 return true
524 })
525 }
526 }
527
528 // URL-based auto-close for OAuth popups
529 if node.autoClose {
530 wm.registerOAuthAutoClose(wrappedView, info.url)
531 }
532 }
533
534 wm.ensureGUIInPane(popupPane)
535
536 // Widgets are already shown by splitNode - no need to show again
537 // GTK4 handles visibility automatically during reparenting
538
539 log.Printf("[workspace] Popup pane inserted and visible for URL: %s", info.url)
540}
541
542// handlePopupClose responds to WebKit's close signal
543func (wm *WorkspaceManager) handlePopupClose(popupID uint64) {
544 log.Printf("[workspace] close signal received for popup native=%d", popupID)
545
546 // Check if it's in pending (not yet shown)
547 if info, ok := wm.pendingPopups[popupID]; ok {
548 delete(wm.pendingPopups, popupID)
549 log.Printf("[workspace] Popup closed before ready-to-show, cleaning up")
550 // Just remove from pending, WebKit will handle cleanup
551 _ = info // avoid unused variable
552 return
553 }
554
555 // Find pane by popupID (stored in node during ready-to-show)
556 var targetNode *paneNode
557 for _, node := range wm.viewToNode {
558 if node != nil && node.popupID == popupID {
559 targetNode = node
560 break
561 }
562 }
563
564 if targetNode == nil {
565 log.Printf("[workspace] close signal for unknown popup ID=%d, already closed?", popupID)
566 return
567 }
568
569 // Remove from parent's activePopupChildren
570 // Find the wrapped WebView for this node to get its IDString
571 var wrappedViewID string
572 for webView, node := range wm.viewToNode {
573 if node == targetNode {
574 wrappedViewID = webView.IDString()
575 break
576 }
577 }
578
579 if targetNode.parentPane != nil && wrappedViewID != "" {
580 for i, childID := range targetNode.parentPane.activePopupChildren {
581 if childID == wrappedViewID {
582 targetNode.parentPane.activePopupChildren = append(
583 targetNode.parentPane.activePopupChildren[:i],
584 targetNode.parentPane.activePopupChildren[i+1:]...)
585 log.Printf("[workspace] Removed popup from parent's activePopupChildren (remaining: %d)",
586 len(targetNode.parentPane.activePopupChildren))
587 break
588 }
589 }
590 }
591
592 // Clean close via existing ClosePane (WebKit already stopped)
593 log.Printf("[workspace] Closing popup pane via ClosePane")
594 if err := wm.ClosePane(targetNode); err != nil {
595 log.Printf("[workspace] Error closing popup pane: %v", err)
596 }
597}