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