detached 9a59c25

refactor(coordinator): extract content.go into coordinator/content/ package

Split 2269-line content.go into focused files:
- coordinator.go: struct, constructor, setters, state helpers
- lifecycle.go: WebView creation, release, widget wrapping
- navigation.go: load events, URI tracking, SPA navigation
- appearance.go: theme CSS injection, script refresh
- title.go: title/favicon management
- popup.go: popup creation/handling (absorbed popup_types.go)
- oauth.go: OAuth popup flow (absorbed coordinator/oauth.go)
- callbacks.go: callback wiring, crash, permissions
- bulk.go: bulk operations (filters, settings, scripts)

Rename ContentCoordinator -> content.Coordinator.
Move popup_types.go and oauth.go into the new package.
commit
9a59c25b7b1845c3622ba14604973a2beda99224
author
brice · · 2026-03-15 17:11 UTC
parent
4cbf000
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
18 files · +2767 -2681
M internal/ui/app.go +12 -11
@@ -27,6 +27,7 @@ import (
2727 "github.com/bnema/dumber/internal/ui/adapter"
2828 "github.com/bnema/dumber/internal/ui/component"
2929 "github.com/bnema/dumber/internal/ui/coordinator"
30+ "github.com/bnema/dumber/internal/ui/coordinator/content"
3031 "github.com/bnema/dumber/internal/ui/dialog"
3132 "github.com/bnema/dumber/internal/ui/dispatcher"
3233 "github.com/bnema/dumber/internal/ui/focus"
@@ -68,7 +69,7 @@ type App struct {
6869 tabsUC *usecase.ManageTabsUseCase
6970
7071 // Coordinators (new architecture)
71- contentCoord *coordinator.ContentCoordinator
72+ contentCoord *content.Coordinator
7273 tabCoord *coordinator.TabCoordinator
7374 wsCoord *coordinator.WorkspaceCoordinator
7475 navCoord *coordinator.NavigationCoordinator
@@ -103,7 +104,7 @@ type App struct {
103104 engine port.Engine
104105 wkEngine *webkit.Engine
105106
106- // Web content (managed by ContentCoordinator)
107+ // Web content (managed by content.Coordinator)
107108 pool *webkit.WebViewPool
108109 webViewFactory *webkit.WebViewFactory
109110 injector *webkit.ContentInjector
@@ -1434,7 +1435,7 @@ func (a *App) runAfterFirstLoadStarted(fn func()) {
14341435 a.deferredInitFn = fn
14351436 }
14361437
1437-// triggerDeferredInit is called from ContentCoordinator on first load_started.
1438+// triggerDeferredInit is called from content.Coordinator on first load_started.
14381439 // It runs deferred initialization at LOW priority so navigation continues unblocked.
14391440 func (a *App) triggerDeferredInit(ctx context.Context) {
14401441 a.deferredInitOnce.Do(func() {
@@ -1522,7 +1523,7 @@ func (a *App) initCoordinators(ctx context.Context) {
15221523 a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, faviconDB)
15231524
15241525 // 1. Content Coordinator (no dependencies on other coordinators)
1525- a.contentCoord = coordinator.NewContentCoordinator(
1526+ a.contentCoord = content.NewCoordinator(
15261527 ctx,
15271528 a.pool,
15281529 a.widgetFactory,
@@ -1622,14 +1623,14 @@ func (a *App) initCoordinators(ctx context.Context) {
16221623 &a.deps.Config.Workspace.Popups,
16231624 a.generateID,
16241625 )
1625- a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input coordinator.InsertPopupInput) error {
1626+ a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input content.InsertPopupInput) error {
16261627 return a.wsCoord.InsertPopup(ctx, input)
16271628 })
16281629 a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error {
16291630 return a.wsCoord.ClosePaneByID(ctx, paneID)
16301631 })
16311632 // Wire tabbed popup behavior to create new tabs
1632- a.wsCoord.SetOnCreatePopupTab(func(ctx context.Context, input coordinator.InsertPopupInput) error {
1633+ a.wsCoord.SetOnCreatePopupTab(func(ctx context.Context, input content.InsertPopupInput) error {
16331634 // Create a new tab with the popup pane
16341635 tab, err := a.tabCoord.CreateWithPane(ctx, input.PopupPane, input.WebView, input.TargetURI)
16351636 if err != nil {
@@ -1761,15 +1762,15 @@ func (a *App) wireWebRTCPermissionIndicator() {
17611762 }
17621763 log := logging.FromContext(ctx)
17631764
1764- a.contentCoord.SetOnPermissionActivity(func(origin string, permTypes []entity.PermissionType, state coordinator.PermissionActivityState) {
1765+ a.contentCoord.SetOnPermissionActivity(func(origin string, permTypes []entity.PermissionType, state content.PermissionActivityState) {
17651766 a.webrtcIndicator.SetOrigin(origin)
17661767
17671768 switch state {
1768- case coordinator.PermissionActivityRequesting:
1769+ case content.PermissionActivityRequesting:
17691770 a.webrtcIndicator.MarkRequesting(permTypes)
1770- case coordinator.PermissionActivityAllowed:
1771+ case content.PermissionActivityAllowed:
17711772 a.webrtcIndicator.MarkAllowed(permTypes)
1772- case coordinator.PermissionActivityBlocked:
1773+ case content.PermissionActivityBlocked:
17731774 a.webrtcIndicator.MarkBlocked(permTypes)
17741775 }
17751776
@@ -1794,7 +1795,7 @@ func (a *App) wireWebRTCPermissionIndicator() {
17941795 // Allowed/requesting → deny future requests.
17951796 // Blocked → allow future requests.
17961797 decision := entity.PermissionDenied
1797- if state == string(coordinator.PermissionActivityBlocked) {
1798+ if state == string(content.PermissionActivityBlocked) {
17981799 decision = entity.PermissionGranted
17991800 }
18001801 if err := a.deps.PermissionUC.SetManualPermissionDecision(ctx, origin, permType, decision); err != nil {
D internal/ui/coordinator/content.go +0 -2316
@@ -1,2316 +0,0 @@
1-package coordinator
2-
3-import (
4- "context"
5- "encoding/json"
6- "fmt"
7- "net/url"
8- "strings"
9- "sync"
10- "time"
11-
12- "github.com/bnema/dumber/internal/application/port"
13- "github.com/bnema/dumber/internal/application/usecase"
14- "github.com/bnema/dumber/internal/domain/entity"
15- urlutil "github.com/bnema/dumber/internal/domain/url"
16- "github.com/bnema/dumber/internal/infrastructure/config"
17- "github.com/bnema/dumber/internal/infrastructure/desktop"
18- "github.com/bnema/dumber/internal/infrastructure/webkit"
19- "github.com/bnema/dumber/internal/logging"
20- "github.com/bnema/dumber/internal/ui/adapter"
21- "github.com/bnema/dumber/internal/ui/component"
22- "github.com/bnema/dumber/internal/ui/input"
23- "github.com/bnema/dumber/internal/ui/layout"
24- webkitlib "github.com/bnema/puregotk-webkit/webkit"
25- "github.com/jwijenbergh/puregotk/v4/gdk"
26- "github.com/jwijenbergh/puregotk/v4/glib"
27-)
28-
29-const (
30- aboutBlankURI = "about:blank"
31- crashPageURI = "dumb://home/crash"
32- logURLMaxLen = 80
33- oauthParentRefreshDebounce = 200 * time.Millisecond
34-
35- // Dark theme background color (#0a0a0b) as float32 RGBA values
36- darkBgR = 0.039
37- darkBgG = 0.039
38- darkBgB = 0.043
39- darkBgA = 1.0
40-)
41-
42-func shouldRenderCrashPage(reason webkitlib.WebProcessTerminationReason) bool {
43- switch reason {
44- case webkitlib.WebProcessCrashedValue, webkitlib.WebProcessExceededMemoryLimitValue:
45- return true
46- case webkitlib.WebProcessTerminatedByApiValue:
47- return false
48- default:
49- return true
50- }
51-}
52-
53-func extractOriginalURIFromCrashPage(uri string) string {
54- if uri == "" {
55- return ""
56- }
57-
58- parsed, err := url.Parse(uri)
59- if err != nil {
60- return uri
61- }
62-
63- if parsed.Scheme != "dumb" || parsed.Host != webkit.HomePath {
64- return uri
65- }
66-
67- if strings.Trim(parsed.Path, "/") != "crash" {
68- return uri
69- }
70-
71- original := strings.TrimSpace(parsed.Query().Get("url"))
72- if original == "" {
73- return ""
74- }
75- return original
76-}
77-
78-func buildCrashPageURI(originalURI string) string {
79- if strings.TrimSpace(originalURI) == "" {
80- return crashPageURI
81- }
82- query := url.Values{}
83- query.Set("url", originalURI)
84- return crashPageURI + "?" + query.Encode()
85-}
86-
87-// ContentCoordinator manages WebView lifecycle, title tracking, and content attachment.
88-type ContentCoordinator struct {
89- pool *webkit.WebViewPool
90- widgetFactory layout.WidgetFactory
91- faviconAdapter *adapter.FaviconAdapter
92- zoomUC *usecase.ManageZoomUseCase
93- permissionUC *usecase.HandlePermissionUseCase
94- injector *webkit.ContentInjector
95-
96- webViews map[entity.PaneID]*webkit.WebView
97- webViewsMu sync.RWMutex
98-
99- activePaneOverride entity.PaneID
100- activePaneOverrideMu sync.RWMutex
101-
102- paneTitles map[entity.PaneID]string
103- titleMu sync.RWMutex
104-
105- // Track original navigation URLs to handle cross-domain redirects
106- // e.g., google.fr → google.com: cache favicon under both domains
107- navOrigins map[entity.PaneID]string
108- navOriginMu sync.RWMutex
109-
110- // Callback to get active workspace state (avoids circular dependency)
111- getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
112-
113- // Callback when title changes (for history persistence)
114- onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string)
115-
116- // Callback when page is committed (for history recording)
117- onHistoryRecord func(ctx context.Context, paneID entity.PaneID, url string)
118-
119- // Callback when pane URI changes (for session snapshots)
120- onPaneURIUpdated func(paneID entity.PaneID, url string)
121-
122- // Callback when active pane title changes (for window title updates)
123- onWindowTitleChanged func(title string)
124-
125- // Callback when media permission activity changes (requesting/allowed/blocked).
126- onPermissionActivity func(origin string, permTypes []entity.PermissionType, state PermissionActivityState)
127-
128- // Callback when the active pane commits a navigation (new page loading).
129- onActiveNavigationCommitted func(uri string)
130-
131- // Callback when the WebView becomes visible (first real commit)
132- onWebViewShown func(paneID entity.PaneID)
133-
134- revealMu sync.Mutex
135- pendingReveal map[entity.PaneID]bool
136-
137- appearanceMu sync.Mutex
138- pendingScriptRefresh map[entity.PaneID]bool
139- pendingThemePanes map[entity.PaneID]bool
140- pendingThemeUpdate pendingThemeUpdate
141- hasPendingThemeUpdate bool
142- currentTheme pendingThemeUpdate
143- hasCurrentTheme bool
144-
145- // Gesture action handler for mouse button navigation
146- gestureActionHandler input.ActionHandler
147-
148- // Popup handling
149- factory *webkit.WebViewFactory
150- popupConfig *config.PopupBehaviorConfig
151- pendingPopups map[port.WebViewID]*PendingPopup
152- popupOAuth map[port.WebViewID]*popupOAuthState
153- popupRefresh map[entity.PaneID]*time.Timer
154- popupMu sync.RWMutex
155-
156- // Callback to insert popup into workspace (avoids circular dependency)
157- onInsertPopup func(ctx context.Context, input InsertPopupInput) error
158-
159- // Callback to close a pane when popup closes
160- onClosePane func(ctx context.Context, paneID entity.PaneID) error
161-
162- // ID generator for popup panes
163- generateID func() string
164-
165- // Idle inhibitor for fullscreen video playback
166- idleInhibitor port.IdleInhibitor
167-
168- // Callback when fullscreen state changes (for hiding/showing tab bar)
169- onFullscreenChanged func(entering bool)
170-
171- // Callback when WebView gains focus (for accent picker text input targeting)
172- onWebViewFocused func(paneID entity.PaneID, wv *webkit.WebView)
173-
174- // Callback for first load_started event (triggers deferred initialization)
175- onFirstLoadStarted func()
176- loadStartedOnce sync.Once
177-}
178-
179-type pendingThemeUpdate struct {
180- prefersDark bool
181- cssText string
182-}
183-
184-type popupOAuthState struct {
185- ParentPaneID entity.PaneID
186- CallbackURI string
187- Success bool
188- Error bool
189- Seen bool
190-}
191-
192-// PermissionActivityState represents the visible state for media permission activity.
193-type PermissionActivityState string
194-
195-const (
196- PermissionActivityRequesting PermissionActivityState = "requesting"
197- PermissionActivityAllowed PermissionActivityState = "allowed"
198- PermissionActivityBlocked PermissionActivityState = "blocked"
199-)
200-
201-// NewContentCoordinator creates a new ContentCoordinator.
202-func NewContentCoordinator(
203- ctx context.Context,
204- pool *webkit.WebViewPool,
205- widgetFactory layout.WidgetFactory,
206- faviconAdapter *adapter.FaviconAdapter,
207- getActiveWS func() (*entity.Workspace, *component.WorkspaceView),
208- zoomUC *usecase.ManageZoomUseCase,
209- permissionUC *usecase.HandlePermissionUseCase,
210-) *ContentCoordinator {
211- log := logging.FromContext(ctx)
212- log.Debug().Msg("creating content coordinator")
213-
214- return &ContentCoordinator{
215- pool: pool,
216- widgetFactory: widgetFactory,
217- faviconAdapter: faviconAdapter,
218- zoomUC: zoomUC,
219- permissionUC: permissionUC,
220- webViews: make(map[entity.PaneID]*webkit.WebView),
221- paneTitles: make(map[entity.PaneID]string),
222- navOrigins: make(map[entity.PaneID]string),
223- pendingReveal: make(map[entity.PaneID]bool),
224- getActiveWS: getActiveWS,
225- pendingPopups: make(map[port.WebViewID]*PendingPopup),
226- popupOAuth: make(map[port.WebViewID]*popupOAuthState),
227- popupRefresh: make(map[entity.PaneID]*time.Timer),
228- }
229-}
230-
231-// SetOnTitleUpdated sets the callback for title changes (for history persistence).
232-func (c *ContentCoordinator) SetOnTitleUpdated(fn func(ctx context.Context, paneID entity.PaneID, url, title string)) {
233- c.onTitleUpdated = fn
234-}
235-
236-// SetOnHistoryRecord sets the callback for recording history on page commit.
237-func (c *ContentCoordinator) SetOnHistoryRecord(fn func(ctx context.Context, paneID entity.PaneID, url string)) {
238- c.onHistoryRecord = fn
239-}
240-
241-// SetOnPermissionActivity sets a callback for WebRTC permission activity changes.
242-func (c *ContentCoordinator) SetOnPermissionActivity(
243- fn func(origin string, permTypes []entity.PermissionType, state PermissionActivityState),
244-) {
245- c.onPermissionActivity = fn
246-}
247-
248-// SetOnActiveNavigationCommitted sets a callback fired when the active pane commits a navigation.
249-func (c *ContentCoordinator) SetOnActiveNavigationCommitted(fn func(uri string)) {
250- c.onActiveNavigationCommitted = fn
251-}
252-
253-// SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots).
254-func (c *ContentCoordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) {
255- c.onPaneURIUpdated = fn
256-}
257-
258-// SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates).
259-func (c *ContentCoordinator) SetOnWindowTitleChanged(fn func(title string)) {
260- c.onWindowTitleChanged = fn
261-}
262-
263-// SetOnWebViewShown sets a callback that fires when a pane's WebView is shown.
264-func (c *ContentCoordinator) SetOnWebViewShown(fn func(paneID entity.PaneID)) {
265- c.onWebViewShown = fn
266-}
267-
268-// SetGestureActionHandler sets the callback for mouse button navigation gestures.
269-func (c *ContentCoordinator) SetGestureActionHandler(handler input.ActionHandler) {
270- c.gestureActionHandler = handler
271-}
272-
273-// SetIdleInhibitor sets the idle inhibitor for fullscreen video playback.
274-func (c *ContentCoordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) {
275- c.idleInhibitor = inhibitor
276-}
277-
278-// SetOnFullscreenChanged sets the callback for fullscreen state changes.
279-func (c *ContentCoordinator) SetOnFullscreenChanged(fn func(entering bool)) {
280- c.onFullscreenChanged = fn
281-}
282-
283-// SetOnWebViewFocused sets the callback for when a WebView gains focus.
284-func (c *ContentCoordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv *webkit.WebView)) {
285- c.onWebViewFocused = fn
286-}
287-
288-// SetOnFirstLoadStarted sets the callback for when the first navigation starts.
289-// This is used to trigger deferred initialization after the initial load_uri()
290-// has been processed by the GTK main loop.
291-func (c *ContentCoordinator) SetOnFirstLoadStarted(fn func()) {
292- c.onFirstLoadStarted = fn
293-}
294-
295-// EnsureWebView acquires or reuses a WebView for the given pane.
296-func (c *ContentCoordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {
297- log := logging.FromContext(ctx)
298-
299- if wv := c.getWebViewLocked(paneID); wv != nil && !wv.IsDestroyed() {
300- return wv, nil
301- }
302-
303- if c.pool == nil {
304- return nil, fmt.Errorf("webview pool not configured")
305- }
306-
307- // Mark tab_created on first webview (first tab)
308- if c.webViewCount() == 0 {
309- logging.Trace().Mark("tab_created")
310- }
311-
312- wv, err := c.pool.Acquire(ctx)
313- if err != nil {
314- return nil, err
315- }
316- logging.Trace().Mark("webview_acquired")
317-
318- c.setWebViewLocked(paneID, wv)
319- c.setupWebViewCallbacks(ctx, paneID, wv)
320-
321- log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane")
322- return wv, nil
323-}
324-
325-// ReleaseWebView returns the WebView for a pane to the pool.
326-func (c *ContentCoordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) {
327- log := logging.FromContext(ctx)
328-
329- wv := c.deleteWebViewLocked(paneID)
330- if wv == nil {
331- return
332- }
333- c.clearPendingAppearance(paneID)
334-
335- // CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing),
336- // we must release the inhibition before destroying the webview.
337- // Otherwise the D-Bus inhibit request stays active forever.
338- if c.idleInhibitor != nil {
339- if wv.IsFullscreen() {
340- log.Debug().Str("pane_id", string(paneID)).Msg("releasing idle inhibition (was fullscreen)")
341- if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
342- log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle on release (fullscreen)")
343- }
344- }
345- if wv.IsPlayingAudio() {
346- log.Debug().Str("pane_id", string(paneID)).Msg("releasing idle inhibition (was playing audio)")
347- if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
348- log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle on release (audio)")
349- }
350- }
351- }
352-
353- // Clean up title tracking
354- c.titleMu.Lock()
355- delete(c.paneTitles, paneID)
356- c.titleMu.Unlock()
357-
358- // Clean up navigation origin tracking
359- c.navOriginMu.Lock()
360- delete(c.navOrigins, paneID)
361- c.navOriginMu.Unlock()
362-
363- if c.pool != nil {
364- c.pool.Release(ctx, wv)
365- } else {
366- wv.Destroy()
367- }
368-
369- log.Debug().Str("pane_id", string(paneID)).Msg("webview released")
370-}
371-
372-// AttachToWorkspace ensures each pane in the workspace has a WebView widget attached.
373-func (c *ContentCoordinator) AttachToWorkspace(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) {
374- log := logging.FromContext(ctx)
375-
376- if ws == nil || wsView == nil || c.widgetFactory == nil {
377- return
378- }
379-
380- for _, pane := range ws.AllPanes() {
381- if pane == nil {
382- continue
383- }
384-
385- wv, err := c.EnsureWebView(ctx, pane.ID)
386- if err != nil {
387- log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to ensure webview for pane")
388- continue
389- }
390-
391- // Load the pane's URI if set and different from current
392- if pane.URI != "" && pane.URI != wv.URI() {
393- if err := wv.LoadURI(ctx, pane.URI); err != nil {
394- log.Warn().Err(err).Str("pane_id", string(pane.ID)).Str("uri", pane.URI).Msg("failed to load pane URI")
395- }
396- }
397-
398- widget := c.WrapWidget(ctx, wv)
399- if widget == nil {
400- continue
401- }
402-
403- if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil {
404- log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget")
405- }
406- logging.Trace().Mark("webview_attached")
407- }
408-}
409-
410-// WrapWidget converts a WebView to a layout.Widget for embedding.
411-// It also attaches gesture handlers for mouse button navigation.
412-func (c *ContentCoordinator) WrapWidget(ctx context.Context, wv *webkit.WebView) layout.Widget {
413- log := logging.FromContext(ctx)
414-
415- if wv == nil || c.widgetFactory == nil {
416- log.Debug().Msg("cannot wrap nil webview or factory")
417- return nil
418- }
419-
420- gtkView := wv.Widget()
421- if gtkView == nil {
422- return nil
423- }
424-
425- widget := c.widgetFactory.WrapWidget(&gtkView.Widget)
426-
427- // Attach gesture handler for mouse button 8/9 navigation
428- if widget != nil {
429- gestureHandler := input.NewGestureHandler(ctx)
430- // Pass WebView directly to preserve user gesture context (like Epiphany)
431- gestureHandler.SetNavigator(wv)
432- // Keep callback as fallback
433- if c.gestureActionHandler != nil {
434- gestureHandler.SetOnAction(c.gestureActionHandler)
435- }
436- gestureHandler.AttachTo(widget.GtkWidget())
437- log.Debug().Msg("gesture handler attached to webview with direct navigator")
438- }
439-
440- return widget
441-}
442-
443-// ActiveWebView returns the WebView for the active pane.
444-func (c *ContentCoordinator) ActiveWebView(ctx context.Context) *webkit.WebView {
445- log := logging.FromContext(ctx)
446-
447- if paneID, ok := c.activePaneOverrideID(); ok {
448- wv := c.getWebViewLocked(paneID)
449- if wv != nil {
450- return wv
451- }
452- log.Debug().Str("pane_id", string(paneID)).Msg("active pane override has no webview, falling back")
453- }
454- if c.getActiveWS == nil {
455- log.Debug().Msg("no active workspace resolver")
456- return nil
457- }
458-
459- ws, _ := c.getActiveWS()
460- if ws == nil {
461- log.Debug().Msg("no active workspace")
462- return nil
463- }
464-
465- pane := ws.ActivePane()
466- if pane == nil || pane.Pane == nil {
467- log.Debug().Msg("no active pane")
468- return nil
469- }
470-
471- return c.getWebViewLocked(pane.Pane.ID)
472-}
473-
474-// ActivePaneID returns the currently active pane ID used by navigation.
475-func (c *ContentCoordinator) ActivePaneID(ctx context.Context) entity.PaneID {
476- if paneID, ok := c.activePaneOverrideID(); ok {
477- return paneID
478- }
479- if c.getActiveWS == nil {
480- return ""
481- }
482-
483- ws, _ := c.getActiveWS()
484- if ws == nil {
485- return ""
486- }
487- return ws.ActivePaneID
488-}
489-
490-// SetActivePaneOverride forces ActiveWebView/ActivePaneID to use a specific pane.
491-func (c *ContentCoordinator) SetActivePaneOverride(paneID entity.PaneID) {
492- c.activePaneOverrideMu.Lock()
493- c.activePaneOverride = paneID
494- c.activePaneOverrideMu.Unlock()
495-}
496-
497-// ClearActivePaneOverride removes any forced active pane override.
498-func (c *ContentCoordinator) ClearActivePaneOverride() {
499- c.activePaneOverrideMu.Lock()
500- c.activePaneOverride = ""
501- c.activePaneOverrideMu.Unlock()
502-}
503-
504-func (c *ContentCoordinator) activePaneOverrideID() (entity.PaneID, bool) {
505- c.activePaneOverrideMu.RLock()
506- defer c.activePaneOverrideMu.RUnlock()
507- if c.activePaneOverride == "" {
508- return "", false
509- }
510- return c.activePaneOverride, true
511-}
512-
513-// GetWebView returns the WebView for a specific pane.
514-func (c *ContentCoordinator) GetWebView(paneID entity.PaneID) *webkit.WebView {
515- return c.getWebViewLocked(paneID)
516-}
517-
518-// RegisterPopupWebView registers a popup WebView that was created externally.
519-// This is used when popup tabs are created and the WebView needs to be tracked.
520-func (c *ContentCoordinator) RegisterPopupWebView(paneID entity.PaneID, wv *webkit.WebView) {
521- if wv != nil && paneID != "" {
522- c.setWebViewLocked(paneID, wv)
523- }
524-}
525-
526-func (c *ContentCoordinator) getWebViewLocked(paneID entity.PaneID) *webkit.WebView {
527- c.webViewsMu.RLock()
528- defer c.webViewsMu.RUnlock()
529- return c.webViews[paneID]
530-}
531-
532-func (c *ContentCoordinator) setWebViewLocked(paneID entity.PaneID, wv *webkit.WebView) {
533- c.webViewsMu.Lock()
534- c.webViews[paneID] = wv
535- c.webViewsMu.Unlock()
536-}
537-
538-func (c *ContentCoordinator) deleteWebViewLocked(paneID entity.PaneID) *webkit.WebView {
539- c.webViewsMu.Lock()
540- defer c.webViewsMu.Unlock()
541- wv := c.webViews[paneID]
542- delete(c.webViews, paneID)
543- return wv
544-}
545-
546-func (c *ContentCoordinator) webViewCount() int {
547- c.webViewsMu.RLock()
548- defer c.webViewsMu.RUnlock()
549- return len(c.webViews)
550-}
551-
552-func (c *ContentCoordinator) snapshotWebViews() map[entity.PaneID]*webkit.WebView {
553- c.webViewsMu.RLock()
554- defer c.webViewsMu.RUnlock()
555- snapshot := make(map[entity.PaneID]*webkit.WebView, len(c.webViews))
556- for paneID, wv := range c.webViews {
557- snapshot[paneID] = wv
558- }
559- return snapshot
560-}
561-
562-// ApplyFiltersToAll applies content filters to all active webviews.
563-// Called when filters become available after webviews were already created.
564-func (c *ContentCoordinator) ApplyFiltersToAll(ctx context.Context, applier webkit.FilterApplier) {
565- log := logging.FromContext(ctx)
566-
567- for paneID, wv := range c.snapshotWebViews() {
568- if wv != nil && !wv.IsDestroyed() {
569- applier.ApplyTo(ctx, wv.UserContentManager())
570- log.Debug().Str("pane_id", string(paneID)).Msg("applied filters to existing webview")
571- }
572- }
573-}
574-
575-// ApplySettingsToAll reapplies WebKit settings to all active WebViews.
576-func (c *ContentCoordinator) ApplySettingsToAll(ctx context.Context, sm *webkit.SettingsManager) {
577- log := logging.FromContext(ctx)
578- if sm == nil {
579- return
580- }
581-
582- for paneID, wv := range c.snapshotWebViews() {
583- if wv == nil || wv.IsDestroyed() {
584- continue
585- }
586- sm.ApplyToWebView(ctx, wv.Widget())
587- log.Debug().Str("pane_id", string(paneID)).Msg("reapplied settings to webview")
588- }
589-}
590-
591-// RefreshInjectedScriptsToAll clears and re-injects user scripts into all active WebViews.
592-//
593-// WebKit user scripts are snapshotted when added to a WebKitUserContentManager, so when
594-// appearance settings change at runtime (dark mode, palettes, UI scale), we must refresh
595-// the scripts so future navigations pick up the latest values.
596-// Script refresh is deferred for any WebView that is currently loading to avoid
597-// removing scripts mid-navigation.
598-func (c *ContentCoordinator) RefreshInjectedScriptsToAll(ctx context.Context, injector *webkit.ContentInjector) {
599- log := logging.FromContext(ctx)
600- if injector == nil {
601- return
602- }
603-
604- c.injector = injector
605- for paneID, wv := range c.webViews {
606- if wv == nil || wv.IsDestroyed() {
607- continue
608- }
609- if c.shouldDeferAppearance(wv) {
610- c.queueScriptRefresh(paneID)
611- log.Debug().Str("pane_id", string(paneID)).Msg("deferred script refresh until load finished")
612- continue
613- }
614-
615- c.refreshInjectedScripts(ctx, injector, paneID, wv)
616- }
617-}
618-
619-// ApplyWebUIThemeToAll updates theme CSS for already-loaded dumb:// pages.
620-// This is necessary because user scripts only run on navigation.
621-func (c *ContentCoordinator) ApplyWebUIThemeToAll(ctx context.Context, prefersDark bool, cssText string) {
622- log := logging.FromContext(ctx)
623- if cssText == "" {
624- return
625- }
626-
627- c.setCurrentTheme(prefersDark, cssText)
628-
629- script, err := buildWebUIThemeScript(prefersDark, cssText)
630- if err != nil {
631- log.Warn().Err(err).Msg("failed to build WebUI theme script")
632- return
633- }
634-
635- for paneID, wv := range c.webViews {
636- if wv == nil || wv.IsDestroyed() {
637- continue
638- }
639- if c.shouldDeferAppearance(wv) {
640- c.queueThemeApply(paneID, prefersDark, cssText)
641- log.Debug().Str("pane_id", string(paneID)).Msg("deferred WebUI theme apply until load committed")
642- continue
643- }
644- c.applyWebUITheme(ctx, paneID, wv, script, prefersDark)
645- }
646-}
647-
648-func buildWebUIThemeScript(prefersDark bool, cssText string) (string, error) {
649- cssJSON, err := json.Marshal(cssText)
650- if err != nil {
651- return "", err
652- }
653-
654- script := fmt.Sprintf(`(function(){
655- try {
656- var cssText = %s;
657- var prefersDark = %t;
658-
659- // Keep the global flag in sync
660- window.__dumber_gtk_prefers_dark = prefersDark;
661-
662- // Update dark/light class
663- if (prefersDark) {
664- document.documentElement.classList.add('dark');
665- document.documentElement.classList.remove('light');
666- } else {
667- document.documentElement.classList.add('light');
668- document.documentElement.classList.remove('dark');
669- }
670-
671- // Update or insert theme style
672- var style = document.querySelector('style[data-dumber-theme-vars]');
673- if (!style) {
674- style = document.createElement('style');
675- style.setAttribute('data-dumber-theme-vars', '');
676- (document.head || document.documentElement).appendChild(style);
677- }
678- style.textContent = cssText;
679-
680- // Notify any running WebUI that theme changed
681- try {
682- window.dispatchEvent(new CustomEvent('dumber:theme-changed', {
683- detail: { prefersDark: prefersDark }
684- }));
685- } catch (e) {
686- // ignore
687- }
688-
689- // Keep color-scheme consistent
690- var meta = document.querySelector('meta[name="color-scheme"]');
691- if (!meta) {
692- meta = document.createElement('meta');
693- meta.name = 'color-scheme';
694- document.documentElement.appendChild(meta);
695- }
696- meta.content = prefersDark ? 'dark light' : 'light dark';
697- } catch (e) {
698- console.error('[dumber] failed to apply theme', e);
699- }
700-})();`, string(cssJSON), prefersDark)
701-
702- return script, nil
703-}
704-
705-func (c *ContentCoordinator) applyWebUITheme(
706- ctx context.Context,
707- paneID entity.PaneID,
708- wv *webkit.WebView,
709- script string,
710- prefersDark bool,
711-) {
712- if wv == nil || wv.IsDestroyed() {
713- return
714- }
715- uri := wv.URI()
716- if !strings.HasPrefix(uri, "dumb://") {
717- return
718- }
719- wv.RunJavaScript(ctx, script, "")
720- logging.FromContext(ctx).
721- Debug().
722- Str("pane_id", string(paneID)).
723- Str("uri", uri).
724- Bool("prefers_dark", prefersDark).
725- Msg("applied WebUI theme")
726-}
727-
728-func (c *ContentCoordinator) queueThemeApply(paneID entity.PaneID, prefersDark bool, cssText string) {
729- c.appearanceMu.Lock()
730- if c.pendingThemePanes == nil {
731- c.pendingThemePanes = make(map[entity.PaneID]bool)
732- }
733- c.pendingThemePanes[paneID] = true
734- c.pendingThemeUpdate = pendingThemeUpdate{
735- prefersDark: prefersDark,
736- cssText: cssText,
737- }
738- c.hasPendingThemeUpdate = true
739- c.appearanceMu.Unlock()
740-}
741-
742-func (c *ContentCoordinator) setCurrentTheme(prefersDark bool, cssText string) {
743- c.appearanceMu.Lock()
744- c.currentTheme = pendingThemeUpdate{
745- prefersDark: prefersDark,
746- cssText: cssText,
747- }
748- c.hasCurrentTheme = true
749- c.appearanceMu.Unlock()
750-}
751-
752-func (c *ContentCoordinator) getCurrentTheme() (pendingThemeUpdate, bool) {
753- c.appearanceMu.Lock()
754- defer c.appearanceMu.Unlock()
755-
756- if !c.hasCurrentTheme {
757- return pendingThemeUpdate{}, false
758- }
759- return c.currentTheme, true
760-}
761-
762-func (c *ContentCoordinator) takePendingThemeApply(paneID entity.PaneID) (pendingThemeUpdate, bool) {
763- c.appearanceMu.Lock()
764- defer c.appearanceMu.Unlock()
765-
766- if !c.hasPendingThemeUpdate || c.pendingThemePanes == nil || !c.pendingThemePanes[paneID] {
767- return pendingThemeUpdate{}, false
768- }
769- delete(c.pendingThemePanes, paneID)
770- update := c.pendingThemeUpdate
771- if len(c.pendingThemePanes) == 0 {
772- c.hasPendingThemeUpdate = false
773- }
774- return update, true
775-}
776-
777-func (c *ContentCoordinator) applyPendingThemeUpdate(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) bool {
778- update, ok := c.takePendingThemeApply(paneID)
779- if !ok {
780- return false
781- }
782-
783- script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
784- if err != nil {
785- logging.FromContext(ctx).Warn().Err(err).Msg("failed to build deferred WebUI theme script")
786- return false
787- }
788- c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
789- return true
790-}
791-
792-func (c *ContentCoordinator) applyCurrentTheme(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) bool {
793- update, ok := c.getCurrentTheme()
794- if !ok || update.cssText == "" {
795- return false
796- }
797-
798- script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
799- if err != nil {
800- logging.FromContext(ctx).Warn().Err(err).Msg("failed to build current WebUI theme script")
801- return false
802- }
803- c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
804- return true
805-}
806-
807-func (c *ContentCoordinator) queueScriptRefresh(paneID entity.PaneID) {
808- c.appearanceMu.Lock()
809- if c.pendingScriptRefresh == nil {
810- c.pendingScriptRefresh = make(map[entity.PaneID]bool)
811- }
812- c.pendingScriptRefresh[paneID] = true
813- c.appearanceMu.Unlock()
814-}
815-
816-func (c *ContentCoordinator) takePendingScriptRefresh(paneID entity.PaneID) bool {
817- c.appearanceMu.Lock()
818- defer c.appearanceMu.Unlock()
819-
820- if c.pendingScriptRefresh == nil || !c.pendingScriptRefresh[paneID] {
821- return false
822- }
823- delete(c.pendingScriptRefresh, paneID)
824- return true
825-}
826-
827-func (c *ContentCoordinator) refreshPendingScripts(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
828- if wv == nil || wv.IsDestroyed() || c.shouldDeferAppearance(wv) {
829- return
830- }
831- if !c.takePendingScriptRefresh(paneID) {
832- return
833- }
834- if c.injector == nil {
835- return
836- }
837- c.refreshInjectedScripts(ctx, c.injector, paneID, wv)
838-}
839-
840-func (c *ContentCoordinator) shouldDeferAppearance(wv *webkit.WebView) bool {
841- if wv == nil || wv.IsDestroyed() {
842- return false
843- }
844- if wv.IsLoading() {
845- return true
846- }
847- return wv.EstimatedProgress() < 1.0
848-}
849-
850-func (c *ContentCoordinator) refreshInjectedScripts(
851- ctx context.Context,
852- injector *webkit.ContentInjector,
853- paneID entity.PaneID,
854- wv *webkit.WebView,
855-) {
856- if injector == nil || wv == nil || wv.IsDestroyed() {
857- return
858- }
859- ucm := wv.UserContentManager()
860- if ucm == nil {
861- return
862- }
863- ucm.RemoveAllScripts()
864- ucm.RemoveAllStyleSheets()
865- injector.InjectScripts(ctx, ucm, wv.ID())
866- logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")
867-}
868-
869-func (c *ContentCoordinator) clearPendingAppearance(paneID entity.PaneID) {
870- c.appearanceMu.Lock()
871- if c.pendingScriptRefresh != nil {
872- delete(c.pendingScriptRefresh, paneID)
873- }
874- if c.pendingThemePanes != nil {
875- delete(c.pendingThemePanes, paneID)
876- if len(c.pendingThemePanes) == 0 {
877- c.hasPendingThemeUpdate = false
878- }
879- }
880- c.appearanceMu.Unlock()
881-}
882-
883-// GetTitle returns the current title for a pane.
884-func (c *ContentCoordinator) GetTitle(paneID entity.PaneID) string {
885- c.titleMu.RLock()
886- defer c.titleMu.RUnlock()
887- return c.paneTitles[paneID]
888-}
889-
890-// onTitleChanged updates title tracking when a WebView's title changes.
891-func (c *ContentCoordinator) onTitleChanged(ctx context.Context, paneID entity.PaneID, title string) {
892- log := logging.FromContext(ctx)
893-
894- // Update title map
895- c.titleMu.Lock()
896- c.paneTitles[paneID] = title
897- c.titleMu.Unlock()
898-
899- // Update domain model and check if this is the active pane
900- isActivePaneTitle := false
901- ws, wsView := c.getActiveWS()
902- if ws != nil {
903- paneNode := ws.FindPane(paneID)
904- if paneNode != nil && paneNode.Pane != nil {
905- paneNode.Pane.Title = title
906- }
907- // Check if this pane is the active one
908- if ws.ActivePaneID == paneID {
909- isActivePaneTitle = true
910- }
911- }
912-
913- // Update StackedView title bar if this pane is in a stack
914- if wsView != nil {
915- tr := wsView.TreeRenderer()
916- if tr != nil {
917- stackedView := tr.GetStackedViewForPane(string(paneID))
918- if stackedView != nil {
919- c.updateStackedPaneTitle(ctx, stackedView, paneID, title)
920- }
921- }
922- }
923-
924- // Notify history persistence (get URL from WebView)
925- if c.onTitleUpdated != nil {
926- if wv := c.getWebViewLocked(paneID); wv != nil {
927- currentURI := wv.URI()
928- if currentURI != "" && title != "" {
929- c.onTitleUpdated(ctx, paneID, currentURI, title)
930- }
931- }
932- }
933-
934- // Notify window title update if this is the active pane
935- if isActivePaneTitle && c.onWindowTitleChanged != nil {
936- c.onWindowTitleChanged(title)
937- }
938-
939- log.Debug().
940- Str("pane_id", string(paneID)).
941- Str("title", title).
942- Msg("pane title updated")
943-}
944-
945-// updateStackedPaneTitle updates the title of a pane in a StackedView.
946-func (c *ContentCoordinator) updateStackedPaneTitle(
947- ctx context.Context,
948- sv *layout.StackedView,
949- paneID entity.PaneID,
950- title string,
951-) {
952- log := logging.FromContext(ctx)
953-
954- // Find the pane's index directly in the StackedView
955- index := sv.FindPaneIndex(string(paneID))
956- if index < 0 {
957- log.Debug().
958- Str("pane_id", string(paneID)).
959- Msg("pane not found in StackedView for title update")
960- return
961- }
962-
963- if err := sv.UpdateTitle(index, title); err != nil {
964- log.Warn().Err(err).Int("index", index).Msg("failed to update stacked pane title")
965- }
966-}
967-
968-// syncStackedTitle updates the stacked title bar for a pane if it's in a stack.
969-// Called from onLoadCommitted to keep titles in sync during navigation.
970-func (c *ContentCoordinator) syncStackedTitle(ctx context.Context, paneID entity.PaneID, title string) {
971- _, wsView := c.getActiveWS()
972- if wsView == nil {
973- return
974- }
975- tr := wsView.TreeRenderer()
976- if tr == nil {
977- return
978- }
979- if sv := tr.GetStackedViewForPane(string(paneID)); sv != nil {
980- c.updateStackedPaneTitle(ctx, sv, paneID, title)
981- }
982-}
983-
984-// onFaviconChanged updates favicon tracking when a WebView's favicon changes.
985-func (c *ContentCoordinator) onFaviconChanged(ctx context.Context, paneID entity.PaneID, emittingWV *webkit.WebView, favicon *gdk.Texture) {
986- log := logging.FromContext(ctx)
987-
988- // Verify this WebView is still bound to the expected pane
989- currentWV := c.getWebViewLocked(paneID)
990- if currentWV == nil || currentWV != emittingWV {
991- log.Debug().
992- Str("pane_id", string(paneID)).
993- Msg("ignoring favicon change from unbound webview")
994- return
995- }
996-
997- uri := emittingWV.URI()
998-
999- // Update favicon cache with domain key (handles cross-domain redirects)
1000- if c.faviconAdapter != nil && favicon != nil && uri != "" {
1001- c.navOriginMu.RLock()
1002- originURL := c.navOrigins[paneID]
1003- c.navOriginMu.RUnlock()
1004- c.faviconAdapter.StoreFromWebKitWithOrigin(ctx, uri, originURL, favicon)
1005- }
1006-
1007- // Update StackedView favicon if this pane is in a stack
1008- c.updateStackedFaviconForPane(ctx, paneID, favicon)
1009-
1010- log.Debug().
1011- Str("pane_id", string(paneID)).
1012- Str("uri", uri).
1013- Bool("has_favicon", favicon != nil).
1014- Msg("pane favicon updated")
1015-}
1016-
1017-// updateStackedPaneFavicon updates the favicon of a pane in a StackedView.
1018-func (c *ContentCoordinator) updateStackedPaneFavicon(
1019- ctx context.Context,
1020- sv *layout.StackedView,
1021- paneID entity.PaneID,
1022- favicon *gdk.Texture,
1023-) {
1024- log := logging.FromContext(ctx)
1025-
1026- // Find the pane's index directly in the StackedView
1027- index := sv.FindPaneIndex(string(paneID))
1028- if index < 0 {
1029- log.Debug().
1030- Str("pane_id", string(paneID)).
1031- Msg("pane not found in StackedView for favicon update")
1032- return
1033- }
1034-
1035- if err := sv.UpdateFaviconTexture(index, favicon); err != nil {
1036- log.Warn().Err(err).Int("index", index).Msg("failed to update stacked pane favicon")
1037- }
1038-}
1039-
1040-// updateStackedFaviconForPane updates the stacked title bar favicon for a pane.
1041-func (c *ContentCoordinator) updateStackedFaviconForPane(ctx context.Context, paneID entity.PaneID, texture *gdk.Texture) {
1042- _, wsView := c.getActiveWS()
1043- if wsView == nil {
1044- return
1045- }
1046- tr := wsView.TreeRenderer()
1047- if tr == nil {
1048- return
1049- }
1050- if sv := tr.GetStackedViewForPane(string(paneID)); sv != nil {
1051- c.updateStackedPaneFavicon(ctx, sv, paneID, texture)
1052- }
1053-}
1054-
1055-// resolveCommittedFavicon ensures the stacked title bar has a favicon after navigation commits.
1056-// First checks if WebKit already has a favicon for the page (common for subpath URLs).
1057-// Falls back to the full GetOrFetch pipeline (cache + WebKit DB + DuckDuckGo API).
1058-// This closes the gap where title bars relied solely on the notify::favicon signal.
1059-func (c *ContentCoordinator) resolveCommittedFavicon(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1060- if c.faviconAdapter == nil || wv == nil {
1061- return
1062- }
1063-
1064- uri := wv.URI()
1065- if uri == "" || strings.HasPrefix(uri, "about:") || strings.HasPrefix(uri, "dumb://") {
1066- return
1067- }
1068-
1069- log := logging.FromContext(ctx)
1070-
1071- // Check if we already have a texture cached for this domain
1072- if texture := c.faviconAdapter.GetTextureByURL(uri); texture != nil {
1073- // Already cached - just ensure the stacked view is updated
1074- c.updateStackedFaviconForPane(ctx, paneID, texture)
1075- return
1076- }
1077-
1078- // Check if WebKit already has a favicon for this page
1079- if icon := wv.Favicon(); icon != nil {
1080- log.Debug().Str("pane_id", string(paneID)).Str("uri", uri).Msg("using existing WebKit favicon for committed page")
1081- // Store and update through the normal path
1082- c.navOriginMu.RLock()
1083- originURL := c.navOrigins[paneID]
1084- c.navOriginMu.RUnlock()
1085- c.faviconAdapter.StoreFromWebKitWithOrigin(ctx, uri, originURL, icon)
1086- c.updateStackedFaviconForPane(ctx, paneID, icon)
1087- return
1088- }
1089-
1090- // Fall back to GetOrFetch (checks service cache, WebKit DB, then DuckDuckGo API)
1091- // Capture current generation to guard against stale callbacks
1092- gen := wv.Generation()
1093- capturedURI := uri
1094- c.faviconAdapter.GetOrFetch(ctx, uri, func(texture *gdk.Texture) {
1095- // Skip nil results — a nil means "couldn't resolve", not "no favicon".
1096- // Without this guard, a late nil callback can overwrite a good favicon
1097- // that was already set by an earlier onFaviconChanged signal.
1098- if texture == nil {
1099- return
1100- }
1101- // Verify WebView is still bound to pane and hasn't been reused
1102- if wv.Generation() != gen {
1103- return
1104- }
1105- currentWV := c.getWebViewLocked(paneID)
1106- if currentWV != wv {
1107- return
1108- }
1109- // Verify the WebView hasn't navigated away since we started the fetch
1110- if currentWV.URI() != capturedURI {
1111- return
1112- }
1113- c.updateStackedFaviconForPane(ctx, paneID, texture)
1114- })
1115-}
1116-
1117-// FaviconAdapter returns the favicon adapter for external use (e.g., omnibox).
1118-func (c *ContentCoordinator) FaviconAdapter() *adapter.FaviconAdapter {
1119- return c.faviconAdapter
1120-}
1121-
1122-// SetNavigationOrigin records the original URL before navigation starts.
1123-// This allows caching favicons under both original and final domains
1124-// when cross-domain redirects occur (e.g., google.fr → google.com).
1125-func (c *ContentCoordinator) SetNavigationOrigin(paneID entity.PaneID, uri string) {
1126- c.navOriginMu.Lock()
1127- c.navOrigins[paneID] = uri
1128- c.navOriginMu.Unlock()
1129-}
1130-
1131-// PreloadCachedFavicon checks the favicon cache and updates the stacked pane
1132-// title bar immediately if a cached favicon exists for the URL.
1133-// This provides instant favicon display without waiting for WebKit.
1134-func (c *ContentCoordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.PaneID, uri string) {
1135- if c.faviconAdapter == nil || uri == "" {
1136- return
1137- }
1138- select {
1139- case <-ctx.Done():
1140- return
1141- default:
1142- }
1143-
1144- // Check memory and disk cache (no external fetch)
1145- texture := c.faviconAdapter.PreloadFromCache(uri)
1146- select {
1147- case <-ctx.Done():
1148- return
1149- default:
1150- }
1151-
1152- // Update stacked pane favicon on GTK main loop.
1153- cb := glib.SourceFunc(func(_ uintptr) bool {
1154- select {
1155- case <-ctx.Done():
1156- return false
1157- default:
1158- }
1159- if c.getActiveWS == nil {
1160- return false
1161- }
1162- // Only update if we found a cached texture.
1163- // If nil, let resolveCommittedFavicon handle it after load commits.
1164- if texture == nil {
1165- return false
1166- }
1167- _, wsView := c.getActiveWS()
1168- if wsView == nil {
1169- return false
1170- }
1171- tr := wsView.TreeRenderer()
1172- if tr == nil {
1173- return false
1174- }
1175- stackedView := tr.GetStackedViewForPane(string(paneID))
1176- if stackedView == nil {
1177- return false
1178- }
1179- c.updateStackedPaneFavicon(ctx, stackedView, paneID, texture)
1180- return false
1181- })
1182- glib.IdleAdd(&cb, 0)
1183-}
1184-
1185-// onLoadCommitted re-applies zoom when page content starts loading and records history.
1186-// WebKit may reset zoom during document transitions, so we reapply after LoadCommitted.
1187-// History is recorded here because the URI is guaranteed to be correct after commit.
1188-// Also shows the WebView widget (it's hidden during creation to avoid white flash).
1189-func (c *ContentCoordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1190- log := logging.FromContext(ctx)
1191- logging.Trace().Mark("load_committed")
1192-
1193- uri := wv.URI()
1194- if uri == "" {
1195- return
1196- }
1197-
1198- // Set appropriate background color based on page type to prevent dark background bleeding.
1199- switch {
1200- case strings.HasPrefix(uri, "dumb://"):
1201- // Internal pages: apply themed background
1202- theme, ok := c.getCurrentTheme()
1203- if ok && theme.prefersDark {
1204- wv.SetBackgroundColor(darkBgR, darkBgG, darkBgB, darkBgA)
1205- } else {
1206- wv.ResetBackgroundToDefault()
1207- }
1208- case strings.HasPrefix(uri, "about:"):
1209- // Keep pool background (no action)
1210- default:
1211- // External pages: white background
1212- wv.ResetBackgroundToDefault()
1213- }
1214-
1215- // Show the WebView now that content is being painted
1216- // (WebViews are hidden on creation to avoid white flash)
1217- // Skip showing if this is about:blank but the pane is loading a different URL
1218- // This prevents the brief flash of about:blank during initial navigation
1219- shouldShow := true
1220- if uri == aboutBlankURI {
1221- // Get the pane's intended URI from the workspace
1222- ws, _ := c.getActiveWS()
1223- if ws != nil {
1224- if paneNode := ws.FindPane(paneID); paneNode != nil && paneNode.Pane != nil {
1225- // Don't show about:blank if the pane is supposed to load a different URL
1226- if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
1227- shouldShow = false
1228- log.Debug().
1229- Str("pane_id", string(paneID)).
1230- Str("pane_uri", paneNode.Pane.URI).
1231- Msg("skipping webview show for about:blank (pane loading different URL)")
1232- }
1233- }
1234- }
1235- }
1236-
1237- if !shouldShow {
1238- // Avoid updating UI/domain state to about:blank when we know the pane is
1239- // navigating to a different URL. This prevents the omnibox/window title from
1240- // briefly showing about:blank on cold start.
1241- c.clearPendingReveal(paneID)
1242- return
1243- }
1244-
1245- if !c.applyPendingThemeUpdate(ctx, paneID, wv) {
1246- c.applyCurrentTheme(ctx, paneID, wv)
1247- }
1248-
1249- c.markPendingReveal(paneID)
1250- if wv.EstimatedProgress() > 0 {
1251- c.revealIfPending(ctx, paneID, uri, "progress-after-commit")
1252- }
1253-
1254- // Update domain model with current URI for session snapshots
1255- c.updatePaneURI(paneID, uri)
1256-
1257- // Sync StackedView title bar with the WebView's current title.
1258- // This keeps the stacked title bar up-to-date immediately on navigation,
1259- // before the asynchronous notify::title signal fires.
1260- if title := wv.Title(); title != "" {
1261- c.syncStackedTitle(ctx, paneID, title)
1262- }
1263-
1264- // Record history - URI is guaranteed to be correct at LoadCommitted
1265- if c.onHistoryRecord != nil {
1266- c.onHistoryRecord(ctx, paneID, uri)
1267- }
1268-
1269- // Resolve favicon for stacked title bar (fills gap when WebKit doesn't emit notify::favicon)
1270- c.resolveCommittedFavicon(ctx, paneID, wv)
1271-
1272- // Notify active pane navigation for permission indicator reset.
1273- c.notifyActiveNavigation(paneID, uri)
1274-
1275- // Apply zoom
1276- if c.zoomUC == nil {
1277- return
1278- }
1279-
1280- domain, err := usecase.ExtractDomain(uri)
1281- if err != nil {
1282- return
1283- }
1284-
1285- _ = c.zoomUC.ApplyToWebView(ctx, wv, domain)
1286-}
1287-
1288-func (c *ContentCoordinator) notifyActiveNavigation(paneID entity.PaneID, uri string) {
1289- if c.onActiveNavigationCommitted == nil {
1290- return
1291- }
1292- ws, _ := c.getActiveWS()
1293- if ws != nil && ws.ActivePaneID == paneID {
1294- c.onActiveNavigationCommitted(uri)
1295- }
1296-}
1297-
1298-func (c *ContentCoordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID, wv *webkit.WebView) bool {
1299- if wv == nil || wv.IsDestroyed() {
1300- return false
1301- }
1302- if wv.URI() != aboutBlankURI {
1303- return false
1304- }
1305- ws, _ := c.getActiveWS()
1306- if ws == nil {
1307- return false
1308- }
1309- paneNode := ws.FindPane(paneID)
1310- if paneNode == nil || paneNode.Pane == nil {
1311- return false
1312- }
1313- if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
1314- return true
1315- }
1316- return false
1317-}
1318-
1319-// onSPANavigation records history when URL changes via JavaScript (History API).
1320-// This handles SPA navigation like YouTube search, where the URL changes without a page load.
1321-func (c *ContentCoordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, uri string) {
1322- // Update domain model with current URI for session snapshots
1323- c.updatePaneURI(paneID, uri)
1324-
1325- // Record history for SPA navigation
1326- if c.onHistoryRecord != nil {
1327- c.onHistoryRecord(ctx, paneID, uri)
1328- }
1329-}
1330-
1331-// updatePaneURI updates the pane's URI in the domain model.
1332-// This is called on navigation so that session snapshots capture the current URL.
1333-func (c *ContentCoordinator) updatePaneURI(paneID entity.PaneID, uri string) {
1334- if c.onPaneURIUpdated != nil {
1335- c.onPaneURIUpdated(paneID, uri)
1336- }
1337-}
1338-
1339-// onLoadStarted shows the progress bar when page loading begins.
1340-func (c *ContentCoordinator) onLoadStarted(paneID entity.PaneID) {
1341- logging.Trace().Mark("load_started")
1342-
1343- // Trigger deferred initialization on first load_started.
1344- // This ensures non-critical init runs after initial navigation starts.
1345- c.loadStartedOnce.Do(func() {
1346- if c.onFirstLoadStarted != nil {
1347- c.onFirstLoadStarted()
1348- }
1349- })
1350-
1351- _, wsView := c.getActiveWS()
1352- if wsView == nil {
1353- return
1354- }
1355-
1356- paneView := wsView.GetPaneView(paneID)
1357- if paneView != nil {
1358- paneView.SetLoading(true)
1359- }
1360-}
1361-
1362-// onLoadFinished hides the progress bar when page loading completes.
1363-func (c *ContentCoordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1364- _, wsView := c.getActiveWS()
1365- if wsView == nil {
1366- return
1367- }
1368-
1369- paneView := wsView.GetPaneView(paneID)
1370- if paneView != nil {
1371- paneView.SetLoading(false)
1372- }
1373-
1374- c.revealIfPending(context.Background(), paneID, "", "load-finished")
1375- if c.shouldSkipAboutBlankAppearance(paneID, wv) {
1376- return
1377- }
1378- c.applyPendingThemeUpdate(ctx, paneID, wv)
1379- c.refreshPendingScripts(ctx, paneID, wv)
1380-}
1381-
1382-// onProgressChanged updates the progress bar with current load progress.
1383-func (c *ContentCoordinator) onProgressChanged(paneID entity.PaneID, progress float64) {
1384- if progress > 0 {
1385- c.revealIfPending(context.Background(), paneID, "", "progress")
1386- }
1387-
1388- _, wsView := c.getActiveWS()
1389- if wsView == nil {
1390- return
1391- }
1392-
1393- paneView := wsView.GetPaneView(paneID)
1394- if paneView != nil {
1395- paneView.SetLoadProgress(progress)
1396- }
1397-}
1398-
1399-func (c *ContentCoordinator) markPendingReveal(paneID entity.PaneID) {
1400- c.revealMu.Lock()
1401- c.pendingReveal[paneID] = true
1402- c.revealMu.Unlock()
1403-}
1404-
1405-func (c *ContentCoordinator) clearPendingReveal(paneID entity.PaneID) {
1406- c.revealMu.Lock()
1407- delete(c.pendingReveal, paneID)
1408- c.revealMu.Unlock()
1409-}
1410-
1411-func (c *ContentCoordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, uri, reason string) {
1412- c.revealMu.Lock()
1413- pending := c.pendingReveal[paneID]
1414- if pending {
1415- delete(c.pendingReveal, paneID)
1416- }
1417- c.revealMu.Unlock()
1418-
1419- if !pending {
1420- return
1421- }
1422-
1423- wv := c.getWebViewLocked(paneID)
1424- if wv == nil || wv.IsDestroyed() {
1425- return
1426- }
1427-
1428- if inner := wv.Widget(); inner != nil {
1429- inner.SetVisible(true)
1430- logging.FromContext(ctx).
1431- Debug().
1432- Str("pane_id", string(paneID)).
1433- Str("uri", uri).
1434- Str("reason", reason).
1435- Msg("webview revealed")
1436- }
1437-
1438- // Mark first_paint and finish startup trace
1439- logging.Trace().Mark("first_paint")
1440- logging.Trace().Finish()
1441-
1442- if c.onWebViewShown != nil {
1443- c.onWebViewShown(paneID)
1444- }
1445-}
1446-
1447-// onLinkHover updates the link status overlay when hovering over links.
1448-func (c *ContentCoordinator) onLinkHover(paneID entity.PaneID, uri string) {
1449- _, wsView := c.getActiveWS()
1450- if wsView == nil {
1451- return
1452- }
1453-
1454- paneView := wsView.GetPaneView(paneID)
1455- if paneView == nil {
1456- return
1457- }
1458-
1459- if uri != "" {
1460- paneView.ShowLinkStatus(uri)
1461- } else {
1462- paneView.HideLinkStatus()
1463- }
1464-}
1465-
1466-// --- Popup Handling ---
1467-
1468-// SetPopupConfig configures popup handling.
1469-func (c *ContentCoordinator) SetPopupConfig(
1470- factory *webkit.WebViewFactory,
1471- popupConfig *config.PopupBehaviorConfig,
1472- generateID func() string,
1473-) {
1474- c.factory = factory
1475- c.popupConfig = popupConfig
1476- c.generateID = generateID
1477-}
1478-
1479-// SetOnInsertPopup sets the callback to insert popups into the workspace.
1480-func (c *ContentCoordinator) SetOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {
1481- c.onInsertPopup = fn
1482-}
1483-
1484-// SetOnClosePane sets the callback to close a pane when its popup closes.
1485-func (c *ContentCoordinator) SetOnClosePane(fn func(ctx context.Context, paneID entity.PaneID) error) {
1486- c.onClosePane = fn
1487-}
1488-
1489-// setupPopupHandling wires the popup create signal for a WebView.
1490-// This should be called after acquiring a WebView for a pane.
1491-func (c *ContentCoordinator) setupPopupHandling(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1492- log := logging.FromContext(ctx)
1493-
1494- if wv == nil {
1495- return
1496- }
1497-
1498- // Wire the OnCreate callback for popup requests
1499- wv.OnCreate = func(req webkit.PopupRequest) *webkit.WebView {
1500- return c.handlePopupCreate(ctx, paneID, wv, req)
1501- }
1502-
1503- log.Debug().Str("pane_id", string(paneID)).Msg("popup handling configured for webview")
1504-}
1505-
1506-// createPopupPane creates a new pane entity for a popup window.
1507-func (c *ContentCoordinator) createPopupPane(
1508- popupID port.WebViewID,
1509- parentPaneID entity.PaneID,
1510- targetURI string,
1511-) (entity.PaneID, *entity.Pane) {
1512- var paneID entity.PaneID
1513- if c.generateID != nil {
1514- paneID = entity.PaneID(c.generateID())
1515- } else {
1516- paneID = entity.PaneID(fmt.Sprintf("popup_%d", popupID))
1517- }
1518-
1519- popupPane := entity.NewPane(paneID)
1520- popupPane.WindowType = entity.WindowPopup
1521- popupPane.IsRelated = true
1522- popupPane.ParentPaneID = &parentPaneID
1523- popupPane.URI = targetURI
1524-
1525- return paneID, popupPane
1526-}
1527-
1528-// handlePopupCreate handles the WebKit "create" signal for popup windows.
1529-// Returns a new related WebView if popup is allowed, nil to block.
1530-//
1531-// IMPORTANT: The WebView MUST be added to a GtkWindow hierarchy BEFORE this
1532-// signal handler returns. Otherwise WebKit won't establish window.opener.
1533-// The WebView stays hidden until ready-to-show signal is received.
1534-func (c *ContentCoordinator) handlePopupCreate(
1535- ctx context.Context,
1536- parentPaneID entity.PaneID,
1537- parentWV *webkit.WebView,
1538- req webkit.PopupRequest,
1539-) *webkit.WebView {
1540- log := logging.FromContext(ctx)
1541-
1542- log.Debug().
1543- Str("parent_pane", string(parentPaneID)).
1544- Str("target_uri", req.TargetURI).
1545- Str("frame_name", req.FrameName).
1546- Bool("user_gesture", req.IsUserGesture).
1547- Msg("popup create request")
1548-
1549- // Check if popups are enabled
1550- if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
1551- log.Debug().Msg("popups disabled by config, blocking")
1552- return nil
1553- }
1554-
1555- // Check if factory is available
1556- if c.factory == nil {
1557- log.Warn().Msg("no webview factory, cannot create popup")
1558- return nil
1559- }
1560-
1561- // Create related WebView for session sharing (created hidden)
1562- parentID := parentWV.ID()
1563- popupWV, err := c.factory.CreateRelated(ctx, parentID)
1564- if err != nil {
1565- log.Error().Err(err).Msg("failed to create related webview for popup")
1566- return nil
1567- }
1568-
1569- // Detect popup type
1570- popupType := DetectPopupType(req.FrameName)
1571- popupID := popupWV.ID()
1572-
1573- // Determine behavior from config
1574- behavior := GetBehavior(popupType, c.popupConfig)
1575- placement := "right"
1576- if c.popupConfig != nil {
1577- placement = c.popupConfig.Placement
1578- }
1579-
1580- // Create popup pane entity
1581- paneID, popupPane := c.createPopupPane(popupID, parentPaneID, req.TargetURI)
1582-
1583- // Check OAuth configuration
1584- hasConfig := c.popupConfig != nil
1585- oauthEnabled := hasConfig && c.popupConfig.OAuthAutoClose
1586- isOAuth := IsOAuthURL(req.TargetURI)
1587- log.Debug().
1588- Bool("has_config", hasConfig).
1589- Bool("oauth_enabled", oauthEnabled).
1590- Bool("is_oauth", isOAuth).
1591- Str("uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
1592- Msg("popup OAuth check")
1593-
1594- // Insert into workspace IMMEDIATELY (WebView stays hidden)
1595- // This is required for WebKit to establish window.opener relationship
1596- if c.onInsertPopup != nil {
1597- popupInput := InsertPopupInput{
1598- ParentPaneID: parentPaneID,
1599- PopupPane: popupPane,
1600- WebView: popupWV,
1601- Behavior: behavior,
1602- Placement: placement,
1603- PopupType: popupType,
1604- TargetURI: req.TargetURI,
1605- }
1606-
1607- if err := c.onInsertPopup(ctx, popupInput); err != nil {
1608- log.Error().Err(err).Msg("failed to insert popup into workspace")
1609- popupWV.Destroy()
1610- return nil
1611- }
1612- }
1613-
1614- // Register WebView in our map (after successful insertion)
1615- c.setWebViewLocked(paneID, popupWV)
1616-
1617- // Setup standard callbacks (after successful insertion to avoid leak)
1618- c.setupWebViewCallbacks(ctx, paneID, popupWV)
1619-
1620- // Setup OAuth auto-close if configured
1621- if hasConfig && oauthEnabled && isOAuth {
1622- popupPane.AutoClose = true
1623- c.trackOAuthPopup(popupID, parentPaneID)
1624- c.setupOAuthAutoClose(ctx, paneID, popupID, popupWV)
1625- log.Debug().Str("pane_id", string(paneID)).Msg("OAuth auto-close enabled for popup")
1626- }
1627-
1628- // Store pending popup for ready-to-show handling (just visibility now)
1629- pending := &PendingPopup{
1630- WebView: popupWV,
1631- ParentPaneID: parentPaneID,
1632- ParentWebViewID: parentID,
1633- TargetURI: req.TargetURI,
1634- FrameName: req.FrameName,
1635- IsUserGesture: req.IsUserGesture,
1636- PopupType: popupType,
1637- CreatedAt: time.Now(),
1638- }
1639-
1640- c.popupMu.Lock()
1641- c.pendingPopups[popupID] = pending
1642- c.popupMu.Unlock()
1643-
1644- // Wire ready-to-show (now just for visibility) and close signals
1645- popupWV.OnReadyToShow = func() {
1646- c.handlePopupReadyToShow(ctx, popupID)
1647- }
1648- popupWV.OnClose = composeOnClose(popupWV.OnClose, func() {
1649- c.handlePopupClose(ctx, popupID)
1650- })
1651-
1652- log.Info().
1653- Uint64("popup_id", uint64(popupID)).
1654- Str("pane_id", string(paneID)).
1655- Str("popup_type", popupType.String()).
1656- Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
1657- Msg("popup inserted (hidden), awaiting ready-to-show for visibility")
1658-
1659- return popupWV
1660-}
1661-
1662-// handlePopupReadyToShow handles the WebKit "ready-to-show" signal.
1663-// The popup WebView was already inserted into the workspace (hidden) during
1664-// the create signal. This handler just makes it visible.
1665-func (c *ContentCoordinator) handlePopupReadyToShow(ctx context.Context, popupID port.WebViewID) {
1666- log := logging.FromContext(ctx)
1667-
1668- // Get pending popup
1669- c.popupMu.Lock()
1670- pending, ok := c.pendingPopups[popupID]
1671- if ok {
1672- delete(c.pendingPopups, popupID)
1673- }
1674- c.popupMu.Unlock()
1675-
1676- if !ok || pending == nil {
1677- log.Warn().Uint64("popup_id", uint64(popupID)).Msg("ready-to-show for unknown popup")
1678- return
1679- }
1680-
1681- log.Debug().
1682- Uint64("popup_id", uint64(popupID)).
1683- Str("popup_type", pending.PopupType.String()).
1684- Msg("popup ready to show - making visible")
1685-
1686- // Make the WebView visible now that it's ready
1687- if pending.WebView != nil {
1688- pending.WebView.Show()
1689- }
1690-
1691- log.Info().
1692- Uint64("popup_id", uint64(popupID)).
1693- Str("target_uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
1694- Msg("popup now visible")
1695-}
1696-
1697-// handlePopupClose handles the WebKit "close" signal for popup windows.
1698-func (c *ContentCoordinator) handlePopupClose(ctx context.Context, popupID port.WebViewID) {
1699- log := logging.FromContext(ctx)
1700- log.Debug().Uint64("popup_id", uint64(popupID)).Msg("popup close signal received")
1701-
1702- // Check if still pending (never shown)
1703- c.popupMu.Lock()
1704- pending, wasPending := c.pendingPopups[popupID]
1705- if wasPending {
1706- delete(c.pendingPopups, popupID)
1707- }
1708- c.popupMu.Unlock()
1709-
1710- if wasPending && pending != nil {
1711- c.handlePopupOAuthClose(ctx, popupID)
1712- // Was never shown, just destroy
1713- pending.WebView.Destroy()
1714- log.Debug().Msg("destroyed pending popup that was never shown")
1715- return
1716- }
1717-
1718- // Find pane by WebView ID
1719- var paneID entity.PaneID
1720- for pid, wv := range c.webViews {
1721- if wv != nil && wv.ID() == popupID {
1722- paneID = pid
1723- break
1724- }
1725- }
1726-
1727- if paneID == "" {
1728- c.handlePopupOAuthClose(ctx, popupID)
1729- log.Warn().Msg("popup close: could not find pane for webview")
1730- return
1731- }
1732-
1733- // Close the pane in workspace (this removes the UI element)
1734- if c.onClosePane != nil {
1735- if err := c.onClosePane(ctx, paneID); err != nil {
1736- log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to close popup pane")
1737- }
1738- }
1739-
1740- c.handlePopupOAuthClose(ctx, popupID)
1741-
1742- // Release the WebView (this will clean up tracking)
1743- c.ReleaseWebView(ctx, paneID)
1744-
1745- log.Info().Str("pane_id", string(paneID)).Msg("popup closed")
1746-}
1747-
1748-// setupWebViewCallbacks configures standard callbacks and popup handling.
1749-func (c *ContentCoordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
1750- log := logging.FromContext(ctx)
1751-
1752- // Title changes
1753- wv.OnTitleChanged = func(title string) {
1754- c.onTitleChanged(ctx, paneID, title)
1755- }
1756-
1757- // Favicon changes - capture generation to detect stale callbacks after pool reuse
1758- faviconGen := wv.Generation()
1759- wv.OnFaviconChanged = func(favicon *gdk.Texture) {
1760- if wv.Generation() != faviconGen {
1761- return // WebView was reused, ignore stale signal
1762- }
1763- c.onFaviconChanged(ctx, paneID, wv, favicon)
1764- }
1765-
1766- // Load events
1767- wv.OnLoadChanged = func(event webkit.LoadEvent) {
1768- switch event {
1769- case webkit.LoadStarted:
1770- c.onLoadStarted(paneID)
1771- case webkit.LoadCommitted:
1772- c.onLoadCommitted(ctx, paneID, wv)
1773- case webkit.LoadFinished:
1774- c.onLoadFinished(ctx, paneID, wv)
1775- }
1776- }
1777-
1778- // Progress
1779- wv.OnProgressChanged = func(progress float64) {
1780- c.onProgressChanged(paneID, progress)
1781- }
1782-
1783- // SPA navigation and external scheme handling
1784- wv.OnURIChanged = func(uri string) {
1785- if uri == "" {
1786- return
1787- }
1788-
1789- // Check for external URL schemes (vscode://, vscode-insiders://, spotify://, etc.)
1790- // These are typically triggered by JavaScript redirects (window.location)
1791- isExternal := urlutil.IsExternalScheme(uri)
1792-
1793- if isExternal {
1794- log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme detected, launching externally")
1795-
1796- // Launch externally
1797- desktop.LaunchExternalURL(uri)
1798-
1799- // Stop loading to prevent WebKit from showing an error page
1800- // The page stays on the previous URL before the JS redirect
1801- _ = wv.Stop(ctx)
1802-
1803- // Navigate back to avoid stale URI in omnibox/history
1804- if wv.CanGoBack() {
1805- _ = wv.GoBack(ctx)
1806- }
1807- return
1808- }
1809-
1810- if !wv.IsLoading() {
1811- c.onSPANavigation(ctx, paneID, uri)
1812- }
1813- }
1814-
1815- // Middle-click / Ctrl+click handler for opening links in new pane
1816- wv.OnLinkMiddleClick = func(uri string) bool {
1817- return c.handleLinkMiddleClick(ctx, paneID, uri)
1818- }
1819-
1820- // Link hover callback for status overlay
1821- wv.OnLinkHover = func(uri string) {
1822- c.onLinkHover(paneID, uri)
1823- }
1824-
1825- wv.OnWebProcessTerminated = func(reason webkitlib.WebProcessTerminationReason, reasonLabel string, uri string) {
1826- originalURI := extractOriginalURIFromCrashPage(uri)
1827- if !shouldRenderCrashPage(reason) {
1828- log.Info().
1829- Str("pane_id", string(paneID)).
1830- Str("reason", reasonLabel).
1831- Str("uri", uri).
1832- Msg("web process termination handled without crash page")
1833- return
1834- }
1835-
1836- crashURI := buildCrashPageURI(originalURI)
1837- log.Warn().
1838- Str("pane_id", string(paneID)).
1839- Str("reason", reasonLabel).
1840- Str("uri", uri).
1841- Str("crash_uri", crashURI).
1842- Msg("web process terminated, redirecting to crash page")
1843-
1844- if err := wv.LoadURI(ctx, crashURI); err != nil {
1845- log.Error().
1846- Err(err).
1847- Str("pane_id", string(paneID)).
1848- Str("reason", reasonLabel).
1849- Str("uri", uri).
1850- Str("crash_uri", crashURI).
1851- Msg("failed to load crash page after web process termination")
1852- }
1853- }
1854-
1855- // Permission request handler
1856- wv.OnPermissionRequest = func(origin string, permTypes []string, allow, deny func()) bool {
1857- return c.handlePermissionRequest(ctx, origin, permTypes, allow, deny)
1858- }
1859- // Fullscreen handlers for idle inhibition
1860- c.setupIdleInhibitionHandlers(ctx, paneID, wv)
1861-
1862- // Popup handling for nested popups
1863- c.setupPopupHandling(ctx, paneID, wv)
1864-}
1865-
1866-// setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes.
1867-// It uses URL pattern detection for standard OAuth callbacks (code=, access_token=, etc.)
1868-// For providers using postMessage (like Google Sign-In), we rely on the provider calling
1869-// window.close() which triggers WebKit's close signal.
1870-// A long safety timeout (30s) catches popups that get stuck.
1871-func (c *ContentCoordinator) setupOAuthAutoClose(
1872- ctx context.Context,
1873- paneID entity.PaneID,
1874- popupID port.WebViewID,
1875- wv *webkit.WebView,
1876-) {
1877- log := logging.FromContext(ctx)
1878-
1879- // Safety timeout - only triggers if popup gets stuck (provider should close via window.close)
1880- var safetyTimer *time.Timer
1881- var safetyTimerMu sync.Mutex
1882- var cancelSafetyTimerOnce sync.Once
1883- var requestCloseOnce sync.Once
1884- const oauthSafetyTimeout = 30 * time.Second
1885- const oauthCloseDelay = 500 * time.Millisecond
1886-
1887- startSafetyTimer := func() {
1888- safetyTimerMu.Lock()
1889- defer safetyTimerMu.Unlock()
1890- if safetyTimer != nil {
1891- safetyTimer.Stop()
1892- }
1893- safetyTimer = time.AfterFunc(oauthSafetyTimeout, func() {
1894- cb := glib.SourceFunc(func(_ uintptr) bool {
1895- if wv != nil && !wv.IsDestroyed() {
1896- uri := wv.URI()
1897- if shouldForceCloseOnSafetyTimeout(uri) {
1898- log.Warn().Str("pane", string(paneID)).Msg("oauth safety timeout, closing stuck popup")
1899- wv.Close()
1900- return false
1901- }
1902- log.Debug().
1903- Str("pane", string(paneID)).
1904- Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
1905- Msg("oauth safety timeout reached during active auth flow, skipping forced close")
1906- }
1907- return false
1908- })
1909- glib.IdleAdd(&cb, 0)
1910- })
1911- }
1912-
1913- cancelSafetyTimer := func() {
1914- cancelSafetyTimerOnce.Do(func() {
1915- safetyTimerMu.Lock()
1916- defer safetyTimerMu.Unlock()
1917- if safetyTimer != nil {
1918- safetyTimer.Stop()
1919- safetyTimer = nil
1920- }
1921- })
1922- }
1923-
1924- requestOAuthClose := func(uri string, reason string) {
1925- c.capturePopupOAuthState(popupID, uri)
1926- cancelSafetyTimer()
1927- log.Info().
1928- Str("pane", string(paneID)).
1929- Str("reason", reason).
1930- Msg("oauth callback detected, closing")
1931- requestCloseOnce.Do(func() {
1932- time.AfterFunc(oauthCloseDelay, func() {
1933- cb := glib.SourceFunc(func(_ uintptr) bool {
1934- if wv != nil && !wv.IsDestroyed() {
1935- wv.Close()
1936- }
1937- return false
1938- })
1939- glib.IdleAdd(&cb, 0)
1940- })
1941- })
1942- }
1943-
1944- // Start safety timer immediately.
1945- startSafetyTimer()
1946-
1947- // Wrap OnURIChanged to check for OAuth callbacks.
1948- wv.OnURIChanged = composeOnURIChanged(wv.OnURIChanged, func(uri string) {
1949- if ShouldAutoClose(uri) {
1950- requestOAuthClose(uri, "uri_changed")
1951- }
1952- })
1953-
1954- // Monitor load events for URL-based detection.
1955- wv.OnLoadChanged = composeOnLoadChanged(wv.OnLoadChanged, func(event webkit.LoadEvent) {
1956- if event == webkit.LoadCommitted {
1957- uri := wv.URI()
1958- if ShouldAutoClose(uri) {
1959- requestOAuthClose(uri, "load_committed")
1960- }
1961- }
1962- })
1963-
1964- // Cancel safety timer on any close path.
1965- wv.OnClose = composeOnClose(func() {
1966- cancelSafetyTimer()
1967- }, wv.OnClose)
1968-}
1969-
1970-func (c *ContentCoordinator) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID) {
1971- c.popupMu.Lock()
1972- defer c.popupMu.Unlock()
1973- if c.popupOAuth == nil {
1974- c.popupOAuth = make(map[port.WebViewID]*popupOAuthState)
1975- }
1976- c.popupOAuth[popupID] = &popupOAuthState{
1977- ParentPaneID: parentPaneID,
1978- }
1979-}
1980-
1981-func (c *ContentCoordinator) capturePopupOAuthState(popupID port.WebViewID, uri string) {
1982- c.popupMu.Lock()
1983- defer c.popupMu.Unlock()
1984-
1985- state, ok := c.popupOAuth[popupID]
1986- if !ok {
1987- return
1988- }
1989-
1990- state.Seen = true
1991- state.CallbackURI = uri
1992- state.Success = IsOAuthSuccess(uri)
1993- state.Error = IsOAuthError(uri)
1994-}
1995-
1996-func (c *ContentCoordinator) handlePopupOAuthClose(ctx context.Context, popupID port.WebViewID) {
1997- log := logging.FromContext(ctx)
1998-
1999- c.popupMu.Lock()
2000- state, ok := c.popupOAuth[popupID]
2001- if ok {
2002- delete(c.popupOAuth, popupID)
2003- }
2004- c.popupMu.Unlock()
2005-
2006- if !ok || state == nil || !state.Seen {
2007- return
2008- }
2009-
2010- log.Debug().
2011- Uint64("popup_id", uint64(popupID)).
2012- Str("parent_pane_id", string(state.ParentPaneID)).
2013- Bool("oauth_success", state.Success).
2014- Bool("oauth_error", state.Error).
2015- Msg("popup oauth result captured on close")
2016-
2017- if !state.Success || state.ParentPaneID == "" {
2018- return
2019- }
2020-
2021- c.scheduleParentPaneRefresh(ctx, state.ParentPaneID, popupID)
2022-}
2023-
2024-func (c *ContentCoordinator) scheduleParentPaneRefresh(
2025- ctx context.Context,
2026- parentPaneID entity.PaneID,
2027- popupID port.WebViewID,
2028-) {
2029- c.popupMu.Lock()
2030- if c.popupRefresh == nil {
2031- c.popupRefresh = make(map[entity.PaneID]*time.Timer)
2032- }
2033- if existing := c.popupRefresh[parentPaneID]; existing != nil {
2034- existing.Stop()
2035- }
2036- c.popupRefresh[parentPaneID] = time.AfterFunc(oauthParentRefreshDebounce, func() {
2037- c.popupMu.Lock()
2038- delete(c.popupRefresh, parentPaneID)
2039- c.popupMu.Unlock()
2040- cb := glib.SourceFunc(func(_ uintptr) bool {
2041- c.refreshPaneAfterOAuth(ctx, parentPaneID, popupID)
2042- return false
2043- })
2044- glib.IdleAdd(&cb, 0)
2045- })
2046- c.popupMu.Unlock()
2047-}
2048-
2049-func (c *ContentCoordinator) refreshPaneAfterOAuth(
2050- ctx context.Context,
2051- parentPaneID entity.PaneID,
2052- popupID port.WebViewID,
2053-) {
2054- log := logging.FromContext(ctx)
2055- wv := c.getWebViewLocked(parentPaneID)
2056- if wv == nil || wv.IsDestroyed() {
2057- log.Debug().
2058- Str("parent_pane_id", string(parentPaneID)).
2059- Uint64("popup_id", uint64(popupID)).
2060- Msg("skipping parent pane refresh after oauth close: parent webview unavailable")
2061- return
2062- }
2063-
2064- if err := wv.Reload(ctx); err != nil {
2065- log.Warn().
2066- Err(err).
2067- Str("parent_pane_id", string(parentPaneID)).
2068- Uint64("popup_id", uint64(popupID)).
2069- Msg("failed parent pane refresh after oauth popup close")
2070- return
2071- }
2072-
2073- log.Info().
2074- Str("parent_pane_id", string(parentPaneID)).
2075- Uint64("popup_id", uint64(popupID)).
2076- Msg("refreshed parent pane after oauth popup success")
2077-}
2078-
2079-// handleLinkMiddleClick handles middle-click / Ctrl+click on links.
2080-// Opens the link in a new pane using blank_target_behavior config.
2081-func (c *ContentCoordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID entity.PaneID, uri string) bool {
2082- log := logging.FromContext(ctx)
2083-
2084- log.Info().
2085- Str("parent_pane", string(parentPaneID)).
2086- Str("uri", uri).
2087- Msg("middle-click/ctrl+click on link")
2088-
2089- // Check if popups are enabled
2090- if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
2091- log.Debug().Msg("popups disabled by config, ignoring middle-click")
2092- return false
2093- }
2094-
2095- // Check if factory is available
2096- if c.factory == nil {
2097- log.Warn().Msg("no webview factory, cannot handle middle-click")
2098- return false
2099- }
2100-
2101- // Create a new WebView (regular, not related - just opening a link)
2102- newWV, err := c.factory.Create(ctx)
2103- if err != nil {
2104- log.Error().Err(err).Msg("failed to create webview for middle-click")
2105- return false
2106- }
2107-
2108- // Generate pane ID
2109- var paneID entity.PaneID
2110- if c.generateID != nil {
2111- paneID = entity.PaneID(c.generateID())
2112- } else {
2113- paneID = entity.PaneID(fmt.Sprintf("link_%d", newWV.ID()))
2114- }
2115-
2116- // Create pane entity - uses blank_target_behavior
2117- newPane := entity.NewPane(paneID)
2118- newPane.WindowType = entity.WindowPopup
2119- newPane.URI = uri
2120-
2121- // Register WebView
2122- c.setWebViewLocked(paneID, newWV)
2123-
2124- // Setup callbacks
2125- c.setupWebViewCallbacks(ctx, paneID, newWV)
2126-
2127- // Get behavior from config (same as _blank links)
2128- behavior := config.PopupBehaviorStacked // default
2129- if c.popupConfig != nil && c.popupConfig.BlankTargetBehavior != "" {
2130- behavior = config.PopupBehavior(c.popupConfig.BlankTargetBehavior)
2131- }
2132- placement := "right"
2133- if c.popupConfig != nil {
2134- placement = c.popupConfig.Placement
2135- }
2136-
2137- // Request insertion into workspace
2138- if c.onInsertPopup != nil {
2139- popupInput := InsertPopupInput{
2140- ParentPaneID: parentPaneID,
2141- PopupPane: newPane,
2142- WebView: newWV,
2143- Behavior: behavior,
2144- Placement: placement,
2145- PopupType: PopupTypeTab, // Treat like _blank
2146- TargetURI: uri,
2147- }
2148-
2149- if err := c.onInsertPopup(ctx, popupInput); err != nil {
2150- log.Error().Err(err).Msg("failed to insert middle-click pane into workspace")
2151- // Clean up on failure
2152- delete(c.webViews, paneID)
2153- newWV.Destroy()
2154- return false
2155- }
2156- }
2157-
2158- // Load the URI after insertion
2159- if err := newWV.LoadURI(ctx, uri); err != nil {
2160- log.Error().Err(err).Str("uri", uri).Msg("failed to load URI in new pane")
2161- }
2162-
2163- log.Info().
2164- Str("pane_id", string(paneID)).
2165- Str("behavior", string(behavior)).
2166- Str("uri", uri).
2167- Msg("middle-click link opened in new pane")
2168-
2169- return true
2170-}
2171-
2172-// handlePermissionRequest processes media permission requests from WebKit.
2173-// It delegates to the permission use case which handles auto-allow, stored permissions, and dialogs.
2174-func (c *ContentCoordinator) handlePermissionRequest(
2175- ctx context.Context,
2176- origin string,
2177- permTypes []string,
2178- allow, deny func(),
2179-) bool {
2180- log := logging.FromContext(ctx)
2181-
2182- // Convert string permission types to entity types
2183- entityTypes := make([]entity.PermissionType, 0, len(permTypes))
2184- for _, pt := range permTypes {
2185- switch pt {
2186- case "microphone":
2187- entityTypes = append(entityTypes, entity.PermissionTypeMicrophone)
2188- case "camera":
2189- entityTypes = append(entityTypes, entity.PermissionTypeCamera)
2190- case "display":
2191- entityTypes = append(entityTypes, entity.PermissionTypeDisplay)
2192- case "device_info":
2193- entityTypes = append(entityTypes, entity.PermissionTypeDeviceInfo)
2194- default:
2195- log.Warn().Str("type", pt).Msg("unknown permission type, skipping")
2196- }
2197- }
2198-
2199- if len(entityTypes) == 0 {
2200- log.Warn().Str("origin", origin).Msg("permission request with no valid types, denying")
2201- deny()
2202- return true
2203- }
2204-
2205- trackedTypes := filterWebRTCPermissionTypes(entityTypes)
2206- notifyActivity := func(state PermissionActivityState) {
2207- if c.onPermissionActivity == nil || len(trackedTypes) == 0 {
2208- return
2209- }
2210- c.onPermissionActivity(origin, trackedTypes, state)
2211- }
2212-
2213- notifyActivity(PermissionActivityRequesting)
2214-
2215- wrappedAllow := func() {
2216- notifyActivity(PermissionActivityAllowed)
2217- allow()
2218- }
2219- wrappedDeny := func() {
2220- notifyActivity(PermissionActivityBlocked)
2221- deny()
2222- }
2223-
2224- // Check if permission use case is available
2225- if c.permissionUC == nil {
2226- log.Warn().Str("origin", origin).Msg("no permission use case available, auto-allowing low-risk permissions")
2227- // Auto-allow display and device_info, deny others
2228- allAutoAllow := true
2229- for _, pt := range entityTypes {
2230- if !entity.IsAutoAllow(pt) {
2231- allAutoAllow = false
2232- break
2233- }
2234- }
2235- if allAutoAllow {
2236- wrappedAllow()
2237- } else {
2238- wrappedDeny()
2239- }
2240- return true
2241- }
2242-
2243- // Delegate to use case
2244- callback := usecase.PermissionCallback{
2245- Allow: wrappedAllow,
2246- Deny: wrappedDeny,
2247- }
2248-
2249- c.permissionUC.HandlePermissionRequest(ctx, origin, entityTypes, callback)
2250- return true
2251-}
2252-
2253-func filterWebRTCPermissionTypes(types []entity.PermissionType) []entity.PermissionType {
2254- filtered := make([]entity.PermissionType, 0, len(types))
2255- for _, permType := range types {
2256- switch permType {
2257- case entity.PermissionTypeMicrophone, entity.PermissionTypeCamera, entity.PermissionTypeDisplay:
2258- filtered = append(filtered, permType)
2259- }
2260- }
2261- return filtered
2262-}
2263-
2264-// setupIdleInhibitionHandlers configures fullscreen and audio callbacks for idle inhibition.
2265-// Idle is inhibited when:
2266-// - The webview enters fullscreen mode (e.g., fullscreen video)
2267-// - The webview is playing audio (e.g., video/music playback)
2268-// The inhibitor uses refcounting, so both can be active simultaneously.
2269-func (c *ContentCoordinator) setupIdleInhibitionHandlers(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
2270- log := logging.FromContext(ctx)
2271-
2272- if wv == nil {
2273- return
2274- }
2275-
2276- // Fullscreen handling
2277- wv.OnEnterFullscreen = func() bool {
2278- if c.idleInhibitor != nil {
2279- if err := c.idleInhibitor.Inhibit(ctx, "Fullscreen video playback"); err != nil {
2280- log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle")
2281- }
2282- }
2283- if c.onFullscreenChanged != nil {
2284- c.onFullscreenChanged(true)
2285- }
2286- return false // Allow fullscreen
2287- }
2288-
2289- wv.OnLeaveFullscreen = func() bool {
2290- if c.idleInhibitor != nil {
2291- if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
2292- log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle")
2293- }
2294- }
2295- if c.onFullscreenChanged != nil {
2296- c.onFullscreenChanged(false)
2297- }
2298- return false // Allow leaving fullscreen
2299- }
2300-
2301- // Audio playback handling
2302- wv.OnAudioStateChanged = func(playing bool) {
2303- if c.idleInhibitor == nil {
2304- return
2305- }
2306- if playing {
2307- if err := c.idleInhibitor.Inhibit(ctx, "Media playback"); err != nil {
2308- log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle for audio")
2309- }
2310- } else {
2311- if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
2312- log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle for audio")
2313- }
2314- }
2315- }
2316-}
A internal/ui/coordinator/content/appearance.go +290 -0
@@ -0,0 +1,290 @@
1+package content
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/infrastructure/webkit"
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+const (
16+ aboutBlankURI = "about:blank"
17+ crashPageURI = "dumb://home/crash"
18+ logURLMaxLen = 80
19+ oauthParentRefreshDebounce = 200 * time.Millisecond
20+
21+ // Dark theme background color (#0a0a0b) as float32 RGBA values
22+ darkBgR = 0.039
23+ darkBgG = 0.039
24+ darkBgB = 0.043
25+ darkBgA = 1.0
26+)
27+
28+// ApplyWebUIThemeToAll updates theme CSS for already-loaded dumb:// pages.
29+// This is necessary because user scripts only run on navigation.
30+func (c *Coordinator) ApplyWebUIThemeToAll(ctx context.Context, prefersDark bool, cssText string) {
31+ log := logging.FromContext(ctx)
32+ if cssText == "" {
33+ return
34+ }
35+
36+ c.setCurrentTheme(prefersDark, cssText)
37+
38+ script, err := buildWebUIThemeScript(prefersDark, cssText)
39+ if err != nil {
40+ log.Warn().Err(err).Msg("failed to build WebUI theme script")
41+ return
42+ }
43+
44+ for paneID, wv := range c.webViews {
45+ if wv == nil || wv.IsDestroyed() {
46+ continue
47+ }
48+ if c.shouldDeferAppearance(wv) {
49+ c.queueThemeApply(paneID, prefersDark, cssText)
50+ log.Debug().Str("pane_id", string(paneID)).Msg("deferred WebUI theme apply until load committed")
51+ continue
52+ }
53+ c.applyWebUITheme(ctx, paneID, wv, script, prefersDark)
54+ }
55+}
56+
57+func buildWebUIThemeScript(prefersDark bool, cssText string) (string, error) {
58+ cssJSON, err := json.Marshal(cssText)
59+ if err != nil {
60+ return "", err
61+ }
62+
63+ script := fmt.Sprintf(`(function(){
64+ try {
65+ var cssText = %s;
66+ var prefersDark = %t;
67+
68+ // Keep the global flag in sync
69+ window.__dumber_gtk_prefers_dark = prefersDark;
70+
71+ // Update dark/light class
72+ if (prefersDark) {
73+ document.documentElement.classList.add('dark');
74+ document.documentElement.classList.remove('light');
75+ } else {
76+ document.documentElement.classList.add('light');
77+ document.documentElement.classList.remove('dark');
78+ }
79+
80+ // Update or insert theme style
81+ var style = document.querySelector('style[data-dumber-theme-vars]');
82+ if (!style) {
83+ style = document.createElement('style');
84+ style.setAttribute('data-dumber-theme-vars', '');
85+ (document.head || document.documentElement).appendChild(style);
86+ }
87+ style.textContent = cssText;
88+
89+ // Notify any running WebUI that theme changed
90+ try {
91+ window.dispatchEvent(new CustomEvent('dumber:theme-changed', {
92+ detail: { prefersDark: prefersDark }
93+ }));
94+ } catch (e) {
95+ // ignore
96+ }
97+
98+ // Keep color-scheme consistent
99+ var meta = document.querySelector('meta[name="color-scheme"]');
100+ if (!meta) {
101+ meta = document.createElement('meta');
102+ meta.name = 'color-scheme';
103+ document.documentElement.appendChild(meta);
104+ }
105+ meta.content = prefersDark ? 'dark light' : 'light dark';
106+ } catch (e) {
107+ console.error('[dumber] failed to apply theme', e);
108+ }
109+})();`, string(cssJSON), prefersDark)
110+
111+ return script, nil
112+}
113+
114+func (c *Coordinator) applyWebUITheme(
115+ ctx context.Context,
116+ paneID entity.PaneID,
117+ wv *webkit.WebView,
118+ script string,
119+ prefersDark bool,
120+) {
121+ if wv == nil || wv.IsDestroyed() {
122+ return
123+ }
124+ uri := wv.URI()
125+ if !strings.HasPrefix(uri, "dumb://") {
126+ return
127+ }
128+ wv.RunJavaScript(ctx, script, "")
129+ logging.FromContext(ctx).
130+ Debug().
131+ Str("pane_id", string(paneID)).
132+ Str("uri", uri).
133+ Bool("prefers_dark", prefersDark).
134+ Msg("applied WebUI theme")
135+}
136+
137+func (c *Coordinator) queueThemeApply(paneID entity.PaneID, prefersDark bool, cssText string) {
138+ c.appearanceMu.Lock()
139+ if c.pendingThemePanes == nil {
140+ c.pendingThemePanes = make(map[entity.PaneID]bool)
141+ }
142+ c.pendingThemePanes[paneID] = true
143+ c.pendingThemeUpdate = pendingThemeUpdate{
144+ prefersDark: prefersDark,
145+ cssText: cssText,
146+ }
147+ c.hasPendingThemeUpdate = true
148+ c.appearanceMu.Unlock()
149+}
150+
151+func (c *Coordinator) setCurrentTheme(prefersDark bool, cssText string) {
152+ c.appearanceMu.Lock()
153+ c.currentTheme = pendingThemeUpdate{
154+ prefersDark: prefersDark,
155+ cssText: cssText,
156+ }
157+ c.hasCurrentTheme = true
158+ c.appearanceMu.Unlock()
159+}
160+
161+func (c *Coordinator) getCurrentTheme() (pendingThemeUpdate, bool) {
162+ c.appearanceMu.Lock()
163+ defer c.appearanceMu.Unlock()
164+
165+ if !c.hasCurrentTheme {
166+ return pendingThemeUpdate{}, false
167+ }
168+ return c.currentTheme, true
169+}
170+
171+func (c *Coordinator) takePendingThemeApply(paneID entity.PaneID) (pendingThemeUpdate, bool) {
172+ c.appearanceMu.Lock()
173+ defer c.appearanceMu.Unlock()
174+
175+ if !c.hasPendingThemeUpdate || c.pendingThemePanes == nil || !c.pendingThemePanes[paneID] {
176+ return pendingThemeUpdate{}, false
177+ }
178+ delete(c.pendingThemePanes, paneID)
179+ update := c.pendingThemeUpdate
180+ if len(c.pendingThemePanes) == 0 {
181+ c.hasPendingThemeUpdate = false
182+ }
183+ return update, true
184+}
185+
186+func (c *Coordinator) applyPendingThemeUpdate(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) bool {
187+ update, ok := c.takePendingThemeApply(paneID)
188+ if !ok {
189+ return false
190+ }
191+
192+ script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
193+ if err != nil {
194+ logging.FromContext(ctx).Warn().Err(err).Msg("failed to build deferred WebUI theme script")
195+ return false
196+ }
197+ c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
198+ return true
199+}
200+
201+func (c *Coordinator) applyCurrentTheme(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) bool {
202+ update, ok := c.getCurrentTheme()
203+ if !ok || update.cssText == "" {
204+ return false
205+ }
206+
207+ script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
208+ if err != nil {
209+ logging.FromContext(ctx).Warn().Err(err).Msg("failed to build current WebUI theme script")
210+ return false
211+ }
212+ c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
213+ return true
214+}
215+
216+func (c *Coordinator) queueScriptRefresh(paneID entity.PaneID) {
217+ c.appearanceMu.Lock()
218+ if c.pendingScriptRefresh == nil {
219+ c.pendingScriptRefresh = make(map[entity.PaneID]bool)
220+ }
221+ c.pendingScriptRefresh[paneID] = true
222+ c.appearanceMu.Unlock()
223+}
224+
225+func (c *Coordinator) takePendingScriptRefresh(paneID entity.PaneID) bool {
226+ c.appearanceMu.Lock()
227+ defer c.appearanceMu.Unlock()
228+
229+ if c.pendingScriptRefresh == nil || !c.pendingScriptRefresh[paneID] {
230+ return false
231+ }
232+ delete(c.pendingScriptRefresh, paneID)
233+ return true
234+}
235+
236+func (c *Coordinator) refreshPendingScripts(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
237+ if wv == nil || wv.IsDestroyed() || c.shouldDeferAppearance(wv) {
238+ return
239+ }
240+ if !c.takePendingScriptRefresh(paneID) {
241+ return
242+ }
243+ if c.injector == nil {
244+ return
245+ }
246+ c.refreshInjectedScripts(ctx, c.injector, paneID, wv)
247+}
248+
249+func (c *Coordinator) shouldDeferAppearance(wv *webkit.WebView) bool {
250+ if wv == nil || wv.IsDestroyed() {
251+ return false
252+ }
253+ if wv.IsLoading() {
254+ return true
255+ }
256+ return wv.EstimatedProgress() < 1.0
257+}
258+
259+func (c *Coordinator) refreshInjectedScripts(
260+ ctx context.Context,
261+ injector *webkit.ContentInjector,
262+ paneID entity.PaneID,
263+ wv *webkit.WebView,
264+) {
265+ if injector == nil || wv == nil || wv.IsDestroyed() {
266+ return
267+ }
268+ ucm := wv.UserContentManager()
269+ if ucm == nil {
270+ return
271+ }
272+ ucm.RemoveAllScripts()
273+ ucm.RemoveAllStyleSheets()
274+ injector.InjectScripts(ctx, ucm, wv.ID())
275+ logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")
276+}
277+
278+func (c *Coordinator) clearPendingAppearance(paneID entity.PaneID) {
279+ c.appearanceMu.Lock()
280+ if c.pendingScriptRefresh != nil {
281+ delete(c.pendingScriptRefresh, paneID)
282+ }
283+ if c.pendingThemePanes != nil {
284+ delete(c.pendingThemePanes, paneID)
285+ if len(c.pendingThemePanes) == 0 {
286+ c.hasPendingThemeUpdate = false
287+ }
288+ }
289+ c.appearanceMu.Unlock()
290+}
A internal/ui/coordinator/content/bulk.go +65 -0
@@ -0,0 +1,65 @@
1+package content
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/infrastructure/webkit"
7+ "github.com/bnema/dumber/internal/logging"
8+)
9+
10+// ApplyFiltersToAll applies content filters to all active webviews.
11+// Called when filters become available after webviews were already created.
12+func (c *Coordinator) ApplyFiltersToAll(ctx context.Context, applier webkit.FilterApplier) {
13+ log := logging.FromContext(ctx)
14+
15+ for paneID, wv := range c.snapshotWebViews() {
16+ if wv != nil && !wv.IsDestroyed() {
17+ applier.ApplyTo(ctx, wv.UserContentManager())
18+ log.Debug().Str("pane_id", string(paneID)).Msg("applied filters to existing webview")
19+ }
20+ }
21+}
22+
23+// ApplySettingsToAll reapplies WebKit settings to all active WebViews.
24+func (c *Coordinator) ApplySettingsToAll(ctx context.Context, sm *webkit.SettingsManager) {
25+ log := logging.FromContext(ctx)
26+ if sm == nil {
27+ return
28+ }
29+
30+ for paneID, wv := range c.snapshotWebViews() {
31+ if wv == nil || wv.IsDestroyed() {
32+ continue
33+ }
34+ sm.ApplyToWebView(ctx, wv.Widget())
35+ log.Debug().Str("pane_id", string(paneID)).Msg("reapplied settings to webview")
36+ }
37+}
38+
39+// RefreshInjectedScriptsToAll clears and re-injects user scripts into all active WebViews.
40+//
41+// WebKit user scripts are snapshotted when added to a WebKitUserContentManager, so when
42+// appearance settings change at runtime (dark mode, palettes, UI scale), we must refresh
43+// the scripts so future navigations pick up the latest values.
44+// Script refresh is deferred for any WebView that is currently loading to avoid
45+// removing scripts mid-navigation.
46+func (c *Coordinator) RefreshInjectedScriptsToAll(ctx context.Context, injector *webkit.ContentInjector) {
47+ log := logging.FromContext(ctx)
48+ if injector == nil {
49+ return
50+ }
51+
52+ c.injector = injector
53+ for paneID, wv := range c.webViews {
54+ if wv == nil || wv.IsDestroyed() {
55+ continue
56+ }
57+ if c.shouldDeferAppearance(wv) {
58+ c.queueScriptRefresh(paneID)
59+ log.Debug().Str("pane_id", string(paneID)).Msg("deferred script refresh until load finished")
60+ continue
61+ }
62+
63+ c.refreshInjectedScripts(ctx, injector, paneID, wv)
64+ }
65+}
A internal/ui/coordinator/content/callbacks.go +296 -0
@@ -0,0 +1,296 @@
1+package content
2+
3+import (
4+ "context"
5+ "net/url"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/infrastructure/webkit"
11+ "github.com/bnema/dumber/internal/logging"
12+ webkitlib "github.com/bnema/puregotk-webkit/webkit"
13+ "github.com/jwijenbergh/puregotk/v4/gdk"
14+)
15+
16+func shouldRenderCrashPage(reason webkitlib.WebProcessTerminationReason) bool {
17+ switch reason {
18+ case webkitlib.WebProcessCrashedValue, webkitlib.WebProcessExceededMemoryLimitValue:
19+ return true
20+ case webkitlib.WebProcessTerminatedByApiValue:
21+ return false
22+ default:
23+ return true
24+ }
25+}
26+
27+func extractOriginalURIFromCrashPage(uri string) string {
28+ if uri == "" {
29+ return ""
30+ }
31+
32+ parsed, err := url.Parse(uri)
33+ if err != nil {
34+ return uri
35+ }
36+
37+ if parsed.Scheme != "dumb" || parsed.Host != webkit.HomePath {
38+ return uri
39+ }
40+
41+ if strings.Trim(parsed.Path, "/") != "crash" {
42+ return uri
43+ }
44+
45+ original := strings.TrimSpace(parsed.Query().Get("url"))
46+ if original == "" {
47+ return ""
48+ }
49+ return original
50+}
51+
52+func buildCrashPageURI(originalURI string) string {
53+ if strings.TrimSpace(originalURI) == "" {
54+ return crashPageURI
55+ }
56+ query := url.Values{}
57+ query.Set("url", originalURI)
58+ return crashPageURI + "?" + query.Encode()
59+}
60+
61+// setupWebViewCallbacks configures standard callbacks and popup handling.
62+func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
63+ log := logging.FromContext(ctx)
64+
65+ // Title changes
66+ wv.OnTitleChanged = func(title string) {
67+ c.onTitleChanged(ctx, paneID, title)
68+ }
69+
70+ // Favicon changes - capture generation to detect stale callbacks after pool reuse
71+ faviconGen := wv.Generation()
72+ wv.OnFaviconChanged = func(favicon *gdk.Texture) {
73+ if wv.Generation() != faviconGen {
74+ return // WebView was reused, ignore stale signal
75+ }
76+ c.onFaviconChanged(ctx, paneID, wv, favicon)
77+ }
78+
79+ // Load events
80+ wv.OnLoadChanged = func(event webkit.LoadEvent) {
81+ switch event {
82+ case webkit.LoadStarted:
83+ c.onLoadStarted(paneID)
84+ case webkit.LoadCommitted:
85+ c.onLoadCommitted(ctx, paneID, wv)
86+ case webkit.LoadFinished:
87+ c.onLoadFinished(ctx, paneID, wv)
88+ }
89+ }
90+
91+ // Progress
92+ wv.OnProgressChanged = func(progress float64) {
93+ c.onProgressChanged(paneID, progress)
94+ }
95+
96+ // SPA navigation and external scheme handling
97+ wv.OnURIChanged = func(uri string) {
98+ c.handleURIChanged(ctx, paneID, wv, uri)
99+ }
100+
101+ // Middle-click / Ctrl+click handler for opening links in new pane
102+ wv.OnLinkMiddleClick = func(uri string) bool {
103+ return c.handleLinkMiddleClick(ctx, paneID, uri)
104+ }
105+
106+ // Link hover callback for status overlay
107+ wv.OnLinkHover = func(uri string) {
108+ c.onLinkHover(paneID, uri)
109+ }
110+
111+ wv.OnWebProcessTerminated = func(reason webkitlib.WebProcessTerminationReason, reasonLabel string, uri string) {
112+ originalURI := extractOriginalURIFromCrashPage(uri)
113+ if !shouldRenderCrashPage(reason) {
114+ log.Info().
115+ Str("pane_id", string(paneID)).
116+ Str("reason", reasonLabel).
117+ Str("uri", uri).
118+ Msg("web process termination handled without crash page")
119+ return
120+ }
121+
122+ crashURI := buildCrashPageURI(originalURI)
123+ log.Warn().
124+ Str("pane_id", string(paneID)).
125+ Str("reason", reasonLabel).
126+ Str("uri", uri).
127+ Str("crash_uri", crashURI).
128+ Msg("web process terminated, redirecting to crash page")
129+
130+ if err := wv.LoadURI(ctx, crashURI); err != nil {
131+ log.Error().
132+ Err(err).
133+ Str("pane_id", string(paneID)).
134+ Str("reason", reasonLabel).
135+ Str("uri", uri).
136+ Str("crash_uri", crashURI).
137+ Msg("failed to load crash page after web process termination")
138+ }
139+ }
140+
141+ // Permission request handler
142+ wv.OnPermissionRequest = func(origin string, permTypes []string, allow, deny func()) bool {
143+ return c.handlePermissionRequest(ctx, origin, permTypes, allow, deny)
144+ }
145+ // Fullscreen handlers for idle inhibition
146+ c.setupIdleInhibitionHandlers(ctx, paneID, wv)
147+
148+ // Popup handling for nested popups
149+ c.setupPopupHandling(ctx, paneID, wv)
150+}
151+
152+// handlePermissionRequest processes media permission requests from WebKit.
153+// It delegates to the permission use case which handles auto-allow, stored permissions, and dialogs.
154+func (c *Coordinator) handlePermissionRequest(
155+ ctx context.Context,
156+ origin string,
157+ permTypes []string,
158+ allow, deny func(),
159+) bool {
160+ log := logging.FromContext(ctx)
161+
162+ // Convert string permission types to entity types
163+ entityTypes := make([]entity.PermissionType, 0, len(permTypes))
164+ for _, pt := range permTypes {
165+ switch pt {
166+ case "microphone":
167+ entityTypes = append(entityTypes, entity.PermissionTypeMicrophone)
168+ case "camera":
169+ entityTypes = append(entityTypes, entity.PermissionTypeCamera)
170+ case "display":
171+ entityTypes = append(entityTypes, entity.PermissionTypeDisplay)
172+ case "device_info":
173+ entityTypes = append(entityTypes, entity.PermissionTypeDeviceInfo)
174+ default:
175+ log.Warn().Str("type", pt).Msg("unknown permission type, skipping")
176+ }
177+ }
178+
179+ if len(entityTypes) == 0 {
180+ log.Warn().Str("origin", origin).Msg("permission request with no valid types, denying")
181+ deny()
182+ return true
183+ }
184+
185+ trackedTypes := filterWebRTCPermissionTypes(entityTypes)
186+ notifyActivity := func(state PermissionActivityState) {
187+ if c.onPermissionActivity == nil || len(trackedTypes) == 0 {
188+ return
189+ }
190+ c.onPermissionActivity(origin, trackedTypes, state)
191+ }
192+
193+ notifyActivity(PermissionActivityRequesting)
194+
195+ wrappedAllow := func() {
196+ notifyActivity(PermissionActivityAllowed)
197+ allow()
198+ }
199+ wrappedDeny := func() {
200+ notifyActivity(PermissionActivityBlocked)
201+ deny()
202+ }
203+
204+ // Check if permission use case is available
205+ if c.permissionUC == nil {
206+ log.Warn().Str("origin", origin).Msg("no permission use case available, auto-allowing low-risk permissions")
207+ // Auto-allow display and device_info, deny others
208+ allAutoAllow := true
209+ for _, pt := range entityTypes {
210+ if !entity.IsAutoAllow(pt) {
211+ allAutoAllow = false
212+ break
213+ }
214+ }
215+ if allAutoAllow {
216+ wrappedAllow()
217+ } else {
218+ wrappedDeny()
219+ }
220+ return true
221+ }
222+
223+ // Delegate to use case
224+ callback := usecase.PermissionCallback{
225+ Allow: wrappedAllow,
226+ Deny: wrappedDeny,
227+ }
228+
229+ c.permissionUC.HandlePermissionRequest(ctx, origin, entityTypes, callback)
230+ return true
231+}
232+
233+func filterWebRTCPermissionTypes(types []entity.PermissionType) []entity.PermissionType {
234+ filtered := make([]entity.PermissionType, 0, len(types))
235+ for _, permType := range types {
236+ switch permType {
237+ case entity.PermissionTypeMicrophone, entity.PermissionTypeCamera, entity.PermissionTypeDisplay:
238+ filtered = append(filtered, permType)
239+ }
240+ }
241+ return filtered
242+}
243+
244+// setupIdleInhibitionHandlers configures fullscreen and audio callbacks for idle inhibition.
245+// Idle is inhibited when:
246+// - The webview enters fullscreen mode (e.g., fullscreen video)
247+// - The webview is playing audio (e.g., video/music playback)
248+// The inhibitor uses refcounting, so both can be active simultaneously.
249+func (c *Coordinator) setupIdleInhibitionHandlers(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
250+ log := logging.FromContext(ctx)
251+
252+ if wv == nil {
253+ return
254+ }
255+
256+ // Fullscreen handling
257+ wv.OnEnterFullscreen = func() bool {
258+ if c.idleInhibitor != nil {
259+ if err := c.idleInhibitor.Inhibit(ctx, "Fullscreen video playback"); err != nil {
260+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle")
261+ }
262+ }
263+ if c.onFullscreenChanged != nil {
264+ c.onFullscreenChanged(true)
265+ }
266+ return false // Allow fullscreen
267+ }
268+
269+ wv.OnLeaveFullscreen = func() bool {
270+ if c.idleInhibitor != nil {
271+ if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
272+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle")
273+ }
274+ }
275+ if c.onFullscreenChanged != nil {
276+ c.onFullscreenChanged(false)
277+ }
278+ return false // Allow leaving fullscreen
279+ }
280+
281+ // Audio playback handling
282+ wv.OnAudioStateChanged = func(playing bool) {
283+ if c.idleInhibitor == nil {
284+ return
285+ }
286+ if playing {
287+ if err := c.idleInhibitor.Inhibit(ctx, "Media playback"); err != nil {
288+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle for audio")
289+ }
290+ } else {
291+ if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
292+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle for audio")
293+ }
294+ }
295+ }
296+}
R internal/ui/coordinator/content_active_override_test.go → internal/ui/coordinator/content/content_active_override_test.go +7 -7 renamed, 81% similar
@@ -1,4 +1,4 @@
1-package coordinator
1+package content
22
33 import (
44 "context"
@@ -11,13 +11,13 @@ import (
1111 "github.com/bnema/dumber/internal/ui/component"
1212 )
1313
14-func TestContentCoordinator_ActiveWebView_UsesOverride(t *testing.T) {
14+func TestCoordinator_ActiveWebView_UsesOverride(t *testing.T) {
1515 t.Parallel()
1616
1717 mainWV := &webkit.WebView{}
1818 floatingWV := &webkit.WebView{}
1919
20- c := &ContentCoordinator{
20+ c := &Coordinator{
2121 webViews: map[entity.PaneID]*webkit.WebView{
2222 "main-pane": mainWV,
2323 "floating-pane": floatingWV,
@@ -30,12 +30,12 @@ func TestContentCoordinator_ActiveWebView_UsesOverride(t *testing.T) {
3030 assert.Equal(t, entity.PaneID("floating-pane"), c.ActivePaneID(context.Background()))
3131 }
3232
33-func TestContentCoordinator_ActiveWebView_ClearOverrideFallsBack(t *testing.T) {
33+func TestCoordinator_ActiveWebView_ClearOverrideFallsBack(t *testing.T) {
3434 t.Parallel()
3535
3636 mainWV := &webkit.WebView{}
3737
38- c := &ContentCoordinator{
38+ c := &Coordinator{
3939 webViews: map[entity.PaneID]*webkit.WebView{
4040 "main-pane": mainWV,
4141 },
@@ -48,7 +48,7 @@ func TestContentCoordinator_ActiveWebView_ClearOverrideFallsBack(t *testing.T) {
4848 assert.Equal(t, entity.PaneID(""), c.ActivePaneID(context.Background()))
4949 }
5050
51-func TestContentCoordinator_ActiveWebView_ClearOverrideFallsBackToWorkspace(t *testing.T) {
51+func TestCoordinator_ActiveWebView_ClearOverrideFallsBackToWorkspace(t *testing.T) {
5252 t.Parallel()
5353
5454 ctx := context.Background()
@@ -56,7 +56,7 @@ func TestContentCoordinator_ActiveWebView_ClearOverrideFallsBackToWorkspace(t *t
5656 mainPane := entity.NewPane(entity.PaneID("main-pane"))
5757 ws := entity.NewWorkspace(entity.WorkspaceID("ws-1"), mainPane)
5858
59- c := &ContentCoordinator{
59+ c := &Coordinator{
6060 webViews: map[entity.PaneID]*webkit.WebView{
6161 mainPane.ID: mainWV,
6262 },
R internal/ui/coordinator/content_reveal_test.go → internal/ui/coordinator/content/content_reveal_test.go +9 -9 renamed, 94% similar
@@ -1,4 +1,4 @@
1-package coordinator
1+package content
22
33 import (
44 "context"
@@ -16,7 +16,7 @@ import (
1616 // without needing to mock WebKit dependencies.
1717
1818 func TestMarkPendingReveal_SetsState(t *testing.T) {
19- c := &ContentCoordinator{
19+ c := &Coordinator{
2020 pendingReveal: make(map[entity.PaneID]bool),
2121 }
2222 paneID := entity.PaneID("pane-1")
@@ -29,7 +29,7 @@ func TestMarkPendingReveal_SetsState(t *testing.T) {
2929 }
3030
3131 func TestClearPendingReveal_RemovesState(t *testing.T) {
32- c := &ContentCoordinator{
32+ c := &Coordinator{
3333 pendingReveal: make(map[entity.PaneID]bool),
3434 }
3535 paneID := entity.PaneID("pane-1")
@@ -47,7 +47,7 @@ func TestClearPendingReveal_RemovesState(t *testing.T) {
4747
4848 func TestRevealIfPending_NotPending_DoesNothing(t *testing.T) {
4949 callbackCalled := false
50- c := &ContentCoordinator{
50+ c := &Coordinator{
5151 pendingReveal: make(map[entity.PaneID]bool),
5252 webViews: make(map[entity.PaneID]*webkit.WebView),
5353 onWebViewShown: func(paneID entity.PaneID) {
@@ -63,7 +63,7 @@ func TestRevealIfPending_NotPending_DoesNothing(t *testing.T) {
6363 }
6464
6565 func TestRevealIfPending_ClearsStateOnReveal(t *testing.T) {
66- c := &ContentCoordinator{
66+ c := &Coordinator{
6767 pendingReveal: make(map[entity.PaneID]bool),
6868 webViews: make(map[entity.PaneID]*webkit.WebView),
6969 }
@@ -82,7 +82,7 @@ func TestRevealIfPending_ClearsStateOnReveal(t *testing.T) {
8282
8383 func TestRevealIfPending_OnlyRevealsOnce(t *testing.T) {
8484 revealCount := 0
85- c := &ContentCoordinator{
85+ c := &Coordinator{
8686 pendingReveal: make(map[entity.PaneID]bool),
8787 webViews: make(map[entity.PaneID]*webkit.WebView),
8888 onWebViewShown: func(paneID entity.PaneID) {
@@ -109,7 +109,7 @@ func TestRevealIfPending_OnlyRevealsOnce(t *testing.T) {
109109 }
110110
111111 func TestPendingReveal_ConcurrentAccess(t *testing.T) {
112- c := &ContentCoordinator{
112+ c := &Coordinator{
113113 pendingReveal: make(map[entity.PaneID]bool),
114114 webViews: make(map[entity.PaneID]*webkit.WebView),
115115 }
@@ -144,7 +144,7 @@ func TestPendingReveal_ConcurrentAccess(t *testing.T) {
144144 }
145145
146146 func TestMarkPendingReveal_MultiplePanes(t *testing.T) {
147- c := &ContentCoordinator{
147+ c := &Coordinator{
148148 pendingReveal: make(map[entity.PaneID]bool),
149149 }
150150
@@ -161,7 +161,7 @@ func TestMarkPendingReveal_MultiplePanes(t *testing.T) {
161161 }
162162
163163 func TestClearPendingReveal_OnlyAffectsTargetPane(t *testing.T) {
164- c := &ContentCoordinator{
164+ c := &Coordinator{
165165 pendingReveal: make(map[entity.PaneID]bool),
166166 }
167167
A internal/ui/coordinator/content/coordinator.go +301 -0
@@ -0,0 +1,301 @@
1+package content
2+
3+import (
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+ "github.com/bnema/dumber/internal/infrastructure/config"
12+ "github.com/bnema/dumber/internal/infrastructure/webkit"
13+ "github.com/bnema/dumber/internal/logging"
14+ "github.com/bnema/dumber/internal/ui/adapter"
15+ "github.com/bnema/dumber/internal/ui/component"
16+ "github.com/bnema/dumber/internal/ui/input"
17+ "github.com/bnema/dumber/internal/ui/layout"
18+)
19+
20+// Coordinator manages WebView lifecycle, title tracking, and content attachment.
21+type Coordinator struct {
22+ pool *webkit.WebViewPool
23+ widgetFactory layout.WidgetFactory
24+ faviconAdapter *adapter.FaviconAdapter
25+ zoomUC *usecase.ManageZoomUseCase
26+ permissionUC *usecase.HandlePermissionUseCase
27+ injector *webkit.ContentInjector
28+
29+ webViews map[entity.PaneID]*webkit.WebView
30+ webViewsMu sync.RWMutex
31+
32+ activePaneOverride entity.PaneID
33+ activePaneOverrideMu sync.RWMutex
34+
35+ paneTitles map[entity.PaneID]string
36+ titleMu sync.RWMutex
37+
38+ // Track original navigation URLs to handle cross-domain redirects
39+ // e.g., google.fr → google.com: cache favicon under both domains
40+ navOrigins map[entity.PaneID]string
41+ navOriginMu sync.RWMutex
42+
43+ // Callback to get active workspace state (avoids circular dependency)
44+ getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
45+
46+ // Callback when title changes (for history persistence)
47+ onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string)
48+
49+ // Callback when page is committed (for history recording)
50+ onHistoryRecord func(ctx context.Context, paneID entity.PaneID, url string)
51+
52+ // Callback when pane URI changes (for session snapshots)
53+ onPaneURIUpdated func(paneID entity.PaneID, url string)
54+
55+ // Callback when active pane title changes (for window title updates)
56+ onWindowTitleChanged func(title string)
57+
58+ // Callback when media permission activity changes (requesting/allowed/blocked).
59+ onPermissionActivity func(origin string, permTypes []entity.PermissionType, state PermissionActivityState)
60+
61+ // Callback when the active pane commits a navigation (new page loading).
62+ onActiveNavigationCommitted func(uri string)
63+
64+ // Callback when the WebView becomes visible (first real commit)
65+ onWebViewShown func(paneID entity.PaneID)
66+
67+ revealMu sync.Mutex
68+ pendingReveal map[entity.PaneID]bool
69+
70+ appearanceMu sync.Mutex
71+ pendingScriptRefresh map[entity.PaneID]bool
72+ pendingThemePanes map[entity.PaneID]bool
73+ pendingThemeUpdate pendingThemeUpdate
74+ hasPendingThemeUpdate bool
75+ currentTheme pendingThemeUpdate
76+ hasCurrentTheme bool
77+
78+ // Gesture action handler for mouse button navigation
79+ gestureActionHandler input.ActionHandler
80+
81+ // Popup handling
82+ factory *webkit.WebViewFactory
83+ popupConfig *config.PopupBehaviorConfig
84+ pendingPopups map[port.WebViewID]*PendingPopup
85+ popupOAuth map[port.WebViewID]*popupOAuthState
86+ popupRefresh map[entity.PaneID]*time.Timer
87+ popupMu sync.RWMutex
88+
89+ // Callback to insert popup into workspace (avoids circular dependency)
90+ onInsertPopup func(ctx context.Context, input InsertPopupInput) error
91+
92+ // Callback to close a pane when popup closes
93+ onClosePane func(ctx context.Context, paneID entity.PaneID) error
94+
95+ // ID generator for popup panes
96+ generateID func() string
97+
98+ // Idle inhibitor for fullscreen video playback
99+ idleInhibitor port.IdleInhibitor
100+
101+ // Callback when fullscreen state changes (for hiding/showing tab bar)
102+ onFullscreenChanged func(entering bool)
103+
104+ // Callback when WebView gains focus (for accent picker text input targeting)
105+ onWebViewFocused func(paneID entity.PaneID, wv *webkit.WebView)
106+
107+ // Callback for first load_started event (triggers deferred initialization)
108+ onFirstLoadStarted func()
109+ loadStartedOnce sync.Once
110+}
111+
112+type pendingThemeUpdate struct {
113+ prefersDark bool
114+ cssText string
115+}
116+
117+type popupOAuthState struct {
118+ ParentPaneID entity.PaneID
119+ CallbackURI string
120+ Success bool
121+ Error bool
122+ Seen bool
123+}
124+
125+// PermissionActivityState represents the visible state for media permission activity.
126+type PermissionActivityState string
127+
128+const (
129+ PermissionActivityRequesting PermissionActivityState = "requesting"
130+ PermissionActivityAllowed PermissionActivityState = "allowed"
131+ PermissionActivityBlocked PermissionActivityState = "blocked"
132+)
133+
134+// NewCoordinator creates a new Coordinator.
135+func NewCoordinator(
136+ ctx context.Context,
137+ pool *webkit.WebViewPool,
138+ widgetFactory layout.WidgetFactory,
139+ faviconAdapter *adapter.FaviconAdapter,
140+ getActiveWS func() (*entity.Workspace, *component.WorkspaceView),
141+ zoomUC *usecase.ManageZoomUseCase,
142+ permissionUC *usecase.HandlePermissionUseCase,
143+) *Coordinator {
144+ log := logging.FromContext(ctx)
145+ log.Debug().Msg("creating content coordinator")
146+
147+ return &Coordinator{
148+ pool: pool,
149+ widgetFactory: widgetFactory,
150+ faviconAdapter: faviconAdapter,
151+ zoomUC: zoomUC,
152+ permissionUC: permissionUC,
153+ webViews: make(map[entity.PaneID]*webkit.WebView),
154+ paneTitles: make(map[entity.PaneID]string),
155+ navOrigins: make(map[entity.PaneID]string),
156+ pendingReveal: make(map[entity.PaneID]bool),
157+ getActiveWS: getActiveWS,
158+ pendingPopups: make(map[port.WebViewID]*PendingPopup),
159+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
160+ popupRefresh: make(map[entity.PaneID]*time.Timer),
161+ }
162+}
163+
164+// SetOnTitleUpdated sets the callback for title changes (for history persistence).
165+func (c *Coordinator) SetOnTitleUpdated(fn func(ctx context.Context, paneID entity.PaneID, url, title string)) {
166+ c.onTitleUpdated = fn
167+}
168+
169+// SetOnHistoryRecord sets the callback for recording history on page commit.
170+func (c *Coordinator) SetOnHistoryRecord(fn func(ctx context.Context, paneID entity.PaneID, url string)) {
171+ c.onHistoryRecord = fn
172+}
173+
174+// SetOnPermissionActivity sets a callback for WebRTC permission activity changes.
175+func (c *Coordinator) SetOnPermissionActivity(
176+ fn func(origin string, permTypes []entity.PermissionType, state PermissionActivityState),
177+) {
178+ c.onPermissionActivity = fn
179+}
180+
181+// SetOnActiveNavigationCommitted sets a callback fired when the active pane commits a navigation.
182+func (c *Coordinator) SetOnActiveNavigationCommitted(fn func(uri string)) {
183+ c.onActiveNavigationCommitted = fn
184+}
185+
186+// SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots).
187+func (c *Coordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) {
188+ c.onPaneURIUpdated = fn
189+}
190+
191+// SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates).
192+func (c *Coordinator) SetOnWindowTitleChanged(fn func(title string)) {
193+ c.onWindowTitleChanged = fn
194+}
195+
196+// SetOnWebViewShown sets a callback that fires when a pane's WebView is shown.
197+func (c *Coordinator) SetOnWebViewShown(fn func(paneID entity.PaneID)) {
198+ c.onWebViewShown = fn
199+}
200+
201+// SetGestureActionHandler sets the callback for mouse button navigation gestures.
202+func (c *Coordinator) SetGestureActionHandler(handler input.ActionHandler) {
203+ c.gestureActionHandler = handler
204+}
205+
206+// SetIdleInhibitor sets the idle inhibitor for fullscreen video playback.
207+func (c *Coordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) {
208+ c.idleInhibitor = inhibitor
209+}
210+
211+// SetOnFullscreenChanged sets the callback for fullscreen state changes.
212+func (c *Coordinator) SetOnFullscreenChanged(fn func(entering bool)) {
213+ c.onFullscreenChanged = fn
214+}
215+
216+// SetOnWebViewFocused sets the callback for when a WebView gains focus.
217+func (c *Coordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv *webkit.WebView)) {
218+ c.onWebViewFocused = fn
219+}
220+
221+// SetOnFirstLoadStarted sets the callback for when the first navigation starts.
222+// This is used to trigger deferred initialization after the initial load_uri()
223+// has been processed by the GTK main loop.
224+func (c *Coordinator) SetOnFirstLoadStarted(fn func()) {
225+ c.onFirstLoadStarted = fn
226+}
227+
228+// ActivePaneID returns the currently active pane ID used by navigation.
229+func (c *Coordinator) ActivePaneID(ctx context.Context) entity.PaneID {
230+ if paneID, ok := c.activePaneOverrideID(); ok {
231+ return paneID
232+ }
233+ if c.getActiveWS == nil {
234+ return ""
235+ }
236+
237+ ws, _ := c.getActiveWS()
238+ if ws == nil {
239+ return ""
240+ }
241+ return ws.ActivePaneID
242+}
243+
244+// SetActivePaneOverride forces ActiveWebView/ActivePaneID to use a specific pane.
245+func (c *Coordinator) SetActivePaneOverride(paneID entity.PaneID) {
246+ c.activePaneOverrideMu.Lock()
247+ c.activePaneOverride = paneID
248+ c.activePaneOverrideMu.Unlock()
249+}
250+
251+// ClearActivePaneOverride removes any forced active pane override.
252+func (c *Coordinator) ClearActivePaneOverride() {
253+ c.activePaneOverrideMu.Lock()
254+ c.activePaneOverride = ""
255+ c.activePaneOverrideMu.Unlock()
256+}
257+
258+func (c *Coordinator) activePaneOverrideID() (entity.PaneID, bool) {
259+ c.activePaneOverrideMu.RLock()
260+ defer c.activePaneOverrideMu.RUnlock()
261+ if c.activePaneOverride == "" {
262+ return "", false
263+ }
264+ return c.activePaneOverride, true
265+}
266+
267+func (c *Coordinator) webViewCount() int {
268+ c.webViewsMu.RLock()
269+ defer c.webViewsMu.RUnlock()
270+ return len(c.webViews)
271+}
272+
273+func (c *Coordinator) getWebViewLocked(paneID entity.PaneID) *webkit.WebView {
274+ c.webViewsMu.RLock()
275+ defer c.webViewsMu.RUnlock()
276+ return c.webViews[paneID]
277+}
278+
279+func (c *Coordinator) setWebViewLocked(paneID entity.PaneID, wv *webkit.WebView) {
280+ c.webViewsMu.Lock()
281+ c.webViews[paneID] = wv
282+ c.webViewsMu.Unlock()
283+}
284+
285+func (c *Coordinator) deleteWebViewLocked(paneID entity.PaneID) *webkit.WebView {
286+ c.webViewsMu.Lock()
287+ defer c.webViewsMu.Unlock()
288+ wv := c.webViews[paneID]
289+ delete(c.webViews, paneID)
290+ return wv
291+}
292+
293+func (c *Coordinator) snapshotWebViews() map[entity.PaneID]*webkit.WebView {
294+ c.webViewsMu.RLock()
295+ defer c.webViewsMu.RUnlock()
296+ snapshot := make(map[entity.PaneID]*webkit.WebView, len(c.webViews))
297+ for paneID, wv := range c.webViews {
298+ snapshot[paneID] = wv
299+ }
300+ return snapshot
301+}
A internal/ui/coordinator/content/lifecycle.go +205 -0
@@ -0,0 +1,205 @@
1+package content
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/infrastructure/webkit"
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/dumber/internal/ui/component"
11+ "github.com/bnema/dumber/internal/ui/input"
12+ "github.com/bnema/dumber/internal/ui/layout"
13+)
14+
15+// EnsureWebView acquires or reuses a WebView for the given pane.
16+func (c *Coordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (*webkit.WebView, error) {
17+ log := logging.FromContext(ctx)
18+
19+ if wv := c.getWebViewLocked(paneID); wv != nil && !wv.IsDestroyed() {
20+ return wv, nil
21+ }
22+
23+ if c.pool == nil {
24+ return nil, fmt.Errorf("webview pool not configured")
25+ }
26+
27+ // Mark tab_created on first webview (first tab)
28+ if c.webViewCount() == 0 {
29+ logging.Trace().Mark("tab_created")
30+ }
31+
32+ wv, err := c.pool.Acquire(ctx)
33+ if err != nil {
34+ return nil, err
35+ }
36+ logging.Trace().Mark("webview_acquired")
37+
38+ c.setWebViewLocked(paneID, wv)
39+ c.setupWebViewCallbacks(ctx, paneID, wv)
40+
41+ log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane")
42+ return wv, nil
43+}
44+
45+// ReleaseWebView returns the WebView for a pane to the pool.
46+func (c *Coordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) {
47+ log := logging.FromContext(ctx)
48+
49+ wv := c.deleteWebViewLocked(paneID)
50+ if wv == nil {
51+ return
52+ }
53+ c.clearPendingAppearance(paneID)
54+
55+ // CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing),
56+ // we must release the inhibition before destroying the webview.
57+ // Otherwise the D-Bus inhibit request stays active forever.
58+ if c.idleInhibitor != nil {
59+ if wv.IsFullscreen() {
60+ log.Debug().Str("pane_id", string(paneID)).Msg("releasing idle inhibition (was fullscreen)")
61+ if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
62+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle on release (fullscreen)")
63+ }
64+ }
65+ if wv.IsPlayingAudio() {
66+ log.Debug().Str("pane_id", string(paneID)).Msg("releasing idle inhibition (was playing audio)")
67+ if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
68+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle on release (audio)")
69+ }
70+ }
71+ }
72+
73+ // Clean up title tracking
74+ c.titleMu.Lock()
75+ delete(c.paneTitles, paneID)
76+ c.titleMu.Unlock()
77+
78+ // Clean up navigation origin tracking
79+ c.navOriginMu.Lock()
80+ delete(c.navOrigins, paneID)
81+ c.navOriginMu.Unlock()
82+
83+ if c.pool != nil {
84+ c.pool.Release(ctx, wv)
85+ } else {
86+ wv.Destroy()
87+ }
88+
89+ log.Debug().Str("pane_id", string(paneID)).Msg("webview released")
90+}
91+
92+// AttachToWorkspace ensures each pane in the workspace has a WebView widget attached.
93+func (c *Coordinator) AttachToWorkspace(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) {
94+ log := logging.FromContext(ctx)
95+
96+ if ws == nil || wsView == nil || c.widgetFactory == nil {
97+ return
98+ }
99+
100+ for _, pane := range ws.AllPanes() {
101+ if pane == nil {
102+ continue
103+ }
104+
105+ wv, err := c.EnsureWebView(ctx, pane.ID)
106+ if err != nil {
107+ log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to ensure webview for pane")
108+ continue
109+ }
110+
111+ // Load the pane's URI if set and different from current
112+ if pane.URI != "" && pane.URI != wv.URI() {
113+ if err := wv.LoadURI(ctx, pane.URI); err != nil {
114+ log.Warn().Err(err).Str("pane_id", string(pane.ID)).Str("uri", pane.URI).Msg("failed to load pane URI")
115+ }
116+ }
117+
118+ widget := c.WrapWidget(ctx, wv)
119+ if widget == nil {
120+ continue
121+ }
122+
123+ if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil {
124+ log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget")
125+ }
126+ logging.Trace().Mark("webview_attached")
127+ }
128+}
129+
130+// WrapWidget converts a WebView to a layout.Widget for embedding.
131+// It also attaches gesture handlers for mouse button navigation.
132+func (c *Coordinator) WrapWidget(ctx context.Context, wv *webkit.WebView) layout.Widget {
133+ log := logging.FromContext(ctx)
134+
135+ if wv == nil || c.widgetFactory == nil {
136+ log.Debug().Msg("cannot wrap nil webview or factory")
137+ return nil
138+ }
139+
140+ gtkView := wv.Widget()
141+ if gtkView == nil {
142+ return nil
143+ }
144+
145+ widget := c.widgetFactory.WrapWidget(&gtkView.Widget)
146+
147+ // Attach gesture handler for mouse button 8/9 navigation
148+ if widget != nil {
149+ gestureHandler := input.NewGestureHandler(ctx)
150+ // Pass WebView directly to preserve user gesture context (like Epiphany)
151+ gestureHandler.SetNavigator(wv)
152+ // Keep callback as fallback
153+ if c.gestureActionHandler != nil {
154+ gestureHandler.SetOnAction(c.gestureActionHandler)
155+ }
156+ gestureHandler.AttachTo(widget.GtkWidget())
157+ log.Debug().Msg("gesture handler attached to webview with direct navigator")
158+ }
159+
160+ return widget
161+}
162+
163+// ActiveWebView returns the WebView for the active pane.
164+func (c *Coordinator) ActiveWebView(ctx context.Context) *webkit.WebView {
165+ log := logging.FromContext(ctx)
166+
167+ if paneID, ok := c.activePaneOverrideID(); ok {
168+ wv := c.getWebViewLocked(paneID)
169+ if wv != nil {
170+ return wv
171+ }
172+ log.Debug().Str("pane_id", string(paneID)).Msg("active pane override has no webview, falling back")
173+ }
174+ if c.getActiveWS == nil {
175+ log.Debug().Msg("no active workspace resolver")
176+ return nil
177+ }
178+
179+ ws, _ := c.getActiveWS()
180+ if ws == nil {
181+ log.Debug().Msg("no active workspace")
182+ return nil
183+ }
184+
185+ pane := ws.ActivePane()
186+ if pane == nil || pane.Pane == nil {
187+ log.Debug().Msg("no active pane")
188+ return nil
189+ }
190+
191+ return c.getWebViewLocked(pane.Pane.ID)
192+}
193+
194+// GetWebView returns the WebView for a specific pane.
195+func (c *Coordinator) GetWebView(paneID entity.PaneID) *webkit.WebView {
196+ return c.getWebViewLocked(paneID)
197+}
198+
199+// RegisterPopupWebView registers a popup WebView that was created externally.
200+// This is used when popup tabs are created and the WebView needs to be tracked.
201+func (c *Coordinator) RegisterPopupWebView(paneID entity.PaneID, wv *webkit.WebView) {
202+ if wv != nil && paneID != "" {
203+ c.setWebViewLocked(paneID, wv)
204+ }
205+}
A internal/ui/coordinator/content/navigation.go +329 -0
@@ -0,0 +1,329 @@
1+package content
2+
3+import (
4+ "context"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/domain/entity"
9+ urlutil "github.com/bnema/dumber/internal/domain/url"
10+ "github.com/bnema/dumber/internal/infrastructure/desktop"
11+ "github.com/bnema/dumber/internal/infrastructure/webkit"
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+// onLoadCommitted re-applies zoom when page content starts loading and records history.
16+// WebKit may reset zoom during document transitions, so we reapply after LoadCommitted.
17+// History is recorded here because the URI is guaranteed to be correct after commit.
18+// Also shows the WebView widget (it's hidden during creation to avoid white flash).
19+func (c *Coordinator) onLoadCommitted(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
20+ log := logging.FromContext(ctx)
21+ logging.Trace().Mark("load_committed")
22+
23+ uri := wv.URI()
24+ if uri == "" {
25+ return
26+ }
27+
28+ // Set appropriate background color based on page type to prevent dark background bleeding.
29+ switch {
30+ case strings.HasPrefix(uri, "dumb://"):
31+ // Internal pages: apply themed background
32+ theme, ok := c.getCurrentTheme()
33+ if ok && theme.prefersDark {
34+ wv.SetBackgroundColor(darkBgR, darkBgG, darkBgB, darkBgA)
35+ } else {
36+ wv.ResetBackgroundToDefault()
37+ }
38+ case strings.HasPrefix(uri, "about:"):
39+ // Keep pool background (no action)
40+ default:
41+ // External pages: white background
42+ wv.ResetBackgroundToDefault()
43+ }
44+
45+ // Show the WebView now that content is being painted
46+ // (WebViews are hidden on creation to avoid white flash)
47+ // Skip showing if this is about:blank but the pane is loading a different URL
48+ // This prevents the brief flash of about:blank during initial navigation
49+ shouldShow := true
50+ if uri == aboutBlankURI {
51+ // Get the pane's intended URI from the workspace
52+ ws, _ := c.getActiveWS()
53+ if ws != nil {
54+ if paneNode := ws.FindPane(paneID); paneNode != nil && paneNode.Pane != nil {
55+ // Don't show about:blank if the pane is supposed to load a different URL
56+ if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
57+ shouldShow = false
58+ log.Debug().
59+ Str("pane_id", string(paneID)).
60+ Str("pane_uri", paneNode.Pane.URI).
61+ Msg("skipping webview show for about:blank (pane loading different URL)")
62+ }
63+ }
64+ }
65+ }
66+
67+ if !shouldShow {
68+ // Avoid updating UI/domain state to about:blank when we know the pane is
69+ // navigating to a different URL. This prevents the omnibox/window title from
70+ // briefly showing about:blank on cold start.
71+ c.clearPendingReveal(paneID)
72+ return
73+ }
74+
75+ if !c.applyPendingThemeUpdate(ctx, paneID, wv) {
76+ c.applyCurrentTheme(ctx, paneID, wv)
77+ }
78+
79+ c.markPendingReveal(paneID)
80+ if wv.EstimatedProgress() > 0 {
81+ c.revealIfPending(ctx, paneID, uri, "progress-after-commit")
82+ }
83+
84+ // Update domain model with current URI for session snapshots
85+ c.updatePaneURI(paneID, uri)
86+
87+ // Sync StackedView title bar with the WebView's current title.
88+ // This keeps the stacked title bar up-to-date immediately on navigation,
89+ // before the asynchronous notify::title signal fires.
90+ if title := wv.Title(); title != "" {
91+ c.syncStackedTitle(ctx, paneID, title)
92+ }
93+
94+ // Record history - URI is guaranteed to be correct at LoadCommitted
95+ if c.onHistoryRecord != nil {
96+ c.onHistoryRecord(ctx, paneID, uri)
97+ }
98+
99+ // Resolve favicon for stacked title bar (fills gap when WebKit doesn't emit notify::favicon)
100+ c.resolveCommittedFavicon(ctx, paneID, wv)
101+
102+ // Notify active pane navigation for permission indicator reset.
103+ c.notifyActiveNavigation(paneID, uri)
104+
105+ // Apply zoom
106+ if c.zoomUC == nil {
107+ return
108+ }
109+
110+ domain, err := usecase.ExtractDomain(uri)
111+ if err != nil {
112+ return
113+ }
114+
115+ _ = c.zoomUC.ApplyToWebView(ctx, wv, domain)
116+}
117+
118+func (c *Coordinator) notifyActiveNavigation(paneID entity.PaneID, uri string) {
119+ if c.onActiveNavigationCommitted == nil {
120+ return
121+ }
122+ ws, _ := c.getActiveWS()
123+ if ws != nil && ws.ActivePaneID == paneID {
124+ c.onActiveNavigationCommitted(uri)
125+ }
126+}
127+
128+func (c *Coordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID, wv *webkit.WebView) bool {
129+ if wv == nil || wv.IsDestroyed() {
130+ return false
131+ }
132+ if wv.URI() != aboutBlankURI {
133+ return false
134+ }
135+ ws, _ := c.getActiveWS()
136+ if ws == nil {
137+ return false
138+ }
139+ paneNode := ws.FindPane(paneID)
140+ if paneNode == nil || paneNode.Pane == nil {
141+ return false
142+ }
143+ if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
144+ return true
145+ }
146+ return false
147+}
148+
149+// onSPANavigation records history when URL changes via JavaScript (History API).
150+// This handles SPA navigation like YouTube search, where the URL changes without a page load.
151+func (c *Coordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, uri string) {
152+ // Update domain model with current URI for session snapshots
153+ c.updatePaneURI(paneID, uri)
154+
155+ // Record history for SPA navigation
156+ if c.onHistoryRecord != nil {
157+ c.onHistoryRecord(ctx, paneID, uri)
158+ }
159+}
160+
161+// updatePaneURI updates the pane's URI in the domain model.
162+// This is called on navigation so that session snapshots capture the current URL.
163+func (c *Coordinator) updatePaneURI(paneID entity.PaneID, uri string) {
164+ if c.onPaneURIUpdated != nil {
165+ c.onPaneURIUpdated(paneID, uri)
166+ }
167+}
168+
169+// onLoadStarted shows the progress bar when page loading begins.
170+func (c *Coordinator) onLoadStarted(paneID entity.PaneID) {
171+ logging.Trace().Mark("load_started")
172+
173+ // Trigger deferred initialization on first load_started.
174+ // This ensures non-critical init runs after initial navigation starts.
175+ c.loadStartedOnce.Do(func() {
176+ if c.onFirstLoadStarted != nil {
177+ c.onFirstLoadStarted()
178+ }
179+ })
180+
181+ _, wsView := c.getActiveWS()
182+ if wsView == nil {
183+ return
184+ }
185+
186+ paneView := wsView.GetPaneView(paneID)
187+ if paneView != nil {
188+ paneView.SetLoading(true)
189+ }
190+}
191+
192+// onLoadFinished hides the progress bar when page loading completes.
193+func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
194+ _, wsView := c.getActiveWS()
195+ if wsView == nil {
196+ return
197+ }
198+
199+ paneView := wsView.GetPaneView(paneID)
200+ if paneView != nil {
201+ paneView.SetLoading(false)
202+ }
203+
204+ c.revealIfPending(context.Background(), paneID, "", "load-finished")
205+ if c.shouldSkipAboutBlankAppearance(paneID, wv) {
206+ return
207+ }
208+ c.applyPendingThemeUpdate(ctx, paneID, wv)
209+ c.refreshPendingScripts(ctx, paneID, wv)
210+}
211+
212+// onProgressChanged updates the progress bar with current load progress.
213+func (c *Coordinator) onProgressChanged(paneID entity.PaneID, progress float64) {
214+ if progress > 0 {
215+ c.revealIfPending(context.Background(), paneID, "", "progress")
216+ }
217+
218+ _, wsView := c.getActiveWS()
219+ if wsView == nil {
220+ return
221+ }
222+
223+ paneView := wsView.GetPaneView(paneID)
224+ if paneView != nil {
225+ paneView.SetLoadProgress(progress)
226+ }
227+}
228+
229+func (c *Coordinator) markPendingReveal(paneID entity.PaneID) {
230+ c.revealMu.Lock()
231+ c.pendingReveal[paneID] = true
232+ c.revealMu.Unlock()
233+}
234+
235+func (c *Coordinator) clearPendingReveal(paneID entity.PaneID) {
236+ c.revealMu.Lock()
237+ delete(c.pendingReveal, paneID)
238+ c.revealMu.Unlock()
239+}
240+
241+func (c *Coordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, uri, reason string) {
242+ c.revealMu.Lock()
243+ pending := c.pendingReveal[paneID]
244+ if pending {
245+ delete(c.pendingReveal, paneID)
246+ }
247+ c.revealMu.Unlock()
248+
249+ if !pending {
250+ return
251+ }
252+
253+ wv := c.getWebViewLocked(paneID)
254+ if wv == nil || wv.IsDestroyed() {
255+ return
256+ }
257+
258+ if inner := wv.Widget(); inner != nil {
259+ inner.SetVisible(true)
260+ logging.FromContext(ctx).
261+ Debug().
262+ Str("pane_id", string(paneID)).
263+ Str("uri", uri).
264+ Str("reason", reason).
265+ Msg("webview revealed")
266+ }
267+
268+ // Mark first_paint and finish startup trace
269+ logging.Trace().Mark("first_paint")
270+ logging.Trace().Finish()
271+
272+ if c.onWebViewShown != nil {
273+ c.onWebViewShown(paneID)
274+ }
275+}
276+
277+// onLinkHover updates the link status overlay when hovering over links.
278+func (c *Coordinator) onLinkHover(paneID entity.PaneID, uri string) {
279+ _, wsView := c.getActiveWS()
280+ if wsView == nil {
281+ return
282+ }
283+
284+ paneView := wsView.GetPaneView(paneID)
285+ if paneView == nil {
286+ return
287+ }
288+
289+ if uri != "" {
290+ paneView.ShowLinkStatus(uri)
291+ } else {
292+ paneView.HideLinkStatus()
293+ }
294+}
295+
296+// handleURIChanged handles URI changes from WebKit, including external scheme detection
297+// and SPA navigation tracking.
298+func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView, uri string) {
299+ if uri == "" {
300+ return
301+ }
302+
303+ log := logging.FromContext(ctx)
304+
305+ // Check for external URL schemes (vscode://, vscode-insiders://, spotify://, etc.)
306+ // These are typically triggered by JavaScript redirects (window.location)
307+ isExternal := urlutil.IsExternalScheme(uri)
308+
309+ if isExternal {
310+ log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme detected, launching externally")
311+
312+ // Launch externally
313+ desktop.LaunchExternalURL(uri)
314+
315+ // Stop loading to prevent WebKit from showing an error page
316+ // The page stays on the previous URL before the JS redirect
317+ _ = wv.Stop(ctx)
318+
319+ // Navigate back to avoid stale URI in omnibox/history
320+ if wv.CanGoBack() {
321+ _ = wv.GoBack(ctx)
322+ }
323+ return
324+ }
325+
326+ if !wv.IsLoading() {
327+ c.onSPANavigation(ctx, paneID, uri)
328+ }
329+}
A internal/ui/coordinator/content/oauth.go +413 -0
@@ -0,0 +1,413 @@
1+package content
2+
3+import (
4+ "context"
5+ "strings"
6+ "sync"
7+ "time"
8+
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/infrastructure/webkit"
12+ "github.com/bnema/dumber/internal/logging"
13+ "github.com/jwijenbergh/puregotk/v4/glib"
14+)
15+
16+// oauthFlowPatterns are URL patterns that indicate an OAuth flow is in progress.
17+// These appear in authorization URLs and callback redirects.
18+var oauthFlowPatterns = []string{
19+ // OAuth flow indicators
20+ "oauth",
21+ "authorize",
22+ "authorization",
23+ "auth/",
24+ "/auth",
25+ "login",
26+ "signin",
27+ "sign-in",
28+ // OpenID Connect
29+ "oidc",
30+ "openid",
31+ // Callback/redirect endpoints
32+ "callback",
33+ "redirect",
34+ "/cb",
35+}
36+
37+// oauthCallbackPatterns are URL query parameters that indicate an OAuth callback.
38+// These appear when the OAuth provider redirects back to the application.
39+var oauthCallbackPatterns = []string{
40+ // Success parameters
41+ "code=",
42+ "access_token=",
43+ "id_token=",
44+ "token_type=",
45+ "refresh_token=",
46+ // Error parameters
47+ "error=",
48+ "error_description=",
49+ "error_uri=",
50+}
51+
52+// oauthRequestPatterns are query parameters found in OAuth authorization requests.
53+var oauthRequestPatterns = []string{
54+ "response_type=",
55+ "client_id=",
56+ "redirect_uri=",
57+ "scope=",
58+ "nonce=",
59+}
60+
61+// IsOAuthURL checks if the URL is related to an OAuth flow.
62+// This includes authorization endpoints, login pages, and callback URLs.
63+func IsOAuthURL(url string) bool {
64+ if url == "" {
65+ return false
66+ }
67+ lower := strings.ToLower(url)
68+
69+ // Check for OAuth flow patterns in URL path
70+ for _, pattern := range oauthFlowPatterns {
71+ if strings.Contains(lower, pattern) {
72+ return true
73+ }
74+ }
75+
76+ // Check for OAuth request parameters
77+ for _, pattern := range oauthRequestPatterns {
78+ if strings.Contains(lower, pattern) {
79+ return true
80+ }
81+ }
82+
83+ return false
84+}
85+
86+// IsOAuthCallback checks if the URL is an OAuth callback with response parameters.
87+// This indicates the OAuth flow has completed (successfully or with error).
88+func IsOAuthCallback(url string) bool {
89+ if url == "" {
90+ return false
91+ }
92+ lower := strings.ToLower(url)
93+
94+ // Must contain callback patterns (code=, access_token=, error=, etc.)
95+ for _, pattern := range oauthCallbackPatterns {
96+ if strings.Contains(lower, pattern) {
97+ return true
98+ }
99+ }
100+
101+ return false
102+}
103+
104+// ShouldAutoClose determines if a popup at this URL should auto-close.
105+// Returns true for OAuth callbacks that indicate flow completion.
106+//
107+// This is comprehensive detection that handles:
108+// - Success: code=, access_token=, id_token=
109+// - Errors: error=, error_description=
110+// - Various OAuth providers (Google, GitHub, Auth0, etc.)
111+func ShouldAutoClose(url string) bool {
112+ return IsOAuthCallback(url)
113+}
114+
115+// shouldForceCloseOnSafetyTimeout determines if a popup should be force-closed
116+// when the OAuth safety timeout is reached.
117+//
118+// Safety-timeout force close is disabled for stability. OAuth popups close on
119+// callback detection or manual user action.
120+func shouldForceCloseOnSafetyTimeout(url string) bool {
121+ _ = url
122+ return false
123+}
124+
125+// IsOAuthSuccess checks if the callback indicates successful authentication.
126+func IsOAuthSuccess(url string) bool {
127+ if url == "" {
128+ return false
129+ }
130+ lower := strings.ToLower(url)
131+
132+ // Check for success indicators
133+ successPatterns := []string{
134+ "code=",
135+ "access_token=",
136+ "id_token=",
137+ }
138+
139+ for _, pattern := range successPatterns {
140+ if strings.Contains(lower, pattern) {
141+ // Make sure it's not an error response
142+ if !strings.Contains(lower, "error=") {
143+ return true
144+ }
145+ }
146+ }
147+
148+ return false
149+}
150+
151+// IsOAuthError checks if the callback indicates an authentication error.
152+func IsOAuthError(url string) bool {
153+ if url == "" {
154+ return false
155+ }
156+ lower := strings.ToLower(url)
157+ return strings.Contains(lower, "error=")
158+}
159+
160+func composeOnClose(existing, next func()) func() {
161+ if existing == nil {
162+ return next
163+ }
164+ if next == nil {
165+ return existing
166+ }
167+ return func() {
168+ existing()
169+ next()
170+ }
171+}
172+
173+func composeOnURIChanged(existing, next func(string)) func(string) {
174+ if existing == nil {
175+ return next
176+ }
177+ if next == nil {
178+ return existing
179+ }
180+ return func(uri string) {
181+ existing(uri)
182+ next(uri)
183+ }
184+}
185+
186+func composeOnLoadChanged(
187+ existing func(port.LoadEvent),
188+ next func(port.LoadEvent),
189+) func(port.LoadEvent) {
190+ if existing == nil {
191+ return next
192+ }
193+ if next == nil {
194+ return existing
195+ }
196+ return func(event port.LoadEvent) {
197+ existing(event)
198+ next(event)
199+ }
200+}
201+
202+// setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes.
203+// It uses URL pattern detection for standard OAuth callbacks (code=, access_token=, etc.)
204+// For providers using postMessage (like Google Sign-In), we rely on the provider calling
205+// window.close() which triggers WebKit's close signal.
206+// A long safety timeout (30s) catches popups that get stuck.
207+func (c *Coordinator) setupOAuthAutoClose(
208+ ctx context.Context,
209+ paneID entity.PaneID,
210+ popupID port.WebViewID,
211+ wv *webkit.WebView,
212+) {
213+ log := logging.FromContext(ctx)
214+
215+ // Safety timeout - only triggers if popup gets stuck (provider should close via window.close)
216+ var safetyTimer *time.Timer
217+ var safetyTimerMu sync.Mutex
218+ var cancelSafetyTimerOnce sync.Once
219+ var requestCloseOnce sync.Once
220+ const oauthSafetyTimeout = 30 * time.Second
221+ const oauthCloseDelay = 500 * time.Millisecond
222+
223+ startSafetyTimer := func() {
224+ safetyTimerMu.Lock()
225+ defer safetyTimerMu.Unlock()
226+ if safetyTimer != nil {
227+ safetyTimer.Stop()
228+ }
229+ safetyTimer = time.AfterFunc(oauthSafetyTimeout, func() {
230+ cb := glib.SourceFunc(func(_ uintptr) bool {
231+ if wv != nil && !wv.IsDestroyed() {
232+ uri := wv.URI()
233+ if shouldForceCloseOnSafetyTimeout(uri) {
234+ log.Warn().Str("pane", string(paneID)).Msg("oauth safety timeout, closing stuck popup")
235+ wv.Close()
236+ return false
237+ }
238+ log.Debug().
239+ Str("pane", string(paneID)).
240+ Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
241+ Msg("oauth safety timeout reached during active auth flow, skipping forced close")
242+ }
243+ return false
244+ })
245+ glib.IdleAdd(&cb, 0)
246+ })
247+ }
248+
249+ cancelSafetyTimer := func() {
250+ cancelSafetyTimerOnce.Do(func() {
251+ safetyTimerMu.Lock()
252+ defer safetyTimerMu.Unlock()
253+ if safetyTimer != nil {
254+ safetyTimer.Stop()
255+ safetyTimer = nil
256+ }
257+ })
258+ }
259+
260+ requestOAuthClose := func(uri string, reason string) {
261+ c.capturePopupOAuthState(popupID, uri)
262+ cancelSafetyTimer()
263+ log.Info().
264+ Str("pane", string(paneID)).
265+ Str("reason", reason).
266+ Msg("oauth callback detected, closing")
267+ requestCloseOnce.Do(func() {
268+ time.AfterFunc(oauthCloseDelay, func() {
269+ cb := glib.SourceFunc(func(_ uintptr) bool {
270+ if wv != nil && !wv.IsDestroyed() {
271+ wv.Close()
272+ }
273+ return false
274+ })
275+ glib.IdleAdd(&cb, 0)
276+ })
277+ })
278+ }
279+
280+ // Start safety timer immediately.
281+ startSafetyTimer()
282+
283+ // Wrap OnURIChanged to check for OAuth callbacks.
284+ wv.OnURIChanged = composeOnURIChanged(wv.OnURIChanged, func(uri string) {
285+ if ShouldAutoClose(uri) {
286+ requestOAuthClose(uri, "uri_changed")
287+ }
288+ })
289+
290+ // Monitor load events for URL-based detection.
291+ wv.OnLoadChanged = composeOnLoadChanged(wv.OnLoadChanged, func(event webkit.LoadEvent) {
292+ if event == webkit.LoadCommitted {
293+ uri := wv.URI()
294+ if ShouldAutoClose(uri) {
295+ requestOAuthClose(uri, "load_committed")
296+ }
297+ }
298+ })
299+
300+ // Cancel safety timer on any close path.
301+ wv.OnClose = composeOnClose(func() {
302+ cancelSafetyTimer()
303+ }, wv.OnClose)
304+}
305+
306+func (c *Coordinator) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID) {
307+ c.popupMu.Lock()
308+ defer c.popupMu.Unlock()
309+ if c.popupOAuth == nil {
310+ c.popupOAuth = make(map[port.WebViewID]*popupOAuthState)
311+ }
312+ c.popupOAuth[popupID] = &popupOAuthState{
313+ ParentPaneID: parentPaneID,
314+ }
315+}
316+
317+func (c *Coordinator) capturePopupOAuthState(popupID port.WebViewID, uri string) {
318+ c.popupMu.Lock()
319+ defer c.popupMu.Unlock()
320+
321+ state, ok := c.popupOAuth[popupID]
322+ if !ok {
323+ return
324+ }
325+
326+ state.Seen = true
327+ state.CallbackURI = uri
328+ state.Success = IsOAuthSuccess(uri)
329+ state.Error = IsOAuthError(uri)
330+}
331+
332+func (c *Coordinator) handlePopupOAuthClose(ctx context.Context, popupID port.WebViewID) {
333+ log := logging.FromContext(ctx)
334+
335+ c.popupMu.Lock()
336+ state, ok := c.popupOAuth[popupID]
337+ if ok {
338+ delete(c.popupOAuth, popupID)
339+ }
340+ c.popupMu.Unlock()
341+
342+ if !ok || state == nil || !state.Seen {
343+ return
344+ }
345+
346+ log.Debug().
347+ Uint64("popup_id", uint64(popupID)).
348+ Str("parent_pane_id", string(state.ParentPaneID)).
349+ Bool("oauth_success", state.Success).
350+ Bool("oauth_error", state.Error).
351+ Msg("popup oauth result captured on close")
352+
353+ if !state.Success || state.ParentPaneID == "" {
354+ return
355+ }
356+
357+ c.scheduleParentPaneRefresh(ctx, state.ParentPaneID, popupID)
358+}
359+
360+func (c *Coordinator) scheduleParentPaneRefresh(
361+ ctx context.Context,
362+ parentPaneID entity.PaneID,
363+ popupID port.WebViewID,
364+) {
365+ c.popupMu.Lock()
366+ if c.popupRefresh == nil {
367+ c.popupRefresh = make(map[entity.PaneID]*time.Timer)
368+ }
369+ if existing := c.popupRefresh[parentPaneID]; existing != nil {
370+ existing.Stop()
371+ }
372+ c.popupRefresh[parentPaneID] = time.AfterFunc(oauthParentRefreshDebounce, func() {
373+ c.popupMu.Lock()
374+ delete(c.popupRefresh, parentPaneID)
375+ c.popupMu.Unlock()
376+ cb := glib.SourceFunc(func(_ uintptr) bool {
377+ c.refreshPaneAfterOAuth(ctx, parentPaneID, popupID)
378+ return false
379+ })
380+ glib.IdleAdd(&cb, 0)
381+ })
382+ c.popupMu.Unlock()
383+}
384+
385+func (c *Coordinator) refreshPaneAfterOAuth(
386+ ctx context.Context,
387+ parentPaneID entity.PaneID,
388+ popupID port.WebViewID,
389+) {
390+ log := logging.FromContext(ctx)
391+ wv := c.getWebViewLocked(parentPaneID)
392+ if wv == nil || wv.IsDestroyed() {
393+ log.Debug().
394+ Str("parent_pane_id", string(parentPaneID)).
395+ Uint64("popup_id", uint64(popupID)).
396+ Msg("skipping parent pane refresh after oauth close: parent webview unavailable")
397+ return
398+ }
399+
400+ if err := wv.Reload(ctx); err != nil {
401+ log.Warn().
402+ Err(err).
403+ Str("parent_pane_id", string(parentPaneID)).
404+ Uint64("popup_id", uint64(popupID)).
405+ Msg("failed parent pane refresh after oauth popup close")
406+ return
407+ }
408+
409+ log.Info().
410+ Str("parent_pane_id", string(parentPaneID)).
411+ Uint64("popup_id", uint64(popupID)).
412+ Msg("refreshed parent pane after oauth popup success")
413+}
R internal/ui/coordinator/oauth_test.go → internal/ui/coordinator/content/oauth_test.go +3 -3 renamed, 98% similar
@@ -1,4 +1,4 @@
1-package coordinator
1+package content
22
33 import (
44 "context"
@@ -52,7 +52,7 @@ func TestHandlePopupOAuthClose_SuccessSchedulesParentRefresh(t *testing.T) {
5252 parentPaneID := entity.PaneID("parent-pane")
5353 popupID := port.WebViewID(101)
5454
55- c := &ContentCoordinator{
55+ c := &Coordinator{
5656 webViews: make(map[entity.PaneID]*webkit.WebView),
5757 popupOAuth: make(map[port.WebViewID]*popupOAuthState),
5858 popupRefresh: make(map[entity.PaneID]*time.Timer),
@@ -81,7 +81,7 @@ func TestHandlePopupOAuthClose_ErrorDoesNotScheduleParentRefresh(t *testing.T) {
8181 parentPaneID := entity.PaneID("parent-pane")
8282 popupID := port.WebViewID(102)
8383
84- c := &ContentCoordinator{
84+ c := &Coordinator{
8585 webViews: make(map[entity.PaneID]*webkit.WebView),
8686 popupOAuth: make(map[port.WebViewID]*popupOAuthState),
8787 popupRefresh: make(map[entity.PaneID]*time.Timer),
A internal/ui/coordinator/content/popup.go +504 -0
@@ -0,0 +1,504 @@
1+package content
2+
3+import (
4+ "context"
5+ "fmt"
6+ "time"
7+
8+ "github.com/bnema/dumber/internal/application/port"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/infrastructure/config"
11+ "github.com/bnema/dumber/internal/infrastructure/webkit"
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+// PopupType indicates whether the popup was triggered by a link or JavaScript.
16+type PopupType int
17+
18+const (
19+ // PopupTypeTab represents a popup from target="_blank" links.
20+ // These are typically user-initiated and open like new tabs.
21+ PopupTypeTab PopupType = iota
22+ // PopupTypePopup represents a popup from window.open() JavaScript calls.
23+ // These may have different placement behavior than tab-like popups.
24+ PopupTypePopup
25+)
26+
27+// String returns a human-readable name for the popup type.
28+func (t PopupType) String() string {
29+ switch t {
30+ case PopupTypeTab:
31+ return "tab"
32+ case PopupTypePopup:
33+ return "popup"
34+ default:
35+ return "unknown"
36+ }
37+}
38+
39+// DetectPopupType determines if this is a tab-like or JS popup based on frame name.
40+func DetectPopupType(frameName string) PopupType {
41+ // _blank is the standard indicator for "open in new tab/window"
42+ if frameName == "_blank" {
43+ return PopupTypeTab
44+ }
45+ // Empty or custom frame names indicate JS window.open()
46+ return PopupTypePopup
47+}
48+
49+// PendingPopup tracks a popup WebView that has been created but not yet
50+// inserted into the workspace. This is used during the three-phase popup
51+// lifecycle: create → ready-to-show → (insert into workspace).
52+type PendingPopup struct {
53+ // WebView is the related WebView created in the "create" phase.
54+ // It shares cookies/session with the parent for OAuth support.
55+ WebView port.WebView
56+
57+ // ParentPaneID is the pane that spawned this popup.
58+ ParentPaneID entity.PaneID
59+
60+ // ParentWebViewID is the WebView ID of the parent (for lookup).
61+ ParentWebViewID port.WebViewID
62+
63+ // TargetURI is the initial URL the popup will load.
64+ TargetURI string
65+
66+ // FrameName is the target frame name from the navigation action.
67+ // "_blank" indicates a tab-like popup, other values indicate JS popups.
68+ FrameName string
69+
70+ // IsUserGesture indicates if the popup was triggered by user action
71+ // (click) vs script-initiated (e.g., popup after timeout).
72+ IsUserGesture bool
73+
74+ // PopupType categorizes this as a tab-like or JS popup.
75+ PopupType PopupType
76+
77+ // CreatedAt is when the popup was created (for timeout handling).
78+ CreatedAt time.Time
79+}
80+
81+// InsertPopupInput contains the data needed to insert a popup into the workspace.
82+type InsertPopupInput struct {
83+ // ParentPaneID is the pane that spawned this popup.
84+ ParentPaneID entity.PaneID
85+
86+ // PopupPane is the pre-created pane entity for the popup.
87+ // It should have IsRelated=true and ParentPaneID set.
88+ PopupPane *entity.Pane
89+
90+ // WebView is the related WebView for the popup.
91+ WebView port.WebView
92+
93+ // Behavior determines how the popup is inserted (split/stacked/tabbed).
94+ Behavior config.PopupBehavior
95+
96+ // Placement specifies direction for split behavior (right/left/top/bottom).
97+ Placement string
98+
99+ // PopupType indicates if this is a tab-like or JS popup.
100+ PopupType PopupType
101+
102+ // TargetURI is the URL to load in the popup.
103+ TargetURI string
104+}
105+
106+// GetBehavior returns the appropriate behavior based on popup type and config.
107+func GetBehavior(popupType PopupType, cfg *config.PopupBehaviorConfig) config.PopupBehavior {
108+ if cfg == nil {
109+ return config.PopupBehaviorSplit // Default
110+ }
111+
112+ switch popupType {
113+ case PopupTypeTab:
114+ // Tab-like popups (_blank) use blank_target_behavior
115+ switch cfg.BlankTargetBehavior {
116+ case "split":
117+ return config.PopupBehaviorSplit
118+ case "stacked":
119+ return config.PopupBehaviorStacked
120+ case "tabbed":
121+ return config.PopupBehaviorTabbed
122+ default:
123+ return config.PopupBehaviorStacked // Default for _blank
124+ }
125+ case PopupTypePopup:
126+ // JS popups use the main behavior setting
127+ return cfg.Behavior
128+ default:
129+ return cfg.Behavior
130+ }
131+}
132+
133+// SetPopupConfig configures popup handling.
134+func (c *Coordinator) SetPopupConfig(
135+ factory *webkit.WebViewFactory,
136+ popupConfig *config.PopupBehaviorConfig,
137+ generateID func() string,
138+) {
139+ c.factory = factory
140+ c.popupConfig = popupConfig
141+ c.generateID = generateID
142+}
143+
144+// SetOnInsertPopup sets the callback to insert popups into the workspace.
145+func (c *Coordinator) SetOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {
146+ c.onInsertPopup = fn
147+}
148+
149+// SetOnClosePane sets the callback to close a pane when its popup closes.
150+func (c *Coordinator) SetOnClosePane(fn func(ctx context.Context, paneID entity.PaneID) error) {
151+ c.onClosePane = fn
152+}
153+
154+// setupPopupHandling wires the popup create signal for a WebView.
155+// This should be called after acquiring a WebView for a pane.
156+func (c *Coordinator) setupPopupHandling(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
157+ log := logging.FromContext(ctx)
158+
159+ if wv == nil {
160+ return
161+ }
162+
163+ // Wire the OnCreate callback for popup requests
164+ wv.OnCreate = func(req webkit.PopupRequest) *webkit.WebView {
165+ return c.handlePopupCreate(ctx, paneID, wv, req)
166+ }
167+
168+ log.Debug().Str("pane_id", string(paneID)).Msg("popup handling configured for webview")
169+}
170+
171+// createPopupPane creates a new pane entity for a popup window.
172+func (c *Coordinator) createPopupPane(
173+ popupID port.WebViewID,
174+ parentPaneID entity.PaneID,
175+ targetURI string,
176+) (entity.PaneID, *entity.Pane) {
177+ var paneID entity.PaneID
178+ if c.generateID != nil {
179+ paneID = entity.PaneID(c.generateID())
180+ } else {
181+ paneID = entity.PaneID(fmt.Sprintf("popup_%d", popupID))
182+ }
183+
184+ popupPane := entity.NewPane(paneID)
185+ popupPane.WindowType = entity.WindowPopup
186+ popupPane.IsRelated = true
187+ popupPane.ParentPaneID = &parentPaneID
188+ popupPane.URI = targetURI
189+
190+ return paneID, popupPane
191+}
192+
193+// handlePopupCreate handles the WebKit "create" signal for popup windows.
194+// Returns a new related WebView if popup is allowed, nil to block.
195+//
196+// IMPORTANT: The WebView MUST be added to a GtkWindow hierarchy BEFORE this
197+// signal handler returns. Otherwise WebKit won't establish window.opener.
198+// The WebView stays hidden until ready-to-show signal is received.
199+func (c *Coordinator) handlePopupCreate(
200+ ctx context.Context,
201+ parentPaneID entity.PaneID,
202+ parentWV *webkit.WebView,
203+ req webkit.PopupRequest,
204+) *webkit.WebView {
205+ log := logging.FromContext(ctx)
206+
207+ log.Debug().
208+ Str("parent_pane", string(parentPaneID)).
209+ Str("target_uri", req.TargetURI).
210+ Str("frame_name", req.FrameName).
211+ Bool("user_gesture", req.IsUserGesture).
212+ Msg("popup create request")
213+
214+ // Check if popups are enabled
215+ if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
216+ log.Debug().Msg("popups disabled by config, blocking")
217+ return nil
218+ }
219+
220+ // Check if factory is available
221+ if c.factory == nil {
222+ log.Warn().Msg("no webview factory, cannot create popup")
223+ return nil
224+ }
225+
226+ // Create related WebView for session sharing (created hidden)
227+ parentID := parentWV.ID()
228+ popupWV, err := c.factory.CreateRelated(ctx, parentID)
229+ if err != nil {
230+ log.Error().Err(err).Msg("failed to create related webview for popup")
231+ return nil
232+ }
233+
234+ // Detect popup type
235+ popupType := DetectPopupType(req.FrameName)
236+ popupID := popupWV.ID()
237+
238+ // Determine behavior from config
239+ behavior := GetBehavior(popupType, c.popupConfig)
240+ placement := "right"
241+ if c.popupConfig != nil {
242+ placement = c.popupConfig.Placement
243+ }
244+
245+ // Create popup pane entity
246+ paneID, popupPane := c.createPopupPane(popupID, parentPaneID, req.TargetURI)
247+
248+ // Check OAuth configuration
249+ hasConfig := c.popupConfig != nil
250+ oauthEnabled := hasConfig && c.popupConfig.OAuthAutoClose
251+ isOAuth := IsOAuthURL(req.TargetURI)
252+ log.Debug().
253+ Bool("has_config", hasConfig).
254+ Bool("oauth_enabled", oauthEnabled).
255+ Bool("is_oauth", isOAuth).
256+ Str("uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
257+ Msg("popup OAuth check")
258+
259+ // Insert into workspace IMMEDIATELY (WebView stays hidden)
260+ // This is required for WebKit to establish window.opener relationship
261+ if c.onInsertPopup != nil {
262+ popupInput := InsertPopupInput{
263+ ParentPaneID: parentPaneID,
264+ PopupPane: popupPane,
265+ WebView: popupWV,
266+ Behavior: behavior,
267+ Placement: placement,
268+ PopupType: popupType,
269+ TargetURI: req.TargetURI,
270+ }
271+
272+ if err := c.onInsertPopup(ctx, popupInput); err != nil {
273+ log.Error().Err(err).Msg("failed to insert popup into workspace")
274+ popupWV.Destroy()
275+ return nil
276+ }
277+ }
278+
279+ // Register WebView in our map (after successful insertion)
280+ c.setWebViewLocked(paneID, popupWV)
281+
282+ // Setup standard callbacks (after successful insertion to avoid leak)
283+ c.setupWebViewCallbacks(ctx, paneID, popupWV)
284+
285+ // Setup OAuth auto-close if configured
286+ if hasConfig && oauthEnabled && isOAuth {
287+ popupPane.AutoClose = true
288+ c.trackOAuthPopup(popupID, parentPaneID)
289+ c.setupOAuthAutoClose(ctx, paneID, popupID, popupWV)
290+ log.Debug().Str("pane_id", string(paneID)).Msg("OAuth auto-close enabled for popup")
291+ }
292+
293+ // Store pending popup for ready-to-show handling (just visibility now)
294+ pending := &PendingPopup{
295+ WebView: popupWV,
296+ ParentPaneID: parentPaneID,
297+ ParentWebViewID: parentID,
298+ TargetURI: req.TargetURI,
299+ FrameName: req.FrameName,
300+ IsUserGesture: req.IsUserGesture,
301+ PopupType: popupType,
302+ CreatedAt: time.Now(),
303+ }
304+
305+ c.popupMu.Lock()
306+ c.pendingPopups[popupID] = pending
307+ c.popupMu.Unlock()
308+
309+ // Wire ready-to-show (now just for visibility) and close signals
310+ popupWV.OnReadyToShow = func() {
311+ c.handlePopupReadyToShow(ctx, popupID)
312+ }
313+ popupWV.OnClose = composeOnClose(popupWV.OnClose, func() {
314+ c.handlePopupClose(ctx, popupID)
315+ })
316+
317+ log.Info().
318+ Uint64("popup_id", uint64(popupID)).
319+ Str("pane_id", string(paneID)).
320+ Str("popup_type", popupType.String()).
321+ Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
322+ Msg("popup inserted (hidden), awaiting ready-to-show for visibility")
323+
324+ return popupWV
325+}
326+
327+// handlePopupReadyToShow handles the WebKit "ready-to-show" signal.
328+// The popup WebView was already inserted into the workspace (hidden) during
329+// the create signal. This handler just makes it visible.
330+func (c *Coordinator) handlePopupReadyToShow(ctx context.Context, popupID port.WebViewID) {
331+ log := logging.FromContext(ctx)
332+
333+ // Get pending popup
334+ c.popupMu.Lock()
335+ pending, ok := c.pendingPopups[popupID]
336+ if ok {
337+ delete(c.pendingPopups, popupID)
338+ }
339+ c.popupMu.Unlock()
340+
341+ if !ok || pending == nil {
342+ log.Warn().Uint64("popup_id", uint64(popupID)).Msg("ready-to-show for unknown popup")
343+ return
344+ }
345+
346+ log.Debug().
347+ Uint64("popup_id", uint64(popupID)).
348+ Str("popup_type", pending.PopupType.String()).
349+ Msg("popup ready to show - making visible")
350+
351+ // Make the WebView visible now that it's ready
352+ if pending.WebView != nil {
353+ pending.WebView.Show()
354+ }
355+
356+ log.Info().
357+ Uint64("popup_id", uint64(popupID)).
358+ Str("target_uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
359+ Msg("popup now visible")
360+}
361+
362+// handlePopupClose handles the WebKit "close" signal for popup windows.
363+func (c *Coordinator) handlePopupClose(ctx context.Context, popupID port.WebViewID) {
364+ log := logging.FromContext(ctx)
365+ log.Debug().Uint64("popup_id", uint64(popupID)).Msg("popup close signal received")
366+
367+ // Check if still pending (never shown)
368+ c.popupMu.Lock()
369+ pending, wasPending := c.pendingPopups[popupID]
370+ if wasPending {
371+ delete(c.pendingPopups, popupID)
372+ }
373+ c.popupMu.Unlock()
374+
375+ if wasPending && pending != nil {
376+ c.handlePopupOAuthClose(ctx, popupID)
377+ // Was never shown, just destroy
378+ pending.WebView.Destroy()
379+ log.Debug().Msg("destroyed pending popup that was never shown")
380+ return
381+ }
382+
383+ // Find pane by WebView ID
384+ var paneID entity.PaneID
385+ for pid, wv := range c.webViews {
386+ if wv != nil && wv.ID() == popupID {
387+ paneID = pid
388+ break
389+ }
390+ }
391+
392+ if paneID == "" {
393+ c.handlePopupOAuthClose(ctx, popupID)
394+ log.Warn().Msg("popup close: could not find pane for webview")
395+ return
396+ }
397+
398+ // Close the pane in workspace (this removes the UI element)
399+ if c.onClosePane != nil {
400+ if err := c.onClosePane(ctx, paneID); err != nil {
401+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to close popup pane")
402+ }
403+ }
404+
405+ c.handlePopupOAuthClose(ctx, popupID)
406+
407+ // Release the WebView (this will clean up tracking)
408+ c.ReleaseWebView(ctx, paneID)
409+
410+ log.Info().Str("pane_id", string(paneID)).Msg("popup closed")
411+}
412+
413+// handleLinkMiddleClick handles middle-click / Ctrl+click on links.
414+// Opens the link in a new pane using blank_target_behavior config.
415+func (c *Coordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID entity.PaneID, uri string) bool {
416+ log := logging.FromContext(ctx)
417+
418+ log.Info().
419+ Str("parent_pane", string(parentPaneID)).
420+ Str("uri", uri).
421+ Msg("middle-click/ctrl+click on link")
422+
423+ // Check if popups are enabled
424+ if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
425+ log.Debug().Msg("popups disabled by config, ignoring middle-click")
426+ return false
427+ }
428+
429+ // Check if factory is available
430+ if c.factory == nil {
431+ log.Warn().Msg("no webview factory, cannot handle middle-click")
432+ return false
433+ }
434+
435+ // Create a new WebView (regular, not related - just opening a link)
436+ newWV, err := c.factory.Create(ctx)
437+ if err != nil {
438+ log.Error().Err(err).Msg("failed to create webview for middle-click")
439+ return false
440+ }
441+
442+ // Generate pane ID
443+ var paneID entity.PaneID
444+ if c.generateID != nil {
445+ paneID = entity.PaneID(c.generateID())
446+ } else {
447+ paneID = entity.PaneID(fmt.Sprintf("link_%d", newWV.ID()))
448+ }
449+
450+ // Create pane entity - uses blank_target_behavior
451+ newPane := entity.NewPane(paneID)
452+ newPane.WindowType = entity.WindowPopup
453+ newPane.URI = uri
454+
455+ // Register WebView
456+ c.setWebViewLocked(paneID, newWV)
457+
458+ // Setup callbacks
459+ c.setupWebViewCallbacks(ctx, paneID, newWV)
460+
461+ // Get behavior from config (same as _blank links)
462+ behavior := config.PopupBehaviorStacked // default
463+ if c.popupConfig != nil && c.popupConfig.BlankTargetBehavior != "" {
464+ behavior = config.PopupBehavior(c.popupConfig.BlankTargetBehavior)
465+ }
466+ placement := "right"
467+ if c.popupConfig != nil {
468+ placement = c.popupConfig.Placement
469+ }
470+
471+ // Request insertion into workspace
472+ if c.onInsertPopup != nil {
473+ popupInput := InsertPopupInput{
474+ ParentPaneID: parentPaneID,
475+ PopupPane: newPane,
476+ WebView: newWV,
477+ Behavior: behavior,
478+ Placement: placement,
479+ PopupType: PopupTypeTab, // Treat like _blank
480+ TargetURI: uri,
481+ }
482+
483+ if err := c.onInsertPopup(ctx, popupInput); err != nil {
484+ log.Error().Err(err).Msg("failed to insert middle-click pane into workspace")
485+ // Clean up on failure
486+ delete(c.webViews, paneID)
487+ newWV.Destroy()
488+ return false
489+ }
490+ }
491+
492+ // Load the URI after insertion
493+ if err := newWV.LoadURI(ctx, uri); err != nil {
494+ log.Error().Err(err).Str("uri", uri).Msg("failed to load URI in new pane")
495+ }
496+
497+ log.Info().
498+ Str("pane_id", string(paneID)).
499+ Str("behavior", string(behavior)).
500+ Str("uri", uri).
501+ Msg("middle-click link opened in new pane")
502+
503+ return true
504+}
A internal/ui/coordinator/content/title.go +316 -0
@@ -0,0 +1,316 @@
1+package content
2+
3+import (
4+ "context"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/infrastructure/webkit"
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/bnema/dumber/internal/ui/adapter"
11+ "github.com/bnema/dumber/internal/ui/layout"
12+ "github.com/jwijenbergh/puregotk/v4/gdk"
13+ "github.com/jwijenbergh/puregotk/v4/glib"
14+)
15+
16+// GetTitle returns the current title for a pane.
17+func (c *Coordinator) GetTitle(paneID entity.PaneID) string {
18+ c.titleMu.RLock()
19+ defer c.titleMu.RUnlock()
20+ return c.paneTitles[paneID]
21+}
22+
23+// onTitleChanged updates title tracking when a WebView's title changes.
24+func (c *Coordinator) onTitleChanged(ctx context.Context, paneID entity.PaneID, title string) {
25+ log := logging.FromContext(ctx)
26+
27+ // Update title map
28+ c.titleMu.Lock()
29+ c.paneTitles[paneID] = title
30+ c.titleMu.Unlock()
31+
32+ // Update domain model and check if this is the active pane
33+ isActivePaneTitle := false
34+ ws, wsView := c.getActiveWS()
35+ if ws != nil {
36+ paneNode := ws.FindPane(paneID)
37+ if paneNode != nil && paneNode.Pane != nil {
38+ paneNode.Pane.Title = title
39+ }
40+ // Check if this pane is the active one
41+ if ws.ActivePaneID == paneID {
42+ isActivePaneTitle = true
43+ }
44+ }
45+
46+ // Update StackedView title bar if this pane is in a stack
47+ if wsView != nil {
48+ tr := wsView.TreeRenderer()
49+ if tr != nil {
50+ stackedView := tr.GetStackedViewForPane(string(paneID))
51+ if stackedView != nil {
52+ c.updateStackedPaneTitle(ctx, stackedView, paneID, title)
53+ }
54+ }
55+ }
56+
57+ // Notify history persistence (get URL from WebView)
58+ if c.onTitleUpdated != nil {
59+ if wv := c.getWebViewLocked(paneID); wv != nil {
60+ currentURI := wv.URI()
61+ if currentURI != "" && title != "" {
62+ c.onTitleUpdated(ctx, paneID, currentURI, title)
63+ }
64+ }
65+ }
66+
67+ // Notify window title update if this is the active pane
68+ if isActivePaneTitle && c.onWindowTitleChanged != nil {
69+ c.onWindowTitleChanged(title)
70+ }
71+
72+ log.Debug().
73+ Str("pane_id", string(paneID)).
74+ Str("title", title).
75+ Msg("pane title updated")
76+}
77+
78+// updateStackedPaneTitle updates the title of a pane in a StackedView.
79+func (c *Coordinator) updateStackedPaneTitle(
80+ ctx context.Context,
81+ sv *layout.StackedView,
82+ paneID entity.PaneID,
83+ title string,
84+) {
85+ log := logging.FromContext(ctx)
86+
87+ // Find the pane's index directly in the StackedView
88+ index := sv.FindPaneIndex(string(paneID))
89+ if index < 0 {
90+ log.Debug().
91+ Str("pane_id", string(paneID)).
92+ Msg("pane not found in StackedView for title update")
93+ return
94+ }
95+
96+ if err := sv.UpdateTitle(index, title); err != nil {
97+ log.Warn().Err(err).Int("index", index).Msg("failed to update stacked pane title")
98+ }
99+}
100+
101+// syncStackedTitle updates the stacked title bar for a pane if it's in a stack.
102+// Called from onLoadCommitted to keep titles in sync during navigation.
103+func (c *Coordinator) syncStackedTitle(ctx context.Context, paneID entity.PaneID, title string) {
104+ _, wsView := c.getActiveWS()
105+ if wsView == nil {
106+ return
107+ }
108+ tr := wsView.TreeRenderer()
109+ if tr == nil {
110+ return
111+ }
112+ if sv := tr.GetStackedViewForPane(string(paneID)); sv != nil {
113+ c.updateStackedPaneTitle(ctx, sv, paneID, title)
114+ }
115+}
116+
117+// onFaviconChanged updates favicon tracking when a WebView's favicon changes.
118+func (c *Coordinator) onFaviconChanged(ctx context.Context, paneID entity.PaneID, emittingWV *webkit.WebView, favicon *gdk.Texture) {
119+ log := logging.FromContext(ctx)
120+
121+ // Verify this WebView is still bound to the expected pane
122+ currentWV := c.getWebViewLocked(paneID)
123+ if currentWV == nil || currentWV != emittingWV {
124+ log.Debug().
125+ Str("pane_id", string(paneID)).
126+ Msg("ignoring favicon change from unbound webview")
127+ return
128+ }
129+
130+ uri := emittingWV.URI()
131+
132+ // Update favicon cache with domain key (handles cross-domain redirects)
133+ if c.faviconAdapter != nil && favicon != nil && uri != "" {
134+ c.navOriginMu.RLock()
135+ originURL := c.navOrigins[paneID]
136+ c.navOriginMu.RUnlock()
137+ c.faviconAdapter.StoreFromWebKitWithOrigin(ctx, uri, originURL, favicon)
138+ }
139+
140+ // Update StackedView favicon if this pane is in a stack
141+ c.updateStackedFaviconForPane(ctx, paneID, favicon)
142+
143+ log.Debug().
144+ Str("pane_id", string(paneID)).
145+ Str("uri", uri).
146+ Bool("has_favicon", favicon != nil).
147+ Msg("pane favicon updated")
148+}
149+
150+// updateStackedPaneFavicon updates the favicon of a pane in a StackedView.
151+func (c *Coordinator) updateStackedPaneFavicon(
152+ ctx context.Context,
153+ sv *layout.StackedView,
154+ paneID entity.PaneID,
155+ favicon *gdk.Texture,
156+) {
157+ log := logging.FromContext(ctx)
158+
159+ // Find the pane's index directly in the StackedView
160+ index := sv.FindPaneIndex(string(paneID))
161+ if index < 0 {
162+ log.Debug().
163+ Str("pane_id", string(paneID)).
164+ Msg("pane not found in StackedView for favicon update")
165+ return
166+ }
167+
168+ if err := sv.UpdateFaviconTexture(index, favicon); err != nil {
169+ log.Warn().Err(err).Int("index", index).Msg("failed to update stacked pane favicon")
170+ }
171+}
172+
173+// updateStackedFaviconForPane updates the stacked title bar favicon for a pane.
174+func (c *Coordinator) updateStackedFaviconForPane(ctx context.Context, paneID entity.PaneID, texture *gdk.Texture) {
175+ _, wsView := c.getActiveWS()
176+ if wsView == nil {
177+ return
178+ }
179+ tr := wsView.TreeRenderer()
180+ if tr == nil {
181+ return
182+ }
183+ if sv := tr.GetStackedViewForPane(string(paneID)); sv != nil {
184+ c.updateStackedPaneFavicon(ctx, sv, paneID, texture)
185+ }
186+}
187+
188+// resolveCommittedFavicon ensures the stacked title bar has a favicon after navigation commits.
189+// First checks if WebKit already has a favicon for the page (common for subpath URLs).
190+// Falls back to the full GetOrFetch pipeline (cache + WebKit DB + DuckDuckGo API).
191+// This closes the gap where title bars relied solely on the notify::favicon signal.
192+func (c *Coordinator) resolveCommittedFavicon(ctx context.Context, paneID entity.PaneID, wv *webkit.WebView) {
193+ if c.faviconAdapter == nil || wv == nil {
194+ return
195+ }
196+
197+ uri := wv.URI()
198+ if uri == "" || strings.HasPrefix(uri, "about:") || strings.HasPrefix(uri, "dumb://") {
199+ return
200+ }
201+
202+ log := logging.FromContext(ctx)
203+
204+ // Check if we already have a texture cached for this domain
205+ if texture := c.faviconAdapter.GetTextureByURL(uri); texture != nil {
206+ // Already cached - just ensure the stacked view is updated
207+ c.updateStackedFaviconForPane(ctx, paneID, texture)
208+ return
209+ }
210+
211+ // Check if WebKit already has a favicon for this page
212+ if icon := wv.Favicon(); icon != nil {
213+ log.Debug().Str("pane_id", string(paneID)).Str("uri", uri).Msg("using existing WebKit favicon for committed page")
214+ // Store and update through the normal path
215+ c.navOriginMu.RLock()
216+ originURL := c.navOrigins[paneID]
217+ c.navOriginMu.RUnlock()
218+ c.faviconAdapter.StoreFromWebKitWithOrigin(ctx, uri, originURL, icon)
219+ c.updateStackedFaviconForPane(ctx, paneID, icon)
220+ return
221+ }
222+
223+ // Fall back to GetOrFetch (checks service cache, WebKit DB, then DuckDuckGo API)
224+ // Capture current generation to guard against stale callbacks
225+ gen := wv.Generation()
226+ capturedURI := uri
227+ c.faviconAdapter.GetOrFetch(ctx, uri, func(texture *gdk.Texture) {
228+ // Skip nil results — a nil means "couldn't resolve", not "no favicon".
229+ // Without this guard, a late nil callback can overwrite a good favicon
230+ // that was already set by an earlier onFaviconChanged signal.
231+ if texture == nil {
232+ return
233+ }
234+ // Verify WebView is still bound to pane and hasn't been reused
235+ if wv.Generation() != gen {
236+ return
237+ }
238+ currentWV := c.getWebViewLocked(paneID)
239+ if currentWV != wv {
240+ return
241+ }
242+ // Verify the WebView hasn't navigated away since we started the fetch
243+ if currentWV.URI() != capturedURI {
244+ return
245+ }
246+ c.updateStackedFaviconForPane(ctx, paneID, texture)
247+ })
248+}
249+
250+// FaviconAdapter returns the favicon adapter for external use (e.g., omnibox).
251+func (c *Coordinator) FaviconAdapter() *adapter.FaviconAdapter {
252+ return c.faviconAdapter
253+}
254+
255+// SetNavigationOrigin records the original URL before navigation starts.
256+// This allows caching favicons under both original and final domains
257+// when cross-domain redirects occur (e.g., google.fr → google.com).
258+func (c *Coordinator) SetNavigationOrigin(paneID entity.PaneID, uri string) {
259+ c.navOriginMu.Lock()
260+ c.navOrigins[paneID] = uri
261+ c.navOriginMu.Unlock()
262+}
263+
264+// PreloadCachedFavicon checks the favicon cache and updates the stacked pane
265+// title bar immediately if a cached favicon exists for the URL.
266+// This provides instant favicon display without waiting for WebKit.
267+func (c *Coordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.PaneID, uri string) {
268+ if c.faviconAdapter == nil || uri == "" {
269+ return
270+ }
271+ select {
272+ case <-ctx.Done():
273+ return
274+ default:
275+ }
276+
277+ // Check memory and disk cache (no external fetch)
278+ texture := c.faviconAdapter.PreloadFromCache(uri)
279+ select {
280+ case <-ctx.Done():
281+ return
282+ default:
283+ }
284+
285+ // Update stacked pane favicon on GTK main loop.
286+ cb := glib.SourceFunc(func(_ uintptr) bool {
287+ select {
288+ case <-ctx.Done():
289+ return false
290+ default:
291+ }
292+ if c.getActiveWS == nil {
293+ return false
294+ }
295+ // Only update if we found a cached texture.
296+ // If nil, let resolveCommittedFavicon handle it after load commits.
297+ if texture == nil {
298+ return false
299+ }
300+ _, wsView := c.getActiveWS()
301+ if wsView == nil {
302+ return false
303+ }
304+ tr := wsView.TreeRenderer()
305+ if tr == nil {
306+ return false
307+ }
308+ stackedView := tr.GetStackedViewForPane(string(paneID))
309+ if stackedView == nil {
310+ return false
311+ }
312+ c.updateStackedPaneFavicon(ctx, stackedView, paneID, texture)
313+ return false
314+ })
315+ glib.IdleAdd(&cb, 0)
316+}
M internal/ui/coordinator/navigation.go +3 -2
@@ -9,6 +9,7 @@ import (
99 "github.com/bnema/dumber/internal/application/usecase"
1010 "github.com/bnema/dumber/internal/domain/entity"
1111 "github.com/bnema/dumber/internal/logging"
12+ "github.com/bnema/dumber/internal/ui/coordinator/content"
1213 )
1314
1415 // OmniboxProvider provides access to omnibox operations.
@@ -22,7 +23,7 @@ type OmniboxProvider interface {
2223 type NavigationCoordinator struct {
2324 contextProvider func() context.Context
2425 navigateUC *usecase.NavigateUseCase
25- contentCoord *ContentCoordinator
26+ contentCoord *content.Coordinator
2627 omniboxProvider OmniboxProvider
2728 }
2829
@@ -32,7 +33,7 @@ const faviconPreloadTimeout = 300 * time.Millisecond
3233 func NewNavigationCoordinator(
3334 ctx context.Context,
3435 navigateUC *usecase.NavigateUseCase,
35- contentCoord *ContentCoordinator,
36+ contentCoord *content.Coordinator,
3637 ) *NavigationCoordinator {
3738 log := logging.FromContext(ctx)
3839 log.Debug().Msg("creating navigation coordinator")
D internal/ui/coordinator/oauth.go +0 -193
@@ -1,193 +0,0 @@
1-package coordinator
2-
3-import (
4- "strings"
5-
6- "github.com/bnema/dumber/internal/application/port"
7-)
8-
9-// oauthFlowPatterns are URL patterns that indicate an OAuth flow is in progress.
10-// These appear in authorization URLs and callback redirects.
11-var oauthFlowPatterns = []string{
12- // OAuth flow indicators
13- "oauth",
14- "authorize",
15- "authorization",
16- "auth/",
17- "/auth",
18- "login",
19- "signin",
20- "sign-in",
21- // OpenID Connect
22- "oidc",
23- "openid",
24- // Callback/redirect endpoints
25- "callback",
26- "redirect",
27- "/cb",
28-}
29-
30-// oauthCallbackPatterns are URL query parameters that indicate an OAuth callback.
31-// These appear when the OAuth provider redirects back to the application.
32-var oauthCallbackPatterns = []string{
33- // Success parameters
34- "code=",
35- "access_token=",
36- "id_token=",
37- "token_type=",
38- "refresh_token=",
39- // Error parameters
40- "error=",
41- "error_description=",
42- "error_uri=",
43-}
44-
45-// oauthRequestPatterns are query parameters found in OAuth authorization requests.
46-var oauthRequestPatterns = []string{
47- "response_type=",
48- "client_id=",
49- "redirect_uri=",
50- "scope=",
51- "nonce=",
52-}
53-
54-// IsOAuthURL checks if the URL is related to an OAuth flow.
55-// This includes authorization endpoints, login pages, and callback URLs.
56-func IsOAuthURL(url string) bool {
57- if url == "" {
58- return false
59- }
60- lower := strings.ToLower(url)
61-
62- // Check for OAuth flow patterns in URL path
63- for _, pattern := range oauthFlowPatterns {
64- if strings.Contains(lower, pattern) {
65- return true
66- }
67- }
68-
69- // Check for OAuth request parameters
70- for _, pattern := range oauthRequestPatterns {
71- if strings.Contains(lower, pattern) {
72- return true
73- }
74- }
75-
76- return false
77-}
78-
79-// IsOAuthCallback checks if the URL is an OAuth callback with response parameters.
80-// This indicates the OAuth flow has completed (successfully or with error).
81-func IsOAuthCallback(url string) bool {
82- if url == "" {
83- return false
84- }
85- lower := strings.ToLower(url)
86-
87- // Must contain callback patterns (code=, access_token=, error=, etc.)
88- for _, pattern := range oauthCallbackPatterns {
89- if strings.Contains(lower, pattern) {
90- return true
91- }
92- }
93-
94- return false
95-}
96-
97-// ShouldAutoClose determines if a popup at this URL should auto-close.
98-// Returns true for OAuth callbacks that indicate flow completion.
99-//
100-// This is comprehensive detection that handles:
101-// - Success: code=, access_token=, id_token=
102-// - Errors: error=, error_description=
103-// - Various OAuth providers (Google, GitHub, Auth0, etc.)
104-func ShouldAutoClose(url string) bool {
105- return IsOAuthCallback(url)
106-}
107-
108-// shouldForceCloseOnSafetyTimeout determines if a popup should be force-closed
109-// when the OAuth safety timeout is reached.
110-//
111-// Safety-timeout force close is disabled for stability. OAuth popups close on
112-// callback detection or manual user action.
113-func shouldForceCloseOnSafetyTimeout(url string) bool {
114- _ = url
115- return false
116-}
117-
118-// IsOAuthSuccess checks if the callback indicates successful authentication.
119-func IsOAuthSuccess(url string) bool {
120- if url == "" {
121- return false
122- }
123- lower := strings.ToLower(url)
124-
125- // Check for success indicators
126- successPatterns := []string{
127- "code=",
128- "access_token=",
129- "id_token=",
130- }
131-
132- for _, pattern := range successPatterns {
133- if strings.Contains(lower, pattern) {
134- // Make sure it's not an error response
135- if !strings.Contains(lower, "error=") {
136- return true
137- }
138- }
139- }
140-
141- return false
142-}
143-
144-// IsOAuthError checks if the callback indicates an authentication error.
145-func IsOAuthError(url string) bool {
146- if url == "" {
147- return false
148- }
149- lower := strings.ToLower(url)
150- return strings.Contains(lower, "error=")
151-}
152-
153-func composeOnClose(existing, next func()) func() {
154- if existing == nil {
155- return next
156- }
157- if next == nil {
158- return existing
159- }
160- return func() {
161- existing()
162- next()
163- }
164-}
165-
166-func composeOnURIChanged(existing, next func(string)) func(string) {
167- if existing == nil {
168- return next
169- }
170- if next == nil {
171- return existing
172- }
173- return func(uri string) {
174- existing(uri)
175- next(uri)
176- }
177-}
178-
179-func composeOnLoadChanged(
180- existing func(port.LoadEvent),
181- next func(port.LoadEvent),
182-) func(port.LoadEvent) {
183- if existing == nil {
184- return next
185- }
186- if next == nil {
187- return existing
188- }
189- return func(event port.LoadEvent) {
190- existing(event)
191- next(event)
192- }
193-}
D internal/ui/coordinator/popup_types.go +0 -127
@@ -1,127 +0,0 @@
1-package coordinator
2-
3-import (
4- "time"
5-
6- "github.com/bnema/dumber/internal/application/port"
7- "github.com/bnema/dumber/internal/domain/entity"
8- "github.com/bnema/dumber/internal/infrastructure/config"
9-)
10-
11-// PopupType indicates whether the popup was triggered by a link or JavaScript.
12-type PopupType int
13-
14-const (
15- // PopupTypeTab represents a popup from target="_blank" links.
16- // These are typically user-initiated and open like new tabs.
17- PopupTypeTab PopupType = iota
18- // PopupTypePopup represents a popup from window.open() JavaScript calls.
19- // These may have different placement behavior than tab-like popups.
20- PopupTypePopup
21-)
22-
23-// String returns a human-readable name for the popup type.
24-func (t PopupType) String() string {
25- switch t {
26- case PopupTypeTab:
27- return "tab"
28- case PopupTypePopup:
29- return "popup"
30- default:
31- return "unknown"
32- }
33-}
34-
35-// PendingPopup tracks a popup WebView that has been created but not yet
36-// inserted into the workspace. This is used during the three-phase popup
37-// lifecycle: create → ready-to-show → (insert into workspace).
38-type PendingPopup struct {
39- // WebView is the related WebView created in the "create" phase.
40- // It shares cookies/session with the parent for OAuth support.
41- WebView port.WebView
42-
43- // ParentPaneID is the pane that spawned this popup.
44- ParentPaneID entity.PaneID
45-
46- // ParentWebViewID is the WebView ID of the parent (for lookup).
47- ParentWebViewID port.WebViewID
48-
49- // TargetURI is the initial URL the popup will load.
50- TargetURI string
51-
52- // FrameName is the target frame name from the navigation action.
53- // "_blank" indicates a tab-like popup, other values indicate JS popups.
54- FrameName string
55-
56- // IsUserGesture indicates if the popup was triggered by user action
57- // (click) vs script-initiated (e.g., popup after timeout).
58- IsUserGesture bool
59-
60- // PopupType categorizes this as a tab-like or JS popup.
61- PopupType PopupType
62-
63- // CreatedAt is when the popup was created (for timeout handling).
64- CreatedAt time.Time
65-}
66-
67-// DetectPopupType determines if this is a tab-like or JS popup based on frame name.
68-func DetectPopupType(frameName string) PopupType {
69- // _blank is the standard indicator for "open in new tab/window"
70- if frameName == "_blank" {
71- return PopupTypeTab
72- }
73- // Empty or custom frame names indicate JS window.open()
74- return PopupTypePopup
75-}
76-
77-// InsertPopupInput contains the data needed to insert a popup into the workspace.
78-type InsertPopupInput struct {
79- // ParentPaneID is the pane that spawned this popup.
80- ParentPaneID entity.PaneID
81-
82- // PopupPane is the pre-created pane entity for the popup.
83- // It should have IsRelated=true and ParentPaneID set.
84- PopupPane *entity.Pane
85-
86- // WebView is the related WebView for the popup.
87- WebView port.WebView
88-
89- // Behavior determines how the popup is inserted (split/stacked/tabbed).
90- Behavior config.PopupBehavior
91-
92- // Placement specifies direction for split behavior (right/left/top/bottom).
93- Placement string
94-
95- // PopupType indicates if this is a tab-like or JS popup.
96- PopupType PopupType
97-
98- // TargetURI is the URL to load in the popup.
99- TargetURI string
100-}
101-
102-// GetBehavior returns the appropriate behavior based on popup type and config.
103-func GetBehavior(popupType PopupType, cfg *config.PopupBehaviorConfig) config.PopupBehavior {
104- if cfg == nil {
105- return config.PopupBehaviorSplit // Default
106- }
107-
108- switch popupType {
109- case PopupTypeTab:
110- // Tab-like popups (_blank) use blank_target_behavior
111- switch cfg.BlankTargetBehavior {
112- case "split":
113- return config.PopupBehaviorSplit
114- case "stacked":
115- return config.PopupBehaviorStacked
116- case "tabbed":
117- return config.PopupBehaviorTabbed
118- default:
119- return config.PopupBehaviorStacked // Default for _blank
120- }
121- case PopupTypePopup:
122- // JS popups use the main behavior setting
123- return cfg.Behavior
124- default:
125- return cfg.Behavior
126- }
127-}
M internal/ui/coordinator/workspace.go +14 -13
@@ -11,6 +11,7 @@ import (
1111 "github.com/bnema/dumber/internal/infrastructure/config"
1212 "github.com/bnema/dumber/internal/logging"
1313 "github.com/bnema/dumber/internal/ui/component"
14+ "github.com/bnema/dumber/internal/ui/coordinator/content"
1415 "github.com/bnema/dumber/internal/ui/focus"
1516 "github.com/bnema/dumber/internal/ui/layout"
1617 "github.com/rs/zerolog"
@@ -27,15 +28,15 @@ type WorkspaceCoordinator struct {
2728 focusMgr *focus.Manager
2829 stackedPaneMgr *component.StackedPaneManager
2930 widgetFactory layout.WidgetFactory
30- contentCoord *ContentCoordinator
31+ contentCoord *content.Coordinator
3132
3233 // Callbacks to avoid circular dependencies
3334 getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
3435 generateID func() string
3536 onCloseLastPane func(ctx context.Context) error
36- onCreatePopupTab func(ctx context.Context, input InsertPopupInput) error // For tabbed popup behavior
37- onStateChanged func() // For session snapshots
38- onPaneClosed func(paneID entity.PaneID) // For pane-specific cleanup hooks
37+ onCreatePopupTab func(ctx context.Context, input content.InsertPopupInput) error // For tabbed popup behavior
38+ onStateChanged func() // For session snapshots
39+ onPaneClosed func(paneID entity.PaneID) // For pane-specific cleanup hooks
3940 }
4041
4142 // WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator.
@@ -44,7 +45,7 @@ type WorkspaceCoordinatorConfig struct {
4445 FocusMgr *focus.Manager
4546 StackedPaneMgr *component.StackedPaneManager
4647 WidgetFactory layout.WidgetFactory
47- ContentCoord *ContentCoordinator
48+ ContentCoord *content.Coordinator
4849 GetActiveWS func() (*entity.Workspace, *component.WorkspaceView)
4950 GenerateID func() string
5051 }
@@ -1180,7 +1181,7 @@ func (c *WorkspaceCoordinator) doIncrementalStackClose(
11801181 // Release the closing pane's webview
11811182 c.contentCoord.ReleaseWebView(ctx, closingPaneID)
11821183
1183- // Sync remaining title bars with current titles from ContentCoordinator.
1184+ // Sync remaining title bars with current titles from content.Coordinator.
11841185 // The domain model (stackNode.Children) has already been updated by the use case,
11851186 // so we iterate the remaining children and update their titles in the UI.
11861187 c.syncStackedPaneTitles(ctx, stackedView, stackNode)
@@ -1756,13 +1757,13 @@ func (c *WorkspaceCoordinator) SetupStackedPaneCallbacks(ctx context.Context, ws
17561757
17571758 // SetOnCreatePopupTab sets the callback for creating popup tabs.
17581759 // This is used when popup behavior is "tabbed".
1759-func (c *WorkspaceCoordinator) SetOnCreatePopupTab(fn func(ctx context.Context, input InsertPopupInput) error) {
1760+func (c *WorkspaceCoordinator) SetOnCreatePopupTab(fn func(ctx context.Context, input content.InsertPopupInput) error) {
17601761 c.onCreatePopupTab = fn
17611762 }
17621763
17631764 // InsertPopup inserts a popup pane into the workspace based on the specified behavior.
17641765 // Supports split, stacked, and tabbed behaviors.
1765-func (c *WorkspaceCoordinator) InsertPopup(ctx context.Context, input InsertPopupInput) error {
1766+func (c *WorkspaceCoordinator) InsertPopup(ctx context.Context, input content.InsertPopupInput) error {
17661767 log := logging.FromContext(ctx)
17671768
17681769 log.Debug().
@@ -1787,7 +1788,7 @@ func (c *WorkspaceCoordinator) InsertPopup(ctx context.Context, input InsertPopu
17871788 }
17881789
17891790 // insertPopupSplit inserts a popup as a split pane adjacent to the parent.
1790-func (c *WorkspaceCoordinator) insertPopupSplit(ctx context.Context, input InsertPopupInput) error {
1791+func (c *WorkspaceCoordinator) insertPopupSplit(ctx context.Context, input content.InsertPopupInput) error {
17911792 log := logging.FromContext(ctx)
17921793
17931794 ws, wsView := c.getActiveWS()
@@ -1882,7 +1883,7 @@ func (c *WorkspaceCoordinator) resolvePopupSplitWidget(
18821883 func (c *WorkspaceCoordinator) attachPopupWebView(
18831884 ctx context.Context,
18841885 wsView *component.WorkspaceView,
1885- input InsertPopupInput,
1886+ input content.InsertPopupInput,
18861887 ) {
18871888 if wsView == nil || input.WebView == nil {
18881889 return
@@ -1902,7 +1903,7 @@ func (c *WorkspaceCoordinator) attachPopupWebView(
19021903
19031904 // insertPopupStacked inserts a popup as a stacked pane on top of the parent.
19041905 // Uses CreateStack and AddToStack use cases for proper domain model management.
1905-func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input InsertPopupInput) error {
1906+func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input content.InsertPopupInput) error {
19061907 log := logging.FromContext(ctx)
19071908
19081909 ws, wsView := c.getActiveWS()
@@ -2040,7 +2041,7 @@ func (c *WorkspaceCoordinator) resolveOrCreateStackNode(
20402041
20412042 func (c *WorkspaceCoordinator) attachPopupPaneView(
20422043 ctx context.Context,
2043- input InsertPopupInput,
2044+ input content.InsertPopupInput,
20442045 wsView *component.WorkspaceView,
20452046 stackNode *entity.PaneNode,
20462047 log *zerolog.Logger,
@@ -2085,7 +2086,7 @@ func (c *WorkspaceCoordinator) attachPopupPaneView(
20852086 }
20862087
20872088 // insertPopupTabbed creates a new tab for the popup.
2088-func (c *WorkspaceCoordinator) insertPopupTabbed(ctx context.Context, input InsertPopupInput) error {
2089+func (c *WorkspaceCoordinator) insertPopupTabbed(ctx context.Context, input content.InsertPopupInput) error {
20892090 log := logging.FromContext(ctx)
20902091
20912092 if c.onCreatePopupTab == nil {

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close