1package content
2
3import (
4 "context"
5 "sync"
6 "time"
7
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)
18
19// Coordinator manages WebView lifecycle, title tracking, and content attachment.
20type Coordinator struct {
21 pool port.WebViewPool
22 widgetFactory layout.WidgetFactory
23 faviconAdapter *adapter.FaviconAdapter
24 zoomUC *usecase.ManageZoomUseCase
25 permissionUC *usecase.HandlePermissionUseCase
26 injector port.ContentInjector
27 settingsApplier port.SettingsApplier // optional: nil if engine doesn't support
28 filterApplier port.FilterApplier // optional: nil if engine doesn't support
29
30 webViews map[entity.PaneID]port.WebView
31 webViewsMu sync.RWMutex
32
33 activePaneOverride entity.PaneID
34 activePaneOverrideMu sync.RWMutex
35
36 paneTitles map[entity.PaneID]string
37 titleMu sync.RWMutex
38
39 // Track original navigation URLs to handle cross-domain redirects
40 // e.g., google.fr → google.com: cache favicon under both domains
41 navOrigins map[entity.PaneID]string
42 navOriginMu sync.RWMutex
43
44 // Callback to get active workspace state (avoids circular dependency)
45 getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
46
47 // Callback when title changes (for history persistence)
48 onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string)
49
50 // Callback when page is committed (for history recording)
51 onHistoryRecord func(ctx context.Context, paneID entity.PaneID, url string)
52
53 // Callback when pane URI changes (for session snapshots)
54 onPaneURIUpdated func(paneID entity.PaneID, url string)
55
56 // Callback when active pane title changes (for window title updates)
57 onWindowTitleChanged func(title string)
58
59 // Callback when media permission activity changes (requesting/allowed/blocked).
60 onPermissionActivity func(origin string, permTypes []entity.PermissionType, state PermissionActivityState)
61
62 // Callback when the active pane commits a navigation (new page loading).
63 onActiveNavigationCommitted func(uri string)
64
65 // Callback when the WebView becomes visible (first real commit)
66 onWebViewShown func(paneID entity.PaneID)
67
68 revealMu sync.Mutex
69 pendingReveal map[entity.PaneID]bool
70
71 appearanceMu sync.Mutex
72 pendingScriptRefresh map[entity.PaneID]bool
73 pendingThemePanes map[entity.PaneID]bool
74 pendingThemeUpdate pendingThemeUpdate
75 hasPendingThemeUpdate bool
76 currentTheme pendingThemeUpdate
77 hasCurrentTheme bool
78
79 // Gesture action handler for mouse button navigation
80 gestureActionHandler input.ActionHandler
81
82 // Popup handling
83 factory port.WebViewFactory
84 popupConfig *entity.PopupBehaviorConfig
85 pendingPopups map[port.WebViewID]*PendingPopup
86 popupOAuth map[port.WebViewID]*popupOAuthState
87 popupRefresh map[entity.PaneID]*time.Timer
88 popupMu sync.RWMutex
89
90 // Callback to insert popup into workspace (avoids circular dependency)
91 onInsertPopup func(ctx context.Context, input InsertPopupInput) error
92
93 // Callback to close a pane when popup closes
94 onClosePane func(ctx context.Context, paneID entity.PaneID) error
95
96 // ID generator for popup panes
97 generateID func() string
98
99 // Idle inhibitor for fullscreen video playback
100 idleInhibitor port.IdleInhibitor
101
102 // Callback when fullscreen state changes (for hiding/showing tab bar)
103 onFullscreenChanged func(entering bool)
104
105 // Callback when WebView gains focus (for accent picker text input targeting)
106 onWebViewFocused func(paneID entity.PaneID, wv port.WebView)
107
108 // Callback for first load_started event (triggers deferred initialization)
109 onFirstLoadStarted func()
110 loadStartedOnce sync.Once
111
112 // Callback to open a URL with the system's default handler (e.g. xdg-open).
113 // Used for external URL schemes like vscode://, spotify://, etc.
114 onLaunchExternalURL func(uri string)
115}
116
117type pendingThemeUpdate struct {
118 prefersDark bool
119 cssText string
120}
121
122type popupOAuthState struct {
123 ParentPaneID entity.PaneID
124 CallbackURI string
125 Success bool
126 Error bool
127 Seen bool
128}
129
130// PermissionActivityState represents the visible state for media permission activity.
131type PermissionActivityState string
132
133const (
134 PermissionActivityRequesting PermissionActivityState = "requesting"
135 PermissionActivityAllowed PermissionActivityState = "allowed"
136 PermissionActivityBlocked PermissionActivityState = "blocked"
137)
138
139// NewCoordinator creates a new Coordinator.
140func NewCoordinator(
141 ctx context.Context,
142 pool port.WebViewPool,
143 injector port.ContentInjector,
144 widgetFactory layout.WidgetFactory,
145 faviconAdapter *adapter.FaviconAdapter,
146 getActiveWS func() (*entity.Workspace, *component.WorkspaceView),
147 zoomUC *usecase.ManageZoomUseCase,
148 permissionUC *usecase.HandlePermissionUseCase,
149) *Coordinator {
150 log := logging.FromContext(ctx)
151 log.Debug().Msg("creating content coordinator")
152
153 return &Coordinator{
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 paneTitles: make(map[entity.PaneID]string),
162 navOrigins: make(map[entity.PaneID]string),
163 pendingReveal: make(map[entity.PaneID]bool),
164 pendingScriptRefresh: make(map[entity.PaneID]bool),
165 pendingThemePanes: make(map[entity.PaneID]bool),
166 getActiveWS: getActiveWS,
167 pendingPopups: make(map[port.WebViewID]*PendingPopup),
168 popupOAuth: make(map[port.WebViewID]*popupOAuthState),
169 popupRefresh: make(map[entity.PaneID]*time.Timer),
170 }
171}
172
173// SetOnTitleUpdated sets the callback for title changes (for history persistence).
174func (c *Coordinator) SetOnTitleUpdated(fn func(ctx context.Context, paneID entity.PaneID, url, title string)) {
175 c.onTitleUpdated = fn
176}
177
178// SetOnHistoryRecord sets the callback for recording history on page commit.
179func (c *Coordinator) SetOnHistoryRecord(fn func(ctx context.Context, paneID entity.PaneID, url string)) {
180 c.onHistoryRecord = fn
181}
182
183// SetOnPermissionActivity sets a callback for WebRTC permission activity changes.
184func (c *Coordinator) SetOnPermissionActivity(
185 fn func(origin string, permTypes []entity.PermissionType, state PermissionActivityState),
186) {
187 c.onPermissionActivity = fn
188}
189
190// SetOnActiveNavigationCommitted sets a callback fired when the active pane commits a navigation.
191func (c *Coordinator) SetOnActiveNavigationCommitted(fn func(uri string)) {
192 c.onActiveNavigationCommitted = fn
193}
194
195// SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots).
196func (c *Coordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) {
197 c.onPaneURIUpdated = fn
198}
199
200// SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates).
201func (c *Coordinator) SetOnWindowTitleChanged(fn func(title string)) {
202 c.onWindowTitleChanged = fn
203}
204
205// SetOnWebViewShown sets a callback that fires when a pane's WebView is shown.
206func (c *Coordinator) SetOnWebViewShown(fn func(paneID entity.PaneID)) {
207 c.onWebViewShown = fn
208}
209
210// SetGestureActionHandler sets the callback for mouse button navigation gestures.
211func (c *Coordinator) SetGestureActionHandler(handler input.ActionHandler) {
212 c.gestureActionHandler = handler
213}
214
215// SetIdleInhibitor sets the idle inhibitor for fullscreen video playback.
216func (c *Coordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) {
217 c.idleInhibitor = inhibitor
218}
219
220// SetOnFullscreenChanged sets the callback for fullscreen state changes.
221func (c *Coordinator) SetOnFullscreenChanged(fn func(entering bool)) {
222 c.onFullscreenChanged = fn
223}
224
225// SetOnWebViewFocused sets the callback for when a WebView gains focus.
226func (c *Coordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv port.WebView)) {
227 c.onWebViewFocused = fn
228}
229
230// SetOnFirstLoadStarted sets the callback for when the first navigation starts.
231// This is used to trigger deferred initialization after the initial load_uri()
232// has been processed by the GTK main loop.
233func (c *Coordinator) SetOnFirstLoadStarted(fn func()) {
234 c.onFirstLoadStarted = fn
235}
236
237// SetOnLaunchExternalURL sets the callback invoked when an external URL scheme
238// (e.g. vscode://, spotify://) is detected and must be handed off to the system.
239func (c *Coordinator) SetOnLaunchExternalURL(fn func(uri string)) {
240 c.onLaunchExternalURL = fn
241}
242
243// SetSettingsApplier sets the engine settings applier for config hot-reload.
244func (c *Coordinator) SetSettingsApplier(sa port.SettingsApplier) {
245 c.settingsApplier = sa
246}
247
248// SetFilterApplier sets the content filter applier for late-binding filters.
249func (c *Coordinator) SetFilterApplier(fa port.FilterApplier) {
250 c.filterApplier = fa
251}
252
253// ActivePaneID returns the currently active pane ID used by navigation.
254func (c *Coordinator) ActivePaneID(ctx context.Context) entity.PaneID {
255 if paneID, ok := c.activePaneOverrideID(); ok {
256 return paneID
257 }
258 if c.getActiveWS == nil {
259 return ""
260 }
261
262 ws, _ := c.getActiveWS()
263 if ws == nil {
264 return ""
265 }
266 return ws.ActivePaneID
267}
268
269// SetActivePaneOverride forces ActiveWebView/ActivePaneID to use a specific pane.
270func (c *Coordinator) SetActivePaneOverride(paneID entity.PaneID) {
271 c.activePaneOverrideMu.Lock()
272 c.activePaneOverride = paneID
273 c.activePaneOverrideMu.Unlock()
274}
275
276// ClearActivePaneOverride removes any forced active pane override.
277func (c *Coordinator) ClearActivePaneOverride() {
278 c.activePaneOverrideMu.Lock()
279 c.activePaneOverride = ""
280 c.activePaneOverrideMu.Unlock()
281}
282
283func (c *Coordinator) activePaneOverrideID() (entity.PaneID, bool) {
284 c.activePaneOverrideMu.RLock()
285 defer c.activePaneOverrideMu.RUnlock()
286 if c.activePaneOverride == "" {
287 return "", false
288 }
289 return c.activePaneOverride, true
290}
291
292func (c *Coordinator) webViewCount() int {
293 c.webViewsMu.RLock()
294 defer c.webViewsMu.RUnlock()
295 return len(c.webViews)
296}
297
298func (c *Coordinator) getWebViewLocked(paneID entity.PaneID) port.WebView {
299 c.webViewsMu.RLock()
300 defer c.webViewsMu.RUnlock()
301 return c.webViews[paneID]
302}
303
304func (c *Coordinator) setWebViewLocked(paneID entity.PaneID, wv port.WebView) {
305 c.webViewsMu.Lock()
306 c.webViews[paneID] = wv
307 c.webViewsMu.Unlock()
308}
309
310func (c *Coordinator) deleteWebViewLocked(paneID entity.PaneID) port.WebView {
311 c.webViewsMu.Lock()
312 defer c.webViewsMu.Unlock()
313 wv := c.webViews[paneID]
314 delete(c.webViews, paneID)
315 return wv
316}