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