package coordinator import ( "context" "encoding/json" "fmt" "strings" "sync" "time" "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/infrastructure/config" "github.com/bnema/dumber/internal/infrastructure/webkit" "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/jwijenbergh/puregotk/v4/gdk" ) // ContentCoordinator manages WebView lifecycle, title tracking, and content attachment. type ContentCoordinator struct { pool *webkit.WebViewPool widgetFactory layout.WidgetFactory faviconAdapter *adapter.FaviconAdapter zoomUC *usecase.ManageZoomUseCase webViews map[entity.PaneID]*webkit.WebView 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) // 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(title string) // Callback when the WebView becomes visible (first real commit) onWebViewShown func(paneID entity.PaneID) revealMu sync.Mutex pendingReveal map[entity.PaneID]bool // Gesture action handler for mouse button navigation gestureActionHandler input.ActionHandler // Popup handling factory *webkit.WebViewFactory popupConfig *config.PopupBehaviorConfig pendingPopups map[port.WebViewID]*PendingPopup popupMu sync.RWMutex // Callback to insert popup into workspace (avoids circular dependency) onInsertPopup func(ctx context.Context, input InsertPopupInput) error // Callback to close a pane when popup closes onClosePane func(ctx context.Context, paneID entity.PaneID) error // ID generator for popup panes generateID func() string // Idle inhibitor for fullscreen video playback idleInhibitor port.IdleInhibitor // Callback when fullscreen state changes (for hiding/showing tab bar) onFullscreenChanged func(entering bool) } // NewContentCoordinator creates a new ContentCoordinator. func NewContentCoordinator( ctx context.Context, pool *webkit.WebViewPool, widgetFactory layout.WidgetFactory, faviconAdapter *adapter.FaviconAdapter, getActiveWS func() (*entity.Workspace, *component.WorkspaceView), zoomUC *usecase.ManageZoomUseCase, ) *ContentCoordinator { log := logging.FromContext(ctx) log.Debug().Msg("creating content coordinator") return &ContentCoordinator{ pool: pool, widgetFactory: widgetFactory, faviconAdapter: faviconAdapter, zoomUC: zoomUC, webViews: make(map[entity.PaneID]*webkit.WebView), paneTitles: make(map[entity.PaneID]string), navOrigins: make(map[entity.PaneID]string), pendingReveal: make(map[entity.PaneID]bool), getActiveWS: getActiveWS, pendingPopups: make(map[port.WebViewID]*PendingPopup), } } // SetOnTitleUpdated sets the callback for title changes (for history persistence). func (c *ContentCoordinator) 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 *ContentCoordinator) SetOnHistoryRecord(fn func(ctx context.Context, paneID entity.PaneID, url string)) { c.onHistoryRecord = fn } // SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots). func (c *ContentCoordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) { c.onPaneURIUpdated = fn } // SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates). func (c *ContentCoordinator) SetOnWindowTitleChanged(fn func(title string)) { c.onWindowTitleChanged = fn } // SetOnWebViewShown sets a callback that fires when a pane's WebView is shown. func (c *ContentCoordinator) SetOnWebViewShown(fn func(paneID entity.PaneID)) { c.onWebViewShown = fn } // SetGestureActionHandler sets the callback for mouse button navigation gestures. func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler) { c.gestureActionHandler = handler } // SetIdleInhibitor sets the idle inhibitor for fullscreen video playback. func (c *ContentCoordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) { c.idleInhibitor = inhibitor } // SetOnFullscreenChanged sets the callback for fullscreen state changes. func (c *ContentCoordinator) SetOnFullscreenChanged(fn func(entering bool)) { c.onFullscreenChanged = fn } // EnsureWebView acquires or reuses a WebView for the given pane. func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) { log := logging.FromContext(ctx) if wv, ok := c.webViews[paneID]; ok && wv != nil && !wv.IsDestroyed() { return wv, nil } if c.pool == nil { return nil, fmt.Errorf("webview pool not configured") } wv, err := c.pool.Acquire(ctx) if err != nil { return nil, err } c.webViews[paneID] = wv // Set up title change callback wv.OnTitleChanged = func(title string) { c.onTitleChanged(ctx, paneID, title) } // Set up favicon change callback wv.OnFaviconChanged = func(favicon *gdk.Texture) { c.onFaviconChanged(ctx, paneID, favicon) } // Set up load change callback to re-apply zoom on navigation and handle progress bar wv.OnLoadChanged = func(event webkit.LoadEvent) { switch event { case webkit.LoadStarted: c.onLoadStarted(paneID) case webkit.LoadCommitted: c.onLoadCommitted(ctx, paneID, wv) case webkit.LoadFinished: c.onLoadFinished(paneID) } } // Set up progress callback for loading indicator wv.OnProgressChanged = func(progress float64) { c.onProgressChanged(paneID, progress) } // Set up URI change callback for SPA navigation (History API) // This fires when URL changes via JavaScript without a full page load wv.OnURIChanged = func(uri string) { // Only record if not loading (SPA navigation via History API) // Full page loads are handled by OnLoadCommitted if !wv.IsLoading() && uri != "" { c.onSPANavigation(ctx, paneID, uri) } } // Set up middle-click / Ctrl+click handler for opening links in new pane wv.OnLinkMiddleClick = func(uri string) bool { return c.handleLinkMiddleClick(ctx, paneID, uri) } // Set up link hover callback for status overlay wv.OnLinkHover = func(uri string) { c.onLinkHover(paneID, uri) } // Set up fullscreen handlers for idle inhibition c.setupIdleInhibitionHandlers(ctx, paneID, wv) // Set up popup handling for this WebView c.SetupPopupHandling(ctx, paneID, wv) log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane") return wv, nil } // ReleaseWebView returns the WebView for a pane to the pool. func (c *ContentCoordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) { log := logging.FromContext(ctx) wv, ok := c.webViews[paneID] if !ok || wv == nil { return } delete(c.webViews, paneID) // CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing), // we must release the inhibition before destroying the webview. // Otherwise the D-Bus inhibit request stays active forever. if c.idleInhibitor != nil { if wv.IsFullscreen() { log.Debug().Str("pane_id", string(paneID)).Msg("releasing idle inhibition (was fullscreen)") if err := c.idleInhibitor.Uninhibit(ctx); err != nil { log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle on release (fullscreen)") } } if wv.IsPlayingAudio() { log.Debug().Str("pane_id", string(paneID)).Msg("releasing idle inhibition (was playing audio)") if err := c.idleInhibitor.Uninhibit(ctx); err != nil { log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle on release (audio)") } } } // Clean up title tracking c.titleMu.Lock() delete(c.paneTitles, paneID) c.titleMu.Unlock() // Clean up navigation origin tracking c.navOriginMu.Lock() delete(c.navOrigins, paneID) c.navOriginMu.Unlock() if c.pool != nil { c.pool.Release(ctx, wv) } else { wv.Destroy() } log.Debug().Str("pane_id", string(paneID)).Msg("webview released") } // AttachToWorkspace ensures each pane in the workspace has a WebView widget attached. func (c *ContentCoordinator) AttachToWorkspace(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) { log := logging.FromContext(ctx) if ws == nil || wsView == nil || c.widgetFactory == nil { return } for _, pane := range ws.AllPanes() { if pane == nil { continue } wv, err := c.EnsureWebView(ctx, pane.ID) if err != nil { log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to ensure webview for pane") continue } // Load the pane's URI if set and different from current if pane.URI != "" && pane.URI != wv.URI() { if err := wv.LoadURI(ctx, pane.URI); err != nil { log.Warn().Err(err).Str("pane_id", string(pane.ID)).Str("uri", pane.URI).Msg("failed to load pane URI") } } widget := c.WrapWidget(ctx, wv) if widget == nil { continue } if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil { log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget") } } } // WrapWidget converts a WebView to a layout.Widget for embedding. // It also attaches gesture handlers for mouse button navigation. func (c *ContentCoordinator) WrapWidget(ctx context.Context, wv *webkit.WebView) layout.Widget { log := logging.FromContext(ctx) if wv == nil || c.widgetFactory == nil { log.Debug().Msg("cannot wrap nil webview or factory") return nil } gtkView := wv.Widget() if gtkView == nil { return nil } widget := c.widgetFactory.WrapWidget(>kView.Widget) // Attach gesture handler for mouse button 8/9 navigation if widget != nil && c.gestureActionHandler != nil { gestureHandler := input.NewGestureHandler(ctx) gestureHandler.SetOnAction(c.gestureActionHandler) gestureHandler.AttachTo(widget.GtkWidget()) log.Debug().Msg("gesture handler attached to webview") } return widget } // ActiveWebView returns the WebView for the active pane. func (c *ContentCoordinator) ActiveWebView(ctx context.Context) *webkit.WebView { log := logging.FromContext(ctx) ws, _ := c.getActiveWS() if ws == nil { log.Debug().Msg("no active workspace") return nil } pane := ws.ActivePane() if pane == nil || pane.Pane == nil { log.Debug().Msg("no active pane") return nil } return c.webViews[pane.Pane.ID] } // GetWebView returns the WebView for a specific pane. func (c *ContentCoordinator) GetWebView(paneID entity.PaneID) *webkit.WebView { return c.webViews[paneID] } // RegisterPopupWebView registers a popup WebView that was created externally. // This is used when popup tabs are created and the WebView needs to be tracked. func (c *ContentCoordinator) RegisterPopupWebView(paneID entity.PaneID, wv *webkit.WebView) { if wv != nil && paneID != "" { c.webViews[paneID] = wv } } // ApplyFiltersToAll applies content filters to all active webviews. // Called when filters become available after webviews were already created. func (c *ContentCoordinator) ApplyFiltersToAll(ctx context.Context, applier webkit.FilterApplier) { log := logging.FromContext(ctx) for paneID, wv := range c.webViews { if wv != nil && !wv.IsDestroyed() { applier.ApplyTo(ctx, wv.UserContentManager()) log.Debug().Str("pane_id", string(paneID)).Msg("applied filters to existing webview") } } } // ApplySettingsToAll reapplies WebKit settings to all active WebViews. func (c *ContentCoordinator) ApplySettingsToAll(ctx context.Context, sm *webkit.SettingsManager) { log := logging.FromContext(ctx) if sm == nil { return } for paneID, wv := range c.webViews { if wv == nil || wv.IsDestroyed() { continue } sm.ApplyToWebView(ctx, wv.Widget()) log.Debug().Str("pane_id", string(paneID)).Msg("reapplied settings to webview") } } // RefreshInjectedScriptsToAll clears and re-injects user scripts into all active WebViews. // // WebKit user scripts are snapshotted when added to a WebKitUserContentManager, so when // appearance settings change at runtime (dark mode, palettes, UI scale), we must refresh // the scripts so future navigations pick up the latest values. func (c *ContentCoordinator) RefreshInjectedScriptsToAll(ctx context.Context, injector *webkit.ContentInjector) { log := logging.FromContext(ctx) if injector == nil { return } for paneID, wv := range c.webViews { if wv == nil || wv.IsDestroyed() { continue } ucm := wv.UserContentManager() if ucm == nil { continue } ucm.RemoveAllScripts() injector.InjectScripts(ctx, ucm, wv.ID()) log.Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview") } } // ApplyWebUIThemeToAll updates theme CSS for already-loaded dumb:// pages. // This is necessary because user scripts only run on navigation. func (c *ContentCoordinator) ApplyWebUIThemeToAll(ctx context.Context, prefersDark bool, cssText string) { log := logging.FromContext(ctx) if cssText == "" { return } // Safely embed CSS in JS cssJSON, err := json.Marshal(cssText) if err != nil { log.Warn().Err(err).Msg("failed to marshal WebUI CSS vars") return } script := fmt.Sprintf(`(function(){ try { var cssText = %s; var prefersDark = %t; // Keep the global flag in sync window.__dumber_gtk_prefers_dark = prefersDark; // Update dark/light class if (prefersDark) { document.documentElement.classList.add('dark'); document.documentElement.classList.remove('light'); } else { document.documentElement.classList.add('light'); document.documentElement.classList.remove('dark'); } // Update or insert theme style var style = document.querySelector('style[data-dumber-theme-vars]'); if (!style) { style = document.createElement('style'); style.setAttribute('data-dumber-theme-vars', ''); (document.head || document.documentElement).appendChild(style); } style.textContent = cssText; // Notify any running WebUI that theme changed try { window.dispatchEvent(new CustomEvent('dumber:theme-changed', { detail: { prefersDark: prefersDark } })); } catch (e) { // ignore } // Keep color-scheme consistent var meta = document.querySelector('meta[name="color-scheme"]'); if (!meta) { meta = document.createElement('meta'); meta.name = 'color-scheme'; document.documentElement.appendChild(meta); } meta.content = prefersDark ? 'dark light' : 'light dark'; } catch (e) { console.error('[dumber] failed to apply theme', e); } })();`, string(cssJSON), prefersDark) for paneID, wv := range c.webViews { if wv == nil || wv.IsDestroyed() { continue } uri := wv.URI() if !strings.HasPrefix(uri, "dumb://") { continue } wv.RunJavaScript(ctx, script, "") log.Debug().Str("pane_id", string(paneID)).Str("uri", uri).Msg("applied WebUI theme") } } // GetTitle returns the current title for a pane. func (c *ContentCoordinator) GetTitle(paneID entity.PaneID) string { c.titleMu.RLock() defer c.titleMu.RUnlock() return c.paneTitles[paneID] } // onTitleChanged updates title tracking when a WebView's title changes. func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.PaneID, title string) { log := logging.FromContext(ctx) // Update title map c.titleMu.Lock() c.paneTitles[paneID] = title c.titleMu.Unlock() // Update domain model and check if this is the active pane isActivePaneTitle := false ws, wsView := c.getActiveWS() if ws != nil { paneNode := ws.FindPane(paneID) if paneNode != nil && paneNode.Pane != nil { paneNode.Pane.Title = title } // Check if this pane is the active one if ws.ActivePaneID == paneID { isActivePaneTitle = true } } // Update StackedView title bar if this pane is in a stack if wsView != nil { tr := wsView.TreeRenderer() if tr != nil { stackedView := tr.GetStackedViewForPane(string(paneID)) if stackedView != nil { c.updateStackedPaneTitle(ctx, stackedView, paneID, title) } } } // Notify history persistence (get URL from WebView) if c.onTitleUpdated != nil { if wv := c.webViews[paneID]; wv != nil { url := wv.URI() if url != "" && title != "" { c.onTitleUpdated(ctx, paneID, url, title) } } } // Notify window title update if this is the active pane if isActivePaneTitle && c.onWindowTitleChanged != nil { c.onWindowTitleChanged(title) } log.Debug(). Str("pane_id", string(paneID)). Str("title", title). Msg("pane title updated") } // updateStackedPaneTitle updates the title of a pane in a StackedView. func (c *ContentCoordinator) updateStackedPaneTitle( ctx context.Context, sv *layout.StackedView, paneID entity.PaneID, title string, ) { log := logging.FromContext(ctx) // Find the pane's index directly in the StackedView index := sv.FindPaneIndex(string(paneID)) if index < 0 { log.Debug(). Str("pane_id", string(paneID)). Msg("pane not found in StackedView for title update") return } if err := sv.UpdateTitle(index, title); err != nil { log.Warn().Err(err).Int("index", index).Msg("failed to update stacked pane title") } } // onFaviconChanged updates favicon tracking when a WebView's favicon changes. func (c *ContentCoordinator) onFaviconChanged(ctx context.Context, paneID entity.PaneID, favicon *gdk.Texture) { log := logging.FromContext(ctx) // Get current URI to extract domain for caching wv := c.webViews[paneID] if wv == nil { return } uri := wv.URI() // Update favicon cache with domain key (handles cross-domain redirects) if c.faviconAdapter != nil && favicon != nil && uri != "" { c.navOriginMu.RLock() originURL := c.navOrigins[paneID] c.navOriginMu.RUnlock() c.faviconAdapter.StoreFromWebKitWithOrigin(ctx, uri, originURL, favicon) } // Update StackedView favicon if this pane is in a stack _, wsView := c.getActiveWS() if wsView != nil { tr := wsView.TreeRenderer() if tr != nil { stackedView := tr.GetStackedViewForPane(string(paneID)) if stackedView != nil { c.updateStackedPaneFavicon(ctx, stackedView, paneID, favicon) } } } log.Debug(). Str("pane_id", string(paneID)). Str("uri", uri). Bool("has_favicon", favicon != nil). Msg("pane favicon updated") } // updateStackedPaneFavicon updates the favicon of a pane in a StackedView. func (c *ContentCoordinator) updateStackedPaneFavicon( ctx context.Context, sv *layout.StackedView, paneID entity.PaneID, favicon *gdk.Texture, ) { log := logging.FromContext(ctx) // Find the pane's index directly in the StackedView index := sv.FindPaneIndex(string(paneID)) if index < 0 { log.Debug(). Str("pane_id", string(paneID)). Msg("pane not found in StackedView for favicon update") return } if err := sv.UpdateFaviconTexture(index, favicon); err != nil { log.Warn().Err(err).Int("index", index).Msg("failed to update stacked pane favicon") } } // FaviconAdapter returns the favicon adapter for external use (e.g., omnibox). func (c *ContentCoordinator) FaviconAdapter() *adapter.FaviconAdapter { return c.faviconAdapter } // SetNavigationOrigin records the original URL before navigation starts. // This allows caching favicons under both original and final domains // when cross-domain redirects occur (e.g., google.fr → google.com). func (c *ContentCoordinator) SetNavigationOrigin(paneID entity.PaneID, url string) { c.navOriginMu.Lock() c.navOrigins[paneID] = url c.navOriginMu.Unlock() } // PreloadCachedFavicon checks the favicon cache and updates the stacked pane // title bar immediately if a cached favicon exists for the URL. // This provides instant favicon display without waiting for WebKit. func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.PaneID, url string) { if c.faviconAdapter == nil || url == "" { return } // Check memory and disk cache (no external fetch) texture := c.faviconAdapter.PreloadFromCache(url) if texture == nil { return } // Update stacked pane favicon if applicable _, wsView := c.getActiveWS() if wsView != nil { tr := wsView.TreeRenderer() if tr != nil { stackedView := tr.GetStackedViewForPane(string(paneID)) if stackedView != nil { c.updateStackedPaneFavicon(ctx, stackedView, paneID, texture) } } } } // onLoadCommitted re-applies zoom when page content starts loading and records history. // WebKit may reset zoom during document transitions, so we reapply after LoadCommitted. // History is recorded here because the URI is guaranteed to be correct after commit. // Also shows the WebView widget (it's hidden during creation to avoid white flash). func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) { log := logging.FromContext(ctx) url := wv.URI() if url == "" { return } // Show the WebView now that content is being painted // (WebViews are hidden on creation to avoid white flash) // Skip showing if this is about:blank but the pane is loading a different URL // This prevents the brief flash of about:blank during initial navigation shouldShow := true if url == "about:blank" { // Get the pane's intended URI from the workspace ws, _ := c.getActiveWS() if ws != nil { if paneNode := ws.FindPane(paneID); paneNode != nil && paneNode.Pane != nil { // Don't show about:blank if the pane is supposed to load a different URL if paneNode.Pane.URI != "" && paneNode.Pane.URI != "about:blank" { shouldShow = false log.Debug(). Str("pane_id", string(paneID)). Str("pane_uri", paneNode.Pane.URI). Msg("skipping webview show for about:blank (pane loading different URL)") } } } } if !shouldShow { // Avoid updating UI/domain state to about:blank when we know the pane is // navigating to a different URL. This prevents the omnibox/window title from // briefly showing about:blank on cold start. c.clearPendingReveal(paneID) return } c.markPendingReveal(paneID) if wv.EstimatedProgress() > 0 { c.revealIfPending(ctx, paneID, url, "progress-after-commit") } // Update domain model with current URI for session snapshots c.updatePaneURI(paneID, url) // Record history - URI is guaranteed to be correct at LoadCommitted if c.onHistoryRecord != nil { c.onHistoryRecord(ctx, paneID, url) } // Apply zoom if c.zoomUC == nil { return } domain, err := usecase.ExtractDomain(url) if err != nil { return } _ = c.zoomUC.ApplyToWebView(ctx, wv, domain) } // onSPANavigation records history when URL changes via JavaScript (History API). // This handles SPA navigation like YouTube search, where the URL changes without a page load. func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, url string) { log := logging.FromContext(ctx) log.Debug().Str("pane_id", string(paneID)).Str("url", url).Msg("SPA navigation detected") // Update domain model with current URI for session snapshots c.updatePaneURI(paneID, url) // Record history for SPA navigation if c.onHistoryRecord != nil { c.onHistoryRecord(ctx, paneID, url) } } // updatePaneURI updates the pane's URI in the domain model. // This is called on navigation so that session snapshots capture the current URL. func (c *ContentCoordinator) updatePaneURI(paneID entity.PaneID, url string) { if c.onPaneURIUpdated != nil { c.onPaneURIUpdated(paneID, url) } } // onLoadStarted shows the progress bar when page loading begins. func (c *ContentCoordinator) onLoadStarted(paneID entity.PaneID) { _, wsView := c.getActiveWS() if wsView == nil { return } paneView := wsView.GetPaneView(paneID) if paneView != nil { paneView.SetLoading(true) } } // onLoadFinished hides the progress bar when page loading completes. func (c *ContentCoordinator) onLoadFinished(paneID entity.PaneID) { _, wsView := c.getActiveWS() if wsView == nil { return } paneView := wsView.GetPaneView(paneID) if paneView != nil { paneView.SetLoading(false) } c.revealIfPending(context.Background(), paneID, "", "load-finished") } // onProgressChanged updates the progress bar with current load progress. func (c *ContentCoordinator) onProgressChanged(paneID entity.PaneID, progress float64) { if progress > 0 { c.revealIfPending(context.Background(), paneID, "", "progress") } _, wsView := c.getActiveWS() if wsView == nil { return } paneView := wsView.GetPaneView(paneID) if paneView != nil { paneView.SetLoadProgress(progress) } } func (c *ContentCoordinator) markPendingReveal(paneID entity.PaneID) { c.revealMu.Lock() c.pendingReveal[paneID] = true c.revealMu.Unlock() } func (c *ContentCoordinator) clearPendingReveal(paneID entity.PaneID) { c.revealMu.Lock() delete(c.pendingReveal, paneID) c.revealMu.Unlock() } func (c *ContentCoordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, url, reason string) { c.revealMu.Lock() pending := c.pendingReveal[paneID] if pending { delete(c.pendingReveal, paneID) } c.revealMu.Unlock() if !pending { return } wv := c.webViews[paneID] if wv == nil || wv.IsDestroyed() { return } if inner := wv.Widget(); inner != nil { inner.SetVisible(true) logging.FromContext(ctx). Debug(). Str("pane_id", string(paneID)). Str("url", url). Str("reason", reason). Msg("webview revealed") } if c.onWebViewShown != nil { c.onWebViewShown(paneID) } } // onLinkHover updates the link status overlay when hovering over links. func (c *ContentCoordinator) onLinkHover(paneID entity.PaneID, uri string) { _, wsView := c.getActiveWS() if wsView == nil { return } paneView := wsView.GetPaneView(paneID) if paneView == nil { return } if uri != "" { paneView.ShowLinkStatus(uri) } else { paneView.HideLinkStatus() } } // --- Popup Handling --- // SetPopupConfig configures popup handling. func (c *ContentCoordinator) SetPopupConfig( factory *webkit.WebViewFactory, popupConfig *config.PopupBehaviorConfig, generateID func() string, ) { c.factory = factory c.popupConfig = popupConfig c.generateID = generateID } // SetOnInsertPopup sets the callback to insert popups into the workspace. func (c *ContentCoordinator) SetOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) { c.onInsertPopup = fn } // SetOnClosePane sets the callback to close a pane when its popup closes. func (c *ContentCoordinator) SetOnClosePane(fn func(ctx context.Context, paneID entity.PaneID) error) { c.onClosePane = fn } // SetupPopupHandling wires the popup create signal for a WebView. // This should be called after acquiring a WebView for a pane. func (c *ContentCoordinator) SetupPopupHandling(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) { log := logging.FromContext(ctx) if wv == nil { return } // Wire the OnCreate callback for popup requests wv.OnCreate = func(req webkit.PopupRequest) *webkit.WebView { return c.handlePopupCreate(ctx, paneID, wv, req) } log.Debug().Str("pane_id", string(paneID)).Msg("popup handling configured for webview") } // handlePopupCreate handles the WebKit "create" signal for popup windows. // Returns a new related WebView if popup is allowed, nil to block. func (c *ContentCoordinator) handlePopupCreate( ctx context.Context, parentPaneID entity.PaneID, parentWV *webkit.WebView, req webkit.PopupRequest, ) *webkit.WebView { log := logging.FromContext(ctx) log.Debug(). Str("parent_pane", string(parentPaneID)). Str("target_uri", req.TargetURI). Str("frame_name", req.FrameName). Bool("user_gesture", req.IsUserGesture). Msg("popup create request") // Check if popups are enabled if c.popupConfig != nil && !c.popupConfig.OpenInNewPane { log.Debug().Msg("popups disabled by config, blocking") return nil } // Check if factory is available if c.factory == nil { log.Warn().Msg("no webview factory, cannot create popup") return nil } // Create related WebView for session sharing parentID := parentWV.ID() popupWV, err := c.factory.CreateRelated(ctx, parentID) if err != nil { log.Error().Err(err).Msg("failed to create related webview for popup") return nil } // Detect popup type popupType := DetectPopupType(req.FrameName) // Store pending popup for ready-to-show handling popupID := popupWV.ID() pending := &PendingPopup{ WebView: popupWV, ParentPaneID: parentPaneID, ParentWebViewID: parentID, TargetURI: req.TargetURI, FrameName: req.FrameName, IsUserGesture: req.IsUserGesture, PopupType: popupType, CreatedAt: time.Now(), } c.popupMu.Lock() c.pendingPopups[popupID] = pending c.popupMu.Unlock() // Wire ready-to-show and close signals popupWV.OnReadyToShow = func() { c.handlePopupReadyToShow(ctx, popupID) } popupWV.OnClose = func() { c.handlePopupClose(ctx, popupID) } log.Info(). Uint64("popup_id", uint64(popupID)). Str("popup_type", popupType.String()). Str("target_uri", req.TargetURI). Msg("popup created, awaiting ready-to-show") return popupWV } // handlePopupReadyToShow handles the WebKit "ready-to-show" signal. // This is called when the popup is configured and ready to be displayed. func (c *ContentCoordinator) handlePopupReadyToShow(ctx context.Context, popupID port.WebViewID) { log := logging.FromContext(ctx) // Get pending popup c.popupMu.Lock() pending, ok := c.pendingPopups[popupID] if ok { delete(c.pendingPopups, popupID) } c.popupMu.Unlock() if !ok || pending == nil { log.Warn().Uint64("popup_id", uint64(popupID)).Msg("ready-to-show for unknown popup") return } log.Debug(). Uint64("popup_id", uint64(popupID)). Str("popup_type", pending.PopupType.String()). Msg("popup ready to show") // Determine behavior from config behavior := GetBehavior(pending.PopupType, c.popupConfig) placement := "right" if c.popupConfig != nil { placement = c.popupConfig.Placement } // Create popup pane entity var paneID entity.PaneID if c.generateID != nil { paneID = entity.PaneID(c.generateID()) } else { paneID = entity.PaneID(fmt.Sprintf("popup_%d", popupID)) } popupPane := entity.NewPane(paneID) popupPane.WindowType = entity.WindowPopup popupPane.IsRelated = true popupPane.ParentPaneID = &pending.ParentPaneID popupPane.URI = pending.TargetURI // Enable OAuth auto-close if configured if c.popupConfig != nil && c.popupConfig.OAuthAutoClose && IsOAuthURL(pending.TargetURI) { popupPane.AutoClose = true c.setupOAuthAutoClose(ctx, paneID, pending.WebView) } // Register WebView in our map c.webViews[paneID] = pending.WebView // Setup standard callbacks c.setupWebViewCallbacks(ctx, paneID, pending.WebView) // Request insertion into workspace if c.onInsertPopup != nil { popupInput := InsertPopupInput{ ParentPaneID: pending.ParentPaneID, PopupPane: popupPane, WebView: pending.WebView, Behavior: behavior, Placement: placement, PopupType: pending.PopupType, TargetURI: pending.TargetURI, } if err := c.onInsertPopup(ctx, popupInput); err != nil { log.Error().Err(err).Msg("failed to insert popup into workspace") // Clean up on failure delete(c.webViews, paneID) pending.WebView.Destroy() return } } log.Info(). Str("pane_id", string(paneID)). Str("behavior", string(behavior)). Str("placement", placement). Msg("popup inserted into workspace") } // handlePopupClose handles the WebKit "close" signal for popup windows. func (c *ContentCoordinator) handlePopupClose(ctx context.Context, popupID port.WebViewID) { log := logging.FromContext(ctx) log.Debug().Uint64("popup_id", uint64(popupID)).Msg("popup close signal received") // Check if still pending (never shown) c.popupMu.Lock() pending, wasPending := c.pendingPopups[popupID] if wasPending { delete(c.pendingPopups, popupID) } c.popupMu.Unlock() if wasPending && pending != nil { // Was never shown, just destroy pending.WebView.Destroy() log.Debug().Msg("destroyed pending popup that was never shown") return } // Find pane by WebView ID var paneID entity.PaneID for pid, wv := range c.webViews { if wv != nil && wv.ID() == popupID { paneID = pid break } } if paneID == "" { log.Warn().Msg("popup close: could not find pane for webview") return } // Close the pane in workspace (this removes the UI element) if c.onClosePane != nil { if err := c.onClosePane(ctx, paneID); err != nil { log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to close popup pane") } } // Release the WebView (this will clean up tracking) c.ReleaseWebView(ctx, paneID) log.Info().Str("pane_id", string(paneID)).Msg("popup closed") } // setupWebViewCallbacks configures standard callbacks for a WebView. func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) { // Title changes wv.OnTitleChanged = func(title string) { c.onTitleChanged(ctx, paneID, title) } // Favicon changes wv.OnFaviconChanged = func(favicon *gdk.Texture) { c.onFaviconChanged(ctx, paneID, favicon) } // Load events wv.OnLoadChanged = func(event webkit.LoadEvent) { switch event { case webkit.LoadStarted: c.onLoadStarted(paneID) case webkit.LoadCommitted: c.onLoadCommitted(ctx, paneID, wv) case webkit.LoadFinished: c.onLoadFinished(paneID) } } // Progress wv.OnProgressChanged = func(progress float64) { c.onProgressChanged(paneID, progress) } // SPA navigation wv.OnURIChanged = func(uri string) { if !wv.IsLoading() && uri != "" { c.onSPANavigation(ctx, paneID, uri) } } // Middle-click / Ctrl+click handler for opening links in new pane wv.OnLinkMiddleClick = func(uri string) bool { return c.handleLinkMiddleClick(ctx, paneID, uri) } // Link hover callback for status overlay wv.OnLinkHover = func(uri string) { c.onLinkHover(paneID, uri) } // Fullscreen handlers for idle inhibition c.setupIdleInhibitionHandlers(ctx, paneID, wv) } // setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes. func (c *ContentCoordinator) setupOAuthAutoClose(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) { log := logging.FromContext(ctx) // Wrap the existing OnURIChanged to also check for OAuth callbacks originalURIChanged := wv.OnURIChanged wv.OnURIChanged = func(uri string) { // Call original handler first if originalURIChanged != nil { originalURIChanged(uri) } // Check for OAuth callback if ShouldAutoClose(uri) { log.Info(). Str("pane_id", string(paneID)). Str("uri", uri). Msg("OAuth callback detected, auto-closing popup") // Trigger close after a short delay to let the page process go func() { time.Sleep(500 * time.Millisecond) if wv != nil && !wv.IsDestroyed() { // This will trigger handlePopupClose wv.Close() } }() } } // Also check on load committed for immediate redirects originalLoadChanged := wv.OnLoadChanged wv.OnLoadChanged = func(event webkit.LoadEvent) { if originalLoadChanged != nil { originalLoadChanged(event) } if event == webkit.LoadCommitted { uri := wv.URI() if ShouldAutoClose(uri) { log.Info(). Str("pane_id", string(paneID)). Str("uri", uri). Msg("OAuth callback detected on load, auto-closing popup") go func() { time.Sleep(500 * time.Millisecond) if wv != nil && !wv.IsDestroyed() { wv.Close() } }() } } } } // handleLinkMiddleClick handles middle-click / Ctrl+click on links. // Opens the link in a new pane using blank_target_behavior config. func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID entity.PaneID, uri string) bool { log := logging.FromContext(ctx) log.Info(). Str("parent_pane", string(parentPaneID)). Str("uri", uri). Msg("middle-click/ctrl+click on link") // Check if popups are enabled if c.popupConfig != nil && !c.popupConfig.OpenInNewPane { log.Debug().Msg("popups disabled by config, ignoring middle-click") return false } // Check if factory is available if c.factory == nil { log.Warn().Msg("no webview factory, cannot handle middle-click") return false } // Create a new WebView (regular, not related - just opening a link) newWV, err := c.factory.Create(ctx) if err != nil { log.Error().Err(err).Msg("failed to create webview for middle-click") return false } // Generate pane ID var paneID entity.PaneID if c.generateID != nil { paneID = entity.PaneID(c.generateID()) } else { paneID = entity.PaneID(fmt.Sprintf("link_%d", newWV.ID())) } // Create pane entity - uses blank_target_behavior newPane := entity.NewPane(paneID) newPane.WindowType = entity.WindowPopup newPane.URI = uri // Register WebView c.webViews[paneID] = newWV // Setup callbacks c.setupWebViewCallbacks(ctx, paneID, newWV) // Get behavior from config (same as _blank links) behavior := config.PopupBehaviorStacked // default if c.popupConfig != nil && c.popupConfig.BlankTargetBehavior != "" { behavior = config.PopupBehavior(c.popupConfig.BlankTargetBehavior) } placement := "right" if c.popupConfig != nil { placement = c.popupConfig.Placement } // Request insertion into workspace if c.onInsertPopup != nil { popupInput := InsertPopupInput{ ParentPaneID: parentPaneID, PopupPane: newPane, WebView: newWV, Behavior: behavior, Placement: placement, PopupType: PopupTypeTab, // Treat like _blank TargetURI: uri, } if err := c.onInsertPopup(ctx, popupInput); err != nil { log.Error().Err(err).Msg("failed to insert middle-click pane into workspace") // Clean up on failure delete(c.webViews, paneID) newWV.Destroy() return false } } // Load the URI after insertion if err := newWV.LoadURI(ctx, uri); err != nil { log.Error().Err(err).Str("uri", uri).Msg("failed to load URI in new pane") } log.Info(). Str("pane_id", string(paneID)). Str("behavior", string(behavior)). Str("uri", uri). Msg("middle-click link opened in new pane") return true } // setupIdleInhibitionHandlers configures fullscreen and audio callbacks for idle inhibition. // Idle is inhibited when: // - The webview enters fullscreen mode (e.g., fullscreen video) // - The webview is playing audio (e.g., video/music playback) // The inhibitor uses refcounting, so both can be active simultaneously. func (c *ContentCoordinator) setupIdleInhibitionHandlers(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) { log := logging.FromContext(ctx) if wv == nil { return } // Fullscreen handling wv.OnEnterFullscreen = func() bool { if c.idleInhibitor != nil { if err := c.idleInhibitor.Inhibit(ctx, "Fullscreen video playback"); err != nil { log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle") } } if c.onFullscreenChanged != nil { c.onFullscreenChanged(true) } return false // Allow fullscreen } wv.OnLeaveFullscreen = func() bool { if c.idleInhibitor != nil { if err := c.idleInhibitor.Uninhibit(ctx); err != nil { log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle") } } if c.onFullscreenChanged != nil { c.onFullscreenChanged(false) } return false // Allow leaving fullscreen } // Audio playback handling wv.OnAudioStateChanged = func(playing bool) { if c.idleInhibitor == nil { return } if playing { if err := c.idleInhibitor.Inhibit(ctx, "Media playback"); err != nil { log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle for audio") } } else { if err := c.idleInhibitor.Uninhibit(ctx); err != nil { log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle for audio") } } } }