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
24// ContentCoordinator manages WebView lifecycle, title tracking, and content attachment.
25type ContentCoordinator struct {
26 pool *webkit.WebViewPool
27 widgetFactory layout.WidgetFactory
28 faviconAdapter *adapter.FaviconAdapter
29 zoomUC *usecase.ManageZoomUseCase
30
31 webViews map[entity.PaneID]*webkit.WebView
32 paneTitles map[entity.PaneID]string
33 titleMu sync.RWMutex
34
35 // Track original navigation URLs to handle cross-domain redirects
36 // e.g., google.fr → google.com: cache favicon under both domains
37 navOrigins map[entity.PaneID]string
38 navOriginMu sync.RWMutex
39
40 // Callback to get active workspace state (avoids circular dependency)
41 getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
42
43 // Callback when title changes (for history persistence)
44 onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string)
45
46 // Callback when page is committed (for history recording)
47 onHistoryRecord func(ctx context.Context, paneID entity.PaneID, url string)
48
49 // Gesture action handler for mouse button navigation
50 gestureActionHandler input.ActionHandler
51
52 // Popup handling
53 factory *webkit.WebViewFactory
54 popupConfig *config.PopupBehaviorConfig
55 pendingPopups map[port.WebViewID]*PendingPopup
56 popupMu sync.RWMutex
57
58 // Callback to insert popup into workspace (avoids circular dependency)
59 onInsertPopup func(ctx context.Context, input InsertPopupInput) error
60
61 // Callback to close a pane when popup closes
62 onClosePane func(ctx context.Context, paneID entity.PaneID) error
63
64 // ID generator for popup panes
65 generateID func() string
66
67 // Idle inhibitor for fullscreen video playback
68 idleInhibitor port.IdleInhibitor
69}
70
71// NewContentCoordinator creates a new ContentCoordinator.
72func NewContentCoordinator(
73 ctx context.Context,
74 pool *webkit.WebViewPool,
75 widgetFactory layout.WidgetFactory,
76 faviconAdapter *adapter.FaviconAdapter,
77 getActiveWS func() (*entity.Workspace, *component.WorkspaceView),
78 zoomUC *usecase.ManageZoomUseCase,
79) *ContentCoordinator {
80 log := logging.FromContext(ctx)
81 log.Debug().Msg("creating content coordinator")
82
83 return &ContentCoordinator{
84 pool: pool,
85 widgetFactory: widgetFactory,
86 faviconAdapter: faviconAdapter,
87 zoomUC: zoomUC,
88 webViews: make(map[entity.PaneID]*webkit.WebView),
89 paneTitles: make(map[entity.PaneID]string),
90 navOrigins: make(map[entity.PaneID]string),
91 getActiveWS: getActiveWS,
92 pendingPopups: make(map[port.WebViewID]*PendingPopup),
93 }
94}
95
96// SetOnTitleUpdated sets the callback for title changes (for history persistence).
97func (c *ContentCoordinator) SetOnTitleUpdated(fn func(ctx context.Context, paneID entity.PaneID, url, title string)) {
98 c.onTitleUpdated = fn
99}
100
101// SetOnHistoryRecord sets the callback for recording history on page commit.
102func (c *ContentCoordinator) SetOnHistoryRecord(fn func(ctx context.Context, paneID entity.PaneID, url string)) {
103 c.onHistoryRecord = fn
104}
105
106// SetGestureActionHandler sets the callback for mouse button navigation gestures.
107func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler) {
108 c.gestureActionHandler = handler
109}
110
111// SetIdleInhibitor sets the idle inhibitor for fullscreen video playback.
112func (c *ContentCoordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) {
113 c.idleInhibitor = inhibitor
114}
115
116// EnsureWebView acquires or reuses a WebView for the given pane.
117func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {
118 log := logging.FromContext(ctx)
119
120 if wv, ok := c.webViews[paneID]; ok && wv != nil && !wv.IsDestroyed() {
121 return wv, nil
122 }
123
124 if c.pool == nil {
125 return nil, fmt.Errorf("webview pool not configured")
126 }
127
128 wv, err := c.pool.Acquire(ctx)
129 if err != nil {
130 return nil, err
131 }
132
133 c.webViews[paneID] = wv
134
135 // Set up title change callback
136 wv.OnTitleChanged = func(title string) {
137 c.onTitleChanged(ctx, paneID, title)
138 }
139
140 // Set up favicon change callback
141 wv.OnFaviconChanged = func(favicon *gdk.Texture) {
142 c.onFaviconChanged(ctx, paneID, favicon)
143 }
144
145 // Set up load change callback to re-apply zoom on navigation and handle progress bar
146 wv.OnLoadChanged = func(event webkit.LoadEvent) {
147 switch event {
148 case webkit.LoadStarted:
149 c.onLoadStarted(paneID)
150 case webkit.LoadCommitted:
151 c.onLoadCommitted(ctx, paneID, wv)
152 case webkit.LoadFinished:
153 c.onLoadFinished(paneID)
154 }
155 }
156
157 // Set up progress callback for loading indicator
158 wv.OnProgressChanged = func(progress float64) {
159 c.onProgressChanged(paneID, progress)
160 }
161
162 // Set up URI change callback for SPA navigation (History API)
163 // This fires when URL changes via JavaScript without a full page load
164 wv.OnURIChanged = func(uri string) {
165 // Only record if not loading (SPA navigation via History API)
166 // Full page loads are handled by OnLoadCommitted
167 if !wv.IsLoading() && uri != "" {
168 c.onSPANavigation(ctx, paneID, uri)
169 }
170 }
171
172 // Set up middle-click / Ctrl+click handler for opening links in new pane
173 wv.OnLinkMiddleClick = func(uri string) bool {
174 return c.handleLinkMiddleClick(ctx, paneID, uri)
175 }
176
177 // Set up fullscreen handlers for idle inhibition
178 c.setupFullscreenHandlers(ctx, paneID, wv)
179
180 // Set up popup handling for this WebView
181 c.SetupPopupHandling(ctx, paneID, wv)
182
183 log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane")
184 return wv, nil
185}
186
187// ReleaseWebView returns the WebView for a pane to the pool.
188func (c *ContentCoordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) {
189 log := logging.FromContext(ctx)
190
191 wv, ok := c.webViews[paneID]
192 if !ok || wv == nil {
193 return
194 }
195 delete(c.webViews, paneID)
196
197 // Clean up title tracking
198 c.titleMu.Lock()
199 delete(c.paneTitles, paneID)
200 c.titleMu.Unlock()
201
202 // Clean up navigation origin tracking
203 c.navOriginMu.Lock()
204 delete(c.navOrigins, paneID)
205 c.navOriginMu.Unlock()
206
207 if c.pool != nil {
208 c.pool.Release(ctx, wv)
209 } else {
210 wv.Destroy()
211 }
212
213 log.Debug().Str("pane_id", string(paneID)).Msg("webview released")
214}
215
216// AttachToWorkspace ensures each pane in the workspace has a WebView widget attached.
217func (c *ContentCoordinator) AttachToWorkspace(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) {
218 log := logging.FromContext(ctx)
219
220 if ws == nil || wsView == nil || c.widgetFactory == nil {
221 return
222 }
223
224 for _, pane := range ws.AllPanes() {
225 if pane == nil {
226 continue
227 }
228
229 wv, err := c.EnsureWebView(ctx, pane.ID)
230 if err != nil {
231 log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to ensure webview for pane")
232 continue
233 }
234
235 // Load the pane's URI if set and different from current
236 if pane.URI != "" && pane.URI != wv.URI() {
237 if err := wv.LoadURI(ctx, pane.URI); err != nil {
238 log.Warn().Err(err).Str("pane_id", string(pane.ID)).Str("uri", pane.URI).Msg("failed to load pane URI")
239 }
240 }
241
242 widget := c.WrapWidget(ctx, wv)
243 if widget == nil {
244 continue
245 }
246
247 if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil {
248 log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget")
249 }
250 }
251}
252
253// WrapWidget converts a WebView to a layout.Widget for embedding.
254// It also attaches gesture handlers for mouse button navigation.
255func (c *ContentCoordinator) WrapWidget(ctx context.Context, wv *webkit.WebView) layout.Widget {
256 log := logging.FromContext(ctx)
257
258 if wv == nil || c.widgetFactory == nil {
259 log.Debug().Msg("cannot wrap nil webview or factory")
260 return nil
261 }
262
263 gtkView := wv.Widget()
264 if gtkView == nil {
265 return nil
266 }
267
268 widget := c.widgetFactory.WrapWidget(>kView.Widget)
269
270 // Attach gesture handler for mouse button 8/9 navigation
271 if widget != nil && c.gestureActionHandler != nil {
272 gestureHandler := input.NewGestureHandler(ctx)
273 gestureHandler.SetOnAction(c.gestureActionHandler)
274 gestureHandler.AttachTo(widget.GtkWidget())
275 log.Debug().Msg("gesture handler attached to webview")
276 }
277
278 return widget
279}
280
281// ActiveWebView returns the WebView for the active pane.
282func (c *ContentCoordinator) ActiveWebView(ctx context.Context) *webkit.WebView {
283 log := logging.FromContext(ctx)
284
285 ws, _ := c.getActiveWS()
286 if ws == nil {
287 log.Debug().Msg("no active workspace")
288 return nil
289 }
290
291 pane := ws.ActivePane()
292 if pane == nil || pane.Pane == nil {
293 log.Debug().Msg("no active pane")
294 return nil
295 }
296
297 return c.webViews[pane.Pane.ID]
298}
299
300// GetWebView returns the WebView for a specific pane.
301func (c *ContentCoordinator) GetWebView(paneID entity.PaneID) *webkit.WebView {
302 return c.webViews[paneID]
303}
304
305// RegisterPopupWebView registers a popup WebView that was created externally.
306// This is used when popup tabs are created and the WebView needs to be tracked.
307func (c *ContentCoordinator) RegisterPopupWebView(paneID entity.PaneID, wv *webkit.WebView) {
308 if wv != nil && paneID != "" {
309 c.webViews[paneID] = wv
310 }
311}
312
313// ApplyFiltersToAll applies content filters to all active webviews.
314// Called when filters become available after webviews were already created.
315func (c *ContentCoordinator) ApplyFiltersToAll(ctx context.Context, applier webkit.FilterApplier) {
316 log := logging.FromContext(ctx)
317
318 for paneID, wv := range c.webViews {
319 if wv != nil && !wv.IsDestroyed() {
320 applier.ApplyTo(ctx, wv.UserContentManager())
321 log.Debug().Str("pane_id", string(paneID)).Msg("applied filters to existing webview")
322 }
323 }
324}
325
326// ApplySettingsToAll reapplies WebKit settings to all active WebViews.
327func (c *ContentCoordinator) ApplySettingsToAll(ctx context.Context, sm *webkit.SettingsManager) {
328 log := logging.FromContext(ctx)
329 if sm == nil {
330 return
331 }
332
333 for paneID, wv := range c.webViews {
334 if wv == nil || wv.IsDestroyed() {
335 continue
336 }
337 sm.ApplyToWebView(ctx, wv.Widget())
338 log.Debug().Str("pane_id", string(paneID)).Msg("reapplied settings to webview")
339 }
340}
341
342// RefreshInjectedScriptsToAll clears and re-injects user scripts into all active WebViews.
343//
344// WebKit user scripts are snapshotted when added to a WebKitUserContentManager, so when
345// appearance settings change at runtime (dark mode, palettes, UI scale), we must refresh
346// the scripts so future navigations pick up the latest values.
347func (c *ContentCoordinator) RefreshInjectedScriptsToAll(ctx context.Context, injector *webkit.ContentInjector) {
348 log := logging.FromContext(ctx)
349 if injector == nil {
350 return
351 }
352
353 for paneID, wv := range c.webViews {
354 if wv == nil || wv.IsDestroyed() {
355 continue
356 }
357 ucm := wv.UserContentManager()
358 if ucm == nil {
359 continue
360 }
361
362 ucm.RemoveAllScripts()
363 injector.InjectScripts(ctx, ucm, wv.ID())
364 log.Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")
365 }
366}
367
368// ApplyWebUIThemeToAll updates theme CSS for already-loaded dumb:// pages.
369// This is necessary because user scripts only run on navigation.
370func (c *ContentCoordinator) ApplyWebUIThemeToAll(ctx context.Context, prefersDark bool, cssText string) {
371 log := logging.FromContext(ctx)
372 if cssText == "" {
373 return
374 }
375
376 // Safely embed CSS in JS
377 cssJSON, err := json.Marshal(cssText)
378 if err != nil {
379 log.Warn().Err(err).Msg("failed to marshal WebUI CSS vars")
380 return
381 }
382
383 script := fmt.Sprintf(`(function(){
384 try {
385 var cssText = %s;
386 var prefersDark = %t;
387
388 // Keep the global flag in sync
389 window.__dumber_gtk_prefers_dark = prefersDark;
390
391 // Update dark/light class
392 if (prefersDark) {
393 document.documentElement.classList.add('dark');
394 document.documentElement.classList.remove('light');
395 } else {
396 document.documentElement.classList.add('light');
397 document.documentElement.classList.remove('dark');
398 }
399
400 // Update or insert theme style
401 var style = document.querySelector('style[data-dumber-theme-vars]');
402 if (!style) {
403 style = document.createElement('style');
404 style.setAttribute('data-dumber-theme-vars', '');
405 (document.head || document.documentElement).appendChild(style);
406 }
407 style.textContent = cssText;
408
409 // Notify any running WebUI that theme changed
410 try {
411 window.dispatchEvent(new CustomEvent('dumber:theme-changed', {
412 detail: { prefersDark: prefersDark }
413 }));
414 } catch (e) {
415 // ignore
416 }
417
418 // Keep color-scheme consistent
419 var meta = document.querySelector('meta[name="color-scheme"]');
420 if (!meta) {
421 meta = document.createElement('meta');
422 meta.name = 'color-scheme';
423 document.documentElement.appendChild(meta);
424 }
425 meta.content = prefersDark ? 'dark light' : 'light dark';
426 } catch (e) {
427 console.error('[dumber] failed to apply theme', e);
428 }
429})();`, string(cssJSON), prefersDark)
430
431 for paneID, wv := range c.webViews {
432 if wv == nil || wv.IsDestroyed() {
433 continue
434 }
435 uri := wv.URI()
436 if !strings.HasPrefix(uri, "dumb://") {
437 continue
438 }
439 wv.RunJavaScript(ctx, script, "")
440 log.Debug().Str("pane_id", string(paneID)).Str("uri", uri).Msg("applied WebUI theme")
441 }
442}
443
444// GetTitle returns the current title for a pane.
445func (c *ContentCoordinator) GetTitle(paneID entity.PaneID) string {
446 c.titleMu.RLock()
447 defer c.titleMu.RUnlock()
448 return c.paneTitles[paneID]
449}
450
451// onTitleChanged updates title tracking when a WebView's title changes.
452func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.PaneID, title string) {
453 log := logging.FromContext(ctx)
454
455 // Update title map
456 c.titleMu.Lock()
457 c.paneTitles[paneID] = title
458 c.titleMu.Unlock()
459
460 // Update domain model
461 ws, wsView := c.getActiveWS()
462 if ws != nil {
463 paneNode := ws.FindPane(paneID)
464 if paneNode != nil && paneNode.Pane != nil {
465 paneNode.Pane.Title = title
466 }
467 }
468
469 // Update StackedView title bar if this pane is in a stack
470 if wsView != nil {
471 tr := wsView.TreeRenderer()
472 if tr != nil {
473 stackedView := tr.GetStackedViewForPane(string(paneID))
474 if stackedView != nil {
475 c.updateStackedPaneTitle(ctx, stackedView, paneID, title)
476 }
477 }
478 }
479
480 // Notify history persistence (get URL from WebView)
481 if c.onTitleUpdated != nil {
482 if wv := c.webViews[paneID]; wv != nil {
483 url := wv.URI()
484 if url != "" && title != "" {
485 c.onTitleUpdated(ctx, paneID, url, title)
486 }
487 }
488 }
489
490 log.Debug().
491 Str("pane_id", string(paneID)).
492 Str("title", title).
493 Msg("pane title updated")
494}
495
496// updateStackedPaneTitle updates the title of a pane in a StackedView.
497func (c *ContentCoordinator) updateStackedPaneTitle(
498 ctx context.Context,
499 sv *layout.StackedView,
500 paneID entity.PaneID,
501 title string,
502) {
503 log := logging.FromContext(ctx)
504
505 // Find the pane's index directly in the StackedView
506 index := sv.FindPaneIndex(string(paneID))
507 if index < 0 {
508 log.Debug().
509 Str("pane_id", string(paneID)).
510 Msg("pane not found in StackedView for title update")
511 return
512 }
513
514 if err := sv.UpdateTitle(index, title); err != nil {
515 log.Warn().Err(err).Int("index", index).Msg("failed to update stacked pane title")
516 }
517}
518
519// onFaviconChanged updates favicon tracking when a WebView's favicon changes.
520func (c *ContentCoordinator) onFaviconChanged(ctx context.Context, paneID entity.PaneID, favicon *gdk.Texture) {
521 log := logging.FromContext(ctx)
522
523 // Get current URI to extract domain for caching
524 wv := c.webViews[paneID]
525 if wv == nil {
526 return
527 }
528 uri := wv.URI()
529
530 // Update favicon cache with domain key (handles cross-domain redirects)
531 if c.faviconAdapter != nil && favicon != nil && uri != "" {
532 c.navOriginMu.RLock()
533 originURL := c.navOrigins[paneID]
534 c.navOriginMu.RUnlock()
535 c.faviconAdapter.StoreFromWebKitWithOrigin(ctx, uri, originURL, favicon)
536 }
537
538 // Update StackedView favicon if this pane is in a stack
539 _, wsView := c.getActiveWS()
540 if wsView != nil {
541 tr := wsView.TreeRenderer()
542 if tr != nil {
543 stackedView := tr.GetStackedViewForPane(string(paneID))
544 if stackedView != nil {
545 c.updateStackedPaneFavicon(ctx, stackedView, paneID, favicon)
546 }
547 }
548 }
549
550 log.Debug().
551 Str("pane_id", string(paneID)).
552 Str("uri", uri).
553 Bool("has_favicon", favicon != nil).
554 Msg("pane favicon updated")
555}
556
557// updateStackedPaneFavicon updates the favicon of a pane in a StackedView.
558func (c *ContentCoordinator) updateStackedPaneFavicon(
559 ctx context.Context,
560 sv *layout.StackedView,
561 paneID entity.PaneID,
562 favicon *gdk.Texture,
563) {
564 log := logging.FromContext(ctx)
565
566 // Find the pane's index directly in the StackedView
567 index := sv.FindPaneIndex(string(paneID))
568 if index < 0 {
569 log.Debug().
570 Str("pane_id", string(paneID)).
571 Msg("pane not found in StackedView for favicon update")
572 return
573 }
574
575 if err := sv.UpdateFaviconTexture(index, favicon); err != nil {
576 log.Warn().Err(err).Int("index", index).Msg("failed to update stacked pane favicon")
577 }
578}
579
580// FaviconAdapter returns the favicon adapter for external use (e.g., omnibox).
581func (c *ContentCoordinator) FaviconAdapter() *adapter.FaviconAdapter {
582 return c.faviconAdapter
583}
584
585// SetNavigationOrigin records the original URL before navigation starts.
586// This allows caching favicons under both original and final domains
587// when cross-domain redirects occur (e.g., google.fr → google.com).
588func (c *ContentCoordinator) SetNavigationOrigin(paneID entity.PaneID, url string) {
589 c.navOriginMu.Lock()
590 c.navOrigins[paneID] = url
591 c.navOriginMu.Unlock()
592}
593
594// PreloadCachedFavicon checks the favicon cache and updates the stacked pane
595// title bar immediately if a cached favicon exists for the URL.
596// This provides instant favicon display without waiting for WebKit.
597func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.PaneID, url string) {
598 if c.faviconAdapter == nil || url == "" {
599 return
600 }
601
602 // Check memory and disk cache (no external fetch)
603 texture := c.faviconAdapter.PreloadFromCache(url)
604 if texture == nil {
605 return
606 }
607
608 // Update stacked pane favicon if applicable
609 _, wsView := c.getActiveWS()
610 if wsView != nil {
611 tr := wsView.TreeRenderer()
612 if tr != nil {
613 stackedView := tr.GetStackedViewForPane(string(paneID))
614 if stackedView != nil {
615 c.updateStackedPaneFavicon(ctx, stackedView, paneID, texture)
616 }
617 }
618 }
619}
620
621// onLoadCommitted re-applies zoom when page content starts loading and records history.
622// WebKit may reset zoom during document transitions, so we reapply after LoadCommitted.
623// History is recorded here because the URI is guaranteed to be correct after commit.
624// Also shows the WebView widget (it's hidden during creation to avoid white flash).
625func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
626 log := logging.FromContext(ctx)
627
628 // Show the WebView now that content is being painted
629 // (WebViews are hidden on creation to avoid white flash)
630 if inner := wv.Widget(); inner != nil {
631 inner.SetVisible(true)
632 log.Debug().Str("pane_id", string(paneID)).Msg("webview shown on load committed")
633 }
634
635 url := wv.URI()
636 if url == "" {
637 return
638 }
639
640 // Record history - URI is guaranteed to be correct at LoadCommitted
641 if c.onHistoryRecord != nil {
642 c.onHistoryRecord(ctx, paneID, url)
643 }
644
645 // Apply zoom
646 if c.zoomUC == nil {
647 return
648 }
649
650 domain, err := usecase.ExtractDomain(url)
651 if err != nil {
652 return
653 }
654
655 _ = c.zoomUC.ApplyToWebView(ctx, wv, domain)
656}
657
658// onSPANavigation records history when URL changes via JavaScript (History API).
659// This handles SPA navigation like YouTube search, where the URL changes without a page load.
660func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, url string) {
661 log := logging.FromContext(ctx)
662 log.Debug().Str("pane_id", string(paneID)).Str("url", url).Msg("SPA navigation detected")
663
664 // Record history for SPA navigation
665 if c.onHistoryRecord != nil {
666 c.onHistoryRecord(ctx, paneID, url)
667 }
668}
669
670// onLoadStarted shows the progress bar when page loading begins.
671func (c *ContentCoordinator) onLoadStarted(paneID entity.PaneID) {
672 _, wsView := c.getActiveWS()
673 if wsView == nil {
674 return
675 }
676
677 paneView := wsView.GetPaneView(paneID)
678 if paneView != nil {
679 paneView.SetLoading(true)
680 }
681}
682
683// onLoadFinished hides the progress bar when page loading completes.
684func (c *ContentCoordinator) onLoadFinished(paneID entity.PaneID) {
685 _, wsView := c.getActiveWS()
686 if wsView == nil {
687 return
688 }
689
690 paneView := wsView.GetPaneView(paneID)
691 if paneView != nil {
692 paneView.SetLoading(false)
693 }
694}
695
696// onProgressChanged updates the progress bar with current load progress.
697func (c *ContentCoordinator) onProgressChanged(paneID entity.PaneID, progress float64) {
698 _, wsView := c.getActiveWS()
699 if wsView == nil {
700 return
701 }
702
703 paneView := wsView.GetPaneView(paneID)
704 if paneView != nil {
705 paneView.SetLoadProgress(progress)
706 }
707}
708
709// --- Popup Handling ---
710
711// SetPopupConfig configures popup handling.
712func (c *ContentCoordinator) SetPopupConfig(
713 factory *webkit.WebViewFactory,
714 popupConfig *config.PopupBehaviorConfig,
715 generateID func() string,
716) {
717 c.factory = factory
718 c.popupConfig = popupConfig
719 c.generateID = generateID
720}
721
722// SetOnInsertPopup sets the callback to insert popups into the workspace.
723func (c *ContentCoordinator) SetOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {
724 c.onInsertPopup = fn
725}
726
727// SetOnClosePane sets the callback to close a pane when its popup closes.
728func (c *ContentCoordinator) SetOnClosePane(fn func(ctx context.Context, paneID entity.PaneID) error) {
729 c.onClosePane = fn
730}
731
732// SetupPopupHandling wires the popup create signal for a WebView.
733// This should be called after acquiring a WebView for a pane.
734func (c *ContentCoordinator) SetupPopupHandling(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
735 log := logging.FromContext(ctx)
736
737 if wv == nil {
738 return
739 }
740
741 // Wire the OnCreate callback for popup requests
742 wv.OnCreate = func(req webkit.PopupRequest) *webkit.WebView {
743 return c.handlePopupCreate(ctx, paneID, wv, req)
744 }
745
746 log.Debug().Str("pane_id", string(paneID)).Msg("popup handling configured for webview")
747}
748
749// handlePopupCreate handles the WebKit "create" signal for popup windows.
750// Returns a new related WebView if popup is allowed, nil to block.
751func (c *ContentCoordinator) handlePopupCreate(
752 ctx context.Context,
753 parentPaneID entity.PaneID,
754 parentWV *webkit.WebView,
755 req webkit.PopupRequest,
756) *webkit.WebView {
757 log := logging.FromContext(ctx)
758
759 log.Debug().
760 Str("parent_pane", string(parentPaneID)).
761 Str("target_uri", req.TargetURI).
762 Str("frame_name", req.FrameName).
763 Bool("user_gesture", req.IsUserGesture).
764 Msg("popup create request")
765
766 // Check if popups are enabled
767 if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
768 log.Debug().Msg("popups disabled by config, blocking")
769 return nil
770 }
771
772 // Check if factory is available
773 if c.factory == nil {
774 log.Warn().Msg("no webview factory, cannot create popup")
775 return nil
776 }
777
778 // Create related WebView for session sharing
779 parentID := parentWV.ID()
780 popupWV, err := c.factory.CreateRelated(ctx, parentID)
781 if err != nil {
782 log.Error().Err(err).Msg("failed to create related webview for popup")
783 return nil
784 }
785
786 // Detect popup type
787 popupType := DetectPopupType(req.FrameName)
788
789 // Store pending popup for ready-to-show handling
790 popupID := popupWV.ID()
791 pending := &PendingPopup{
792 WebView: popupWV,
793 ParentPaneID: parentPaneID,
794 ParentWebViewID: parentID,
795 TargetURI: req.TargetURI,
796 FrameName: req.FrameName,
797 IsUserGesture: req.IsUserGesture,
798 PopupType: popupType,
799 CreatedAt: time.Now(),
800 }
801
802 c.popupMu.Lock()
803 c.pendingPopups[popupID] = pending
804 c.popupMu.Unlock()
805
806 // Wire ready-to-show and close signals
807 popupWV.OnReadyToShow = func() {
808 c.handlePopupReadyToShow(ctx, popupID)
809 }
810 popupWV.OnClose = func() {
811 c.handlePopupClose(ctx, popupID)
812 }
813
814 log.Info().
815 Uint64("popup_id", uint64(popupID)).
816 Str("popup_type", popupType.String()).
817 Str("target_uri", req.TargetURI).
818 Msg("popup created, awaiting ready-to-show")
819
820 return popupWV
821}
822
823// handlePopupReadyToShow handles the WebKit "ready-to-show" signal.
824// This is called when the popup is configured and ready to be displayed.
825func (c *ContentCoordinator) handlePopupReadyToShow(ctx context.Context, popupID port.WebViewID) {
826 log := logging.FromContext(ctx)
827
828 // Get pending popup
829 c.popupMu.Lock()
830 pending, ok := c.pendingPopups[popupID]
831 if ok {
832 delete(c.pendingPopups, popupID)
833 }
834 c.popupMu.Unlock()
835
836 if !ok || pending == nil {
837 log.Warn().Uint64("popup_id", uint64(popupID)).Msg("ready-to-show for unknown popup")
838 return
839 }
840
841 log.Debug().
842 Uint64("popup_id", uint64(popupID)).
843 Str("popup_type", pending.PopupType.String()).
844 Msg("popup ready to show")
845
846 // Determine behavior from config
847 behavior := GetBehavior(pending.PopupType, c.popupConfig)
848 placement := "right"
849 if c.popupConfig != nil {
850 placement = c.popupConfig.Placement
851 }
852
853 // Create popup pane entity
854 var paneID entity.PaneID
855 if c.generateID != nil {
856 paneID = entity.PaneID(c.generateID())
857 } else {
858 paneID = entity.PaneID(fmt.Sprintf("popup_%d", popupID))
859 }
860
861 popupPane := entity.NewPane(paneID)
862 popupPane.WindowType = entity.WindowPopup
863 popupPane.IsRelated = true
864 popupPane.ParentPaneID = &pending.ParentPaneID
865 popupPane.URI = pending.TargetURI
866
867 // Enable OAuth auto-close if configured
868 if c.popupConfig != nil && c.popupConfig.OAuthAutoClose && IsOAuthURL(pending.TargetURI) {
869 popupPane.AutoClose = true
870 c.setupOAuthAutoClose(ctx, paneID, pending.WebView)
871 }
872
873 // Register WebView in our map
874 c.webViews[paneID] = pending.WebView
875
876 // Setup standard callbacks
877 c.setupWebViewCallbacks(ctx, paneID, pending.WebView)
878
879 // Request insertion into workspace
880 if c.onInsertPopup != nil {
881 popupInput := InsertPopupInput{
882 ParentPaneID: pending.ParentPaneID,
883 PopupPane: popupPane,
884 WebView: pending.WebView,
885 Behavior: behavior,
886 Placement: placement,
887 PopupType: pending.PopupType,
888 TargetURI: pending.TargetURI,
889 }
890
891 if err := c.onInsertPopup(ctx, popupInput); err != nil {
892 log.Error().Err(err).Msg("failed to insert popup into workspace")
893 // Clean up on failure
894 delete(c.webViews, paneID)
895 pending.WebView.Destroy()
896 return
897 }
898 }
899
900 log.Info().
901 Str("pane_id", string(paneID)).
902 Str("behavior", string(behavior)).
903 Str("placement", placement).
904 Msg("popup inserted into workspace")
905}
906
907// handlePopupClose handles the WebKit "close" signal for popup windows.
908func (c *ContentCoordinator) handlePopupClose(ctx context.Context, popupID port.WebViewID) {
909 log := logging.FromContext(ctx)
910 log.Debug().Uint64("popup_id", uint64(popupID)).Msg("popup close signal received")
911
912 // Check if still pending (never shown)
913 c.popupMu.Lock()
914 pending, wasPending := c.pendingPopups[popupID]
915 if wasPending {
916 delete(c.pendingPopups, popupID)
917 }
918 c.popupMu.Unlock()
919
920 if wasPending && pending != nil {
921 // Was never shown, just destroy
922 pending.WebView.Destroy()
923 log.Debug().Msg("destroyed pending popup that was never shown")
924 return
925 }
926
927 // Find pane by WebView ID
928 var paneID entity.PaneID
929 for pid, wv := range c.webViews {
930 if wv != nil && wv.ID() == popupID {
931 paneID = pid
932 break
933 }
934 }
935
936 if paneID == "" {
937 log.Warn().Msg("popup close: could not find pane for webview")
938 return
939 }
940
941 // Close the pane in workspace (this removes the UI element)
942 if c.onClosePane != nil {
943 if err := c.onClosePane(ctx, paneID); err != nil {
944 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to close popup pane")
945 }
946 }
947
948 // Release the WebView (this will clean up tracking)
949 c.ReleaseWebView(ctx, paneID)
950
951 log.Info().Str("pane_id", string(paneID)).Msg("popup closed")
952}
953
954// setupWebViewCallbacks configures standard callbacks for a WebView.
955func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
956 // Title changes
957 wv.OnTitleChanged = func(title string) {
958 c.onTitleChanged(ctx, paneID, title)
959 }
960
961 // Favicon changes
962 wv.OnFaviconChanged = func(favicon *gdk.Texture) {
963 c.onFaviconChanged(ctx, paneID, favicon)
964 }
965
966 // Load events
967 wv.OnLoadChanged = func(event webkit.LoadEvent) {
968 switch event {
969 case webkit.LoadStarted:
970 c.onLoadStarted(paneID)
971 case webkit.LoadCommitted:
972 c.onLoadCommitted(ctx, paneID, wv)
973 case webkit.LoadFinished:
974 c.onLoadFinished(paneID)
975 }
976 }
977
978 // Progress
979 wv.OnProgressChanged = func(progress float64) {
980 c.onProgressChanged(paneID, progress)
981 }
982
983 // SPA navigation
984 wv.OnURIChanged = func(uri string) {
985 if !wv.IsLoading() && uri != "" {
986 c.onSPANavigation(ctx, paneID, uri)
987 }
988 }
989
990 // Middle-click / Ctrl+click handler for opening links in new pane
991 wv.OnLinkMiddleClick = func(uri string) bool {
992 return c.handleLinkMiddleClick(ctx, paneID, uri)
993 }
994
995 // Fullscreen handlers for idle inhibition
996 c.setupFullscreenHandlers(ctx, paneID, wv)
997}
998
999// setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes.
1000func (c *ContentCoordinator) setupOAuthAutoClose(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1001 log := logging.FromContext(ctx)
1002
1003 // Wrap the existing OnURIChanged to also check for OAuth callbacks
1004 originalURIChanged := wv.OnURIChanged
1005 wv.OnURIChanged = func(uri string) {
1006 // Call original handler first
1007 if originalURIChanged != nil {
1008 originalURIChanged(uri)
1009 }
1010
1011 // Check for OAuth callback
1012 if ShouldAutoClose(uri) {
1013 log.Info().
1014 Str("pane_id", string(paneID)).
1015 Str("uri", uri).
1016 Msg("OAuth callback detected, auto-closing popup")
1017
1018 // Trigger close after a short delay to let the page process
1019 go func() {
1020 time.Sleep(500 * time.Millisecond)
1021 if wv != nil && !wv.IsDestroyed() {
1022 // This will trigger handlePopupClose
1023 wv.Close()
1024 }
1025 }()
1026 }
1027 }
1028
1029 // Also check on load committed for immediate redirects
1030 originalLoadChanged := wv.OnLoadChanged
1031 wv.OnLoadChanged = func(event webkit.LoadEvent) {
1032 if originalLoadChanged != nil {
1033 originalLoadChanged(event)
1034 }
1035
1036 if event == webkit.LoadCommitted {
1037 uri := wv.URI()
1038 if ShouldAutoClose(uri) {
1039 log.Info().
1040 Str("pane_id", string(paneID)).
1041 Str("uri", uri).
1042 Msg("OAuth callback detected on load, auto-closing popup")
1043
1044 go func() {
1045 time.Sleep(500 * time.Millisecond)
1046 if wv != nil && !wv.IsDestroyed() {
1047 wv.Close()
1048 }
1049 }()
1050 }
1051 }
1052 }
1053}
1054
1055// handleLinkMiddleClick handles middle-click / Ctrl+click on links.
1056// Opens the link in a new pane using blank_target_behavior config.
1057func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID entity.PaneID, uri string) bool {
1058 log := logging.FromContext(ctx)
1059
1060 log.Info().
1061 Str("parent_pane", string(parentPaneID)).
1062 Str("uri", uri).
1063 Msg("middle-click/ctrl+click on link")
1064
1065 // Check if popups are enabled
1066 if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
1067 log.Debug().Msg("popups disabled by config, ignoring middle-click")
1068 return false
1069 }
1070
1071 // Check if factory is available
1072 if c.factory == nil {
1073 log.Warn().Msg("no webview factory, cannot handle middle-click")
1074 return false
1075 }
1076
1077 // Create a new WebView (regular, not related - just opening a link)
1078 newWV, err := c.factory.Create(ctx)
1079 if err != nil {
1080 log.Error().Err(err).Msg("failed to create webview for middle-click")
1081 return false
1082 }
1083
1084 // Generate pane ID
1085 var paneID entity.PaneID
1086 if c.generateID != nil {
1087 paneID = entity.PaneID(c.generateID())
1088 } else {
1089 paneID = entity.PaneID(fmt.Sprintf("link_%d", newWV.ID()))
1090 }
1091
1092 // Create pane entity - uses blank_target_behavior
1093 newPane := entity.NewPane(paneID)
1094 newPane.WindowType = entity.WindowPopup
1095 newPane.URI = uri
1096
1097 // Register WebView
1098 c.webViews[paneID] = newWV
1099
1100 // Setup callbacks
1101 c.setupWebViewCallbacks(ctx, paneID, newWV)
1102
1103 // Get behavior from config (same as _blank links)
1104 behavior := config.PopupBehaviorStacked // default
1105 if c.popupConfig != nil && c.popupConfig.BlankTargetBehavior != "" {
1106 behavior = config.PopupBehavior(c.popupConfig.BlankTargetBehavior)
1107 }
1108 placement := "right"
1109 if c.popupConfig != nil {
1110 placement = c.popupConfig.Placement
1111 }
1112
1113 // Request insertion into workspace
1114 if c.onInsertPopup != nil {
1115 popupInput := InsertPopupInput{
1116 ParentPaneID: parentPaneID,
1117 PopupPane: newPane,
1118 WebView: newWV,
1119 Behavior: behavior,
1120 Placement: placement,
1121 PopupType: PopupTypeTab, // Treat like _blank
1122 TargetURI: uri,
1123 }
1124
1125 if err := c.onInsertPopup(ctx, popupInput); err != nil {
1126 log.Error().Err(err).Msg("failed to insert middle-click pane into workspace")
1127 // Clean up on failure
1128 delete(c.webViews, paneID)
1129 newWV.Destroy()
1130 return false
1131 }
1132 }
1133
1134 // Load the URI after insertion
1135 if err := newWV.LoadURI(ctx, uri); err != nil {
1136 log.Error().Err(err).Str("uri", uri).Msg("failed to load URI in new pane")
1137 }
1138
1139 log.Info().
1140 Str("pane_id", string(paneID)).
1141 Str("behavior", string(behavior)).
1142 Str("uri", uri).
1143 Msg("middle-click link opened in new pane")
1144
1145 return true
1146}
1147
1148// setupFullscreenHandlers configures fullscreen callbacks for idle inhibition.
1149func (c *ContentCoordinator) setupFullscreenHandlers(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1150 log := logging.FromContext(ctx)
1151
1152 if wv == nil {
1153 return
1154 }
1155
1156 wv.OnEnterFullscreen = func() bool {
1157 if c.idleInhibitor != nil {
1158 if err := c.idleInhibitor.Inhibit(ctx, "Fullscreen video playback"); err != nil {
1159 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle")
1160 }
1161 }
1162 return false // Allow fullscreen
1163 }
1164
1165 wv.OnLeaveFullscreen = func() bool {
1166 if c.idleInhibitor != nil {
1167 if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
1168 log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle")
1169 }
1170 }
1171 return false // Allow leaving fullscreen
1172 }
1173}