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