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