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