1package content
2
3import (
4 "context"
5 "fmt"
6 "strings"
7 "sync"
8 "time"
9
10 "github.com/bnema/dumber/internal/application/dto"
11 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/domain/entity"
13 "github.com/bnema/dumber/internal/logging"
14)
15
16// popupManager owns popup-specific coordinator state. It stays in the UI
17// adapter layer so popup pane bookkeeping and workspace orchestration do not
18// leak into the application/usecase or domain layers.
19type popupManager struct {
20 factory port.WebViewFactory
21 popupConfig entity.BrowsingContextConfig
22 hasPopupConfig bool
23 onInsertPopup func(ctx context.Context, input InsertPopupInput) error
24 onOpenNativePopup func(ctx context.Context, input NativePopupInput) error
25 onClosePane func(ctx context.Context, paneID entity.PaneID) error
26 generatePaneID func() string
27 windowIDForPane func(entity.PaneID) (string, bool)
28 policy browsingContextPolicy
29 namedContexts *namedBrowsingContextRegistry
30 pendingPopups map[port.WebViewID]*PendingPopup
31 popupOAuth map[port.WebViewID]*popupOAuthState
32 popupRefresh map[entity.PaneID]*time.Timer
33 mu sync.RWMutex
34}
35
36type popupOAuthState struct {
37 ParentPaneID entity.PaneID
38 ParentURIAtOpen string
39 CallbackURI string
40 Success bool
41 Error bool
42 Seen bool
43}
44
45type popupCreateContext struct {
46 ParentPaneID entity.PaneID
47 ParentWebViewID port.WebViewID
48 ParentURIAtOpen string
49 PopupID port.WebViewID
50 PopupWebView port.WebView
51 PopupPane *entity.Pane
52 PopupPaneID entity.PaneID
53 PopupType PopupType
54 Behavior entity.PopupBehavior
55 Placement string
56 Request port.PopupRequest
57}
58
59type popupCoordinatorHooks struct {
60 setupWebViewCallbacks func(context.Context, entity.PaneID, port.WebView)
61 registerPopupWebView func(entity.PaneID, port.WebView)
62 setWebView func(entity.PaneID, port.WebView)
63 getWebView func(entity.PaneID) port.WebView
64 deleteWebView func(entity.PaneID) port.WebView
65 releaseWebView func(context.Context, entity.PaneID)
66 findPaneByWebViewID func(port.WebViewID) (entity.PaneID, bool)
67 setupOAuthAutoClose func(context.Context, entity.PaneID, port.WebViewID, port.WebView)
68 handlePopupOAuthClose func(context.Context, port.WebViewID)
69}
70
71func newPopupManager() *popupManager {
72 pm := &popupManager{}
73 pm.ensureInitialized()
74 return pm
75}
76
77func (pm *popupManager) ensureInitialized() {
78 if pm == nil {
79 return
80 }
81 pm.mu.Lock()
82 defer pm.mu.Unlock()
83 if pm.pendingPopups == nil {
84 pm.pendingPopups = make(map[port.WebViewID]*PendingPopup)
85 }
86 if pm.namedContexts == nil {
87 pm.namedContexts = newNamedBrowsingContextRegistry()
88 }
89 if pm.popupOAuth == nil {
90 pm.popupOAuth = make(map[port.WebViewID]*popupOAuthState)
91 }
92 if pm.popupRefresh == nil {
93 pm.popupRefresh = make(map[entity.PaneID]*time.Timer)
94 }
95}
96
97func (pm *popupManager) setConfig(
98 factory port.WebViewFactory,
99 popupConfig *entity.BrowsingContextConfig,
100 generateID func() string,
101) {
102 if pm == nil {
103 return
104 }
105 pm.ensureInitialized()
106 pm.factory = factory
107 pm.setPopupConfig(popupConfig)
108 pm.generatePaneID = generateID
109}
110
111func (pm *popupManager) setPopupConfig(popupConfig *entity.BrowsingContextConfig) {
112 if pm == nil {
113 return
114 }
115 if popupConfig == nil {
116 pm.popupConfig = entity.BrowsingContextConfig{}
117 pm.hasPopupConfig = false
118 return
119 }
120 pm.popupConfig = *popupConfig
121 pm.hasPopupConfig = true
122}
123
124func (pm *popupManager) updatePopupConfig(popupConfig entity.BrowsingContextConfig) {
125 if pm == nil {
126 return
127 }
128 pm.popupConfig = popupConfig
129 pm.hasPopupConfig = true
130}
131
132func (pm *popupManager) currentPopupConfig() *entity.BrowsingContextConfig {
133 if pm == nil || !pm.hasPopupConfig {
134 return nil
135 }
136 return &pm.popupConfig
137}
138
139func (pm *popupManager) setWindowIDResolver(fn func(entity.PaneID) (string, bool)) {
140 if pm == nil {
141 return
142 }
143 pm.windowIDForPane = fn
144}
145
146func popupTabInsertionConfig(cfg *entity.BrowsingContextConfig) (entity.PopupBehavior, string) {
147 behavior := GetBehavior(PopupTypeTab, cfg)
148 placement := "right"
149 if cfg != nil {
150 placement = cfg.Placement
151 }
152 return behavior, placement
153}
154
155func (*popupManager) setBrowsingContextDecision(wv port.WebView, decision dto.HostDecision) {
156 if carrier, ok := wv.(port.BrowsingContextHostDecisionCapable); ok {
157 carrier.SetBrowsingContextHostDecision(decision)
158 }
159}
160
161func (pm *popupManager) setOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {
162 if pm == nil {
163 return
164 }
165 pm.onInsertPopup = fn
166}
167
168func (pm *popupManager) setOnClosePane(fn func(ctx context.Context, paneID entity.PaneID) error) {
169 if pm == nil {
170 return
171 }
172 pm.onClosePane = fn
173}
174
175func (pm *popupManager) setOnOpenNativePopup(fn func(ctx context.Context, input NativePopupInput) error) {
176 if pm == nil {
177 return
178 }
179 pm.onOpenNativePopup = fn
180}
181
182func (pm *popupManager) createPopupPane(
183 popupID port.WebViewID,
184 parentPaneID entity.PaneID,
185 targetURI string,
186) (entity.PaneID, *entity.Pane) {
187 var paneID entity.PaneID
188 if pm != nil && pm.generatePaneID != nil {
189 paneID = entity.PaneID(pm.generatePaneID())
190 } else {
191 paneID = entity.PaneID(fmt.Sprintf("popup_%d", popupID))
192 }
193
194 popupPane := entity.NewPane(paneID)
195 popupPane.WindowType = entity.WindowPopup
196 popupPane.IsRelated = true
197 popupPane.ParentPaneID = &parentPaneID
198 popupPane.URI = targetURI
199
200 return paneID, popupPane
201}
202
203func (pm *popupManager) lookupReusableNamedPopup(
204 parentPaneID entity.PaneID,
205 frameName string,
206 hooks popupCoordinatorHooks,
207) (port.WebView, bool) {
208 if pm == nil || pm.namedContexts == nil {
209 return nil, false
210 }
211
212 name := reusableBrowsingContextName(frameName)
213 if name == "" || pm.windowIDForPane == nil || hooks.getWebView == nil {
214 return nil, false
215 }
216
217 windowID, ok := pm.windowIDForPane(parentPaneID)
218 if !ok || windowID == "" {
219 return nil, false
220 }
221
222 _, wv, ok := pm.namedContexts.Lookup(windowID, name, hooks.getWebView, pm.windowIDForPane)
223 if !ok {
224 return nil, false
225 }
226 return wv, true
227}
228
229func (pm *popupManager) storeReusableNamedPopup(parentPaneID entity.PaneID, frameName string, paneID entity.PaneID, wv port.WebView) {
230 if pm == nil || pm.namedContexts == nil || wv == nil {
231 return
232 }
233 name := reusableBrowsingContextName(frameName)
234 if name == "" || pm.windowIDForPane == nil {
235 return
236 }
237 windowID, ok := pm.windowIDForPane(parentPaneID)
238 if !ok || windowID == "" {
239 return
240 }
241 pm.namedContexts.Register(windowID, name, paneID, wv.ID())
242}
243
244func (pm *popupManager) updatePendingPopupTarget(popupID port.WebViewID, targetURI string) {
245 if pm == nil {
246 return
247 }
248 pm.mu.Lock()
249 if pending, ok := pm.pendingPopups[popupID]; ok && pending != nil {
250 pending.TargetURI = targetURI
251 }
252 pm.mu.Unlock()
253}
254
255func (pm *popupManager) clearReusableNamedPopupByWebViewID(popupID port.WebViewID) {
256 if pm == nil || pm.namedContexts == nil {
257 return
258 }
259 pm.namedContexts.UnregisterByWebViewID(popupID)
260}
261
262func (pm *popupManager) clearReusableNamedPopupByPaneID(paneID entity.PaneID) {
263 if pm == nil || pm.namedContexts == nil {
264 return
265 }
266 pm.namedContexts.UnregisterByPaneID(paneID)
267}
268
269func (pm *popupManager) clearReusableNamedPopupsForWindow(windowID string) {
270 if pm == nil || pm.namedContexts == nil {
271 return
272 }
273 pm.namedContexts.UnregisterWindow(windowID)
274}
275
276func (pm *popupManager) storePendingPopup(popupID port.WebViewID, pending *PendingPopup) {
277 if pm == nil || pending == nil {
278 return
279 }
280 pm.mu.Lock()
281 pm.pendingPopups[popupID] = pending
282 pm.mu.Unlock()
283}
284
285func (pm *popupManager) takePendingPopup(popupID port.WebViewID) (*PendingPopup, bool) {
286 if pm == nil {
287 return nil, false
288 }
289 pm.mu.Lock()
290 defer pm.mu.Unlock()
291 pending, ok := pm.pendingPopups[popupID]
292 if ok {
293 delete(pm.pendingPopups, popupID)
294 }
295 return pending, ok
296}
297
298func (pm *popupManager) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID, parentURIAtOpen string) {
299 if pm == nil {
300 return
301 }
302 pm.mu.Lock()
303 pm.popupOAuth[popupID] = &popupOAuthState{
304 ParentPaneID: parentPaneID,
305 ParentURIAtOpen: strings.TrimSpace(parentURIAtOpen),
306 }
307 pm.mu.Unlock()
308}
309
310func (pm *popupManager) capturePopupOAuthState(popupID port.WebViewID, uri string) {
311 if pm == nil {
312 return
313 }
314 pm.mu.Lock()
315 defer pm.mu.Unlock()
316 state, ok := pm.popupOAuth[popupID]
317 if !ok {
318 return
319 }
320 state.Seen = true
321 state.CallbackURI = uri
322 state.Success = IsOAuthSuccess(uri)
323 state.Error = IsOAuthError(uri)
324}
325
326func (pm *popupManager) capturePopupOAuthMessage(popupID port.WebViewID) {
327 if pm == nil {
328 return
329 }
330 pm.mu.Lock()
331 defer pm.mu.Unlock()
332 state, ok := pm.popupOAuth[popupID]
333 if !ok {
334 return
335 }
336 state.Seen = true
337 if state.CallbackURI == "" {
338 state.CallbackURI = "postmessage://oauth-complete"
339 }
340 if !state.Error {
341 state.Success = true
342 }
343}
344
345func (pm *popupManager) takePopupOAuthState(popupID port.WebViewID) (*popupOAuthState, bool) {
346 if pm == nil {
347 return nil, false
348 }
349 pm.mu.Lock()
350 defer pm.mu.Unlock()
351 state, ok := pm.popupOAuth[popupID]
352 if ok {
353 delete(pm.popupOAuth, popupID)
354 }
355 return state, ok
356}
357
358func (pm *popupManager) schedulePopupRefresh(parentPaneID entity.PaneID, debounce time.Duration, fn func()) {
359 if pm == nil || fn == nil {
360 return
361 }
362
363 var timer *time.Timer
364 pm.mu.Lock()
365 if existing := pm.popupRefresh[parentPaneID]; existing != nil {
366 existing.Stop()
367 }
368 timer = time.AfterFunc(debounce, func() {
369 pm.mu.Lock()
370 if pm.popupRefresh[parentPaneID] != timer {
371 pm.mu.Unlock()
372 return
373 }
374 delete(pm.popupRefresh, parentPaneID)
375 pm.mu.Unlock()
376 fn()
377 })
378 pm.popupRefresh[parentPaneID] = timer
379 pm.mu.Unlock()
380}
381
382func (pm *popupManager) createPopupWebView(
383 ctx context.Context,
384 parentID port.WebViewID,
385 targetURI string,
386 preparePaneHosted bool,
387) (port.WebView, error) {
388 if pm == nil || pm.factory == nil {
389 return nil, fmt.Errorf("no webview factory configured")
390 }
391
392 popupWV, err := pm.factory.CreateRelated(ctx, parentID)
393 if err != nil {
394 return nil, fmt.Errorf("create related popup webview for %s: %w", logging.TruncateURL(targetURI, logURLMaxLen), err)
395 }
396 if popupWV == nil {
397 return nil, fmt.Errorf("related popup webview factory returned nil for %s", logging.TruncateURL(targetURI, logURLMaxLen))
398 }
399 if preparePaneHosted {
400 if paneHosted, ok := popupWV.(port.PaneHostedBrowsingContextCapable); ok {
401 paneHosted.PreparePaneHostedBrowsingContext()
402 }
403 }
404 return popupWV, nil
405}
406
407func (pm *popupManager) reuseNamedPopup(
408 ctx context.Context,
409 hooks popupCoordinatorHooks,
410 parentPaneID entity.PaneID,
411 frameName string,
412 targetURI string,
413) (port.WebView, bool) {
414 log := logging.FromContext(ctx)
415
416 existingWV, ok := pm.lookupReusableNamedPopup(parentPaneID, frameName, hooks)
417 if !ok {
418 return nil, false
419 }
420
421 pm.updatePendingPopupTarget(existingWV.ID(), targetURI)
422 if err := existingWV.LoadURI(ctx, targetURI); err != nil {
423 log.Warn().Err(err).
424 Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
425 Msg("failed to load target URI in reused popup")
426 }
427 log.Info().
428 Str("parent_pane", string(parentPaneID)).
429 Str("frame_name", frameName).
430 Str("target_uri", logging.TruncateURL(targetURI, logURLMaxLen)).
431 Msg("reused named popup")
432 return existingWV, true
433}
434
435func (pm *popupManager) popupParentURIAtOpen(
436 parentPaneID entity.PaneID,
437 parentWV port.WebView,
438 hooks popupCoordinatorHooks,
439 targetURI string,
440) string {
441 cfg := pm.currentPopupConfig()
442 if cfg == nil || !cfg.OAuthAutoClose || !IsOAuthURL(targetURI) {
443 return ""
444 }
445 parentURIAtOpen := parentWV.URI()
446 if parentURIAtOpen == "" && hooks.getWebView != nil {
447 if parent := hooks.getWebView(parentPaneID); parent != nil {
448 parentURIAtOpen = parent.URI()
449 }
450 }
451 return parentURIAtOpen
452}
453
454func (pm *popupManager) openNativePopup(
455 ctx context.Context,
456 parentPaneID entity.PaneID,
457 parentID port.WebViewID,
458 parentURIAtOpen string,
459 req port.PopupRequest,
460 decision dto.HostDecision,
461) port.WebView {
462 log := logging.FromContext(ctx)
463 if pm.onOpenNativePopup == nil {
464 log.Warn().Msg("native popup host callback not configured")
465 return nil
466 }
467 popupWV, err := pm.createPopupWebView(ctx, parentID, req.TargetURI, false)
468 if err != nil {
469 log.Error().Err(err).Msg("failed to create webview for native popup")
470 return nil
471 }
472 cfg := pm.currentPopupConfig()
473 pm.setBrowsingContextDecision(popupWV, decision)
474 if err := pm.onOpenNativePopup(ctx, NativePopupInput{
475 ParentPaneID: parentPaneID,
476 ParentWebViewID: parentID,
477 ParentURIAtOpen: parentURIAtOpen,
478 PopupWebView: popupWV,
479 TargetURI: req.TargetURI,
480 Request: req,
481 ObserveOAuthAutoClose: cfg != nil && cfg.OAuthAutoClose && IsOAuthURL(req.TargetURI),
482 }); err != nil {
483 log.Error().Err(err).Msg("failed to open native popup host")
484 popupWV.Destroy()
485 return nil
486 }
487 if lifecycle, ok := popupWV.(port.PopupLifecycleCapable); ok {
488 lifecycle.PrimePopupNavigation(req.TargetURI)
489 }
490 return popupWV
491}
492
493func (pm *popupManager) handlePopupCreate(
494 ctx context.Context,
495 hooks popupCoordinatorHooks,
496 parentPaneID entity.PaneID,
497 parentWV port.WebView,
498 req port.PopupRequest,
499) port.WebView {
500 log := logging.FromContext(ctx)
501
502 log.Debug().
503 Str("parent_pane", string(parentPaneID)).
504 Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
505 Str("frame_name", req.FrameName).
506 Bool("user_gesture", req.IsUserGesture).
507 Bool("no_javascript_access", req.NoJavaScriptAccess).
508 Msg("popup create request")
509
510 cfg := pm.currentPopupConfig()
511 if cfg != nil && !cfg.OpenInNewPane {
512 log.Debug().Msg("popups disabled by config, blocking")
513 return nil
514 }
515 if pm.factory == nil {
516 log.Warn().Msg("no webview factory, cannot create popup")
517 return nil
518 }
519
520 parentID := parentWV.ID()
521 parentURIAtOpen := pm.popupParentURIAtOpen(parentPaneID, parentWV, hooks, req.TargetURI)
522
523 request := buildPopupBrowsingContextRequest(req)
524 namedContextExists := false
525 if !req.NoJavaScriptAccess {
526 _, namedContextExists = pm.lookupReusableNamedPopup(parentPaneID, req.FrameName, hooks)
527 }
528 decision := pm.policy.Decide(request, namedContextExists)
529 log.Debug().
530 Str("decision", string(decision.Kind)).
531 Str("reason", decision.Reason).
532 Str("context_name", decision.BrowsingContextName).
533 Msg("browsing context host decision")
534
535 switch decision.Kind {
536 case dto.HostDecisionDeny:
537 return nil
538 case dto.HostDecisionReuseNamedPane:
539 if req.NoJavaScriptAccess {
540 log.Warn().Str("frame_name", req.FrameName).Msg("named browsing context reuse denied for noopener popup")
541 return nil
542 }
543 reused, ok := pm.reuseNamedPopup(ctx, hooks, parentPaneID, req.FrameName, req.TargetURI)
544 if ok {
545 pm.setBrowsingContextDecision(reused, decision)
546 return reused
547 }
548 log.Warn().Str("frame_name", req.FrameName).Msg("named browsing context reuse requested but target was unavailable")
549 decision.Kind = dto.HostDecisionCreatePane
550 decision.ReuseContextName = ""
551 decision.Reason = "named browsing context unavailable; creating replacement pane"
552 case dto.HostDecisionCreateNativeWin:
553 return pm.openNativePopup(ctx, parentPaneID, parentID, parentURIAtOpen, req, decision)
554 case dto.HostDecisionCreatePane:
555 // Continue below.
556 break
557 }
558
559 popupWV, err := pm.createPopupWebView(ctx, parentID, req.TargetURI, true)
560 if err != nil {
561 log.Error().Err(err).Msg("failed to create webview for popup")
562 return nil
563 }
564
565 pm.setBrowsingContextDecision(popupWV, decision)
566
567 popupType := DetectPopupType(req.FrameName)
568 popupID := popupWV.ID()
569 behavior := GetBehavior(popupType, cfg)
570 placement := "right"
571 if cfg != nil {
572 placement = cfg.Placement
573 }
574 paneID, popupPane := pm.createPopupPane(popupID, parentPaneID, req.TargetURI)
575
576 return pm.finishPopupCreate(ctx, hooks, popupCreateContext{
577 ParentPaneID: parentPaneID,
578 ParentWebViewID: parentID,
579 ParentURIAtOpen: parentURIAtOpen,
580 PopupID: popupID,
581 PopupWebView: popupWV,
582 PopupPane: popupPane,
583 PopupPaneID: paneID,
584 PopupType: popupType,
585 Behavior: behavior,
586 Placement: placement,
587 Request: req,
588 })
589}
590
591func (pm *popupManager) finishPopupCreate(
592 ctx context.Context,
593 hooks popupCoordinatorHooks,
594 create popupCreateContext,
595) port.WebView {
596 log := logging.FromContext(ctx)
597 cfg := pm.currentPopupConfig()
598 hasConfig := cfg != nil
599 oauthEnabled := hasConfig && cfg.OAuthAutoClose
600 isOAuth := IsOAuthURL(create.Request.TargetURI)
601
602 log.Debug().
603 Bool("has_config", hasConfig).
604 Bool("oauth_enabled", oauthEnabled).
605 Bool("is_oauth", isOAuth).
606 Str("uri", logging.TruncateURL(create.Request.TargetURI, logURLMaxLen)).
607 Msg("popup OAuth check")
608
609 if hooks.setupWebViewCallbacks != nil {
610 hooks.setupWebViewCallbacks(ctx, create.PopupPaneID, create.PopupWebView)
611 }
612 if hooks.registerPopupWebView != nil {
613 hooks.registerPopupWebView(create.PopupPaneID, create.PopupWebView)
614 }
615
616 inserted := false
617 defer func() {
618 if inserted {
619 return
620 }
621 if hooks.getWebView != nil && hooks.deleteWebView != nil {
622 if current := hooks.getWebView(create.PopupPaneID); current == create.PopupWebView {
623 hooks.deleteWebView(create.PopupPaneID)
624 }
625 }
626 }()
627
628 if pm.onInsertPopup != nil {
629 popupInput := InsertPopupInput{
630 ParentPaneID: create.ParentPaneID,
631 PopupPane: create.PopupPane,
632 WebView: create.PopupWebView,
633 Behavior: create.Behavior,
634 Placement: create.Placement,
635 PopupType: create.PopupType,
636 TargetURI: create.Request.TargetURI,
637 }
638 if err := pm.onInsertPopup(ctx, popupInput); err != nil {
639 log.Error().Err(err).Msg("failed to insert popup into workspace")
640 create.PopupWebView.Destroy()
641 return nil
642 }
643 }
644 inserted = true
645
646 if lifecycle, ok := create.PopupWebView.(port.PopupLifecycleCapable); ok {
647 lifecycle.PrimePopupNavigation(create.Request.TargetURI)
648 }
649 if !create.Request.NoJavaScriptAccess {
650 pm.storeReusableNamedPopup(create.ParentPaneID, create.Request.FrameName, create.PopupPaneID, create.PopupWebView)
651 }
652 if _, hasNativePopupLifecycle := create.PopupWebView.(port.PopupLifecycleCapable); !hasNativePopupLifecycle {
653 if closeCapable, ok := create.PopupWebView.(port.OAuthCallbackCapable); ok {
654 closeCapable.AddCloseCallback(func() {
655 pm.handlePopupClose(context.Background(), hooks, create.PopupID)
656 })
657 }
658 }
659
660 if hasConfig && oauthEnabled && isOAuth {
661 create.PopupPane.AutoClose = true
662 pm.trackOAuthPopup(create.PopupID, create.ParentPaneID, create.ParentURIAtOpen)
663 if hooks.setupOAuthAutoClose != nil {
664 hooks.setupOAuthAutoClose(ctx, create.PopupPaneID, create.PopupID, create.PopupWebView)
665 }
666 log.Debug().Str("pane_id", string(create.PopupPaneID)).Msg("OAuth auto-close enabled for popup")
667 }
668
669 pending := &PendingPopup{
670 PaneID: create.PopupPaneID,
671 WebView: create.PopupWebView,
672 ParentPaneID: create.ParentPaneID,
673 ParentWebViewID: create.ParentWebViewID,
674 TargetURI: create.Request.TargetURI,
675 FrameName: create.Request.FrameName,
676 IsUserGesture: create.Request.IsUserGesture,
677 PopupType: create.PopupType,
678 CreatedAt: time.Now(),
679 }
680 pm.storePendingPopup(create.PopupID, pending)
681
682 if lifecycle, ok := create.PopupWebView.(port.PopupLifecycleCapable); ok {
683 lifecycle.SetOnReadyToShow(func() {
684 pm.handlePopupReadyToShow(context.Background(), create.PopupID)
685 })
686 lifecycle.SetOnClose(func() {
687 pm.handlePopupClose(context.Background(), hooks, create.PopupID)
688 })
689 log.Info().
690 Uint64("popup_id", uint64(create.PopupID)).
691 Str("pane_id", string(create.PopupPaneID)).
692 Str("popup_type", create.PopupType.String()).
693 Str("target_uri", logging.TruncateURL(create.Request.TargetURI, logURLMaxLen)).
694 Msg("popup inserted (hidden), awaiting ready-to-show for visibility")
695 } else {
696 log.Info().
697 Uint64("popup_id", uint64(create.PopupID)).
698 Str("pane_id", string(create.PopupPaneID)).
699 Str("popup_type", create.PopupType.String()).
700 Str("target_uri", logging.TruncateURL(create.Request.TargetURI, logURLMaxLen)).
701 Msg("popup inserted, immediately ready (no PopupLifecycleCapable)")
702 pm.handlePopupReadyToShow(context.Background(), create.PopupID)
703 }
704
705 return create.PopupWebView
706}
707
708func (pm *popupManager) handlePopupReadyToShow(ctx context.Context, popupID port.WebViewID) {
709 log := logging.FromContext(ctx)
710 pending, ok := pm.takePendingPopup(popupID)
711 if !ok || pending == nil {
712 log.Warn().Uint64("popup_id", uint64(popupID)).Msg("ready-to-show for unknown popup")
713 return
714 }
715
716 log.Debug().
717 Uint64("popup_id", uint64(popupID)).
718 Str("popup_type", pending.PopupType.String()).
719 Msg("popup ready to show - making visible")
720
721 if pending.WebView != nil {
722 lifecycle, preloadsNavigation := pending.WebView.(port.PopupLifecycleCapable)
723 if preloadsNavigation {
724 lifecycle.Show()
725 }
726 if !preloadsNavigation && pending.TargetURI != "" && !pending.WebView.IsLoading() && pending.WebView.URI() == "" {
727 if err := pending.WebView.LoadURI(ctx, pending.TargetURI); err != nil {
728 log.Warn().Err(err).
729 Str("uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
730 Msg("failed to load target URI in popup")
731 }
732 }
733 }
734
735 log.Info().
736 Uint64("popup_id", uint64(popupID)).
737 Str("target_uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
738 Msg("popup now visible")
739}
740
741func (pm *popupManager) logPopupCloseSignal(ctx context.Context, hooks popupCoordinatorHooks, popupID port.WebViewID) {
742 fields := logging.FromContext(ctx).Debug().Uint64("popup_id", uint64(popupID))
743 if hooks.findPaneByWebViewID != nil && hooks.getWebView != nil {
744 if paneID, ok := hooks.findPaneByWebViewID(popupID); ok && paneID != "" {
745 fields = fields.Str("pane_id", string(paneID))
746 if wv := hooks.getWebView(paneID); wv != nil {
747 fields = fields.
748 Str("current_uri", logging.TruncateURL(wv.URI(), logURLMaxLen)).
749 Bool("is_loading", wv.IsLoading())
750 }
751 }
752 }
753 fields.Msg("popup close signal received")
754}
755
756func (pm *popupManager) findPopupPaneForClose(hooks popupCoordinatorHooks, popupID port.WebViewID) (entity.PaneID, bool) {
757 if hooks.findPaneByWebViewID == nil {
758 return "", false
759 }
760 paneID, ok := hooks.findPaneByWebViewID(popupID)
761 return paneID, ok && paneID != ""
762}
763
764func (pm *popupManager) releasePopupWebView(ctx context.Context, hooks popupCoordinatorHooks, paneID entity.PaneID) {
765 if paneID == "" || hooks.getWebView == nil || hooks.releaseWebView == nil {
766 return
767 }
768 if hooks.getWebView(paneID) != nil {
769 hooks.releaseWebView(ctx, paneID)
770 }
771}
772
773func (pm *popupManager) cleanupPopupClose(
774 ctx context.Context,
775 hooks popupCoordinatorHooks,
776 popupID port.WebViewID,
777 paneID entity.PaneID,
778 closeErrMsg string,
779) {
780 log := logging.FromContext(ctx)
781 if hooks.handlePopupOAuthClose != nil {
782 hooks.handlePopupOAuthClose(ctx, popupID)
783 }
784 if paneID != "" && pm.onClosePane != nil {
785 if err := pm.onClosePane(ctx, paneID); err != nil {
786 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg(closeErrMsg)
787 }
788 }
789 pm.clearReusableNamedPopupByWebViewID(popupID)
790 pm.releasePopupWebView(ctx, hooks, paneID)
791}
792
793func (pm *popupManager) handlePopupClose(ctx context.Context, hooks popupCoordinatorHooks, popupID port.WebViewID) {
794 log := logging.FromContext(ctx)
795 pm.logPopupCloseSignal(ctx, hooks, popupID)
796
797 pending, wasPending := pm.takePendingPopup(popupID)
798 if wasPending && pending != nil {
799 pm.cleanupPopupClose(ctx, hooks, popupID, pending.PaneID, "failed to close pending popup pane")
800 log.Debug().Str("pane_id", string(pending.PaneID)).Msg("cleaned up pending popup that was never shown")
801 return
802 }
803
804 paneID, ok := pm.findPopupPaneForClose(hooks, popupID)
805 if !ok {
806 pm.cleanupPopupClose(ctx, hooks, popupID, "", "")
807 log.Warn().Msg("popup close: could not find pane for webview")
808 return
809 }
810
811 pm.cleanupPopupClose(ctx, hooks, popupID, paneID, "failed to close popup pane")
812 log.Info().Str("pane_id", string(paneID)).Msg("popup closed")
813}
814
815func (pm *popupManager) handleLinkMiddleClick(
816 ctx context.Context,
817 hooks popupCoordinatorHooks,
818 parentPaneID entity.PaneID,
819 uri string,
820) bool {
821 log := logging.FromContext(ctx)
822
823 log.Info().
824 Str("parent_pane", string(parentPaneID)).
825 Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
826 Msg("middle-click/ctrl+click on link")
827
828 cfg := pm.currentPopupConfig()
829 if cfg != nil && !cfg.OpenInNewPane {
830 log.Debug().Msg("popups disabled by config, ignoring middle-click")
831 return false
832 }
833 if pm.factory == nil {
834 log.Warn().Msg("no webview factory, cannot handle middle-click")
835 return false
836 }
837 if hooks.getWebView == nil {
838 log.Warn().Msg("no parent webview lookup available for middle-click browsing context")
839 return false
840 }
841 parentWV := hooks.getWebView(parentPaneID)
842 if parentWV == nil {
843 log.Warn().Str("parent_pane", string(parentPaneID)).Msg("parent webview not found for middle-click browsing context")
844 return false
845 }
846
847 decision := pm.policy.Decide(buildLinkBrowsingContextRequest(parentWV.ID(), uri), false)
848 log.Debug().
849 Str("decision", string(decision.Kind)).
850 Str("reason", decision.Reason).
851 Msg("middle-click browsing context host decision")
852 if decision.Kind != dto.HostDecisionCreatePane {
853 log.Info().Str("decision", string(decision.Kind)).Msg("middle-click browsing context not pane-hosted")
854 return false
855 }
856
857 newWV, err := pm.createPopupWebView(ctx, parentWV.ID(), uri, true)
858 if err != nil {
859 log.Error().Err(err).Msg("failed to create webview for middle-click")
860 return false
861 }
862 pm.setBrowsingContextDecision(newWV, decision)
863
864 var paneID entity.PaneID
865 if pm.generatePaneID != nil {
866 paneID = entity.PaneID(pm.generatePaneID())
867 } else {
868 paneID = entity.PaneID(fmt.Sprintf("link_%d", newWV.ID()))
869 }
870
871 newPane := entity.NewPane(paneID)
872 newPane.WindowType = entity.WindowPopup
873 newPane.URI = uri
874
875 if hooks.setWebView != nil {
876 hooks.setWebView(paneID, newWV)
877 }
878 if hooks.setupWebViewCallbacks != nil {
879 hooks.setupWebViewCallbacks(ctx, paneID, newWV)
880 }
881
882 behavior, placement := popupTabInsertionConfig(pm.currentPopupConfig())
883
884 if pm.onInsertPopup != nil {
885 popupInput := InsertPopupInput{
886 ParentPaneID: parentPaneID,
887 PopupPane: newPane,
888 WebView: newWV,
889 Behavior: behavior,
890 Placement: placement,
891 PopupType: PopupTypeTab,
892 TargetURI: uri,
893 }
894 if err := pm.onInsertPopup(ctx, popupInput); err != nil {
895 log.Error().Err(err).Msg("failed to insert middle-click pane into workspace")
896 if hooks.deleteWebView != nil {
897 hooks.deleteWebView(paneID)
898 }
899 newWV.Destroy()
900 return false
901 }
902 }
903
904 if err := newWV.LoadURI(ctx, uri); err != nil {
905 log.Error().Err(err).Str("uri", logging.TruncateURL(uri, logURLMaxLen)).Msg("failed to load URI in new pane")
906 }
907
908 log.Info().
909 Str("pane_id", string(paneID)).
910 Str("behavior", string(behavior)).
911 Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
912 Msg("middle-click link opened in new pane")
913
914 return true
915}