1package content
2
3import (
4 "context"
5 "sync"
6
7 "github.com/bnema/dumber/internal/application/dto"
8 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/application/usecase"
10 "github.com/bnema/dumber/internal/domain/entity"
11
12 "github.com/bnema/dumber/internal/logging"
13 "github.com/bnema/dumber/internal/ui/adapter"
14 "github.com/bnema/dumber/internal/ui/component"
15 "github.com/bnema/dumber/internal/ui/input"
16 "github.com/bnema/dumber/internal/ui/layout"
17 "github.com/rs/zerolog"
18)
19
20// Coordinator manages WebView lifecycle, title tracking, and content attachment.
21type Coordinator struct {
22 logger zerolog.Logger
23 pool port.WebViewPool
24 widgetFactory layout.WidgetFactory
25 faviconAdapter *adapter.FaviconAdapter
26 zoomUC *usecase.ManageZoomUseCase
27 permissionUC *usecase.HandlePermissionUseCase
28 injector port.ContentInjector
29 settingsApplier port.SettingsApplier // optional: nil if engine doesn't support
30 filterApplier port.FilterApplier // optional: nil if engine doesn't support
31
32 webViews map[entity.PaneID]port.WebView
33 webViewPaneIDs map[port.WebViewID]entity.PaneID
34 webViewsMu sync.RWMutex
35
36 activePaneOverride entity.PaneID
37 activePaneOverrideMu sync.RWMutex
38
39 paneTitles map[entity.PaneID]string
40 titleMu sync.RWMutex
41
42 // Track original navigation URLs to handle cross-domain redirects
43 // e.g., google.fr → google.com: cache favicon under both domains
44 navOrigins map[entity.PaneID]string
45 navOriginMu sync.RWMutex
46
47 // Callback to get active workspace state (avoids circular dependency)
48 getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
49
50 // Resolves a pane's view across all tabs/windows (JS dialogs can come from
51 // background panes). JS dialogs are not shown while it is nil.
52 paneViewResolver func(paneID entity.PaneID) *component.PaneView
53 // Current UI scale, used to size JS dialogs.
54 uiScaleProvider func() float64
55
56 // Callback when title changes (for history persistence)
57 onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string)
58
59 // Callback when page is committed (for history recording)
60 onHistoryRecord func(ctx context.Context, paneID entity.PaneID, url string)
61
62 // Callback when pane URI changes (for session snapshots)
63 onPaneURIUpdated func(paneID entity.PaneID, url string)
64
65 // Callback when active pane title changes (for window title updates)
66 onWindowTitleChanged func(paneID entity.PaneID, title string)
67
68 // Callback when media permission activity changes (requesting/allowed/blocked).
69 onPermissionActivity func(paneID entity.PaneID, origin string, permTypes []entity.PermissionType, state PermissionActivityState)
70
71 // Callback invoked for every permission request, before it is handled, so the
72 // UI can bind the permission dialog of the window owning the pane.
73 onPermissionPrompt func(paneID entity.PaneID)
74
75 // Callback when the active pane commits a navigation (new page loading).
76 onActiveNavigationCommitted func(paneID entity.PaneID, uri string)
77
78 // Callback when a CEF touchpad history navigation gesture progresses.
79 onTouchpadNavigationGesture func(paneID entity.PaneID, gesture entity.TouchpadNavigationGesture)
80
81 // Callback when the WebView becomes visible (first real commit)
82 onWebViewShown func(paneID entity.PaneID)
83
84 // setWebViewVisible is test-only injection for deterministic lifecycle
85 // serialization tests. Production uses the native widget provider directly.
86 setWebViewVisible func(port.WebView)
87
88 // revealMutationMu serializes mapping replacement/release with the native
89 // visibility transition. It is acquired before webViewsMu and revealMu.
90 revealMutationMu sync.Mutex
91
92 // revealMu is always acquired after webViewsMu. Reveal state is keyed by the
93 // WebView identity (ID plus pool-reuse generation), never by pane alone.
94 revealMu sync.Mutex
95 pendingReveal map[entity.PaneID]webViewIdentity
96 revealedWebViews map[entity.PaneID]webViewIdentity
97
98 appearanceMu sync.Mutex
99 pendingScriptRefresh map[entity.PaneID]bool
100 pendingThemePanes map[entity.PaneID]bool
101 pendingThemeUpdate pendingThemeUpdate
102 hasPendingThemeUpdate bool
103 currentTheme pendingThemeUpdate
104 hasCurrentTheme bool
105
106 // Gesture action handler for mouse button navigation
107 gestureActionHandler input.ActionHandler
108
109 // Popup handling stays in a dedicated UI-layer manager so popup-specific
110 // pane state does not bloat the main coordinator.
111 popups *popupManager
112 popupsOnce sync.Once
113
114 // Idle inhibitor for fullscreen video playback
115 idleInhibitor port.IdleInhibitor
116
117 // Callback when fullscreen state changes (for hiding/showing tab bar)
118 onFullscreenChanged func(paneID entity.PaneID, entering bool)
119
120 // Callback when WebView gains focus (for accent picker text input targeting)
121 onWebViewFocused func(paneID entity.PaneID, wv port.WebView)
122
123 // Callback when a page editable target gains or loses focus.
124 onEditableFocusChanged func(paneID entity.PaneID, editable bool)
125 // onVimPageInteractionEnded fires when a page finishes link hints or visual selection.
126 onVimPageInteractionEnded func(paneID entity.PaneID)
127 // onVimPageModeChanged fires when a page interaction moves to another sub-mode.
128 onVimPageModeChanged func(paneID entity.PaneID, mode dto.VimPageMode)
129
130 // Callback for first load_started event (triggers deferred initialization)
131 onFirstLoadStarted func()
132 loadStartedOnce sync.Once
133
134 // Callback to open a URL with the system's default handler (e.g. xdg-open).
135 // Used for external URL schemes like vscode://, spotify://, etc.
136 onLaunchExternalURL func(uri string)
137}
138
139type pendingThemeUpdate struct {
140 prefersDark bool
141 cssText string
142}
143
144// PermissionActivityState represents the visible state for media permission activity.
145type PermissionActivityState string
146
147const (
148 PermissionActivityRequesting PermissionActivityState = "requesting"
149 PermissionActivityAllowed PermissionActivityState = "allowed"
150 PermissionActivityBlocked PermissionActivityState = "blocked"
151)
152
153// NewCoordinator creates a new Coordinator.
154func NewCoordinator(
155 ctx context.Context,
156 pool port.WebViewPool,
157 injector port.ContentInjector,
158 widgetFactory layout.WidgetFactory,
159 faviconAdapter *adapter.FaviconAdapter,
160 getActiveWS func() (*entity.Workspace, *component.WorkspaceView),
161 zoomUC *usecase.ManageZoomUseCase,
162 permissionUC *usecase.HandlePermissionUseCase,
163) *Coordinator {
164 log := logging.FromContext(ctx)
165 log.Debug().Msg("creating content coordinator")
166
167 return &Coordinator{
168 logger: log.With().Str("component", "content-coordinator").Logger(),
169 pool: pool,
170 injector: injector,
171 widgetFactory: widgetFactory,
172 faviconAdapter: faviconAdapter,
173 zoomUC: zoomUC,
174 permissionUC: permissionUC,
175 webViews: make(map[entity.PaneID]port.WebView),
176 webViewPaneIDs: make(map[port.WebViewID]entity.PaneID),
177 paneTitles: make(map[entity.PaneID]string),
178 navOrigins: make(map[entity.PaneID]string),
179 pendingReveal: make(map[entity.PaneID]webViewIdentity),
180 revealedWebViews: make(map[entity.PaneID]webViewIdentity),
181 pendingScriptRefresh: make(map[entity.PaneID]bool),
182 pendingThemePanes: make(map[entity.PaneID]bool),
183 getActiveWS: getActiveWS,
184 popups: newPopupManager(),
185 }
186}
187
188func (c *Coordinator) ensurePopupManager() *popupManager {
189 if c == nil {
190 return nil
191 }
192 c.popupsOnce.Do(func() {
193 if c.popups == nil {
194 c.popups = newPopupManager()
195 }
196 })
197 c.popups.ensureInitialized()
198 return c.popups
199}
200
201// SetOnTitleUpdated sets the callback for title changes (for history persistence).
202func (c *Coordinator) SetOnTitleUpdated(fn func(ctx context.Context, paneID entity.PaneID, url, title string)) {
203 c.onTitleUpdated = fn
204}
205
206// SetOnHistoryRecord sets the callback for recording history on page commit.
207func (c *Coordinator) SetOnHistoryRecord(fn func(ctx context.Context, paneID entity.PaneID, url string)) {
208 c.onHistoryRecord = fn
209}
210
211// SetOnPermissionActivity sets a callback for WebRTC permission activity changes.
212func (c *Coordinator) SetOnPermissionActivity(
213 fn func(paneID entity.PaneID, origin string, permTypes []entity.PermissionType, state PermissionActivityState),
214) {
215 c.onPermissionActivity = fn
216}
217
218// SetOnPermissionPrompt sets a callback invoked for every permission request
219// (not only WebRTC ones) before it reaches the permission use case.
220func (c *Coordinator) SetOnPermissionPrompt(fn func(paneID entity.PaneID)) {
221 c.onPermissionPrompt = fn
222}
223
224// SetOnActiveNavigationCommitted sets a callback fired when the active pane commits a navigation.
225func (c *Coordinator) SetOnActiveNavigationCommitted(fn func(paneID entity.PaneID, uri string)) {
226 c.onActiveNavigationCommitted = fn
227}
228
229// SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots).
230func (c *Coordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) {
231 c.onPaneURIUpdated = fn
232}
233
234// SetOnTouchpadNavigationGesture sets the callback for CEF two-finger history
235// navigation gesture progress.
236func (c *Coordinator) SetOnTouchpadNavigationGesture(fn func(paneID entity.PaneID, gesture entity.TouchpadNavigationGesture)) {
237 c.onTouchpadNavigationGesture = fn
238}
239
240// SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates).
241func (c *Coordinator) SetOnWindowTitleChanged(fn func(paneID entity.PaneID, title string)) {
242 c.onWindowTitleChanged = fn
243}
244
245// SetOnWebViewShown sets a callback that fires when a pane's WebView is shown.
246func (c *Coordinator) SetOnWebViewShown(fn func(paneID entity.PaneID)) {
247 c.onWebViewShown = fn
248}
249
250// SetGestureActionHandler sets the callback for mouse button navigation gestures.
251func (c *Coordinator) SetGestureActionHandler(handler input.ActionHandler) {
252 c.gestureActionHandler = handler
253}
254
255// SetIdleInhibitor sets the idle inhibitor for fullscreen video playback.
256func (c *Coordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) {
257 c.idleInhibitor = inhibitor
258}
259
260// SetOnFullscreenChanged sets the callback for fullscreen state changes.
261func (c *Coordinator) SetOnFullscreenChanged(fn func(paneID entity.PaneID, entering bool)) {
262 c.onFullscreenChanged = fn
263}
264
265// SetOnWebViewFocused sets the callback for when a WebView gains focus.
266func (c *Coordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv port.WebView)) {
267 c.onWebViewFocused = fn
268}
269
270// SetPaneViewResolver sets a resolver that finds a pane's view in any tab/window.
271func (c *Coordinator) SetPaneViewResolver(fn func(paneID entity.PaneID) *component.PaneView) {
272 c.paneViewResolver = fn
273}
274
275// SetUIScaleProvider sets the source of the current UI scale.
276func (c *Coordinator) SetUIScaleProvider(fn func() float64) {
277 c.uiScaleProvider = fn
278}
279
280// SetOnEditableFocusChanged sets the callback for page editable focus changes.
281func (c *Coordinator) SetOnEditableFocusChanged(fn func(paneID entity.PaneID, editable bool)) {
282 c.onEditableFocusChanged = fn
283}
284
285// SetOnVimPageInteractionEnded sets the callback for the end of an in-page
286// Vim interaction (link hints or visual selection).
287func (c *Coordinator) SetOnVimPageInteractionEnded(fn func(paneID entity.PaneID)) {
288 c.onVimPageInteractionEnded = fn
289}
290
291// SetOnVimPageModeChanged sets the callback for sub-mode changes of an
292// in-page Vim interaction (hints, caret, visual).
293func (c *Coordinator) SetOnVimPageModeChanged(fn func(paneID entity.PaneID, mode dto.VimPageMode)) {
294 c.onVimPageModeChanged = fn
295}
296
297// SetOnFirstLoadStarted sets the callback for when the first navigation starts.
298// This is used to trigger deferred initialization after the initial load_uri()
299// has been processed by the GTK main loop.
300func (c *Coordinator) SetOnFirstLoadStarted(fn func()) {
301 c.onFirstLoadStarted = fn
302}
303
304// SetOnLaunchExternalURL sets the callback invoked when an external URL scheme
305// (e.g. vscode://, spotify://) is detected and must be handed off to the system.
306func (c *Coordinator) SetOnLaunchExternalURL(fn func(uri string)) {
307 c.onLaunchExternalURL = fn
308}
309
310// SetSettingsApplier sets the engine settings applier for config hot-reload.
311func (c *Coordinator) SetSettingsApplier(sa port.SettingsApplier) {
312 c.settingsApplier = sa
313}
314
315// SetFilterApplier sets the content filter applier for late-binding filters.
316func (c *Coordinator) SetFilterApplier(fa port.FilterApplier) {
317 c.filterApplier = fa
318}
319
320// ActivePaneID returns the currently active pane ID used by navigation.
321func (c *Coordinator) ActivePaneID(ctx context.Context) entity.PaneID {
322 if paneID, ok := c.activePaneOverrideID(); ok {
323 return paneID
324 }
325 if c.getActiveWS == nil {
326 return ""
327 }
328
329 ws, _ := c.getActiveWS()
330 if ws == nil {
331 return ""
332 }
333 return ws.ActivePaneID
334}
335
336// SetActivePaneOverride forces ActiveWebView/ActivePaneID to use a specific pane.
337func (c *Coordinator) SetActivePaneOverride(paneID entity.PaneID) {
338 c.activePaneOverrideMu.Lock()
339 c.activePaneOverride = paneID
340 c.activePaneOverrideMu.Unlock()
341}
342
343// ClearActivePaneOverride removes any forced active pane override.
344func (c *Coordinator) ClearActivePaneOverride() {
345 c.activePaneOverrideMu.Lock()
346 c.activePaneOverride = ""
347 c.activePaneOverrideMu.Unlock()
348}
349
350// ActivePaneOverrideID returns the currently forced active pane override, if any.
351func (c *Coordinator) ActivePaneOverrideID() (entity.PaneID, bool) {
352 return c.activePaneOverrideID()
353}
354
355func (c *Coordinator) activePaneOverrideID() (entity.PaneID, bool) {
356 c.activePaneOverrideMu.RLock()
357 defer c.activePaneOverrideMu.RUnlock()
358 if c.activePaneOverride == "" {
359 return "", false
360 }
361 return c.activePaneOverride, true
362}
363
364func (c *Coordinator) getWebViewLocked(paneID entity.PaneID) port.WebView {
365 c.webViewsMu.RLock()
366 defer c.webViewsMu.RUnlock()
367 return c.webViews[paneID]
368}
369
370type webViewIdentity struct {
371 id port.WebViewID
372 generation uint64
373}
374
375func identityForWebView(wv port.WebView) (webViewIdentity, bool) {
376 if wv == nil {
377 return webViewIdentity{}, false
378 }
379 return webViewIdentity{id: wv.ID(), generation: wv.Generation()}, true
380}
381
382func (c *Coordinator) ensureRevealMapsLocked() {
383 if c.pendingReveal == nil {
384 c.pendingReveal = make(map[entity.PaneID]webViewIdentity)
385 }
386 if c.revealedWebViews == nil {
387 c.revealedWebViews = make(map[entity.PaneID]webViewIdentity)
388 }
389}
390
391// setWebViewLocked replaces the pane mapping and resets all reveal state in
392// one critical section. It is used by normal acquisition and popup paths.
393func (c *Coordinator) setWebViewLocked(paneID entity.PaneID, wv port.WebView) {
394 c.revealMutationMu.Lock()
395 defer c.revealMutationMu.Unlock()
396 c.webViewsMu.Lock()
397 c.revealMu.Lock()
398 defer c.revealMu.Unlock()
399 defer c.webViewsMu.Unlock()
400 if c.webViews == nil {
401 c.webViews = make(map[entity.PaneID]port.WebView)
402 }
403 if existing := c.webViews[paneID]; existing != nil && c.webViewPaneIDs != nil {
404 delete(c.webViewPaneIDs, existing.ID())
405 }
406 c.webViews[paneID] = wv
407 if wv != nil && c.webViewPaneIDs != nil {
408 c.webViewPaneIDs[wv.ID()] = paneID
409 }
410 c.ensureRevealMapsLocked()
411 delete(c.pendingReveal, paneID)
412 delete(c.revealedWebViews, paneID)
413}
414
415// deleteWebViewLocked removes both the mapping and any presentation state,
416// including callers which release after another path already removed the map.
417func (c *Coordinator) deleteWebViewLocked(paneID entity.PaneID) port.WebView {
418 c.revealMutationMu.Lock()
419 defer c.revealMutationMu.Unlock()
420 c.webViewsMu.Lock()
421 c.revealMu.Lock()
422 defer c.revealMu.Unlock()
423 defer c.webViewsMu.Unlock()
424 wv := c.webViews[paneID]
425 delete(c.webViews, paneID)
426 if wv != nil && c.webViewPaneIDs != nil {
427 delete(c.webViewPaneIDs, wv.ID())
428 }
429 c.ensureRevealMapsLocked()
430 delete(c.pendingReveal, paneID)
431 delete(c.revealedWebViews, paneID)
432 return wv
433}
434
435func (c *Coordinator) paneIDByWebViewIDLocked(webViewID port.WebViewID) (entity.PaneID, bool) {
436 paneID, ok := c.webViewPaneIDs[webViewID]
437 return paneID, ok
438}
439
440func (c *Coordinator) findPaneByWebViewID(webViewID port.WebViewID) (entity.PaneID, bool) {
441 if webViewID == 0 {
442 return "", false
443 }
444
445 c.webViewsMu.RLock()
446 defer c.webViewsMu.RUnlock()
447 if paneID, ok := c.paneIDByWebViewIDLocked(webViewID); ok && paneID != "" {
448 return paneID, true
449 }
450
451 c.logger.Debug().
452 Uint64("webview_id", uint64(webViewID)).
453 Msg("findPaneByWebViewID: using fallback scan")
454 for paneID, wv := range c.webViews {
455 if wv != nil && wv.ID() == webViewID {
456 return paneID, true
457 }
458 }
459 return "", false
460}
461
462func (c *Coordinator) popupHooks() popupCoordinatorHooks {
463 return popupCoordinatorHooks{
464 setupWebViewCallbacks: c.setupWebViewCallbacks,
465 registerPopupWebView: c.RegisterPopupWebView,
466 setWebView: c.setWebViewLocked,
467 getWebView: c.getWebViewLocked,
468 deleteWebView: c.deleteWebViewLocked,
469 releaseWebView: c.ReleaseWebView,
470 findPaneByWebViewID: c.findPaneByWebViewID,
471 setupOAuthAutoClose: c.setupOAuthAutoClose,
472 handlePopupOAuthClose: c.handlePopupOAuthClose,
473 }
474}