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 revealMu sync.Mutex
74 pendingReveal map[entity.PaneID]bool
75
76 appearanceMu sync.Mutex
77 pendingScriptRefresh map[entity.PaneID]bool
78 pendingThemePanes map[entity.PaneID]bool
79 pendingThemeUpdate pendingThemeUpdate
80 hasPendingThemeUpdate bool
81 currentTheme pendingThemeUpdate
82 hasCurrentTheme bool
83
84 // Gesture action handler for mouse button navigation
85 gestureActionHandler input.ActionHandler
86
87 // Popup handling stays in a dedicated UI-layer manager so popup-specific
88 // pane state does not bloat the main coordinator.
89 popups *popupManager
90 popupsOnce sync.Once
91
92 // Idle inhibitor for fullscreen video playback
93 idleInhibitor port.IdleInhibitor
94
95 // Callback when fullscreen state changes (for hiding/showing tab bar)
96 onFullscreenChanged func(paneID entity.PaneID, entering bool)
97
98 // Callback when WebView gains focus (for accent picker text input targeting)
99 onWebViewFocused func(paneID entity.PaneID, wv port.WebView)
100
101 // Callback for first load_started event (triggers deferred initialization)
102 onFirstLoadStarted func()
103 loadStartedOnce sync.Once
104
105 // Callback to open a URL with the system's default handler (e.g. xdg-open).
106 // Used for external URL schemes like vscode://, spotify://, etc.
107 onLaunchExternalURL func(uri string)
108}
109
110type pendingThemeUpdate struct {
111 prefersDark bool
112 cssText string
113}
114
115// PermissionActivityState represents the visible state for media permission activity.
116type PermissionActivityState string
117
118const (
119 PermissionActivityRequesting PermissionActivityState = "requesting"
120 PermissionActivityAllowed PermissionActivityState = "allowed"
121 PermissionActivityBlocked PermissionActivityState = "blocked"
122)
123
124// NewCoordinator creates a new Coordinator.
125func NewCoordinator(
126 ctx context.Context,
127 pool port.WebViewPool,
128 injector port.ContentInjector,
129 widgetFactory layout.WidgetFactory,
130 faviconAdapter *adapter.FaviconAdapter,
131 getActiveWS func() (*entity.Workspace, *component.WorkspaceView),
132 zoomUC *usecase.ManageZoomUseCase,
133 permissionUC *usecase.HandlePermissionUseCase,
134) *Coordinator {
135 log := logging.FromContext(ctx)
136 log.Debug().Msg("creating content coordinator")
137
138 return &Coordinator{
139 logger: log.With().Str("component", "content-coordinator").Logger(),
140 pool: pool,
141 injector: injector,
142 widgetFactory: widgetFactory,
143 faviconAdapter: faviconAdapter,
144 zoomUC: zoomUC,
145 permissionUC: permissionUC,
146 webViews: make(map[entity.PaneID]port.WebView),
147 webViewPaneIDs: make(map[port.WebViewID]entity.PaneID),
148 paneTitles: make(map[entity.PaneID]string),
149 navOrigins: make(map[entity.PaneID]string),
150 pendingReveal: make(map[entity.PaneID]bool),
151 pendingScriptRefresh: make(map[entity.PaneID]bool),
152 pendingThemePanes: make(map[entity.PaneID]bool),
153 getActiveWS: getActiveWS,
154 popups: newPopupManager(),
155 }
156}
157
158func (c *Coordinator) ensurePopupManager() *popupManager {
159 if c == nil {
160 return nil
161 }
162 c.popupsOnce.Do(func() {
163 if c.popups == nil {
164 c.popups = newPopupManager()
165 }
166 })
167 c.popups.ensureInitialized()
168 return c.popups
169}
170
171// SetOnTitleUpdated sets the callback for title changes (for history persistence).
172func (c *Coordinator) SetOnTitleUpdated(fn func(ctx context.Context, paneID entity.PaneID, url, title string)) {
173 c.onTitleUpdated = fn
174}
175
176// SetOnHistoryRecord sets the callback for recording history on page commit.
177func (c *Coordinator) SetOnHistoryRecord(fn func(ctx context.Context, paneID entity.PaneID, url string)) {
178 c.onHistoryRecord = fn
179}
180
181// SetOnPermissionActivity sets a callback for WebRTC permission activity changes.
182func (c *Coordinator) SetOnPermissionActivity(
183 fn func(paneID entity.PaneID, origin string, permTypes []entity.PermissionType, state PermissionActivityState),
184) {
185 c.onPermissionActivity = fn
186}
187
188// SetOnActiveNavigationCommitted sets a callback fired when the active pane commits a navigation.
189func (c *Coordinator) SetOnActiveNavigationCommitted(fn func(paneID entity.PaneID, uri string)) {
190 c.onActiveNavigationCommitted = fn
191}
192
193// SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots).
194func (c *Coordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) {
195 c.onPaneURIUpdated = fn
196}
197
198// SetOnTouchpadNavigationGesture sets the callback for CEF two-finger history
199// navigation gesture progress.
200func (c *Coordinator) SetOnTouchpadNavigationGesture(fn func(paneID entity.PaneID, gesture entity.TouchpadNavigationGesture)) {
201 c.onTouchpadNavigationGesture = fn
202}
203
204// SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates).
205func (c *Coordinator) SetOnWindowTitleChanged(fn func(paneID entity.PaneID, title string)) {
206 c.onWindowTitleChanged = fn
207}
208
209// SetOnWebViewShown sets a callback that fires when a pane's WebView is shown.
210func (c *Coordinator) SetOnWebViewShown(fn func(paneID entity.PaneID)) {
211 c.onWebViewShown = fn
212}
213
214// SetGestureActionHandler sets the callback for mouse button navigation gestures.
215func (c *Coordinator) SetGestureActionHandler(handler input.ActionHandler) {
216 c.gestureActionHandler = handler
217}
218
219// SetIdleInhibitor sets the idle inhibitor for fullscreen video playback.
220func (c *Coordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) {
221 c.idleInhibitor = inhibitor
222}
223
224// SetOnFullscreenChanged sets the callback for fullscreen state changes.
225func (c *Coordinator) SetOnFullscreenChanged(fn func(paneID entity.PaneID, entering bool)) {
226 c.onFullscreenChanged = fn
227}
228
229// SetOnWebViewFocused sets the callback for when a WebView gains focus.
230func (c *Coordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv port.WebView)) {
231 c.onWebViewFocused = fn
232}
233
234// SetOnFirstLoadStarted sets the callback for when the first navigation starts.
235// This is used to trigger deferred initialization after the initial load_uri()
236// has been processed by the GTK main loop.
237func (c *Coordinator) SetOnFirstLoadStarted(fn func()) {
238 c.onFirstLoadStarted = fn
239}
240
241// SetOnLaunchExternalURL sets the callback invoked when an external URL scheme
242// (e.g. vscode://, spotify://) is detected and must be handed off to the system.
243func (c *Coordinator) SetOnLaunchExternalURL(fn func(uri string)) {
244 c.onLaunchExternalURL = fn
245}
246
247// SetSettingsApplier sets the engine settings applier for config hot-reload.
248func (c *Coordinator) SetSettingsApplier(sa port.SettingsApplier) {
249 c.settingsApplier = sa
250}
251
252// SetFilterApplier sets the content filter applier for late-binding filters.
253func (c *Coordinator) SetFilterApplier(fa port.FilterApplier) {
254 c.filterApplier = fa
255}
256
257// ActivePaneID returns the currently active pane ID used by navigation.
258func (c *Coordinator) ActivePaneID(ctx context.Context) entity.PaneID {
259 if paneID, ok := c.activePaneOverrideID(); ok {
260 return paneID
261 }
262 if c.getActiveWS == nil {
263 return ""
264 }
265
266 ws, _ := c.getActiveWS()
267 if ws == nil {
268 return ""
269 }
270 return ws.ActivePaneID
271}
272
273// SetActivePaneOverride forces ActiveWebView/ActivePaneID to use a specific pane.
274func (c *Coordinator) SetActivePaneOverride(paneID entity.PaneID) {
275 c.activePaneOverrideMu.Lock()
276 c.activePaneOverride = paneID
277 c.activePaneOverrideMu.Unlock()
278}
279
280// ClearActivePaneOverride removes any forced active pane override.
281func (c *Coordinator) ClearActivePaneOverride() {
282 c.activePaneOverrideMu.Lock()
283 c.activePaneOverride = ""
284 c.activePaneOverrideMu.Unlock()
285}
286
287// ActivePaneOverrideID returns the currently forced active pane override, if any.
288func (c *Coordinator) ActivePaneOverrideID() (entity.PaneID, bool) {
289 return c.activePaneOverrideID()
290}
291
292func (c *Coordinator) activePaneOverrideID() (entity.PaneID, bool) {
293 c.activePaneOverrideMu.RLock()
294 defer c.activePaneOverrideMu.RUnlock()
295 if c.activePaneOverride == "" {
296 return "", false
297 }
298 return c.activePaneOverride, true
299}
300
301func (c *Coordinator) webViewCount() int {
302 c.webViewsMu.RLock()
303 defer c.webViewsMu.RUnlock()
304 return len(c.webViews)
305}
306
307func (c *Coordinator) getWebViewLocked(paneID entity.PaneID) port.WebView {
308 c.webViewsMu.RLock()
309 defer c.webViewsMu.RUnlock()
310 return c.webViews[paneID]
311}
312
313func (c *Coordinator) setWebViewLocked(paneID entity.PaneID, wv port.WebView) {
314 c.webViewsMu.Lock()
315 if c.webViews == nil {
316 c.webViews = make(map[entity.PaneID]port.WebView)
317 }
318 if existing := c.webViews[paneID]; existing != nil && c.webViewPaneIDs != nil {
319 delete(c.webViewPaneIDs, existing.ID())
320 }
321 c.webViews[paneID] = wv
322 if wv != nil && c.webViewPaneIDs != nil {
323 c.webViewPaneIDs[wv.ID()] = paneID
324 }
325 c.webViewsMu.Unlock()
326}
327
328func (c *Coordinator) deleteWebViewLocked(paneID entity.PaneID) port.WebView {
329 c.webViewsMu.Lock()
330 defer c.webViewsMu.Unlock()
331 wv := c.webViews[paneID]
332 delete(c.webViews, paneID)
333 if wv != nil && c.webViewPaneIDs != nil {
334 delete(c.webViewPaneIDs, wv.ID())
335 }
336 return wv
337}
338
339func (c *Coordinator) paneIDByWebViewIDLocked(webViewID port.WebViewID) (entity.PaneID, bool) {
340 paneID, ok := c.webViewPaneIDs[webViewID]
341 return paneID, ok
342}
343
344func (c *Coordinator) findPaneByWebViewID(webViewID port.WebViewID) (entity.PaneID, bool) {
345 if webViewID == 0 {
346 return "", false
347 }
348
349 c.webViewsMu.RLock()
350 defer c.webViewsMu.RUnlock()
351 if paneID, ok := c.paneIDByWebViewIDLocked(webViewID); ok && paneID != "" {
352 return paneID, true
353 }
354
355 c.logger.Debug().
356 Uint64("webview_id", uint64(webViewID)).
357 Msg("findPaneByWebViewID: using fallback scan")
358 for paneID, wv := range c.webViews {
359 if wv != nil && wv.ID() == webViewID {
360 return paneID, true
361 }
362 }
363 return "", false
364}
365
366func (c *Coordinator) popupHooks() popupCoordinatorHooks {
367 return popupCoordinatorHooks{
368 setupWebViewCallbacks: c.setupWebViewCallbacks,
369 registerPopupWebView: c.RegisterPopupWebView,
370 setWebView: c.setWebViewLocked,
371 getWebView: c.getWebViewLocked,
372 deleteWebView: c.deleteWebViewLocked,
373 releaseWebView: c.ReleaseWebView,
374 findPaneByWebViewID: c.findPaneByWebViewID,
375 setupOAuthAutoClose: c.setupOAuthAutoClose,
376 handlePopupOAuthClose: c.handlePopupOAuthClose,
377 }
378}