1package content
2
3import (
4 "context"
5 "fmt"
6 "time"
7
8 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/domain/entity"
10 "github.com/bnema/dumber/internal/logging"
11)
12
13// PopupType indicates whether the popup was triggered by a link or JavaScript.
14type PopupType int
15
16const (
17 // PopupTypeTab represents a popup from target="_blank" links.
18 // These are typically user-initiated and open like new tabs.
19 PopupTypeTab PopupType = iota
20 // PopupTypePopup represents a popup from window.open() JavaScript calls.
21 // These may have different placement behavior than tab-like popups.
22 PopupTypePopup
23)
24
25// String returns a human-readable name for the popup type.
26func (t PopupType) String() string {
27 switch t {
28 case PopupTypeTab:
29 return "tab"
30 case PopupTypePopup:
31 return "popup"
32 default:
33 return "unknown"
34 }
35}
36
37// DetectPopupType determines if this is a tab-like or JS popup based on frame name.
38func DetectPopupType(frameName string) PopupType {
39 // _blank is the standard indicator for "open in new tab/window"
40 if frameName == "_blank" {
41 return PopupTypeTab
42 }
43 // Empty or custom frame names indicate JS window.open()
44 return PopupTypePopup
45}
46
47// PendingPopup tracks a popup WebView that has been created but not yet
48// inserted into the workspace. This is used during the three-phase popup
49// lifecycle: create → ready-to-show → (insert into workspace).
50type PendingPopup struct {
51 // PaneID is the popup pane created during the create phase.
52 PaneID entity.PaneID
53
54 // WebView is the related WebView created in the "create" phase.
55 // It shares cookies/session with the parent for OAuth support.
56 WebView port.WebView
57
58 // ParentPaneID is the pane that spawned this popup.
59 ParentPaneID entity.PaneID
60
61 // ParentWebViewID is the WebView ID of the parent (for lookup).
62 ParentWebViewID port.WebViewID
63
64 // TargetURI is the initial URL the popup will load.
65 TargetURI string
66
67 // FrameName is the target frame name from the navigation action.
68 // "_blank" indicates a tab-like popup, other values indicate JS popups.
69 FrameName string
70
71 // IsUserGesture indicates if the popup was triggered by user action
72 // (click) vs script-initiated (e.g., popup after timeout).
73 IsUserGesture bool
74
75 // PopupType categorizes this as a tab-like or JS popup.
76 PopupType PopupType
77
78 // CreatedAt is when the popup was created (for timeout handling).
79 CreatedAt time.Time
80}
81
82// InsertPopupInput contains the data needed to insert a popup into the workspace.
83type InsertPopupInput struct {
84 // ParentPaneID is the pane that spawned this popup.
85 ParentPaneID entity.PaneID
86
87 // PopupPane is the pre-created pane entity for the popup.
88 // It should have IsRelated=true and ParentPaneID set.
89 PopupPane *entity.Pane
90
91 // WebView is the related WebView for the popup.
92 WebView port.WebView
93
94 // Behavior determines how the popup is inserted (split/stacked/tabbed).
95 Behavior entity.PopupBehavior
96
97 // Placement specifies direction for split behavior (right/left/top/bottom).
98 Placement string
99
100 // PopupType indicates if this is a tab-like or JS popup.
101 PopupType PopupType
102
103 // TargetURI is the URL to load in the popup.
104 TargetURI string
105}
106
107// GetBehavior returns the appropriate behavior based on popup type and config.
108func GetBehavior(popupType PopupType, cfg *entity.PopupBehaviorConfig) entity.PopupBehavior {
109 if cfg == nil {
110 return entity.PopupBehaviorSplit // Default
111 }
112
113 switch popupType {
114 case PopupTypeTab:
115 // Tab-like popups (_blank) use blank_target_behavior
116 switch cfg.BlankTargetBehavior {
117 case "split":
118 return entity.PopupBehaviorSplit
119 case "stacked":
120 return entity.PopupBehaviorStacked
121 case "tabbed":
122 return entity.PopupBehaviorTabbed
123 default:
124 return entity.PopupBehaviorStacked // Default for _blank
125 }
126 case PopupTypePopup:
127 // JS popups use the main behavior setting
128 return cfg.Behavior
129 default:
130 return cfg.Behavior
131 }
132}
133
134// SetPopupConfig configures popup handling.
135func (c *Coordinator) SetPopupConfig(
136 factory port.WebViewFactory,
137 popupConfig *entity.PopupBehaviorConfig,
138 generateID func() string,
139) {
140 c.factory = factory
141 c.popupConfig = popupConfig
142 c.generateID = generateID
143}
144
145// SetOnInsertPopup sets the callback to insert popups into the workspace.
146func (c *Coordinator) SetOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {
147 c.onInsertPopup = fn
148}
149
150// SetOnClosePane sets the callback to close a pane when its popup closes.
151func (c *Coordinator) SetOnClosePane(fn func(ctx context.Context, paneID entity.PaneID) error) {
152 c.onClosePane = fn
153}
154
155// buildPopupCreateHandler returns the OnCreate callback for a WebView.
156// Returns nil if popup handling is not configured.
157func (c *Coordinator) buildPopupCreateHandler(
158 ctx context.Context, paneID entity.PaneID, wv port.WebView,
159) func(port.PopupRequest) port.WebView {
160 if wv == nil {
161 return nil
162 }
163
164 log := logging.FromContext(ctx)
165 log.Debug().Str("pane_id", string(paneID)).Msg("popup handling configured for webview")
166
167 return func(req port.PopupRequest) port.WebView {
168 return c.handlePopupCreate(ctx, paneID, wv, req)
169 }
170}
171
172// createPopupPane creates a new pane entity for a popup window.
173func (c *Coordinator) createPopupPane(
174 popupID port.WebViewID,
175 parentPaneID entity.PaneID,
176 targetURI string,
177) (entity.PaneID, *entity.Pane) {
178 var paneID entity.PaneID
179 if c.generateID != nil {
180 paneID = entity.PaneID(c.generateID())
181 } else {
182 paneID = entity.PaneID(fmt.Sprintf("popup_%d", popupID))
183 }
184
185 popupPane := entity.NewPane(paneID)
186 popupPane.WindowType = entity.WindowPopup
187 popupPane.IsRelated = true
188 popupPane.ParentPaneID = &parentPaneID
189 popupPane.URI = targetURI
190
191 return paneID, popupPane
192}
193
194// handlePopupCreate handles the WebKit "create" signal for popup windows.
195// Returns a new related WebView if popup is allowed, nil to block.
196//
197// IMPORTANT: The WebView MUST be added to a GtkWindow hierarchy BEFORE this
198// signal handler returns. Otherwise WebKit won't establish window.opener.
199// The WebView stays hidden until ready-to-show signal is received.
200func (c *Coordinator) handlePopupCreate(
201 ctx context.Context,
202 parentPaneID entity.PaneID,
203 parentWV port.WebView,
204 req port.PopupRequest,
205) port.WebView {
206 log := logging.FromContext(ctx)
207
208 log.Debug().
209 Str("parent_pane", string(parentPaneID)).
210 Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
211 Str("frame_name", req.FrameName).
212 Bool("user_gesture", req.IsUserGesture).
213 Msg("popup create request")
214
215 // Check if popups are enabled
216 if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
217 log.Debug().Msg("popups disabled by config, blocking")
218 return nil
219 }
220
221 // Check if factory is available
222 if c.factory == nil {
223 log.Warn().Msg("no webview factory, cannot create popup")
224 return nil
225 }
226
227 // Create related WebView for session sharing (created hidden)
228 parentID := parentWV.ID()
229 popupWV, err := c.factory.CreateRelated(ctx, parentID)
230 if err != nil {
231 log.Error().Err(err).Msg("failed to create related webview for popup")
232 return nil
233 }
234
235 // Detect popup type
236 popupType := DetectPopupType(req.FrameName)
237 popupID := popupWV.ID()
238
239 // Determine behavior from config
240 behavior := GetBehavior(popupType, c.popupConfig)
241 placement := "right"
242 if c.popupConfig != nil {
243 placement = c.popupConfig.Placement
244 }
245
246 // Create popup pane entity
247 paneID, popupPane := c.createPopupPane(popupID, parentPaneID, req.TargetURI)
248
249 // Check OAuth configuration
250 hasConfig := c.popupConfig != nil
251 oauthEnabled := hasConfig && c.popupConfig.OAuthAutoClose
252 isOAuth := IsOAuthURL(req.TargetURI)
253 log.Debug().
254 Bool("has_config", hasConfig).
255 Bool("oauth_enabled", oauthEnabled).
256 Bool("is_oauth", isOAuth).
257 Str("uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
258 Msg("popup OAuth check")
259
260 // Insert into workspace IMMEDIATELY (WebView stays hidden)
261 // This is required for WebKit to establish window.opener relationship
262 if c.onInsertPopup != nil {
263 popupInput := InsertPopupInput{
264 ParentPaneID: parentPaneID,
265 PopupPane: popupPane,
266 WebView: popupWV,
267 Behavior: behavior,
268 Placement: placement,
269 PopupType: popupType,
270 TargetURI: req.TargetURI,
271 }
272
273 if err := c.onInsertPopup(ctx, popupInput); err != nil {
274 log.Error().Err(err).Msg("failed to insert popup into workspace")
275 popupWV.Destroy()
276 return nil
277 }
278 }
279
280 // Register WebView in our map (after successful insertion)
281 c.setWebViewLocked(paneID, popupWV)
282
283 // Setup standard callbacks (after successful insertion to avoid leak)
284 c.setupWebViewCallbacks(ctx, paneID, popupWV)
285
286 // Setup OAuth auto-close if configured
287 if hasConfig && oauthEnabled && isOAuth {
288 popupPane.AutoClose = true
289 c.trackOAuthPopup(popupID, parentPaneID)
290 c.setupOAuthAutoClose(ctx, paneID, popupID, popupWV)
291 log.Debug().Str("pane_id", string(paneID)).Msg("OAuth auto-close enabled for popup")
292 }
293
294 // Store pending popup for ready-to-show handling (just visibility now)
295 pending := &PendingPopup{
296 PaneID: paneID,
297 WebView: popupWV,
298 ParentPaneID: parentPaneID,
299 ParentWebViewID: parentID,
300 TargetURI: req.TargetURI,
301 FrameName: req.FrameName,
302 IsUserGesture: req.IsUserGesture,
303 PopupType: popupType,
304 CreatedAt: time.Now(),
305 }
306
307 c.popupMu.Lock()
308 c.pendingPopups[popupID] = pending
309 c.popupMu.Unlock()
310
311 // Wire ready-to-show and close signals via the PopupCapable interface.
312 if pc, ok := popupWV.(port.PopupCapable); ok {
313 pc.SetOnReadyToShow(func() {
314 c.handlePopupReadyToShow(ctx, popupID)
315 })
316 pc.SetOnClose(func() {
317 c.handlePopupClose(ctx, popupID)
318 })
319 } else {
320 log.Warn().Uint64("popup_id", uint64(popupID)).Msg("webview does not support popup lifecycle callbacks (PopupCapable)")
321 }
322
323 log.Info().
324 Uint64("popup_id", uint64(popupID)).
325 Str("pane_id", string(paneID)).
326 Str("popup_type", popupType.String()).
327 Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
328 Msg("popup inserted (hidden), awaiting ready-to-show for visibility")
329
330 return popupWV
331}
332
333// handlePopupReadyToShow handles the WebKit "ready-to-show" signal.
334// The popup WebView was already inserted into the workspace (hidden) during
335// the create signal. This handler just makes it visible.
336func (c *Coordinator) handlePopupReadyToShow(ctx context.Context, popupID port.WebViewID) {
337 log := logging.FromContext(ctx)
338
339 // Get pending popup
340 c.popupMu.Lock()
341 pending, ok := c.pendingPopups[popupID]
342 if ok {
343 delete(c.pendingPopups, popupID)
344 }
345 c.popupMu.Unlock()
346
347 if !ok || pending == nil {
348 log.Warn().Uint64("popup_id", uint64(popupID)).Msg("ready-to-show for unknown popup")
349 return
350 }
351
352 log.Debug().
353 Uint64("popup_id", uint64(popupID)).
354 Str("popup_type", pending.PopupType.String()).
355 Msg("popup ready to show - making visible")
356
357 // Make the WebView visible now that it's ready
358 if pending.WebView != nil {
359 if pc, ok := pending.WebView.(port.PopupCapable); ok {
360 pc.Show()
361 } else {
362 log.Warn().Uint64("popup_id", uint64(popupID)).Msg("webview does not support Show() (PopupCapable)")
363 }
364 }
365
366 log.Info().
367 Uint64("popup_id", uint64(popupID)).
368 Str("target_uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
369 Msg("popup now visible")
370}
371
372// handlePopupClose handles the WebKit "close" signal for popup windows.
373func (c *Coordinator) handlePopupClose(ctx context.Context, popupID port.WebViewID) {
374 log := logging.FromContext(ctx)
375 log.Debug().Uint64("popup_id", uint64(popupID)).Msg("popup close signal received")
376
377 // Check if still pending (never shown)
378 c.popupMu.Lock()
379 pending, wasPending := c.pendingPopups[popupID]
380 if wasPending {
381 delete(c.pendingPopups, popupID)
382 }
383 c.popupMu.Unlock()
384
385 if wasPending && pending != nil {
386 if c.onClosePane != nil {
387 if err := c.onClosePane(ctx, pending.PaneID); err != nil {
388 log.Warn().Err(err).Str("pane_id", string(pending.PaneID)).Msg("failed to close pending popup pane")
389 }
390 }
391 c.handlePopupOAuthClose(ctx, popupID)
392 c.ReleaseWebView(ctx, pending.PaneID)
393 log.Debug().Str("pane_id", string(pending.PaneID)).Msg("cleaned up pending popup that was never shown")
394 return
395 }
396
397 // Find pane by WebView ID
398 var paneID entity.PaneID
399 c.webViewsMu.RLock()
400 for pid, wv := range c.webViews {
401 if wv != nil && wv.ID() == popupID {
402 paneID = pid
403 break
404 }
405 }
406 c.webViewsMu.RUnlock()
407
408 if paneID == "" {
409 c.handlePopupOAuthClose(ctx, popupID)
410 log.Warn().Msg("popup close: could not find pane for webview")
411 return
412 }
413
414 // Close the pane in workspace (this removes the UI element)
415 if c.onClosePane != nil {
416 if err := c.onClosePane(ctx, paneID); err != nil {
417 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to close popup pane")
418 }
419 }
420
421 c.handlePopupOAuthClose(ctx, popupID)
422
423 // Release the WebView (this will clean up tracking)
424 c.ReleaseWebView(ctx, paneID)
425
426 log.Info().Str("pane_id", string(paneID)).Msg("popup closed")
427}
428
429// handleLinkMiddleClick handles middle-click / Ctrl+click on links.
430// Opens the link in a new pane using blank_target_behavior config.
431func (c *Coordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID entity.PaneID, uri string) bool {
432 log := logging.FromContext(ctx)
433
434 log.Info().
435 Str("parent_pane", string(parentPaneID)).
436 Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
437 Msg("middle-click/ctrl+click on link")
438
439 // Check if popups are enabled
440 if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
441 log.Debug().Msg("popups disabled by config, ignoring middle-click")
442 return false
443 }
444
445 // Check if factory is available
446 if c.factory == nil {
447 log.Warn().Msg("no webview factory, cannot handle middle-click")
448 return false
449 }
450
451 // Create a new WebView (regular, not related - just opening a link)
452 newWV, err := c.factory.Create(ctx)
453 if err != nil {
454 log.Error().Err(err).Msg("failed to create webview for middle-click")
455 return false
456 }
457
458 // Generate pane ID
459 var paneID entity.PaneID
460 if c.generateID != nil {
461 paneID = entity.PaneID(c.generateID())
462 } else {
463 paneID = entity.PaneID(fmt.Sprintf("link_%d", newWV.ID()))
464 }
465
466 // Create pane entity - uses blank_target_behavior
467 newPane := entity.NewPane(paneID)
468 newPane.WindowType = entity.WindowPopup
469 newPane.URI = uri
470
471 // Register WebView
472 c.setWebViewLocked(paneID, newWV)
473
474 // Setup callbacks
475 c.setupWebViewCallbacks(ctx, paneID, newWV)
476
477 // Get behavior from config (same as _blank links)
478 behavior := entity.PopupBehaviorStacked // default
479 if c.popupConfig != nil && c.popupConfig.BlankTargetBehavior != "" {
480 behavior = entity.PopupBehavior(c.popupConfig.BlankTargetBehavior)
481 }
482 placement := "right"
483 if c.popupConfig != nil {
484 placement = c.popupConfig.Placement
485 }
486
487 // Request insertion into workspace
488 if c.onInsertPopup != nil {
489 popupInput := InsertPopupInput{
490 ParentPaneID: parentPaneID,
491 PopupPane: newPane,
492 WebView: newWV,
493 Behavior: behavior,
494 Placement: placement,
495 PopupType: PopupTypeTab, // Treat like _blank
496 TargetURI: uri,
497 }
498
499 if err := c.onInsertPopup(ctx, popupInput); err != nil {
500 log.Error().Err(err).Msg("failed to insert middle-click pane into workspace")
501 // Clean up on failure — use the locking helper to avoid a data race.
502 c.deleteWebViewLocked(paneID)
503 newWV.Destroy()
504 return false
505 }
506 }
507
508 // Load the URI after insertion
509 if err := newWV.LoadURI(ctx, uri); err != nil {
510 log.Error().Err(err).Str("uri", logging.TruncateURL(uri, logURLMaxLen)).Msg("failed to load URI in new pane")
511 }
512
513 log.Info().
514 Str("pane_id", string(paneID)).
515 Str("behavior", string(behavior)).
516 Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
517 Msg("middle-click link opened in new pane")
518
519 return true
520}