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