1package coordinator
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "strings"
8 "sync"
9 "time"
10
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/infrastructure/config"
15 "github.com/bnema/dumber/internal/infrastructure/webkit"
16 "github.com/bnema/dumber/internal/logging"
17 "github.com/bnema/dumber/internal/ui/adapter"
18 "github.com/bnema/dumber/internal/ui/component"
19 "github.com/bnema/dumber/internal/ui/input"
20 "github.com/bnema/dumber/internal/ui/layout"
21 "github.com/jwijenbergh/puregotk/v4/gdk"
22)
23
24const (
25 aboutBlankURI = "about:blank"
26 logURLMaxLen = 80
27
28 // Dark theme background color (#0a0a0b) as float32 RGBA values
29 darkBgR = 0.039
30 darkBgG = 0.039
31 darkBgB = 0.043
32 darkBgA = 1.0
33)
34
35// ContentCoordinator manages WebView lifecycle, title tracking, and content attachment.
36type ContentCoordinator struct {
37 pool *webkit.WebViewPool
38 widgetFactory layout.WidgetFactory
39 faviconAdapter *adapter.FaviconAdapter
40 zoomUC *usecase.ManageZoomUseCase
41 injector *webkit.ContentInjector
42
43 webViews map[entity.PaneID]*webkit.WebView
44 paneTitles map[entity.PaneID]string
45 titleMu sync.RWMutex
46
47 // Track original navigation URLs to handle cross-domain redirects
48 // e.g., google.fr → google.com: cache favicon under both domains
49 navOrigins map[entity.PaneID]string
50 navOriginMu sync.RWMutex
51
52 // Callback to get active workspace state (avoids circular dependency)
53 getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
54
55 // Callback when title changes (for history persistence)
56 onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string)
57
58 // Callback when page is committed (for history recording)
59 onHistoryRecord func(ctx context.Context, paneID entity.PaneID, url string)
60
61 // Callback when pane URI changes (for session snapshots)
62 onPaneURIUpdated func(paneID entity.PaneID, url string)
63
64 // Callback when active pane title changes (for window title updates)
65 onWindowTitleChanged func(title string)
66
67 // Callback when the WebView becomes visible (first real commit)
68 onWebViewShown func(paneID entity.PaneID)
69
70 revealMu sync.Mutex
71 pendingReveal map[entity.PaneID]bool
72
73 appearanceMu sync.Mutex
74 pendingScriptRefresh map[entity.PaneID]bool
75 pendingThemePanes map[entity.PaneID]bool
76 pendingThemeUpdate pendingThemeUpdate
77 hasPendingThemeUpdate bool
78 currentTheme pendingThemeUpdate
79 hasCurrentTheme bool
80
81 // Gesture action handler for mouse button navigation
82 gestureActionHandler input.ActionHandler
83
84 // Popup handling
85 factory *webkit.WebViewFactory
86 popupConfig *config.PopupBehaviorConfig
87 pendingPopups map[port.WebViewID]*PendingPopup
88 popupMu sync.RWMutex
89
90 // Callback to insert popup into workspace (avoids circular dependency)
91 onInsertPopup func(ctx context.Context, input InsertPopupInput) error
92
93 // Callback to close a pane when popup closes
94 onClosePane func(ctx context.Context, paneID entity.PaneID) error
95
96 // ID generator for popup panes
97 generateID func() string
98
99 // Idle inhibitor for fullscreen video playback
100 idleInhibitor port.IdleInhibitor
101
102 // Callback when fullscreen state changes (for hiding/showing tab bar)
103 onFullscreenChanged func(entering bool)
104
105 // Callback when WebView gains focus (for accent picker text input targeting)
106 onWebViewFocused func(paneID entity.PaneID, wv *webkit.WebView)
107
108 // Callback for first load_started event (triggers deferred initialization)
109 onFirstLoadStarted func()
110 loadStartedOnce sync.Once
111}
112
113type pendingThemeUpdate struct {
114 prefersDark bool
115 cssText string
116}
117
118// NewContentCoordinator creates a new ContentCoordinator.
119func NewContentCoordinator(
120 ctx context.Context,
121 pool *webkit.WebViewPool,
122 widgetFactory layout.WidgetFactory,
123 faviconAdapter *adapter.FaviconAdapter,
124 getActiveWS func() (*entity.Workspace, *component.WorkspaceView),
125 zoomUC *usecase.ManageZoomUseCase,
126) *ContentCoordinator {
127 log := logging.FromContext(ctx)
128 log.Debug().Msg("creating content coordinator")
129
130 return &ContentCoordinator{
131 pool: pool,
132 widgetFactory: widgetFactory,
133 faviconAdapter: faviconAdapter,
134 zoomUC: zoomUC,
135 webViews: make(map[entity.PaneID]*webkit.WebView),
136 paneTitles: make(map[entity.PaneID]string),
137 navOrigins: make(map[entity.PaneID]string),
138 pendingReveal: make(map[entity.PaneID]bool),
139 getActiveWS: getActiveWS,
140 pendingPopups: make(map[port.WebViewID]*PendingPopup),
141 }
142}
143
144// SetOnTitleUpdated sets the callback for title changes (for history persistence).
145func (c *ContentCoordinator) SetOnTitleUpdated(fn func(ctx context.Context, paneID entity.PaneID, url, title string)) {
146 c.onTitleUpdated = fn
147}
148
149// SetOnHistoryRecord sets the callback for recording history on page commit.
150func (c *ContentCoordinator) SetOnHistoryRecord(fn func(ctx context.Context, paneID entity.PaneID, url string)) {
151 c.onHistoryRecord = fn
152}
153
154// SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots).
155func (c *ContentCoordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) {
156 c.onPaneURIUpdated = fn
157}
158
159// SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates).
160func (c *ContentCoordinator) SetOnWindowTitleChanged(fn func(title string)) {
161 c.onWindowTitleChanged = fn
162}
163
164// SetOnWebViewShown sets a callback that fires when a pane's WebView is shown.
165func (c *ContentCoordinator) SetOnWebViewShown(fn func(paneID entity.PaneID)) {
166 c.onWebViewShown = fn
167}
168
169// SetGestureActionHandler sets the callback for mouse button navigation gestures.
170func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler) {
171 c.gestureActionHandler = handler
172}
173
174// SetIdleInhibitor sets the idle inhibitor for fullscreen video playback.
175func (c *ContentCoordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) {
176 c.idleInhibitor = inhibitor
177}
178
179// SetOnFullscreenChanged sets the callback for fullscreen state changes.
180func (c *ContentCoordinator) SetOnFullscreenChanged(fn func(entering bool)) {
181 c.onFullscreenChanged = fn
182}
183
184// SetOnWebViewFocused sets the callback for when a WebView gains focus.
185func (c *ContentCoordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv *webkit.WebView)) {
186 c.onWebViewFocused = fn
187}
188
189// SetOnFirstLoadStarted sets the callback for when the first navigation starts.
190// This is used to trigger deferred initialization after the initial load_uri()
191// has been processed by the GTK main loop.
192func (c *ContentCoordinator) SetOnFirstLoadStarted(fn func()) {
193 c.onFirstLoadStarted = fn
194}
195
196// EnsureWebView acquires or reuses a WebView for the given pane.
197func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {
198 log := logging.FromContext(ctx)
199
200 if wv, ok := c.webViews[paneID]; ok && wv != nil && !wv.IsDestroyed() {
201 return wv, nil
202 }
203
204 if c.pool == nil {
205 return nil, fmt.Errorf("webview pool not configured")
206 }
207
208 // Mark tab_created on first webview (first tab)
209 if len(c.webViews) == 0 {
210 logging.Trace().Mark("tab_created")
211 }
212
213 wv, err := c.pool.Acquire(ctx)
214 if err != nil {
215 return nil, err
216 }
217 logging.Trace().Mark("webview_acquired")
218
219 c.webViews[paneID] = wv
220
221 // Set up title change callback
222 wv.OnTitleChanged = func(title string) {
223 c.onTitleChanged(ctx, paneID, title)
224 }
225
226 // Set up favicon change callback
227 wv.OnFaviconChanged = func(favicon *gdk.Texture) {
228 c.onFaviconChanged(ctx, paneID, favicon)
229 }
230
231 // Set up load change callback to re-apply zoom on navigation and handle progress bar
232 wv.OnLoadChanged = func(event webkit.LoadEvent) {
233 switch event {
234 case webkit.LoadStarted:
235 c.onLoadStarted(paneID)
236 case webkit.LoadCommitted:
237 c.onLoadCommitted(ctx, paneID, wv)
238 case webkit.LoadFinished:
239 c.onLoadFinished(ctx, paneID, wv)
240 }
241 }
242
243 // Set up progress callback for loading indicator
244 wv.OnProgressChanged = func(progress float64) {
245 c.onProgressChanged(paneID, progress)
246 }
247
248 // Set up URI change callback for SPA navigation (History API)
249 // This fires when URL changes via JavaScript without a full page load
250 wv.OnURIChanged = func(uri string) {
251 // Only record if not loading (SPA navigation via History API)
252 // Full page loads are handled by OnLoadCommitted
253 if !wv.IsLoading() && uri != "" {
254 c.onSPANavigation(ctx, paneID, uri)
255 }
256 }
257
258 // Set up middle-click / Ctrl+click handler for opening links in new pane
259 wv.OnLinkMiddleClick = func(uri string) bool {
260 return c.handleLinkMiddleClick(ctx, paneID, uri)
261 }
262
263 // Set up link hover callback for status overlay
264 wv.OnLinkHover = func(uri string) {
265 c.onLinkHover(paneID, uri)
266 }
267
268 // Set up fullscreen handlers for idle inhibition
269 c.setupIdleInhibitionHandlers(ctx, paneID, wv)
270
271 // Set up popup handling for this WebView
272 c.SetupPopupHandling(ctx, paneID, wv)
273
274 log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane")
275 return wv, nil
276}
277
278// ReleaseWebView returns the WebView for a pane to the pool.
279func (c *ContentCoordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) {
280 log := logging.FromContext(ctx)
281
282 wv, ok := c.webViews[paneID]
283 if !ok || wv == nil {
284 return
285 }
286 delete(c.webViews, paneID)
287 c.clearPendingAppearance(paneID)
288
289 // CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing),
290 // we must release the inhibition before destroying the webview.
291 // Otherwise the D-Bus inhibit request stays active forever.
292 if c.idleInhibitor != nil {
293 if wv.IsFullscreen() {
294 log.Debug().Str("pane_id", string(paneID)).Msg("releasing idle inhibition (was fullscreen)")
295 if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
296 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle on release (fullscreen)")
297 }
298 }
299 if wv.IsPlayingAudio() {
300 log.Debug().Str("pane_id", string(paneID)).Msg("releasing idle inhibition (was playing audio)")
301 if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
302 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle on release (audio)")
303 }
304 }
305 }
306
307 // Clean up title tracking
308 c.titleMu.Lock()
309 delete(c.paneTitles, paneID)
310 c.titleMu.Unlock()
311
312 // Clean up navigation origin tracking
313 c.navOriginMu.Lock()
314 delete(c.navOrigins, paneID)
315 c.navOriginMu.Unlock()
316
317 if c.pool != nil {
318 c.pool.Release(ctx, wv)
319 } else {
320 wv.Destroy()
321 }
322
323 log.Debug().Str("pane_id", string(paneID)).Msg("webview released")
324}
325
326// AttachToWorkspace ensures each pane in the workspace has a WebView widget attached.
327func (c *ContentCoordinator) AttachToWorkspace(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) {
328 log := logging.FromContext(ctx)
329
330 if ws == nil || wsView == nil || c.widgetFactory == nil {
331 return
332 }
333
334 for _, pane := range ws.AllPanes() {
335 if pane == nil {
336 continue
337 }
338
339 wv, err := c.EnsureWebView(ctx, pane.ID)
340 if err != nil {
341 log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to ensure webview for pane")
342 continue
343 }
344
345 // Load the pane's URI if set and different from current
346 if pane.URI != "" && pane.URI != wv.URI() {
347 if err := wv.LoadURI(ctx, pane.URI); err != nil {
348 log.Warn().Err(err).Str("pane_id", string(pane.ID)).Str("uri", pane.URI).Msg("failed to load pane URI")
349 }
350 }
351
352 widget := c.WrapWidget(ctx, wv)
353 if widget == nil {
354 continue
355 }
356
357 if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil {
358 log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget")
359 }
360 logging.Trace().Mark("webview_attached")
361 }
362}
363
364// WrapWidget converts a WebView to a layout.Widget for embedding.
365// It also attaches gesture handlers for mouse button navigation.
366func (c *ContentCoordinator) WrapWidget(ctx context.Context, wv *webkit.WebView) layout.Widget {
367 log := logging.FromContext(ctx)
368
369 if wv == nil || c.widgetFactory == nil {
370 log.Debug().Msg("cannot wrap nil webview or factory")
371 return nil
372 }
373
374 gtkView := wv.Widget()
375 if gtkView == nil {
376 return nil
377 }
378
379 widget := c.widgetFactory.WrapWidget(>kView.Widget)
380
381 // Attach gesture handler for mouse button 8/9 navigation
382 if widget != nil && c.gestureActionHandler != nil {
383 gestureHandler := input.NewGestureHandler(ctx)
384 gestureHandler.SetOnAction(c.gestureActionHandler)
385 gestureHandler.AttachTo(widget.GtkWidget())
386 log.Debug().Msg("gesture handler attached to webview")
387 }
388
389 return widget
390}
391
392// ActiveWebView returns the WebView for the active pane.
393func (c *ContentCoordinator) ActiveWebView(ctx context.Context) *webkit.WebView {
394 log := logging.FromContext(ctx)
395
396 ws, _ := c.getActiveWS()
397 if ws == nil {
398 log.Debug().Msg("no active workspace")
399 return nil
400 }
401
402 pane := ws.ActivePane()
403 if pane == nil || pane.Pane == nil {
404 log.Debug().Msg("no active pane")
405 return nil
406 }
407
408 return c.webViews[pane.Pane.ID]
409}
410
411// GetWebView returns the WebView for a specific pane.
412func (c *ContentCoordinator) GetWebView(paneID entity.PaneID) *webkit.WebView {
413 return c.webViews[paneID]
414}
415
416// RegisterPopupWebView registers a popup WebView that was created externally.
417// This is used when popup tabs are created and the WebView needs to be tracked.
418func (c *ContentCoordinator) RegisterPopupWebView(paneID entity.PaneID, wv *webkit.WebView) {
419 if wv != nil && paneID != "" {
420 c.webViews[paneID] = wv
421 }
422}
423
424// ApplyFiltersToAll applies content filters to all active webviews.
425// Called when filters become available after webviews were already created.
426func (c *ContentCoordinator) ApplyFiltersToAll(ctx context.Context, applier webkit.FilterApplier) {
427 log := logging.FromContext(ctx)
428
429 for paneID, wv := range c.webViews {
430 if wv != nil && !wv.IsDestroyed() {
431 applier.ApplyTo(ctx, wv.UserContentManager())
432 log.Debug().Str("pane_id", string(paneID)).Msg("applied filters to existing webview")
433 }
434 }
435}
436
437// ApplySettingsToAll reapplies WebKit settings to all active WebViews.
438func (c *ContentCoordinator) ApplySettingsToAll(ctx context.Context, sm *webkit.SettingsManager) {
439 log := logging.FromContext(ctx)
440 if sm == nil {
441 return
442 }
443
444 for paneID, wv := range c.webViews {
445 if wv == nil || wv.IsDestroyed() {
446 continue
447 }
448 sm.ApplyToWebView(ctx, wv.Widget())
449 log.Debug().Str("pane_id", string(paneID)).Msg("reapplied settings to webview")
450 }
451}
452
453// RefreshInjectedScriptsToAll clears and re-injects user scripts into all active WebViews.
454//
455// WebKit user scripts are snapshotted when added to a WebKitUserContentManager, so when
456// appearance settings change at runtime (dark mode, palettes, UI scale), we must refresh
457// the scripts so future navigations pick up the latest values.
458// Script refresh is deferred for any WebView that is currently loading to avoid
459// removing scripts mid-navigation.
460func (c *ContentCoordinator) RefreshInjectedScriptsToAll(ctx context.Context, injector *webkit.ContentInjector) {
461 log := logging.FromContext(ctx)
462 if injector == nil {
463 return
464 }
465
466 c.injector = injector
467 for paneID, wv := range c.webViews {
468 if wv == nil || wv.IsDestroyed() {
469 continue
470 }
471 if c.shouldDeferAppearance(wv) {
472 c.queueScriptRefresh(paneID)
473 log.Debug().Str("pane_id", string(paneID)).Msg("deferred script refresh until load finished")
474 continue
475 }
476
477 c.refreshInjectedScripts(ctx, injector, paneID, wv)
478 }
479}
480
481// ApplyWebUIThemeToAll updates theme CSS for already-loaded dumb:// pages.
482// This is necessary because user scripts only run on navigation.
483func (c *ContentCoordinator) ApplyWebUIThemeToAll(ctx context.Context, prefersDark bool, cssText string) {
484 log := logging.FromContext(ctx)
485 if cssText == "" {
486 return
487 }
488
489 c.setCurrentTheme(prefersDark, cssText)
490
491 script, err := buildWebUIThemeScript(prefersDark, cssText)
492 if err != nil {
493 log.Warn().Err(err).Msg("failed to build WebUI theme script")
494 return
495 }
496
497 for paneID, wv := range c.webViews {
498 if wv == nil || wv.IsDestroyed() {
499 continue
500 }
501 if c.shouldDeferAppearance(wv) {
502 c.queueThemeApply(paneID, prefersDark, cssText)
503 log.Debug().Str("pane_id", string(paneID)).Msg("deferred WebUI theme apply until load committed")
504 continue
505 }
506 c.applyWebUITheme(ctx, paneID, wv, script, prefersDark)
507 }
508}
509
510func buildWebUIThemeScript(prefersDark bool, cssText string) (string, error) {
511 cssJSON, err := json.Marshal(cssText)
512 if err != nil {
513 return "", err
514 }
515
516 script := fmt.Sprintf(`(function(){
517 try {
518 var cssText = %s;
519 var prefersDark = %t;
520
521 // Keep the global flag in sync
522 window.__dumber_gtk_prefers_dark = prefersDark;
523
524 // Update dark/light class
525 if (prefersDark) {
526 document.documentElement.classList.add('dark');
527 document.documentElement.classList.remove('light');
528 } else {
529 document.documentElement.classList.add('light');
530 document.documentElement.classList.remove('dark');
531 }
532
533 // Update or insert theme style
534 var style = document.querySelector('style[data-dumber-theme-vars]');
535 if (!style) {
536 style = document.createElement('style');
537 style.setAttribute('data-dumber-theme-vars', '');
538 (document.head || document.documentElement).appendChild(style);
539 }
540 style.textContent = cssText;
541
542 // Notify any running WebUI that theme changed
543 try {
544 window.dispatchEvent(new CustomEvent('dumber:theme-changed', {
545 detail: { prefersDark: prefersDark }
546 }));
547 } catch (e) {
548 // ignore
549 }
550
551 // Keep color-scheme consistent
552 var meta = document.querySelector('meta[name="color-scheme"]');
553 if (!meta) {
554 meta = document.createElement('meta');
555 meta.name = 'color-scheme';
556 document.documentElement.appendChild(meta);
557 }
558 meta.content = prefersDark ? 'dark light' : 'light dark';
559 } catch (e) {
560 console.error('[dumber] failed to apply theme', e);
561 }
562})();`, string(cssJSON), prefersDark)
563
564 return script, nil
565}
566
567func (c *ContentCoordinator) applyWebUITheme(
568 ctx context.Context,
569 paneID entity.PaneID,
570 wv *webkit.WebView,
571 script string,
572 prefersDark bool,
573) {
574 if wv == nil || wv.IsDestroyed() {
575 return
576 }
577 uri := wv.URI()
578 if !strings.HasPrefix(uri, "dumb://") {
579 return
580 }
581 wv.RunJavaScript(ctx, script, "")
582 logging.FromContext(ctx).
583 Debug().
584 Str("pane_id", string(paneID)).
585 Str("uri", uri).
586 Bool("prefers_dark", prefersDark).
587 Msg("applied WebUI theme")
588}
589
590func (c *ContentCoordinator) queueThemeApply(paneID entity.PaneID, prefersDark bool, cssText string) {
591 c.appearanceMu.Lock()
592 if c.pendingThemePanes == nil {
593 c.pendingThemePanes = make(map[entity.PaneID]bool)
594 }
595 c.pendingThemePanes[paneID] = true
596 c.pendingThemeUpdate = pendingThemeUpdate{
597 prefersDark: prefersDark,
598 cssText: cssText,
599 }
600 c.hasPendingThemeUpdate = true
601 c.appearanceMu.Unlock()
602}
603
604func (c *ContentCoordinator) setCurrentTheme(prefersDark bool, cssText string) {
605 c.appearanceMu.Lock()
606 c.currentTheme = pendingThemeUpdate{
607 prefersDark: prefersDark,
608 cssText: cssText,
609 }
610 c.hasCurrentTheme = true
611 c.appearanceMu.Unlock()
612}
613
614func (c *ContentCoordinator) getCurrentTheme() (pendingThemeUpdate, bool) {
615 c.appearanceMu.Lock()
616 defer c.appearanceMu.Unlock()
617
618 if !c.hasCurrentTheme {
619 return pendingThemeUpdate{}, false
620 }
621 return c.currentTheme, true
622}
623
624func (c *ContentCoordinator) takePendingThemeApply(paneID entity.PaneID) (pendingThemeUpdate, bool) {
625 c.appearanceMu.Lock()
626 defer c.appearanceMu.Unlock()
627
628 if !c.hasPendingThemeUpdate || c.pendingThemePanes == nil || !c.pendingThemePanes[paneID] {
629 return pendingThemeUpdate{}, false
630 }
631 delete(c.pendingThemePanes, paneID)
632 update := c.pendingThemeUpdate
633 if len(c.pendingThemePanes) == 0 {
634 c.hasPendingThemeUpdate = false
635 }
636 return update, true
637}
638
639func (c *ContentCoordinator) applyPendingThemeUpdate(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) bool {
640 update, ok := c.takePendingThemeApply(paneID)
641 if !ok {
642 return false
643 }
644
645 script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
646 if err != nil {
647 logging.FromContext(ctx).Warn().Err(err).Msg("failed to build deferred WebUI theme script")
648 return false
649 }
650 c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
651 return true
652}
653
654func (c *ContentCoordinator) applyCurrentTheme(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) bool {
655 update, ok := c.getCurrentTheme()
656 if !ok || update.cssText == "" {
657 return false
658 }
659
660 script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
661 if err != nil {
662 logging.FromContext(ctx).Warn().Err(err).Msg("failed to build current WebUI theme script")
663 return false
664 }
665 c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
666 return true
667}
668
669func (c *ContentCoordinator) queueScriptRefresh(paneID entity.PaneID) {
670 c.appearanceMu.Lock()
671 if c.pendingScriptRefresh == nil {
672 c.pendingScriptRefresh = make(map[entity.PaneID]bool)
673 }
674 c.pendingScriptRefresh[paneID] = true
675 c.appearanceMu.Unlock()
676}
677
678func (c *ContentCoordinator) takePendingScriptRefresh(paneID entity.PaneID) bool {
679 c.appearanceMu.Lock()
680 defer c.appearanceMu.Unlock()
681
682 if c.pendingScriptRefresh == nil || !c.pendingScriptRefresh[paneID] {
683 return false
684 }
685 delete(c.pendingScriptRefresh, paneID)
686 return true
687}
688
689func (c *ContentCoordinator) refreshPendingScripts(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
690 if wv == nil || wv.IsDestroyed() || c.shouldDeferAppearance(wv) {
691 return
692 }
693 if !c.takePendingScriptRefresh(paneID) {
694 return
695 }
696 if c.injector == nil {
697 return
698 }
699 c.refreshInjectedScripts(ctx, c.injector, paneID, wv)
700}
701
702func (c *ContentCoordinator) shouldDeferAppearance(wv *webkit.WebView) bool {
703 if wv == nil || wv.IsDestroyed() {
704 return false
705 }
706 if wv.IsLoading() {
707 return true
708 }
709 return wv.EstimatedProgress() < 1.0
710}
711
712func (c *ContentCoordinator) refreshInjectedScripts(
713 ctx context.Context,
714 injector *webkit.ContentInjector,
715 paneID entity.PaneID,
716 wv *webkit.WebView,
717) {
718 if injector == nil || wv == nil || wv.IsDestroyed() {
719 return
720 }
721 ucm := wv.UserContentManager()
722 if ucm == nil {
723 return
724 }
725 ucm.RemoveAllScripts()
726 ucm.RemoveAllStyleSheets()
727 injector.InjectScripts(ctx, ucm, wv.ID())
728 logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")
729}
730
731func (c *ContentCoordinator) clearPendingAppearance(paneID entity.PaneID) {
732 c.appearanceMu.Lock()
733 if c.pendingScriptRefresh != nil {
734 delete(c.pendingScriptRefresh, paneID)
735 }
736 if c.pendingThemePanes != nil {
737 delete(c.pendingThemePanes, paneID)
738 if len(c.pendingThemePanes) == 0 {
739 c.hasPendingThemeUpdate = false
740 }
741 }
742 c.appearanceMu.Unlock()
743}
744
745// GetTitle returns the current title for a pane.
746func (c *ContentCoordinator) GetTitle(paneID entity.PaneID) string {
747 c.titleMu.RLock()
748 defer c.titleMu.RUnlock()
749 return c.paneTitles[paneID]
750}
751
752// onTitleChanged updates title tracking when a WebView's title changes.
753func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.PaneID, title string) {
754 log := logging.FromContext(ctx)
755
756 // Update title map
757 c.titleMu.Lock()
758 c.paneTitles[paneID] = title
759 c.titleMu.Unlock()
760
761 // Update domain model and check if this is the active pane
762 isActivePaneTitle := false
763 ws, wsView := c.getActiveWS()
764 if ws != nil {
765 paneNode := ws.FindPane(paneID)
766 if paneNode != nil && paneNode.Pane != nil {
767 paneNode.Pane.Title = title
768 }
769 // Check if this pane is the active one
770 if ws.ActivePaneID == paneID {
771 isActivePaneTitle = true
772 }
773 }
774
775 // Update StackedView title bar if this pane is in a stack
776 if wsView != nil {
777 tr := wsView.TreeRenderer()
778 if tr != nil {
779 stackedView := tr.GetStackedViewForPane(string(paneID))
780 if stackedView != nil {
781 c.updateStackedPaneTitle(ctx, stackedView, paneID, title)
782 }
783 }
784 }
785
786 // Notify history persistence (get URL from WebView)
787 if c.onTitleUpdated != nil {
788 if wv := c.webViews[paneID]; wv != nil {
789 url := wv.URI()
790 if url != "" && title != "" {
791 c.onTitleUpdated(ctx, paneID, url, title)
792 }
793 }
794 }
795
796 // Notify window title update if this is the active pane
797 if isActivePaneTitle && c.onWindowTitleChanged != nil {
798 c.onWindowTitleChanged(title)
799 }
800
801 log.Debug().
802 Str("pane_id", string(paneID)).
803 Str("title", title).
804 Msg("pane title updated")
805}
806
807// updateStackedPaneTitle updates the title of a pane in a StackedView.
808func (c *ContentCoordinator) updateStackedPaneTitle(
809 ctx context.Context,
810 sv *layout.StackedView,
811 paneID entity.PaneID,
812 title string,
813) {
814 log := logging.FromContext(ctx)
815
816 // Find the pane's index directly in the StackedView
817 index := sv.FindPaneIndex(string(paneID))
818 if index < 0 {
819 log.Debug().
820 Str("pane_id", string(paneID)).
821 Msg("pane not found in StackedView for title update")
822 return
823 }
824
825 if err := sv.UpdateTitle(index, title); err != nil {
826 log.Warn().Err(err).Int("index", index).Msg("failed to update stacked pane title")
827 }
828}
829
830// onFaviconChanged updates favicon tracking when a WebView's favicon changes.
831func (c *ContentCoordinator) onFaviconChanged(ctx context.Context, paneID entity.PaneID, favicon *gdk.Texture) {
832 log := logging.FromContext(ctx)
833
834 // Get current URI to extract domain for caching
835 wv := c.webViews[paneID]
836 if wv == nil {
837 return
838 }
839 uri := wv.URI()
840
841 // Update favicon cache with domain key (handles cross-domain redirects)
842 if c.faviconAdapter != nil && favicon != nil && uri != "" {
843 c.navOriginMu.RLock()
844 originURL := c.navOrigins[paneID]
845 c.navOriginMu.RUnlock()
846 c.faviconAdapter.StoreFromWebKitWithOrigin(ctx, uri, originURL, favicon)
847 }
848
849 // Update StackedView favicon if this pane is in a stack
850 _, wsView := c.getActiveWS()
851 if wsView != nil {
852 tr := wsView.TreeRenderer()
853 if tr != nil {
854 stackedView := tr.GetStackedViewForPane(string(paneID))
855 if stackedView != nil {
856 c.updateStackedPaneFavicon(ctx, stackedView, paneID, favicon)
857 }
858 }
859 }
860
861 log.Debug().
862 Str("pane_id", string(paneID)).
863 Str("uri", uri).
864 Bool("has_favicon", favicon != nil).
865 Msg("pane favicon updated")
866}
867
868// updateStackedPaneFavicon updates the favicon of a pane in a StackedView.
869func (c *ContentCoordinator) updateStackedPaneFavicon(
870 ctx context.Context,
871 sv *layout.StackedView,
872 paneID entity.PaneID,
873 favicon *gdk.Texture,
874) {
875 log := logging.FromContext(ctx)
876
877 // Find the pane's index directly in the StackedView
878 index := sv.FindPaneIndex(string(paneID))
879 if index < 0 {
880 log.Debug().
881 Str("pane_id", string(paneID)).
882 Msg("pane not found in StackedView for favicon update")
883 return
884 }
885
886 if err := sv.UpdateFaviconTexture(index, favicon); err != nil {
887 log.Warn().Err(err).Int("index", index).Msg("failed to update stacked pane favicon")
888 }
889}
890
891// FaviconAdapter returns the favicon adapter for external use (e.g., omnibox).
892func (c *ContentCoordinator) FaviconAdapter() *adapter.FaviconAdapter {
893 return c.faviconAdapter
894}
895
896// SetNavigationOrigin records the original URL before navigation starts.
897// This allows caching favicons under both original and final domains
898// when cross-domain redirects occur (e.g., google.fr → google.com).
899func (c *ContentCoordinator) SetNavigationOrigin(paneID entity.PaneID, url string) {
900 c.navOriginMu.Lock()
901 c.navOrigins[paneID] = url
902 c.navOriginMu.Unlock()
903}
904
905// PreloadCachedFavicon checks the favicon cache and updates the stacked pane
906// title bar immediately if a cached favicon exists for the URL.
907// This provides instant favicon display without waiting for WebKit.
908func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.PaneID, url string) {
909 if c.faviconAdapter == nil || url == "" {
910 return
911 }
912
913 // Check memory and disk cache (no external fetch)
914 texture := c.faviconAdapter.PreloadFromCache(url)
915 if texture == nil {
916 return
917 }
918
919 // Update stacked pane favicon if applicable
920 _, wsView := c.getActiveWS()
921 if wsView != nil {
922 tr := wsView.TreeRenderer()
923 if tr != nil {
924 stackedView := tr.GetStackedViewForPane(string(paneID))
925 if stackedView != nil {
926 c.updateStackedPaneFavicon(ctx, stackedView, paneID, texture)
927 }
928 }
929 }
930}
931
932// onLoadCommitted re-applies zoom when page content starts loading and records history.
933// WebKit may reset zoom during document transitions, so we reapply after LoadCommitted.
934// History is recorded here because the URI is guaranteed to be correct after commit.
935// Also shows the WebView widget (it's hidden during creation to avoid white flash).
936func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
937 log := logging.FromContext(ctx)
938 logging.Trace().Mark("load_committed")
939
940 url := wv.URI()
941 if url == "" {
942 return
943 }
944
945 // Set appropriate background color based on page type to prevent dark background bleeding.
946 switch {
947 case strings.HasPrefix(url, "dumb://"):
948 // Internal pages: apply themed background
949 theme, ok := c.getCurrentTheme()
950 if ok && theme.prefersDark {
951 wv.SetBackgroundColor(darkBgR, darkBgG, darkBgB, darkBgA)
952 } else {
953 wv.ResetBackgroundToDefault()
954 }
955 case strings.HasPrefix(url, "about:"):
956 // Keep pool background (no action)
957 default:
958 // External pages: white background
959 wv.ResetBackgroundToDefault()
960 }
961
962 // Show the WebView now that content is being painted
963 // (WebViews are hidden on creation to avoid white flash)
964 // Skip showing if this is about:blank but the pane is loading a different URL
965 // This prevents the brief flash of about:blank during initial navigation
966 shouldShow := true
967 if url == aboutBlankURI {
968 // Get the pane's intended URI from the workspace
969 ws, _ := c.getActiveWS()
970 if ws != nil {
971 if paneNode := ws.FindPane(paneID); paneNode != nil && paneNode.Pane != nil {
972 // Don't show about:blank if the pane is supposed to load a different URL
973 if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
974 shouldShow = false
975 log.Debug().
976 Str("pane_id", string(paneID)).
977 Str("pane_uri", paneNode.Pane.URI).
978 Msg("skipping webview show for about:blank (pane loading different URL)")
979 }
980 }
981 }
982 }
983
984 if !shouldShow {
985 // Avoid updating UI/domain state to about:blank when we know the pane is
986 // navigating to a different URL. This prevents the omnibox/window title from
987 // briefly showing about:blank on cold start.
988 c.clearPendingReveal(paneID)
989 return
990 }
991
992 if !c.applyPendingThemeUpdate(ctx, paneID, wv) {
993 c.applyCurrentTheme(ctx, paneID, wv)
994 }
995
996 c.markPendingReveal(paneID)
997 if wv.EstimatedProgress() > 0 {
998 c.revealIfPending(ctx, paneID, url, "progress-after-commit")
999 }
1000
1001 // Update domain model with current URI for session snapshots
1002 c.updatePaneURI(paneID, url)
1003
1004 // Record history - URI is guaranteed to be correct at LoadCommitted
1005 if c.onHistoryRecord != nil {
1006 c.onHistoryRecord(ctx, paneID, url)
1007 }
1008
1009 // Apply zoom
1010 if c.zoomUC == nil {
1011 return
1012 }
1013
1014 domain, err := usecase.ExtractDomain(url)
1015 if err != nil {
1016 return
1017 }
1018
1019 _ = c.zoomUC.ApplyToWebView(ctx, wv, domain)
1020}
1021
1022func (c *ContentCoordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID, wv *webkit.WebView) bool {
1023 if wv == nil || wv.IsDestroyed() {
1024 return false
1025 }
1026 if wv.URI() != aboutBlankURI {
1027 return false
1028 }
1029 ws, _ := c.getActiveWS()
1030 if ws == nil {
1031 return false
1032 }
1033 paneNode := ws.FindPane(paneID)
1034 if paneNode == nil || paneNode.Pane == nil {
1035 return false
1036 }
1037 if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
1038 return true
1039 }
1040 return false
1041}
1042
1043// onSPANavigation records history when URL changes via JavaScript (History API).
1044// This handles SPA navigation like YouTube search, where the URL changes without a page load.
1045func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, url string) {
1046 log := logging.FromContext(ctx)
1047 log.Debug().Str("pane_id", string(paneID)).Str("url", url).Msg("SPA navigation detected")
1048
1049 // Update domain model with current URI for session snapshots
1050 c.updatePaneURI(paneID, url)
1051
1052 // Record history for SPA navigation
1053 if c.onHistoryRecord != nil {
1054 c.onHistoryRecord(ctx, paneID, url)
1055 }
1056}
1057
1058// updatePaneURI updates the pane's URI in the domain model.
1059// This is called on navigation so that session snapshots capture the current URL.
1060func (c *ContentCoordinator) updatePaneURI(paneID entity.PaneID, url string) {
1061 if c.onPaneURIUpdated != nil {
1062 c.onPaneURIUpdated(paneID, url)
1063 }
1064}
1065
1066// onLoadStarted shows the progress bar when page loading begins.
1067func (c *ContentCoordinator) onLoadStarted(paneID entity.PaneID) {
1068 logging.Trace().Mark("load_started")
1069
1070 // Trigger deferred initialization on first load_started.
1071 // This ensures non-critical init runs after initial navigation starts.
1072 c.loadStartedOnce.Do(func() {
1073 if c.onFirstLoadStarted != nil {
1074 c.onFirstLoadStarted()
1075 }
1076 })
1077
1078 _, wsView := c.getActiveWS()
1079 if wsView == nil {
1080 return
1081 }
1082
1083 paneView := wsView.GetPaneView(paneID)
1084 if paneView != nil {
1085 paneView.SetLoading(true)
1086 }
1087}
1088
1089// onLoadFinished hides the progress bar when page loading completes.
1090func (c *ContentCoordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1091 _, wsView := c.getActiveWS()
1092 if wsView == nil {
1093 return
1094 }
1095
1096 paneView := wsView.GetPaneView(paneID)
1097 if paneView != nil {
1098 paneView.SetLoading(false)
1099 }
1100
1101 c.revealIfPending(context.Background(), paneID, "", "load-finished")
1102 if c.shouldSkipAboutBlankAppearance(paneID, wv) {
1103 return
1104 }
1105 c.applyPendingThemeUpdate(ctx, paneID, wv)
1106 c.refreshPendingScripts(ctx, paneID, wv)
1107}
1108
1109// onProgressChanged updates the progress bar with current load progress.
1110func (c *ContentCoordinator) onProgressChanged(paneID entity.PaneID, progress float64) {
1111 if progress > 0 {
1112 c.revealIfPending(context.Background(), paneID, "", "progress")
1113 }
1114
1115 _, wsView := c.getActiveWS()
1116 if wsView == nil {
1117 return
1118 }
1119
1120 paneView := wsView.GetPaneView(paneID)
1121 if paneView != nil {
1122 paneView.SetLoadProgress(progress)
1123 }
1124}
1125
1126func (c *ContentCoordinator) markPendingReveal(paneID entity.PaneID) {
1127 c.revealMu.Lock()
1128 c.pendingReveal[paneID] = true
1129 c.revealMu.Unlock()
1130}
1131
1132func (c *ContentCoordinator) clearPendingReveal(paneID entity.PaneID) {
1133 c.revealMu.Lock()
1134 delete(c.pendingReveal, paneID)
1135 c.revealMu.Unlock()
1136}
1137
1138func (c *ContentCoordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, url, reason string) {
1139 c.revealMu.Lock()
1140 pending := c.pendingReveal[paneID]
1141 if pending {
1142 delete(c.pendingReveal, paneID)
1143 }
1144 c.revealMu.Unlock()
1145
1146 if !pending {
1147 return
1148 }
1149
1150 wv := c.webViews[paneID]
1151 if wv == nil || wv.IsDestroyed() {
1152 return
1153 }
1154
1155 if inner := wv.Widget(); inner != nil {
1156 inner.SetVisible(true)
1157 logging.FromContext(ctx).
1158 Debug().
1159 Str("pane_id", string(paneID)).
1160 Str("url", url).
1161 Str("reason", reason).
1162 Msg("webview revealed")
1163 }
1164
1165 // Mark first_paint and finish startup trace
1166 logging.Trace().Mark("first_paint")
1167 logging.Trace().Finish()
1168
1169 if c.onWebViewShown != nil {
1170 c.onWebViewShown(paneID)
1171 }
1172}
1173
1174// onLinkHover updates the link status overlay when hovering over links.
1175func (c *ContentCoordinator) onLinkHover(paneID entity.PaneID, uri string) {
1176 _, wsView := c.getActiveWS()
1177 if wsView == nil {
1178 return
1179 }
1180
1181 paneView := wsView.GetPaneView(paneID)
1182 if paneView == nil {
1183 return
1184 }
1185
1186 if uri != "" {
1187 paneView.ShowLinkStatus(uri)
1188 } else {
1189 paneView.HideLinkStatus()
1190 }
1191}
1192
1193// --- Popup Handling ---
1194
1195// SetPopupConfig configures popup handling.
1196func (c *ContentCoordinator) SetPopupConfig(
1197 factory *webkit.WebViewFactory,
1198 popupConfig *config.PopupBehaviorConfig,
1199 generateID func() string,
1200) {
1201 c.factory = factory
1202 c.popupConfig = popupConfig
1203 c.generateID = generateID
1204}
1205
1206// SetOnInsertPopup sets the callback to insert popups into the workspace.
1207func (c *ContentCoordinator) SetOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {
1208 c.onInsertPopup = fn
1209}
1210
1211// SetOnClosePane sets the callback to close a pane when its popup closes.
1212func (c *ContentCoordinator) SetOnClosePane(fn func(ctx context.Context, paneID entity.PaneID) error) {
1213 c.onClosePane = fn
1214}
1215
1216// SetupPopupHandling wires the popup create signal for a WebView.
1217// This should be called after acquiring a WebView for a pane.
1218func (c *ContentCoordinator) SetupPopupHandling(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1219 log := logging.FromContext(ctx)
1220
1221 if wv == nil {
1222 return
1223 }
1224
1225 // Wire the OnCreate callback for popup requests
1226 wv.OnCreate = func(req webkit.PopupRequest) *webkit.WebView {
1227 return c.handlePopupCreate(ctx, paneID, wv, req)
1228 }
1229
1230 log.Debug().Str("pane_id", string(paneID)).Msg("popup handling configured for webview")
1231}
1232
1233// createPopupPane creates a new pane entity for a popup window.
1234func (c *ContentCoordinator) createPopupPane(
1235 popupID port.WebViewID,
1236 parentPaneID entity.PaneID,
1237 targetURI string,
1238) (entity.PaneID, *entity.Pane) {
1239 var paneID entity.PaneID
1240 if c.generateID != nil {
1241 paneID = entity.PaneID(c.generateID())
1242 } else {
1243 paneID = entity.PaneID(fmt.Sprintf("popup_%d", popupID))
1244 }
1245
1246 popupPane := entity.NewPane(paneID)
1247 popupPane.WindowType = entity.WindowPopup
1248 popupPane.IsRelated = true
1249 popupPane.ParentPaneID = &parentPaneID
1250 popupPane.URI = targetURI
1251
1252 return paneID, popupPane
1253}
1254
1255// handlePopupCreate handles the WebKit "create" signal for popup windows.
1256// Returns a new related WebView if popup is allowed, nil to block.
1257//
1258// IMPORTANT: The WebView MUST be added to a GtkWindow hierarchy BEFORE this
1259// signal handler returns. Otherwise WebKit won't establish window.opener.
1260// The WebView stays hidden until ready-to-show signal is received.
1261func (c *ContentCoordinator) handlePopupCreate(
1262 ctx context.Context,
1263 parentPaneID entity.PaneID,
1264 parentWV *webkit.WebView,
1265 req webkit.PopupRequest,
1266) *webkit.WebView {
1267 log := logging.FromContext(ctx)
1268
1269 log.Debug().
1270 Str("parent_pane", string(parentPaneID)).
1271 Str("target_uri", req.TargetURI).
1272 Str("frame_name", req.FrameName).
1273 Bool("user_gesture", req.IsUserGesture).
1274 Msg("popup create request")
1275
1276 // Check if popups are enabled
1277 if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
1278 log.Debug().Msg("popups disabled by config, blocking")
1279 return nil
1280 }
1281
1282 // Check if factory is available
1283 if c.factory == nil {
1284 log.Warn().Msg("no webview factory, cannot create popup")
1285 return nil
1286 }
1287
1288 // Create related WebView for session sharing (created hidden)
1289 parentID := parentWV.ID()
1290 popupWV, err := c.factory.CreateRelated(ctx, parentID)
1291 if err != nil {
1292 log.Error().Err(err).Msg("failed to create related webview for popup")
1293 return nil
1294 }
1295
1296 // Detect popup type
1297 popupType := DetectPopupType(req.FrameName)
1298 popupID := popupWV.ID()
1299
1300 // Determine behavior from config
1301 behavior := GetBehavior(popupType, c.popupConfig)
1302 placement := "right"
1303 if c.popupConfig != nil {
1304 placement = c.popupConfig.Placement
1305 }
1306
1307 // Create popup pane entity
1308 paneID, popupPane := c.createPopupPane(popupID, parentPaneID, req.TargetURI)
1309
1310 // Check OAuth configuration
1311 hasConfig := c.popupConfig != nil
1312 oauthEnabled := hasConfig && c.popupConfig.OAuthAutoClose
1313 isOAuth := IsOAuthURL(req.TargetURI)
1314 log.Debug().
1315 Bool("has_config", hasConfig).
1316 Bool("oauth_enabled", oauthEnabled).
1317 Bool("is_oauth", isOAuth).
1318 Str("uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
1319 Msg("popup OAuth check")
1320
1321 // Insert into workspace IMMEDIATELY (WebView stays hidden)
1322 // This is required for WebKit to establish window.opener relationship
1323 if c.onInsertPopup != nil {
1324 popupInput := InsertPopupInput{
1325 ParentPaneID: parentPaneID,
1326 PopupPane: popupPane,
1327 WebView: popupWV,
1328 Behavior: behavior,
1329 Placement: placement,
1330 PopupType: popupType,
1331 TargetURI: req.TargetURI,
1332 }
1333
1334 if err := c.onInsertPopup(ctx, popupInput); err != nil {
1335 log.Error().Err(err).Msg("failed to insert popup into workspace")
1336 popupWV.Destroy()
1337 return nil
1338 }
1339 }
1340
1341 // Register WebView in our map (after successful insertion)
1342 c.webViews[paneID] = popupWV
1343
1344 // Setup standard callbacks (after successful insertion to avoid leak)
1345 c.setupWebViewCallbacks(ctx, paneID, popupWV)
1346
1347 // Setup OAuth auto-close if configured
1348 if hasConfig && oauthEnabled && isOAuth {
1349 popupPane.AutoClose = true
1350 c.setupOAuthAutoClose(ctx, paneID, popupWV)
1351 log.Debug().Str("pane_id", string(paneID)).Msg("OAuth auto-close enabled for popup")
1352 }
1353
1354 // Store pending popup for ready-to-show handling (just visibility now)
1355 pending := &PendingPopup{
1356 WebView: popupWV,
1357 ParentPaneID: parentPaneID,
1358 ParentWebViewID: parentID,
1359 TargetURI: req.TargetURI,
1360 FrameName: req.FrameName,
1361 IsUserGesture: req.IsUserGesture,
1362 PopupType: popupType,
1363 CreatedAt: time.Now(),
1364 }
1365
1366 c.popupMu.Lock()
1367 c.pendingPopups[popupID] = pending
1368 c.popupMu.Unlock()
1369
1370 // Wire ready-to-show (now just for visibility) and close signals
1371 popupWV.OnReadyToShow = func() {
1372 c.handlePopupReadyToShow(ctx, popupID)
1373 }
1374 popupWV.OnClose = func() {
1375 c.handlePopupClose(ctx, popupID)
1376 }
1377
1378 log.Info().
1379 Uint64("popup_id", uint64(popupID)).
1380 Str("pane_id", string(paneID)).
1381 Str("popup_type", popupType.String()).
1382 Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
1383 Msg("popup inserted (hidden), awaiting ready-to-show for visibility")
1384
1385 return popupWV
1386}
1387
1388// handlePopupReadyToShow handles the WebKit "ready-to-show" signal.
1389// The popup WebView was already inserted into the workspace (hidden) during
1390// the create signal. This handler just makes it visible.
1391func (c *ContentCoordinator) handlePopupReadyToShow(ctx context.Context, popupID port.WebViewID) {
1392 log := logging.FromContext(ctx)
1393
1394 // Get pending popup
1395 c.popupMu.Lock()
1396 pending, ok := c.pendingPopups[popupID]
1397 if ok {
1398 delete(c.pendingPopups, popupID)
1399 }
1400 c.popupMu.Unlock()
1401
1402 if !ok || pending == nil {
1403 log.Warn().Uint64("popup_id", uint64(popupID)).Msg("ready-to-show for unknown popup")
1404 return
1405 }
1406
1407 log.Debug().
1408 Uint64("popup_id", uint64(popupID)).
1409 Str("popup_type", pending.PopupType.String()).
1410 Msg("popup ready to show - making visible")
1411
1412 // Make the WebView visible now that it's ready
1413 if pending.WebView != nil {
1414 pending.WebView.Show()
1415 }
1416
1417 log.Info().
1418 Uint64("popup_id", uint64(popupID)).
1419 Str("target_uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
1420 Msg("popup now visible")
1421}
1422
1423// handlePopupClose handles the WebKit "close" signal for popup windows.
1424func (c *ContentCoordinator) handlePopupClose(ctx context.Context, popupID port.WebViewID) {
1425 log := logging.FromContext(ctx)
1426 log.Debug().Uint64("popup_id", uint64(popupID)).Msg("popup close signal received")
1427
1428 // Check if still pending (never shown)
1429 c.popupMu.Lock()
1430 pending, wasPending := c.pendingPopups[popupID]
1431 if wasPending {
1432 delete(c.pendingPopups, popupID)
1433 }
1434 c.popupMu.Unlock()
1435
1436 if wasPending && pending != nil {
1437 // Was never shown, just destroy
1438 pending.WebView.Destroy()
1439 log.Debug().Msg("destroyed pending popup that was never shown")
1440 return
1441 }
1442
1443 // Find pane by WebView ID
1444 var paneID entity.PaneID
1445 for pid, wv := range c.webViews {
1446 if wv != nil && wv.ID() == popupID {
1447 paneID = pid
1448 break
1449 }
1450 }
1451
1452 if paneID == "" {
1453 log.Warn().Msg("popup close: could not find pane for webview")
1454 return
1455 }
1456
1457 // Close the pane in workspace (this removes the UI element)
1458 if c.onClosePane != nil {
1459 if err := c.onClosePane(ctx, paneID); err != nil {
1460 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to close popup pane")
1461 }
1462 }
1463
1464 // Release the WebView (this will clean up tracking)
1465 c.ReleaseWebView(ctx, paneID)
1466
1467 log.Info().Str("pane_id", string(paneID)).Msg("popup closed")
1468}
1469
1470// setupWebViewCallbacks configures standard callbacks for a WebView.
1471func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1472 // Title changes
1473 wv.OnTitleChanged = func(title string) {
1474 c.onTitleChanged(ctx, paneID, title)
1475 }
1476
1477 // Favicon changes
1478 wv.OnFaviconChanged = func(favicon *gdk.Texture) {
1479 c.onFaviconChanged(ctx, paneID, favicon)
1480 }
1481
1482 // Load events
1483 wv.OnLoadChanged = func(event webkit.LoadEvent) {
1484 switch event {
1485 case webkit.LoadStarted:
1486 c.onLoadStarted(paneID)
1487 case webkit.LoadCommitted:
1488 c.onLoadCommitted(ctx, paneID, wv)
1489 case webkit.LoadFinished:
1490 c.onLoadFinished(ctx, paneID, wv)
1491 }
1492 }
1493
1494 // Progress
1495 wv.OnProgressChanged = func(progress float64) {
1496 c.onProgressChanged(paneID, progress)
1497 }
1498
1499 // SPA navigation
1500 wv.OnURIChanged = func(uri string) {
1501 if !wv.IsLoading() && uri != "" {
1502 c.onSPANavigation(ctx, paneID, uri)
1503 }
1504 }
1505
1506 // Middle-click / Ctrl+click handler for opening links in new pane
1507 wv.OnLinkMiddleClick = func(uri string) bool {
1508 return c.handleLinkMiddleClick(ctx, paneID, uri)
1509 }
1510
1511 // Link hover callback for status overlay
1512 wv.OnLinkHover = func(uri string) {
1513 c.onLinkHover(paneID, uri)
1514 }
1515
1516 // Fullscreen handlers for idle inhibition
1517 c.setupIdleInhibitionHandlers(ctx, paneID, wv)
1518}
1519
1520// setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes.
1521// It uses URL pattern detection for standard OAuth callbacks (code=, access_token=, etc.)
1522// For providers using postMessage (like Google Sign-In), we rely on the provider calling
1523// window.close() which triggers WebKit's close signal.
1524// A long safety timeout (30s) catches popups that get stuck.
1525func (c *ContentCoordinator) setupOAuthAutoClose(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1526 log := logging.FromContext(ctx)
1527
1528 // Safety timeout - only triggers if popup gets stuck (provider should close via window.close)
1529 var safetyTimer *time.Timer
1530 var safetyTimerMu sync.Mutex
1531 const oauthSafetyTimeout = 30 * time.Second
1532
1533 startSafetyTimer := func() {
1534 safetyTimerMu.Lock()
1535 defer safetyTimerMu.Unlock()
1536 if safetyTimer != nil {
1537 safetyTimer.Stop()
1538 }
1539 safetyTimer = time.AfterFunc(oauthSafetyTimeout, func() {
1540 if wv != nil && !wv.IsDestroyed() {
1541 log.Warn().Str("pane", string(paneID)).Msg("oauth safety timeout, closing stuck popup")
1542 wv.Close()
1543 }
1544 })
1545 }
1546
1547 cancelSafetyTimer := func() {
1548 safetyTimerMu.Lock()
1549 defer safetyTimerMu.Unlock()
1550 if safetyTimer != nil {
1551 safetyTimer.Stop()
1552 safetyTimer = nil
1553 }
1554 }
1555
1556 // Start safety timer immediately
1557 startSafetyTimer()
1558
1559 // Wrap the existing OnURIChanged to also check for OAuth callbacks
1560 originalURIChanged := wv.OnURIChanged
1561 wv.OnURIChanged = func(uri string) {
1562 // Call original handler first
1563 if originalURIChanged != nil {
1564 originalURIChanged(uri)
1565 }
1566
1567 // Check for OAuth callback (URL-based detection)
1568 if ShouldAutoClose(uri) {
1569 cancelSafetyTimer()
1570 log.Info().Str("pane", string(paneID)).Msg("oauth callback detected, closing")
1571 go func() {
1572 time.Sleep(500 * time.Millisecond)
1573 if wv != nil && !wv.IsDestroyed() {
1574 wv.Close()
1575 }
1576 }()
1577 }
1578 }
1579
1580 // Monitor load events for URL-based detection
1581 originalLoadChanged := wv.OnLoadChanged
1582 wv.OnLoadChanged = func(event webkit.LoadEvent) {
1583 if originalLoadChanged != nil {
1584 originalLoadChanged(event)
1585 }
1586
1587 // Check URL on commit for immediate detection
1588 if event == webkit.LoadCommitted {
1589 uri := wv.URI()
1590 if ShouldAutoClose(uri) {
1591 cancelSafetyTimer()
1592 log.Info().Str("pane", string(paneID)).Msg("oauth callback on load, closing")
1593 go func() {
1594 time.Sleep(500 * time.Millisecond)
1595 if wv != nil && !wv.IsDestroyed() {
1596 wv.Close()
1597 }
1598 }()
1599 }
1600 }
1601 }
1602
1603 // Wrap OnClose to cancel safety timer when popup closes normally
1604 originalOnClose := wv.OnClose
1605 wv.OnClose = func() {
1606 cancelSafetyTimer()
1607 if originalOnClose != nil {
1608 originalOnClose()
1609 }
1610 }
1611}
1612
1613// handleLinkMiddleClick handles middle-click / Ctrl+click on links.
1614// Opens the link in a new pane using blank_target_behavior config.
1615func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID entity.PaneID, uri string) bool {
1616 log := logging.FromContext(ctx)
1617
1618 log.Info().
1619 Str("parent_pane", string(parentPaneID)).
1620 Str("uri", uri).
1621 Msg("middle-click/ctrl+click on link")
1622
1623 // Check if popups are enabled
1624 if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
1625 log.Debug().Msg("popups disabled by config, ignoring middle-click")
1626 return false
1627 }
1628
1629 // Check if factory is available
1630 if c.factory == nil {
1631 log.Warn().Msg("no webview factory, cannot handle middle-click")
1632 return false
1633 }
1634
1635 // Create a new WebView (regular, not related - just opening a link)
1636 newWV, err := c.factory.Create(ctx)
1637 if err != nil {
1638 log.Error().Err(err).Msg("failed to create webview for middle-click")
1639 return false
1640 }
1641
1642 // Generate pane ID
1643 var paneID entity.PaneID
1644 if c.generateID != nil {
1645 paneID = entity.PaneID(c.generateID())
1646 } else {
1647 paneID = entity.PaneID(fmt.Sprintf("link_%d", newWV.ID()))
1648 }
1649
1650 // Create pane entity - uses blank_target_behavior
1651 newPane := entity.NewPane(paneID)
1652 newPane.WindowType = entity.WindowPopup
1653 newPane.URI = uri
1654
1655 // Register WebView
1656 c.webViews[paneID] = newWV
1657
1658 // Setup callbacks
1659 c.setupWebViewCallbacks(ctx, paneID, newWV)
1660
1661 // Get behavior from config (same as _blank links)
1662 behavior := config.PopupBehaviorStacked // default
1663 if c.popupConfig != nil && c.popupConfig.BlankTargetBehavior != "" {
1664 behavior = config.PopupBehavior(c.popupConfig.BlankTargetBehavior)
1665 }
1666 placement := "right"
1667 if c.popupConfig != nil {
1668 placement = c.popupConfig.Placement
1669 }
1670
1671 // Request insertion into workspace
1672 if c.onInsertPopup != nil {
1673 popupInput := InsertPopupInput{
1674 ParentPaneID: parentPaneID,
1675 PopupPane: newPane,
1676 WebView: newWV,
1677 Behavior: behavior,
1678 Placement: placement,
1679 PopupType: PopupTypeTab, // Treat like _blank
1680 TargetURI: uri,
1681 }
1682
1683 if err := c.onInsertPopup(ctx, popupInput); err != nil {
1684 log.Error().Err(err).Msg("failed to insert middle-click pane into workspace")
1685 // Clean up on failure
1686 delete(c.webViews, paneID)
1687 newWV.Destroy()
1688 return false
1689 }
1690 }
1691
1692 // Load the URI after insertion
1693 if err := newWV.LoadURI(ctx, uri); err != nil {
1694 log.Error().Err(err).Str("uri", uri).Msg("failed to load URI in new pane")
1695 }
1696
1697 log.Info().
1698 Str("pane_id", string(paneID)).
1699 Str("behavior", string(behavior)).
1700 Str("uri", uri).
1701 Msg("middle-click link opened in new pane")
1702
1703 return true
1704}
1705
1706// setupIdleInhibitionHandlers configures fullscreen and audio callbacks for idle inhibition.
1707// Idle is inhibited when:
1708// - The webview enters fullscreen mode (e.g., fullscreen video)
1709// - The webview is playing audio (e.g., video/music playback)
1710// The inhibitor uses refcounting, so both can be active simultaneously.
1711func (c *ContentCoordinator) setupIdleInhibitionHandlers(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1712 log := logging.FromContext(ctx)
1713
1714 if wv == nil {
1715 return
1716 }
1717
1718 // Fullscreen handling
1719 wv.OnEnterFullscreen = func() bool {
1720 if c.idleInhibitor != nil {
1721 if err := c.idleInhibitor.Inhibit(ctx, "Fullscreen video playback"); err != nil {
1722 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle")
1723 }
1724 }
1725 if c.onFullscreenChanged != nil {
1726 c.onFullscreenChanged(true)
1727 }
1728 return false // Allow fullscreen
1729 }
1730
1731 wv.OnLeaveFullscreen = func() bool {
1732 if c.idleInhibitor != nil {
1733 if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
1734 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle")
1735 }
1736 }
1737 if c.onFullscreenChanged != nil {
1738 c.onFullscreenChanged(false)
1739 }
1740 return false // Allow leaving fullscreen
1741 }
1742
1743 // Audio playback handling
1744 wv.OnAudioStateChanged = func(playing bool) {
1745 if c.idleInhibitor == nil {
1746 return
1747 }
1748 if playing {
1749 if err := c.idleInhibitor.Inhibit(ctx, "Media playback"); err != nil {
1750 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle for audio")
1751 }
1752 } else {
1753 if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
1754 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle for audio")
1755 }
1756 }
1757 }
1758}