package content import ( "context" "sync" "github.com/bnema/dumber/internal/application/dto" "github.com/bnema/dumber/internal/application/port" "github.com/bnema/dumber/internal/application/usecase" "github.com/bnema/dumber/internal/domain/entity" "github.com/bnema/dumber/internal/logging" "github.com/bnema/dumber/internal/ui/adapter" "github.com/bnema/dumber/internal/ui/component" "github.com/bnema/dumber/internal/ui/input" "github.com/bnema/dumber/internal/ui/layout" "github.com/rs/zerolog" ) // Coordinator manages WebView lifecycle, title tracking, and content attachment. type Coordinator struct { logger zerolog.Logger pool port.WebViewPool widgetFactory layout.WidgetFactory faviconAdapter *adapter.FaviconAdapter zoomUC *usecase.ManageZoomUseCase permissionUC *usecase.HandlePermissionUseCase injector port.ContentInjector settingsApplier port.SettingsApplier // optional: nil if engine doesn't support filterApplier port.FilterApplier // optional: nil if engine doesn't support webViews map[entity.PaneID]port.WebView webViewPaneIDs map[port.WebViewID]entity.PaneID webViewsMu sync.RWMutex activePaneOverride entity.PaneID activePaneOverrideMu sync.RWMutex paneTitles map[entity.PaneID]string titleMu sync.RWMutex // Track original navigation URLs to handle cross-domain redirects // e.g., google.fr → google.com: cache favicon under both domains navOrigins map[entity.PaneID]string navOriginMu sync.RWMutex // Callback to get active workspace state (avoids circular dependency) getActiveWS func() (*entity.Workspace, *component.WorkspaceView) // Resolves a pane's view across all tabs/windows (JS dialogs can come from // background panes). JS dialogs are not shown while it is nil. paneViewResolver func(paneID entity.PaneID) *component.PaneView // Current UI scale, used to size JS dialogs. uiScaleProvider func() float64 // Callback when title changes (for history persistence) onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string) // Callback when page is committed (for history recording) onHistoryRecord func(ctx context.Context, paneID entity.PaneID, url string) // Callback when pane URI changes (for session snapshots) onPaneURIUpdated func(paneID entity.PaneID, url string) // Callback when active pane title changes (for window title updates) onWindowTitleChanged func(paneID entity.PaneID, title string) // Callback when media permission activity changes (requesting/allowed/blocked). onPermissionActivity func(paneID entity.PaneID, origin string, permTypes []entity.PermissionType, state PermissionActivityState) // Callback invoked for every permission request, before it is handled, so the // UI can bind the permission dialog of the window owning the pane. onPermissionPrompt func(paneID entity.PaneID) // Callback when the active pane commits a navigation (new page loading). onActiveNavigationCommitted func(paneID entity.PaneID, uri string) // Callback when a CEF touchpad history navigation gesture progresses. onTouchpadNavigationGesture func(paneID entity.PaneID, gesture entity.TouchpadNavigationGesture) // Callback when the WebView becomes visible (first real commit) onWebViewShown func(paneID entity.PaneID) // setWebViewVisible is test-only injection for deterministic lifecycle // serialization tests. Production uses the native widget provider directly. setWebViewVisible func(port.WebView) // revealMutationMu serializes mapping replacement/release with the native // visibility transition. It is acquired before webViewsMu and revealMu. revealMutationMu sync.Mutex // revealMu is always acquired after webViewsMu. Reveal state is keyed by the // WebView identity (ID plus pool-reuse generation), never by pane alone. revealMu sync.Mutex pendingReveal map[entity.PaneID]webViewIdentity revealedWebViews map[entity.PaneID]webViewIdentity appearanceMu sync.Mutex pendingScriptRefresh map[entity.PaneID]bool pendingThemePanes map[entity.PaneID]bool pendingThemeUpdate pendingThemeUpdate hasPendingThemeUpdate bool currentTheme pendingThemeUpdate hasCurrentTheme bool // Gesture action handler for mouse button navigation gestureActionHandler input.ActionHandler // Popup handling stays in a dedicated UI-layer manager so popup-specific // pane state does not bloat the main coordinator. popups *popupManager popupsOnce sync.Once // Idle inhibitor for fullscreen video playback idleInhibitor port.IdleInhibitor // Callback when fullscreen state changes (for hiding/showing tab bar) onFullscreenChanged func(paneID entity.PaneID, entering bool) // Callback when WebView gains focus (for accent picker text input targeting) onWebViewFocused func(paneID entity.PaneID, wv port.WebView) // Callback when a page editable target gains or loses focus. onEditableFocusChanged func(paneID entity.PaneID, editable bool) // onVimPageInteractionEnded fires when a page finishes link hints or visual selection. onVimPageInteractionEnded func(paneID entity.PaneID) // onVimPageModeChanged fires when a page interaction moves to another sub-mode. onVimPageModeChanged func(paneID entity.PaneID, mode dto.VimPageMode) // Callback for first load_started event (triggers deferred initialization) onFirstLoadStarted func() loadStartedOnce sync.Once // Callback to open a URL with the system's default handler (e.g. xdg-open). // Used for external URL schemes like vscode://, spotify://, etc. onLaunchExternalURL func(uri string) // Callback asking the user to confirm an external scheme launch for a pane. // respond must be called exactly once on the GTK thread. // origin is the host of the requesting page, empty when unknown. onConfirmExternalLaunch func(paneID entity.PaneID, origin, uri string, respond func(allowed bool)) // externalPromptMu guards the external-launch prompt state: outstanding // prompts per pane, the token sequence, and the last web host per pane. externalPromptMu sync.Mutex externalPrompts map[entity.PaneID]externalPrompt externalPromptSeq uint64 externalPromptOrigins map[entity.PaneID]string } type pendingThemeUpdate struct { prefersDark bool cssText string } // PermissionActivityState represents the visible state for media permission activity. type PermissionActivityState string const ( PermissionActivityRequesting PermissionActivityState = "requesting" PermissionActivityAllowed PermissionActivityState = "allowed" PermissionActivityBlocked PermissionActivityState = "blocked" ) // NewCoordinator creates a new Coordinator. func NewCoordinator( ctx context.Context, pool port.WebViewPool, injector port.ContentInjector, widgetFactory layout.WidgetFactory, faviconAdapter *adapter.FaviconAdapter, getActiveWS func() (*entity.Workspace, *component.WorkspaceView), zoomUC *usecase.ManageZoomUseCase, permissionUC *usecase.HandlePermissionUseCase, ) *Coordinator { log := logging.FromContext(ctx) log.Debug().Msg("creating content coordinator") return &Coordinator{ logger: log.With().Str("component", "content-coordinator").Logger(), pool: pool, injector: injector, widgetFactory: widgetFactory, faviconAdapter: faviconAdapter, zoomUC: zoomUC, permissionUC: permissionUC, webViews: make(map[entity.PaneID]port.WebView), webViewPaneIDs: make(map[port.WebViewID]entity.PaneID), paneTitles: make(map[entity.PaneID]string), navOrigins: make(map[entity.PaneID]string), pendingReveal: make(map[entity.PaneID]webViewIdentity), revealedWebViews: make(map[entity.PaneID]webViewIdentity), pendingScriptRefresh: make(map[entity.PaneID]bool), pendingThemePanes: make(map[entity.PaneID]bool), getActiveWS: getActiveWS, popups: newPopupManager(), } } func (c *Coordinator) ensurePopupManager() *popupManager { if c == nil { return nil } c.popupsOnce.Do(func() { if c.popups == nil { c.popups = newPopupManager() } }) c.popups.ensureInitialized() return c.popups } // SetOnTitleUpdated sets the callback for title changes (for history persistence). func (c *Coordinator) SetOnTitleUpdated(fn func(ctx context.Context, paneID entity.PaneID, url, title string)) { c.onTitleUpdated = fn } // SetOnHistoryRecord sets the callback for recording history on page commit. func (c *Coordinator) SetOnHistoryRecord(fn func(ctx context.Context, paneID entity.PaneID, url string)) { c.onHistoryRecord = fn } // SetOnPermissionActivity sets a callback for WebRTC permission activity changes. func (c *Coordinator) SetOnPermissionActivity( fn func(paneID entity.PaneID, origin string, permTypes []entity.PermissionType, state PermissionActivityState), ) { c.onPermissionActivity = fn } // SetOnPermissionPrompt sets a callback invoked for every permission request // (not only WebRTC ones) before it reaches the permission use case. func (c *Coordinator) SetOnPermissionPrompt(fn func(paneID entity.PaneID)) { c.onPermissionPrompt = fn } // SetOnActiveNavigationCommitted sets a callback fired when the active pane commits a navigation. func (c *Coordinator) SetOnActiveNavigationCommitted(fn func(paneID entity.PaneID, uri string)) { c.onActiveNavigationCommitted = fn } // SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots). func (c *Coordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) { c.onPaneURIUpdated = fn } // SetOnTouchpadNavigationGesture sets the callback for CEF two-finger history // navigation gesture progress. func (c *Coordinator) SetOnTouchpadNavigationGesture(fn func(paneID entity.PaneID, gesture entity.TouchpadNavigationGesture)) { c.onTouchpadNavigationGesture = fn } // SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates). func (c *Coordinator) SetOnWindowTitleChanged(fn func(paneID entity.PaneID, title string)) { c.onWindowTitleChanged = fn } // SetOnWebViewShown sets a callback that fires when a pane's WebView is shown. func (c *Coordinator) SetOnWebViewShown(fn func(paneID entity.PaneID)) { c.onWebViewShown = fn } // SetGestureActionHandler sets the callback for mouse button navigation gestures. func (c *Coordinator) SetGestureActionHandler(handler input.ActionHandler) { c.gestureActionHandler = handler } // SetIdleInhibitor sets the idle inhibitor for fullscreen video playback. func (c *Coordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) { c.idleInhibitor = inhibitor } // SetOnFullscreenChanged sets the callback for fullscreen state changes. func (c *Coordinator) SetOnFullscreenChanged(fn func(paneID entity.PaneID, entering bool)) { c.onFullscreenChanged = fn } // SetOnWebViewFocused sets the callback for when a WebView gains focus. func (c *Coordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv port.WebView)) { c.onWebViewFocused = fn } // SetPaneViewResolver sets a resolver that finds a pane's view in any tab/window. func (c *Coordinator) SetPaneViewResolver(fn func(paneID entity.PaneID) *component.PaneView) { c.paneViewResolver = fn } // SetUIScaleProvider sets the source of the current UI scale. func (c *Coordinator) SetUIScaleProvider(fn func() float64) { c.uiScaleProvider = fn } // SetOnEditableFocusChanged sets the callback for page editable focus changes. func (c *Coordinator) SetOnEditableFocusChanged(fn func(paneID entity.PaneID, editable bool)) { c.onEditableFocusChanged = fn } // SetOnVimPageInteractionEnded sets the callback for the end of an in-page // Vim interaction (link hints or visual selection). func (c *Coordinator) SetOnVimPageInteractionEnded(fn func(paneID entity.PaneID)) { c.onVimPageInteractionEnded = fn } // SetOnVimPageModeChanged sets the callback for sub-mode changes of an // in-page Vim interaction (hints, caret, visual). func (c *Coordinator) SetOnVimPageModeChanged(fn func(paneID entity.PaneID, mode dto.VimPageMode)) { c.onVimPageModeChanged = fn } // SetOnFirstLoadStarted sets the callback for when the first navigation starts. // This is used to trigger deferred initialization after the initial load_uri() // has been processed by the GTK main loop. func (c *Coordinator) SetOnFirstLoadStarted(fn func()) { c.onFirstLoadStarted = fn } // SetOnLaunchExternalURL sets the callback invoked when an external URL scheme // (e.g. vscode://, spotify://) is detected and must be handed off to the system. func (c *Coordinator) SetOnLaunchExternalURL(fn func(uri string)) { c.onLaunchExternalURL = fn } // SetOnConfirmExternalLaunch sets the callback that asks the user whether an // external URL scheme reached by page navigation may be opened. func (c *Coordinator) SetOnConfirmExternalLaunch(fn func(paneID entity.PaneID, origin, uri string, respond func(allowed bool))) { c.onConfirmExternalLaunch = fn } // SetSettingsApplier sets the engine settings applier for config hot-reload. func (c *Coordinator) SetSettingsApplier(sa port.SettingsApplier) { c.settingsApplier = sa } // SetFilterApplier sets the content filter applier for late-binding filters. func (c *Coordinator) SetFilterApplier(fa port.FilterApplier) { c.filterApplier = fa } // ActivePaneID returns the currently active pane ID used by navigation. func (c *Coordinator) ActivePaneID(ctx context.Context) entity.PaneID { if paneID, ok := c.activePaneOverrideID(); ok { return paneID } if c.getActiveWS == nil { return "" } ws, _ := c.getActiveWS() if ws == nil { return "" } return ws.ActivePaneID } // SetActivePaneOverride forces ActiveWebView/ActivePaneID to use a specific pane. func (c *Coordinator) SetActivePaneOverride(paneID entity.PaneID) { c.activePaneOverrideMu.Lock() c.activePaneOverride = paneID c.activePaneOverrideMu.Unlock() } // ClearActivePaneOverride removes any forced active pane override. func (c *Coordinator) ClearActivePaneOverride() { c.activePaneOverrideMu.Lock() c.activePaneOverride = "" c.activePaneOverrideMu.Unlock() } // ActivePaneOverrideID returns the currently forced active pane override, if any. func (c *Coordinator) ActivePaneOverrideID() (entity.PaneID, bool) { return c.activePaneOverrideID() } func (c *Coordinator) activePaneOverrideID() (entity.PaneID, bool) { c.activePaneOverrideMu.RLock() defer c.activePaneOverrideMu.RUnlock() if c.activePaneOverride == "" { return "", false } return c.activePaneOverride, true } func (c *Coordinator) getWebViewLocked(paneID entity.PaneID) port.WebView { c.webViewsMu.RLock() defer c.webViewsMu.RUnlock() return c.webViews[paneID] } type webViewIdentity struct { id port.WebViewID generation uint64 } func identityForWebView(wv port.WebView) (webViewIdentity, bool) { if wv == nil { return webViewIdentity{}, false } return webViewIdentity{id: wv.ID(), generation: wv.Generation()}, true } func (c *Coordinator) ensureRevealMapsLocked() { if c.pendingReveal == nil { c.pendingReveal = make(map[entity.PaneID]webViewIdentity) } if c.revealedWebViews == nil { c.revealedWebViews = make(map[entity.PaneID]webViewIdentity) } } // setWebViewLocked replaces the pane mapping and resets all reveal state in // one critical section. It is used by normal acquisition and popup paths. func (c *Coordinator) setWebViewLocked(paneID entity.PaneID, wv port.WebView) { c.revealMutationMu.Lock() defer c.revealMutationMu.Unlock() c.webViewsMu.Lock() c.revealMu.Lock() defer c.revealMu.Unlock() defer c.webViewsMu.Unlock() if c.webViews == nil { c.webViews = make(map[entity.PaneID]port.WebView) } if existing := c.webViews[paneID]; existing != nil && c.webViewPaneIDs != nil { delete(c.webViewPaneIDs, existing.ID()) } c.webViews[paneID] = wv if wv != nil && c.webViewPaneIDs != nil { c.webViewPaneIDs[wv.ID()] = paneID } c.ensureRevealMapsLocked() delete(c.pendingReveal, paneID) delete(c.revealedWebViews, paneID) } // deleteWebViewLocked removes both the mapping and any presentation state, // including callers which release after another path already removed the map. func (c *Coordinator) deleteWebViewLocked(paneID entity.PaneID) port.WebView { c.revealMutationMu.Lock() defer c.revealMutationMu.Unlock() c.webViewsMu.Lock() c.revealMu.Lock() defer c.revealMu.Unlock() defer c.webViewsMu.Unlock() wv := c.webViews[paneID] delete(c.webViews, paneID) if wv != nil && c.webViewPaneIDs != nil { delete(c.webViewPaneIDs, wv.ID()) } c.ensureRevealMapsLocked() delete(c.pendingReveal, paneID) delete(c.revealedWebViews, paneID) return wv } func (c *Coordinator) paneIDByWebViewIDLocked(webViewID port.WebViewID) (entity.PaneID, bool) { paneID, ok := c.webViewPaneIDs[webViewID] return paneID, ok } func (c *Coordinator) findPaneByWebViewID(webViewID port.WebViewID) (entity.PaneID, bool) { if webViewID == 0 { return "", false } c.webViewsMu.RLock() defer c.webViewsMu.RUnlock() if paneID, ok := c.paneIDByWebViewIDLocked(webViewID); ok && paneID != "" { return paneID, true } c.logger.Debug(). Uint64("webview_id", uint64(webViewID)). Msg("findPaneByWebViewID: using fallback scan") for paneID, wv := range c.webViews { if wv != nil && wv.ID() == webViewID { return paneID, true } } return "", false } func (c *Coordinator) popupHooks() popupCoordinatorHooks { return popupCoordinatorHooks{ setupWebViewCallbacks: c.setupWebViewCallbacks, registerPopupWebView: c.RegisterPopupWebView, setWebView: c.setWebViewLocked, getWebView: c.getWebViewLocked, deleteWebView: c.deleteWebViewLocked, releaseWebView: c.ReleaseWebView, findPaneByWebViewID: c.findPaneByWebViewID, setupOAuthAutoClose: c.setupOAuthAutoClose, handlePopupOAuthClose: c.handlePopupOAuthClose, } }