detached 2f35bdf

Merge pull request #193 from bnema/refactor/engine-abstraction

refactor: engine abstraction layer
commit
2f35bdf9b7b545d0ba2d0a0bdceba7e92a2041c7
author
bnema · · 2026-03-18 19:56 UTC
committer
GitHub · · 2026-03-18 19:56 UTC
merge of
7fa0377 + f19a207 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
121 files · +19000 -6804
M .mockery.yaml +46 -2
@@ -20,7 +20,6 @@ packages:
2020 WebViewFactory: {}
2121 WebViewPool: {}
2222 MemoryPressureApplier: {}
23- WebKitContextProvider: {}
2423 HardwareSurveyor: {}
2524 ColorSchemeDetector: {}
2625 ColorSchemeResolver: {}
@@ -32,6 +31,52 @@ packages:
3231 KeybindingsSaver: {}
3332 DownloadResponse: {}
3433 PermissionDialogPresenter: {}
34+ PermissionRepository: {}
35+ Engine: {}
36+ ContentInjector: {}
37+ SchemeHandler: {}
38+ MessageRouter: {}
39+ SettingsApplier: {}
40+ FilterApplier: {}
41+ FaviconDatabase: {}
42+ IdleInhibitor: {}
43+ PopupCapable: {}
44+ OAuthCallbackCapable: {}
45+ DevToolsOpener: {}
46+ Printer: {}
47+ AccentKeyHandler: {}
48+ AutoCopyConfig: {}
49+ Clipboard: {}
50+ DesktopIntegration: {}
51+ DownloadEventHandler: {}
52+ DownloadPreparer: {}
53+ FaviconService: {}
54+ FileSystem: {}
55+ FilterManager: {}
56+ Focusable: {}
57+ FindController: {}
58+ EntryInputTarget: {}
59+ HomepageFavorites: {}
60+ HomepageHistory: {}
61+ AllKeybindingsResetter: {}
62+ KeybindingResetter: {}
63+ KeybindingSetter: {}
64+ KeybindingsGetter: {}
65+ MediaDiagnostics: {}
66+ NativeWidgetProvider: {}
67+ Notification: {}
68+ RenderingEnvManager: {}
69+ RuntimeVersionProbe: {}
70+ ScriptRefresher: {}
71+ SnapshotService: {}
72+ TextInputTargetProvider: {}
73+ Texture: {}
74+ ToolkitAvailabilityNotifier: {}
75+ UpdateApplier: {}
76+ UpdateChecker: {}
77+ UpdateDownloader: {}
78+ WebUIConfigSaver: {}
79+ XDGPaths: {}
3580 github.com/bnema/dumber/internal/domain/repository:
3681 config:
3782 dir: '{{.InterfaceDir}}/mocks'
@@ -46,7 +91,6 @@ packages:
4691 SessionStateRepository: {}
4792 TagRepository: {}
4893 ZoomRepository: {}
49- PermissionRepository: {}
5094 github.com/bnema/dumber/internal/infrastructure/filtering:
5195 config:
5296 dir: '{{.InterfaceDir}}/mocks'
M cmd/dumber/main.go +74 -32
@@ -18,18 +18,23 @@ import (
1818 "github.com/bnema/dumber/internal/domain/repository"
1919 "github.com/bnema/dumber/internal/infrastructure/cache"
2020 "github.com/bnema/dumber/internal/infrastructure/clipboard"
21- "github.com/bnema/dumber/internal/infrastructure/colorscheme"
2221 "github.com/bnema/dumber/internal/infrastructure/config"
2322 "github.com/bnema/dumber/internal/infrastructure/deps"
23+ "github.com/bnema/dumber/internal/infrastructure/desktop"
2424 "github.com/bnema/dumber/internal/infrastructure/favicon"
25+ "github.com/bnema/dumber/internal/infrastructure/filesystem"
2526 "github.com/bnema/dumber/internal/infrastructure/idle"
2627 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
28+ "github.com/bnema/dumber/internal/infrastructure/snapshot"
29+ "github.com/bnema/dumber/internal/infrastructure/textinput"
2730 "github.com/bnema/dumber/internal/infrastructure/updater"
2831 "github.com/bnema/dumber/internal/infrastructure/xdg"
2932 "github.com/bnema/dumber/internal/logging"
3033 "github.com/bnema/dumber/internal/ui"
34+ "github.com/bnema/dumber/internal/ui/adapter"
3135 "github.com/bnema/dumber/internal/ui/component"
3236 "github.com/bnema/dumber/internal/ui/theme"
37+ "github.com/jwijenbergh/puregotk/v4/gtk"
3338 "github.com/rs/zerolog"
3439 )
3540
@@ -94,7 +99,7 @@ func runGUI() int {
9499
95100 needsEagerDB := restoreSessionID != "" || cfg.Session.AutoRestore
96101
97- stack, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, needsEagerDB)
102+ engine, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, needsEagerDB)
98103 if err != nil {
99104 bootstrapLog.Fatal().Err(err).Msg("failed to initialize database/webkit")
100105 }
@@ -111,9 +116,7 @@ func runGUI() int {
111116 logging.Trace().UpdateLogger(log)
112117 logCoreDumpLimits(ctx)
113118
114- if stack.MessageRouter != nil {
115- stack.MessageRouter.SetBaseContext(ctx)
116- }
119+ engine.SetHandlerContext(ctx)
117120
118121 useCases := createUseCases(repos, cfg)
119122 if needsEagerDB {
@@ -124,7 +127,7 @@ func runGUI() int {
124127 defer closeIdleInhibitor(idleInhibitor)
125128 timer.Mark("use_cases")
126129
127- app, err := buildAndConfigureApp(ctx, cfg, initResult, &stack, repos, useCases, idleInhibitor, browserSession)
130+ app, err := buildAndConfigureApp(ctx, cfg, initResult, engine, repos, useCases, idleInhibitor, browserSession)
128131 if err != nil {
129132 log.Error().Err(err).Msg("failed to create application")
130133 return 1
@@ -194,7 +197,7 @@ func buildAndConfigureApp(
194197 ctx context.Context,
195198 cfg *config.Config,
196199 initResult *bootstrap.ParallelInitResult,
197- stack *bootstrap.WebKitStack,
200+ engine port.Engine,
198201 repos *repositories,
199202 useCases *useCases,
200203 idleInhibitor port.IdleInhibitor,
@@ -203,14 +206,14 @@ func buildAndConfigureApp(
203206 uiDeps := buildUIDependencies(
204207 ctx, cfg, initResult.ThemeManager,
205208 initResult.ColorResolver, initResult.AdwaitaDetector,
206- stack, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.UnexpectedCloseReports(),
209+ engine, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.UnexpectedCloseReports(),
207210 )
208211 configureDeferredInit(uiDeps, cfg, browserSession)
209212 return ui.New(uiDeps)
210213 }
211214
212215 func initStartupContext(cfg *config.Config) context.Context {
213- deps.ApplyPrefixEnv(cfg.Runtime.Prefix)
216+ deps.ApplyPrefixEnv(cfg.Engine.WebKit.Prefix)
214217 bootstrapLogger := logging.NewFromConfigValuesWithTimeFormat(
215218 cfg.Logging.Level,
216219 cfg.Logging.Format,
@@ -230,10 +233,10 @@ func initStackAndRepos(
230233 cfg *config.Config,
231234 initResult *bootstrap.ParallelInitResult,
232235 needsEagerDB bool,
233-) (bootstrap.WebKitStack, *repositories, func(), error) {
236+) (port.Engine, *repositories, func(), error) {
234237 if needsEagerDB {
235- // Parallel phase 2: Database + WebKit stack initialize concurrently
236- dbWebKit, err := bootstrap.RunParallelDBWebKit(bootstrap.ParallelDBWebKitInput{
238+ // Parallel phase 2: Database + engine initialize concurrently
239+ dbEngine, err := bootstrap.RunParallelDBEngine(bootstrap.ParallelDBEngineInput{
237240 Ctx: ctx,
238241 Config: cfg,
239242 DataDir: initResult.DataDir,
@@ -242,13 +245,13 @@ func initStackAndRepos(
242245 ColorResolver: initResult.ColorResolver,
243246 })
244247 if err != nil {
245- return bootstrap.WebKitStack{}, nil, nil, err
248+ return nil, nil, nil, err
246249 }
247- return dbWebKit.Stack, createRepositories(dbWebKit.DB), dbWebKit.DBCleanup, nil
250+ return dbEngine.Engine, createRepositories(dbEngine.DB), dbEngine.DBCleanup, nil
248251 }
249252
250253 log := logging.FromContext(ctx)
251- stack := bootstrap.BuildWebKitStack(bootstrap.WebKitStackInput{
254+ engine, err := bootstrap.BuildEngine(bootstrap.EngineInput{
252255 Ctx: ctx,
253256 Config: cfg,
254257 DataDir: initResult.DataDir,
@@ -257,13 +260,16 @@ func initStackAndRepos(
257260 ColorResolver: initResult.ColorResolver,
258261 Logger: *log,
259262 })
263+ if err != nil {
264+ return nil, nil, nil, err
265+ }
260266
261267 lazyDB, err := bootstrap.CreateLazyDatabase()
262268 if err != nil {
263- return stack, nil, nil, err
269+ return nil, nil, nil, err
264270 }
265271 dbCleanup := func() { _ = lazyDB.Close() }
266- return stack, createLazyRepositories(lazyDB), dbCleanup, nil
272+ return engine, createLazyRepositories(lazyDB), dbCleanup, nil
267273 }
268274
269275 func configureDeferredInit(
@@ -462,7 +468,7 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases {
462468 xdgDirs, _ := config.GetXDGDirs()
463469 stateDir, _ := config.GetStateDir()
464470 defaultZoom := cfg.DefaultWebpageZoom
465- zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize)
471+ zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Engine.ZoomCacheSize)
466472
467473 buildInfo := build.Info{
468474 Version: version,
@@ -500,15 +506,22 @@ func buildUIDependencies(
500506 cfg *config.Config,
501507 themeManager *theme.Manager,
502508 colorResolver port.ColorSchemeResolver,
503- adwaitaDetector *colorscheme.AdwaitaDetector,
504- stack *bootstrap.WebKitStack,
509+ adwaitaDetector port.ToolkitAvailabilityNotifier,
510+ engine port.Engine,
505511 repos *repositories,
506512 uc *useCases,
507513 idleInhibitor port.IdleInhibitor,
508514 currentSessionID entity.SessionID,
509515 startupCrashReports []string,
510516 ) *ui.Dependencies {
511- return &ui.Dependencies{
517+ var filterManager port.FilterManager
518+ if fmp, ok := engine.(port.FilterManagerProvider); ok {
519+ filterManager = fmp.InternalFilterManager()
520+ }
521+
522+ focusProvider := textinput.NewFocusProvider()
523+
524+ uiDeps := &ui.Dependencies{
512525 Ctx: ctx,
513526 Config: cfg,
514527 InitialURL: initialURL,
@@ -518,12 +531,8 @@ func buildUIDependencies(
518531 ColorResolver: colorResolver,
519532 AdwaitaDetector: adwaitaDetector,
520533 XDG: xdg.New(),
521- WebContext: stack.Context,
522- Pool: stack.Pool,
523- Settings: stack.Settings,
524- Injector: stack.Injector,
525- MessageRouter: stack.MessageRouter,
526- FilterManager: stack.FilterManager,
534+ Engine: engine,
535+ FilterManager: filterManager,
527536 HistoryRepo: repos.history,
528537 FavoriteRepo: repos.favorite,
529538 ZoomRepo: repos.zoom,
@@ -538,12 +547,45 @@ func buildUIDependencies(
538547 CopyURLUC: uc.copyURL,
539548 Clipboard: uc.clipboard,
540549 FaviconService: uc.favicon,
541- IdleInhibitor: idleInhibitor,
542- SessionRepo: repos.session,
543- SessionStateRepo: repos.sessionState,
544- CurrentSessionID: currentSessionID,
545- SnapshotUC: uc.snapshot,
550+ FaviconAdapterConfig: adapter.FaviconAdapterConfig{
551+ IsInternalURL: favicon.IsInternalURL,
552+ InternalDomain: favicon.InternalDomain,
553+ NormalizedIconSize: favicon.NormalizedIconSize,
554+ GetLogoBytes: favicon.GetLogoBytes,
555+ },
556+ IdleInhibitor: idleInhibitor,
557+ SessionRepo: repos.session,
558+ SessionStateRepo: repos.sessionState,
559+ CurrentSessionID: currentSessionID,
560+ SnapshotUC: uc.snapshot,
561+ SnapshotServiceFactory: func(provider port.TabListProvider, intervalMs int) port.SnapshotService {
562+ return snapshot.NewService(uc.snapshot, provider, intervalMs)
563+ },
546564 CheckUpdateUC: uc.checkUpdate,
547565 ApplyUpdateUC: uc.applyUpdate,
566+ SessionSpawner: desktop.NewSessionSpawner(ctx),
567+ FileSystem: filesystem.New(),
568+ AccentFocusProvider: focusProvider,
569+ NewGTKEntryTarget: func(entry *gtk.SearchEntry) port.TextInputTarget {
570+ return textinput.NewGTKEntryTarget(entry)
571+ },
572+ OnConfigChange: func(callback func()) {
573+ config.GetManager().OnConfigChange(func(newCfg *config.Config) {
574+ // No synchronization needed: viper fires this callback on the
575+ // GTK main thread (via glib.IdleAdd), and all callers of cfg run
576+ // on the same GTK main thread, so there is no concurrent access.
577+ if cfg != nil && newCfg != nil {
578+ *cfg = *newCfg
579+ }
580+ callback()
581+ })
582+ },
583+ WatchConfig: func() error {
584+ return config.GetManager().Watch()
585+ },
586+ LaunchExternalURL: desktop.LaunchExternalURL,
587+ ConfigMigrator: config.NewMigrator(),
548588 }
589+
590+ return uiDeps
549591 }
M internal/application/port/color_scheme.go +5 -0
@@ -33,6 +33,11 @@ type ColorSchemeDetector interface {
3333 Detect() (prefersDark bool, ok bool)
3434 }
3535
36+// ToolkitAvailabilityNotifier signals that a toolkit (e.g., GTK/Adwaita) is available.
37+type ToolkitAvailabilityNotifier interface {
38+ MarkAvailable()
39+}
40+
3641 // ColorSchemeResolver resolves the effective color scheme preference.
3742 // It manages multiple detectors and respects config overrides.
3843 type ColorSchemeResolver interface {
M internal/application/port/content_injector.go +8 -0
@@ -8,4 +8,12 @@ type ContentInjector interface {
88 // InjectThemeCSS injects CSS variables into the page for theming.
99 // The css parameter should contain CSS custom property declarations.
1010 InjectThemeCSS(ctx context.Context, css string) error
11+
12+ // InjectFindHighlightCSS injects CSS used to style in-page find highlights.
13+ InjectFindHighlightCSS(ctx context.Context, css string) error
14+
15+ // RefreshScripts clears and re-injects user scripts for a single WebView.
16+ // Called when appearance settings change so future navigations pick up latest values.
17+ // Returns an error if the refresh could not be performed.
18+ RefreshScripts(ctx context.Context, wv WebView) error
1119 }
A internal/application/port/engine.go +237 -0
@@ -0,0 +1,237 @@
1+// Package port defines application-layer interfaces for external capabilities.
2+// Ports abstract infrastructure concerns, allowing the application layer to
3+// remain independent of specific implementations (WebKit, GTK, etc.).
4+package port
5+
6+import "context"
7+
8+// CookiePolicy controls cookie acceptance behavior for the engine's network session.
9+type CookiePolicy string
10+
11+const (
12+ // CookiePolicyAlways accepts all cookies.
13+ CookiePolicyAlways CookiePolicy = "always"
14+ // CookiePolicyNoThirdParty rejects third-party cookies.
15+ CookiePolicyNoThirdParty CookiePolicy = "no_third_party"
16+ // CookiePolicyNever rejects all cookies.
17+ CookiePolicyNever CookiePolicy = "never"
18+)
19+
20+// MemoryPressureConfig holds memory pressure settings for an engine process.
21+// This is a value type (no infrastructure dependencies) co-located with the
22+// interfaces that use it (MemoryPressureApplier, EngineOptions).
23+// Zero values mean "use engine defaults".
24+type MemoryPressureConfig struct {
25+ // MemoryLimitMB sets the memory limit in megabytes.
26+ // 0 means unset (uses engine default).
27+ MemoryLimitMB int
28+
29+ // PollIntervalSec sets the interval for memory usage checks.
30+ // 0 means unset (uses engine default: 30 seconds).
31+ PollIntervalSec float64
32+
33+ // ConservativeThreshold sets threshold for conservative memory release.
34+ // Must be in (0, 1). 0 means unset (uses engine default: 0.33).
35+ ConservativeThreshold float64
36+
37+ // StrictThreshold sets threshold for strict memory release.
38+ // Must be in (0, 1). 0 means unset (uses engine default: 0.5).
39+ StrictThreshold float64
40+}
41+
42+// IsConfigured returns true if any memory pressure setting is configured.
43+func (c *MemoryPressureConfig) IsConfigured() bool {
44+ if c == nil {
45+ return false
46+ }
47+ return c.MemoryLimitMB > 0 ||
48+ c.PollIntervalSec > 0 ||
49+ c.ConservativeThreshold > 0 ||
50+ c.StrictThreshold > 0
51+}
52+
53+// MemoryPressureApplier applies memory pressure settings to engine processes.
54+// This interface abstracts engine-specific memory pressure configuration
55+// to allow testing without engine dependencies.
56+type MemoryPressureApplier interface {
57+ // ApplyNetworkProcessSettings applies memory pressure settings to the network process.
58+ // Must be called BEFORE creating any NetworkSession.
59+ ApplyNetworkProcessSettings(ctx context.Context, cfg *MemoryPressureConfig) error
60+
61+ // ApplyWebProcessSettings applies memory pressure settings to web processes.
62+ // Returns an opaque settings object that should be passed to context creation.
63+ // Returns nil if no settings are configured.
64+ ApplyWebProcessSettings(ctx context.Context, cfg *MemoryPressureConfig) (any, error)
65+}
66+
67+// EngineOptions configures engine initialization.
68+type EngineOptions struct {
69+ // DataDir is the directory for persistent data (cookies, storage, etc.).
70+ DataDir string
71+
72+ // CacheDir is the directory for cache data.
73+ CacheDir string
74+
75+ // CookiePolicy controls cookie acceptance behavior.
76+ // Empty value means runtime defaults are used.
77+ CookiePolicy CookiePolicy
78+
79+ // WebProcessMemory configures memory pressure for web processes.
80+ // nil means use engine defaults.
81+ WebProcessMemory *MemoryPressureConfig
82+
83+ // NetworkProcessMemory configures memory pressure for the network process.
84+ // nil means use engine defaults.
85+ NetworkProcessMemory *MemoryPressureConfig
86+}
87+
88+// Engine is the top-level interface for a browser engine implementation.
89+// It provides access to all engine subsystems and manages the lifecycle
90+// of the underlying browser context.
91+//
92+// Engines are fully initialized by their constructor (e.g., webkit.NewEngine).
93+// There is no separate Init step.
94+type Engine interface {
95+ // Factory returns the WebViewFactory for creating new WebView instances.
96+ Factory() WebViewFactory
97+
98+ // Pool returns the WebViewPool for acquiring pre-warmed WebView instances.
99+ Pool() WebViewPool
100+
101+ // ContentInjector returns the ContentInjector for injecting scripts and styles.
102+ ContentInjector() ContentInjector
103+
104+ // SettingsApplier returns the SettingsApplier for applying browser settings.
105+ SettingsApplier() SettingsApplier
106+
107+ // FilterApplier returns the FilterApplier for applying content filters.
108+ // Returns nil if content filtering is not supported by this engine.
109+ FilterApplier() FilterApplier
110+
111+ // FaviconDatabase returns the FaviconDatabase for async favicon lookups.
112+ FaviconDatabase() FaviconDatabase
113+
114+ // InternalSchemePath returns the URI scheme used for internal app resources.
115+ InternalSchemePath() string
116+
117+ // Close releases all resources held by the engine.
118+ Close() error
119+
120+ // RegisterHandlers registers all WebUI message bridge handlers.
121+ RegisterHandlers(ctx context.Context, deps HandlerDependencies) error
122+
123+ // RegisterAccentHandlers registers accent/dead-key input handlers.
124+ RegisterAccentHandlers(ctx context.Context, handler AccentKeyHandler) error
125+
126+ // ConfigureDownloads sets up download handling for this engine.
127+ ConfigureDownloads(ctx context.Context, downloadPath string, eventHandler DownloadEventHandler, preparer DownloadPreparer) error
128+
129+ // OnToolkitReady is called after the UI toolkit has initialized.
130+ OnToolkitReady(ctx context.Context) error
131+
132+ // UpdateAppearance updates default background color for new WebViews.
133+ UpdateAppearance(ctx context.Context, r, g, b, alpha float64) error
134+
135+ // UpdateSettings applies runtime config changes to engine internals.
136+ UpdateSettings(ctx context.Context, update EngineSettingsUpdate) error
137+
138+ // SetHandlerContext sets the base context for message handler dispatch.
139+ SetHandlerContext(ctx context.Context)
140+}
141+
142+// EngineSettingsUpdate carries a runtime config change to the engine.
143+//
144+// The Raw field holds the implementation-specific config object; each engine
145+// implementation type-asserts it to its concrete config type (e.g. the WebKit
146+// engine asserts to *infrastructure/config.Config).
147+//
148+// This is the only intentional use of `any` in the Engine port. A fully typed
149+// approach would require either moving the entire config schema into the domain
150+// layer (a large refactor) or defining a SettingsProvider interface — which
151+// would itself reduce to `any` because callers would embed engine-specific data
152+// behind an empty interface. Until the config schema is promoted to domain,
153+// the type assertion in the engine implementation is the least-invasive option.
154+type EngineSettingsUpdate struct {
155+ // Raw holds the implementation-specific config.
156+ // WebKit engine expects *infrastructure/config.Config.
157+ Raw any //nolint:iface // intentional: see type comment above
158+}
159+
160+// HandlerDependencies holds use cases needed by WebUI message handlers.
161+type HandlerDependencies struct {
162+ HistoryUC HomepageHistory
163+ FavoritesUC HomepageFavorites
164+ Clipboard Clipboard
165+ AutoCopyConfig AutoCopyConfig
166+ OnClipboardCopied func(textLen int)
167+}
168+
169+// SchemeHandler defines the port interface for registering custom URI schemes.
170+// Implementations intercept navigation to registered schemes and serve content
171+// from application code.
172+type SchemeHandler interface {
173+ // RegisterScheme registers a handler for the given URI scheme.
174+ // The handler receives the full URI and returns the response body,
175+ // MIME type, and any error.
176+ RegisterScheme(scheme string, handler func(uri string) ([]byte, string, error))
177+}
178+
179+// MessageRouter defines the port interface for bidirectional JS-to-Go messaging.
180+// It allows JavaScript running in a WebView to invoke named Go handlers,
181+// and allows Go code to post messages back to a specific WebView.
182+type MessageRouter interface {
183+ // RegisterHandler registers a named message handler callable from JavaScript.
184+ // The handler receives a JSON-encoded message string and returns a
185+ // JSON-encoded response string.
186+ RegisterHandler(name string, handler func(message string) (string, error))
187+
188+ // PostMessage sends a message to the JavaScript context of the given WebView.
189+ PostMessage(webviewID WebViewID, message string) error
190+}
191+
192+// SettingsApplier defines the port interface for applying browser settings to WebViews.
193+// Implementations apply engine-specific settings (security, features, etc.) uniformly
194+// across a set of WebView instances.
195+type SettingsApplier interface {
196+ // ApplyToAll applies settings to all provided WebView instances.
197+ ApplyToAll(ctx context.Context, webviews []WebView)
198+}
199+
200+// FilterApplier defines the port interface for applying content filters to WebViews.
201+// Implementations configure content blocking rules on a set of WebView instances.
202+// This interface is optional; Engine.FilterApplier() returns nil if not supported.
203+type FilterApplier interface {
204+ // ApplyToAll applies content filters to all provided WebView instances.
205+ ApplyToAll(ctx context.Context, webviews []WebView)
206+}
207+
208+// FaviconDatabase defines the port interface for async favicon lookups.
209+// Implementations retrieve favicons from an engine-managed database and
210+// deliver them via callback on the main thread.
211+type FaviconDatabase interface {
212+ // GetFaviconAsync retrieves the favicon for the given page URL asynchronously.
213+ // The callback is invoked with the favicon Texture when available.
214+ GetFaviconAsync(pageURL string, callback func(Texture))
215+}
216+
217+// ScriptRefresher defines the port interface for refreshing injected scripts
218+// across multiple WebView instances (e.g., after a settings change).
219+type ScriptRefresher interface {
220+ // RefreshAll re-injects scripts into all provided WebView instances.
221+ RefreshAll(ctx context.Context, webviews []WebView)
222+}
223+
224+// FilterManagerProvider is an optional capability interface for engines that
225+// support content filter management (e.g. ad blocking). Callers should type-assert
226+// the Engine to FilterManagerProvider rather than a concrete engine type.
227+type FilterManagerProvider interface {
228+ InternalFilterManager() FilterManager
229+}
230+
231+// NativeWidgetProvider is an optional capability for WebViews that can provide
232+// a native widget pointer for embedding into the host toolkit's layout system.
233+// For GTK-based engines this returns a *gtk.Widget pointer; other engines
234+// provide their equivalent embedding handle.
235+type NativeWidgetProvider interface {
236+ NativeWidget() uintptr
237+}
R internal/application/port/webkit_context_test.go → internal/application/port/engine_context_test.go +12 -12 renamed, 82% similar
@@ -81,27 +81,27 @@ func TestMemoryPressureConfig_IsConfigured(t *testing.T) {
8181 }
8282 }
8383
84-func TestWebKitContextOptions_IsWebProcessMemoryConfigured(t *testing.T) {
84+func TestEngineOptions_IsWebProcessMemoryConfigured(t *testing.T) {
8585 tests := []struct {
8686 name string
87- opts port.WebKitContextOptions
87+ opts port.EngineOptions
8888 expected bool
8989 }{
9090 {
9191 name: "nil WebProcessMemory",
92- opts: port.WebKitContextOptions{},
92+ opts: port.EngineOptions{},
9393 expected: false,
9494 },
9595 {
9696 name: "empty WebProcessMemory",
97- opts: port.WebKitContextOptions{
97+ opts: port.EngineOptions{
9898 WebProcessMemory: &port.MemoryPressureConfig{},
9999 },
100100 expected: false,
101101 },
102102 {
103103 name: "configured WebProcessMemory",
104- opts: port.WebKitContextOptions{
104+ opts: port.EngineOptions{
105105 WebProcessMemory: &port.MemoryPressureConfig{
106106 MemoryLimitMB: 1024,
107107 },
@@ -112,33 +112,33 @@ func TestWebKitContextOptions_IsWebProcessMemoryConfigured(t *testing.T) {
112112
113113 for _, tt := range tests {
114114 t.Run(tt.name, func(t *testing.T) {
115- result := tt.opts.IsWebProcessMemoryConfigured()
115+ result := tt.opts.WebProcessMemory.IsConfigured()
116116 assert.Equal(t, tt.expected, result)
117117 })
118118 }
119119 }
120120
121-func TestWebKitContextOptions_IsNetworkProcessMemoryConfigured(t *testing.T) {
121+func TestEngineOptions_IsNetworkProcessMemoryConfigured(t *testing.T) {
122122 tests := []struct {
123123 name string
124- opts port.WebKitContextOptions
124+ opts port.EngineOptions
125125 expected bool
126126 }{
127127 {
128128 name: "nil NetworkProcessMemory",
129- opts: port.WebKitContextOptions{},
129+ opts: port.EngineOptions{},
130130 expected: false,
131131 },
132132 {
133133 name: "empty NetworkProcessMemory",
134- opts: port.WebKitContextOptions{
134+ opts: port.EngineOptions{
135135 NetworkProcessMemory: &port.MemoryPressureConfig{},
136136 },
137137 expected: false,
138138 },
139139 {
140140 name: "configured NetworkProcessMemory",
141- opts: port.WebKitContextOptions{
141+ opts: port.EngineOptions{
142142 NetworkProcessMemory: &port.MemoryPressureConfig{
143143 PollIntervalSec: 20.0,
144144 },
@@ -149,7 +149,7 @@ func TestWebKitContextOptions_IsNetworkProcessMemoryConfigured(t *testing.T) {
149149
150150 for _, tt := range tests {
151151 t.Run(tt.name, func(t *testing.T) {
152- result := tt.opts.IsNetworkProcessMemoryConfigured()
152+ result := tt.opts.NetworkProcessMemory.IsConfigured()
153153 assert.Equal(t, tt.expected, result)
154154 })
155155 }
A internal/application/port/favicon_service.go +19 -0
@@ -0,0 +1,19 @@
1+package port
2+
3+import "context"
4+
5+// FaviconService provides favicon retrieval and caching.
6+type FaviconService interface {
7+ GetCached(domain string) ([]byte, bool)
8+ Get(ctx context.Context, domain string) ([]byte, error)
9+ DiskPathPNG(domain string) string
10+ HasPNGOnDisk(domain string) bool
11+ HasPNGSizedOnDisk(domain string, size int) bool
12+ EnsureSizedPNG(ctx context.Context, domain string, size int) error
13+ EnsureCacheDir() error
14+ // EnsureDiskCache ensures a favicon is written to the on-disk cache for the given
15+ // domain. It is intentionally fire-and-forget: any errors are logged internally by
16+ // the implementation and are not returned to the caller.
17+ EnsureDiskCache(ctx context.Context, domain string)
18+ Close()
19+}
A internal/application/port/filter_manager.go +33 -0
@@ -0,0 +1,33 @@
1+package port
2+
3+import "context"
4+
5+// FilterState represents the current state of the content filter system.
6+type FilterState string
7+
8+const (
9+ // FilterStateUninitialized means the filter manager has not been initialized yet.
10+ FilterStateUninitialized FilterState = "uninitialized"
11+ // FilterStateLoading means filters are being downloaded or compiled.
12+ FilterStateLoading FilterState = "loading"
13+ // FilterStateActive means filters are loaded and active.
14+ FilterStateActive FilterState = "active"
15+ // FilterStateDisabled means filtering is disabled by configuration.
16+ FilterStateDisabled FilterState = "disabled"
17+ // FilterStateError means an error occurred during filter loading.
18+ FilterStateError FilterState = "error"
19+)
20+
21+// FilterStatus represents the current status of the content filter system.
22+// Used for UI toast notifications and status reporting.
23+type FilterStatus struct {
24+ State FilterState
25+ Message string
26+ Version string
27+}
28+
29+// FilterManager manages content filters.
30+type FilterManager interface {
31+ SetStatusCallback(fn func(FilterStatus))
32+ LoadAsync(ctx context.Context)
33+}
A internal/application/port/handler_deps.go +32 -0
@@ -0,0 +1,32 @@
1+package port
2+
3+import "context"
4+
5+// AccentKeyHandler receives accent/dead-key events from the WebView JS bridge.
6+type AccentKeyHandler interface {
7+ OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool
8+ OnKeyReleased(ctx context.Context, char rune)
9+}
10+
11+// DownloadPreparer resolves download destination paths with deduplication.
12+type DownloadPreparer interface {
13+ Execute(ctx context.Context, input DownloadPrepareInput) *DownloadPrepareOutput
14+}
15+
16+// DownloadPrepareInput matches usecase.PrepareDownloadInput.
17+type DownloadPrepareInput struct {
18+ SuggestedFilename string
19+ Response DownloadResponse
20+ DownloadDir string
21+}
22+
23+// DownloadPrepareOutput matches usecase.PrepareDownloadOutput.
24+type DownloadPrepareOutput struct {
25+ Filename string
26+ DestinationPath string
27+}
28+
29+// AutoCopyConfig provides the clipboard auto-copy setting.
30+type AutoCopyConfig interface {
31+ IsAutoCopyEnabled() bool
32+}
A internal/application/port/homepage.go +50 -0
@@ -0,0 +1,50 @@
1+package port
2+
3+import (
4+ "context"
5+ "time"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+)
9+
10+// HistorySearchInput holds search parameters (moved from usecase.SearchInput).
11+type HistorySearchInput struct {
12+ Query string
13+ Limit int
14+}
15+
16+// HistorySearchOutput holds search results (moved from usecase.SearchOutput).
17+type HistorySearchOutput struct {
18+ Matches []entity.HistoryMatch
19+}
20+
21+// HomepageHistory provides history operations needed by the WebUI homepage handlers.
22+type HomepageHistory interface {
23+ GetRecent(ctx context.Context, limit, offset int) ([]*entity.HistoryEntry, error)
24+ Search(ctx context.Context, input HistorySearchInput) (*HistorySearchOutput, error)
25+ Delete(ctx context.Context, id int64) error
26+ ClearAll(ctx context.Context) error
27+ ClearOlderThan(ctx context.Context, before time.Time) error
28+ GetAnalytics(ctx context.Context) (*entity.HistoryAnalytics, error)
29+ GetDomainStats(ctx context.Context, limit int) ([]*entity.DomainStat, error)
30+ DeleteByDomain(ctx context.Context, domain string) error
31+}
32+
33+// HomepageFavorites provides favorites/folders/tags operations
34+// needed by the WebUI homepage handlers.
35+type HomepageFavorites interface {
36+ GetAll(ctx context.Context) ([]*entity.Favorite, error)
37+ SetShortcut(ctx context.Context, id entity.FavoriteID, key *int) error
38+ GetByShortcut(ctx context.Context, key int) (*entity.Favorite, error)
39+ Move(ctx context.Context, id entity.FavoriteID, folderID *entity.FolderID) error
40+ GetAllFolders(ctx context.Context) ([]*entity.Folder, error)
41+ CreateFolder(ctx context.Context, name string, parentID *entity.FolderID) (*entity.Folder, error)
42+ DeleteFolder(ctx context.Context, id entity.FolderID) error
43+ UpdateFolder(ctx context.Context, id entity.FolderID, name, icon string) error
44+ GetAllTags(ctx context.Context) ([]*entity.Tag, error)
45+ AddTag(ctx context.Context, name, color string) (*entity.Tag, error)
46+ DeleteTag(ctx context.Context, id entity.TagID) error
47+ UpdateTag(ctx context.Context, id entity.TagID, name, color string) error
48+ TagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error
49+ UntagFavorite(ctx context.Context, favID entity.FavoriteID, tagID entity.TagID) error
50+}
M internal/application/port/keybindings.go +20 -0
@@ -64,3 +64,23 @@ type KeybindingsSaver interface {
6464 ResetKeybinding(ctx context.Context, req ResetKeybindingRequest) error
6565 ResetAllKeybindings(ctx context.Context) error
6666 }
67+
68+// KeybindingsGetter retrieves all keybindings (narrow interface for the handler).
69+type KeybindingsGetter interface {
70+ Execute(ctx context.Context) (KeybindingsConfig, error)
71+}
72+
73+// KeybindingSetter updates a single keybinding (narrow interface for the handler).
74+type KeybindingSetter interface {
75+ Execute(ctx context.Context, req SetKeybindingRequest) (SetKeybindingResponse, error)
76+}
77+
78+// KeybindingResetter resets a single keybinding to default (narrow interface for the handler).
79+type KeybindingResetter interface {
80+ Execute(ctx context.Context, req ResetKeybindingRequest) error
81+}
82+
83+// AllKeybindingsResetter resets all keybindings to defaults (narrow interface for the handler).
84+type AllKeybindingsResetter interface {
85+ Execute(ctx context.Context) error
86+}
M internal/application/port/mocks/mocks.go +10933 -1760 large diff (12693 lines changed): collapsed
M internal/application/port/permission_repository.go +2 -1
@@ -7,7 +7,8 @@ import (
77 )
88
99 // PermissionRepository defines operations for permission persistence.
10-// Only microphone and camera permissions are persisted per W3C spec.
10+// Persistable types include microphone, camera, website_data_access,
11+// and others where CanPersist() returns true.
1112 type PermissionRepository interface {
1213 // Get retrieves the permission record for a specific origin and permission type.
1314 // Returns nil if no record exists (treat as "prompt" state).
M internal/application/port/rendering_env.go +4 -41
@@ -17,55 +17,18 @@ const (
1717 GPUVendorUnknown GPUVendor = "unknown"
1818 )
1919
20-// RenderingEnvSettings contains all rendering-related environment settings.
21-// This is a port-local type to avoid import cycles with config package.
22-type RenderingEnvSettings struct {
23- // --- GStreamer settings (from MediaConfig) ---
24- // ForceVSync enables vertical sync for video playback.
25- ForceVSync bool
26- // GLRenderingMode controls OpenGL API selection: "auto", "gles2", "gl3", "none".
27- GLRenderingMode string
28- // GStreamerDebugLevel sets GStreamer debug verbosity (0-5).
29- GStreamerDebugLevel int
30-
31- // --- WebKit compositor settings (from RenderingConfig) ---
32- DisableDMABufRenderer bool
33- ForceCompositingMode bool
34- DisableCompositingMode bool
35-
36- // --- GTK/GSK settings (from RenderingConfig) ---
37- GSKRenderer string
38- DisableMipmaps bool
39- PreferGL bool
40-
41- // --- Debug settings ---
42- ShowFPS bool
43- SampleMemory bool
44- DebugFrames bool
45-
46- // --- Skia rendering thread settings (from PerformanceConfig) ---
47- // SkiaCPUPaintingThreads sets WEBKIT_SKIA_CPU_PAINTING_THREADS.
48- // 0 means unset (use WebKit default).
49- SkiaCPUPaintingThreads int
50- // SkiaGPUPaintingThreads sets WEBKIT_SKIA_GPU_PAINTING_THREADS.
51- // -1 means unset; 0 disables GPU tile painting.
52- SkiaGPUPaintingThreads int
53- // SkiaEnableCPURendering forces CPU rendering via WEBKIT_SKIA_ENABLE_CPU_RENDERING=1.
54- SkiaEnableCPURendering bool
55-}
56-
5720 // RenderingEnvManager configures rendering environment variables
5821 // for GStreamer, WebKit, and GTK/GSK.
5922 // Environment variables must be set BEFORE GTK/WebKit initialization.
23+//
24+// Note: ApplyEnvironment was removed from this interface because rendering
25+// settings are engine-specific and are handled by concrete implementations
26+// (e.g. WebKit/GTK/GStreamer) to avoid port bloat.
6027 type RenderingEnvManager interface {
6128 // DetectGPUVendor identifies the primary GPU vendor from system info.
6229 // Uses /sys/class/drm/card*/device/vendor as primary detection method.
6330 DetectGPUVendor(ctx context.Context) GPUVendor
6431
65- // ApplyEnvironment sets all rendering environment variables.
66- // Must be called before any GTK/GStreamer initialization.
67- ApplyEnvironment(ctx context.Context, settings RenderingEnvSettings) error
68-
6932 // GetAppliedVars returns a map of environment variables that were set.
7033 // Useful for logging and debugging.
7134 GetAppliedVars() map[string]string
M internal/application/port/session_state.go +20 -1
@@ -1,6 +1,10 @@
11 package port
22
3-import "github.com/bnema/dumber/internal/domain/entity"
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+)
48
59 // TabListProvider provides access to the current tab list state.
610 // Implemented by the UI layer to allow the snapshot service to read state.
@@ -16,3 +20,18 @@ type SessionSpawner interface {
1620 // SpawnWithSession starts a new dumber instance to restore a session.
1721 SpawnWithSession(sessionID entity.SessionID) error
1822 }
23+
24+// SnapshotService manages debounced session-state snapshots.
25+type SnapshotService interface {
26+ // Start begins the snapshot service with the given context.
27+ Start(ctx context.Context)
28+ // Stop stops the service and saves the final state.
29+ Stop(ctx context.Context) error
30+ // SetReady marks the service as ready to save snapshots.
31+ // Call this after the session has been persisted to the database.
32+ SetReady()
33+ // MarkDirty signals that session state has changed and should be saved.
34+ MarkDirty()
35+ // SaveNow forces an immediate save (e.g. for shutdown).
36+ SaveNow(ctx context.Context) error
37+}
M internal/application/port/text_input.go +7 -0
@@ -18,6 +18,13 @@ type Focusable interface {
1818 Focus(ctx context.Context)
1919 }
2020
21+// EntryInputTarget is a marker interface implemented by text input targets
22+// that wrap a native entry widget. Used for keyboard routing decisions.
23+type EntryInputTarget interface {
24+ TextInputTarget
25+ IsGTKEntryTarget() bool
26+}
27+
2128 // FocusedInputProvider tracks which text input target currently has focus.
2229 // This allows the accent picker to insert text into the correct input field.
2330 type FocusedInputProvider interface {
D internal/application/port/webkit_context.go +0 -119
@@ -1,119 +0,0 @@
1-// Package port defines interfaces for external dependencies.
2-package port
3-
4-import "context"
5-
6-// WebKitCookiePolicy controls cookie acceptance behavior for NetworkSession.
7-type WebKitCookiePolicy string
8-
9-const (
10- WebKitCookiePolicyAlways WebKitCookiePolicy = "always"
11- WebKitCookiePolicyNoThirdParty WebKitCookiePolicy = "no_third_party"
12- WebKitCookiePolicyNever WebKitCookiePolicy = "never"
13-)
14-
15-// MemoryPressureConfig holds memory pressure settings for a WebKit process.
16-// Zero values mean "use WebKit defaults".
17-type MemoryPressureConfig struct {
18- // MemoryLimitMB sets the memory limit in megabytes.
19- // 0 means unset (uses WebKit default: system RAM capped at 3GB).
20- MemoryLimitMB int
21-
22- // PollIntervalSec sets the interval for memory usage checks.
23- // 0 means unset (uses WebKit default: 30 seconds).
24- PollIntervalSec float64
25-
26- // ConservativeThreshold sets threshold for conservative memory release.
27- // Must be in (0, 1). 0 means unset (uses WebKit default: 0.33).
28- ConservativeThreshold float64
29-
30- // StrictThreshold sets threshold for strict memory release.
31- // Must be in (0, 1). 0 means unset (uses WebKit default: 0.5).
32- StrictThreshold float64
33-}
34-
35-// IsConfigured returns true if any memory pressure setting is configured.
36-func (c *MemoryPressureConfig) IsConfigured() bool {
37- if c == nil {
38- return false
39- }
40- return c.MemoryLimitMB > 0 ||
41- c.PollIntervalSec > 0 ||
42- c.ConservativeThreshold > 0 ||
43- c.StrictThreshold > 0
44-}
45-
46-// WebKitContextOptions configures WebKitContext creation.
47-type WebKitContextOptions struct {
48- // DataDir is the directory for persistent data (cookies, storage, etc.).
49- DataDir string
50-
51- // CacheDir is the directory for cache data.
52- CacheDir string
53-
54- // CookiePolicy controls cookie acceptance behavior.
55- // Empty value means runtime defaults are used.
56- CookiePolicy WebKitCookiePolicy
57-
58- // ITPEnabled controls Intelligent Tracking Prevention for NetworkSession.
59- // False means ITP remains disabled unless enabled explicitly.
60- ITPEnabled bool
61-
62- // WebProcessMemory configures memory pressure for web processes.
63- // nil means use WebKit defaults.
64- WebProcessMemory *MemoryPressureConfig
65-
66- // NetworkProcessMemory configures memory pressure for the network process.
67- // nil means use WebKit defaults.
68- NetworkProcessMemory *MemoryPressureConfig
69-}
70-
71-// IsWebProcessMemoryConfigured returns true if web process memory settings are configured.
72-func (o *WebKitContextOptions) IsWebProcessMemoryConfigured() bool {
73- if o == nil {
74- return false
75- }
76- return o.WebProcessMemory.IsConfigured()
77-}
78-
79-// IsNetworkProcessMemoryConfigured returns true if network process memory settings are configured.
80-func (o *WebKitContextOptions) IsNetworkProcessMemoryConfigured() bool {
81- if o == nil {
82- return false
83- }
84- return o.NetworkProcessMemory.IsConfigured()
85-}
86-
87-// MemoryPressureApplier applies memory pressure settings to WebKit processes.
88-// This interface abstracts the WebKit-specific memory pressure configuration
89-// to allow testing without WebKit dependencies.
90-type MemoryPressureApplier interface {
91- // ApplyNetworkProcessSettings applies memory pressure settings to the network process.
92- // Must be called BEFORE creating any NetworkSession.
93- ApplyNetworkProcessSettings(ctx context.Context, cfg *MemoryPressureConfig) error
94-
95- // ApplyWebProcessSettings applies memory pressure settings to web processes.
96- // Returns an opaque settings object that should be passed to WebContext creation.
97- // Returns nil if no settings are configured.
98- ApplyWebProcessSettings(ctx context.Context, cfg *MemoryPressureConfig) (any, error)
99-}
100-
101-// WebKitContextProvider creates and manages WebKit contexts.
102-// This interface abstracts WebKit context creation for testability.
103-type WebKitContextProvider interface {
104- // Initialize sets up the WebKit context with the given options.
105- // Must be called before any other methods.
106- Initialize(ctx context.Context, opts WebKitContextOptions) error
107-
108- // DataDir returns the data directory path.
109- DataDir() string
110-
111- // CacheDir returns the cache directory path.
112- CacheDir() string
113-
114- // PrefetchDNS prefetches DNS for the given hostname.
115- PrefetchDNS(hostname string)
116-
117- // Close releases resources held by the context.
118- Close() error
119-}
M internal/application/port/webview.go +85 -5
@@ -70,11 +70,10 @@ type PopupRequest struct {
7070 ParentViewID WebViewID
7171 }
7272
73-// Texture represents a graphics texture (abstraction over gdk.Texture).
74-// This interface allows the port layer to work with textures without
75-// importing GTK/GDK packages directly.
73+// Texture represents a graphics texture returned by the engine.
74+// GoPointer returns a native toolkit pointer (e.g. *gdk.Texture in GTK engines).
75+// Engine implementations are responsible for the concrete type.
7676 type Texture interface {
77- // GoPointer returns the underlying C pointer for GTK interop.
7877 GoPointer() uintptr
7978 }
8079
@@ -109,6 +108,21 @@ type WebViewCallbacks struct {
109108 // Return true to indicate the request was handled. Call allow()/deny() to respond.
110109 // The permission types are passed as strings ("microphone", "camera", "display", etc.).
111110 OnPermissionRequest func(origin string, permTypes []string, allow, deny func()) bool
111+
112+ // OnLinkMiddleClick is called when a link is middle-clicked.
113+ // Return true if handled (blocks default navigation).
114+ OnLinkMiddleClick func(uri string) bool
115+
116+ // OnEnterFullscreen is called when the WebView requests fullscreen mode.
117+ // Return true to prevent fullscreen.
118+ OnEnterFullscreen func() bool
119+
120+ // OnLeaveFullscreen is called when the WebView exits fullscreen mode.
121+ // Return true to prevent leaving fullscreen.
122+ OnLeaveFullscreen func() bool
123+
124+ // OnAudioStateChanged is called when audio playback starts or stops.
125+ OnAudioStateChanged func(playing bool)
112126 }
113127
114128 // FindOptions configures search behavior.
@@ -210,6 +224,26 @@ type WebView interface {
210224 // Pass nil to clear all callbacks.
211225 SetCallbacks(callbacks *WebViewCallbacks)
212226
227+ // --- Appearance ---
228+
229+ // RunJavaScript executes a script in the main world. Fire-and-forget.
230+ RunJavaScript(ctx context.Context, script string)
231+
232+ // SetBackgroundColor sets the background color shown before content paints (0.0-1.0 RGBA).
233+ SetBackgroundColor(r, g, b, a float64)
234+
235+ // ResetBackgroundToDefault resets the background to white (browser default).
236+ ResetBackgroundToDefault()
237+
238+ // --- Favicon ---
239+
240+ // Favicon returns the current page favicon, or nil if unavailable.
241+ Favicon() Texture
242+
243+ // Generation returns a monotonic counter incremented on pool reuse.
244+ // Used to detect stale callbacks after a WebView is recycled.
245+ Generation() uint64
246+
213247 // --- Media/Fullscreen State ---
214248
215249 // IsFullscreen returns true if the WebView is currently in fullscreen mode.
@@ -240,10 +274,15 @@ type WebViewPool interface {
240274 // If the pool is full, the WebView will be destroyed.
241275 Release(wv WebView)
242276
243- // Prewarm creates WebViews in the background to populate the pool.
277+ // Prewarm creates WebViews synchronously to populate the pool.
244278 // Pass count <= 0 to use the default prewarm count.
245279 Prewarm(count int)
246280
281+ // PrewarmAsync schedules WebView creation on the toolkit idle loop,
282+ // avoiding blocking startup while still warming up WebViews.
283+ // Pass count <= 0 to use the default prewarm count.
284+ PrewarmAsync(ctx context.Context, count int)
285+
247286 // Size returns the current number of available WebViews in the pool.
248287 Size() int
249288
@@ -262,3 +301,44 @@ type WebViewFactory interface {
262301 // Popup WebViews bypass the pool since they must be related to a specific parent.
263302 CreateRelated(ctx context.Context, parentID WebViewID) (WebView, error)
264303 }
304+
305+// DevToolsOpener is an optional capability for WebViews that support developer tools.
306+type DevToolsOpener interface {
307+ OpenDevTools()
308+}
309+
310+// Printer is an optional capability for WebViews that support printing.
311+type Printer interface {
312+ PrintPage()
313+}
314+
315+// PopupCapable is implemented by WebViews that support popup lifecycle callbacks.
316+// SetOnClose composes the provided function with any existing close handler so
317+// that multiple callers can each register a close callback without overwriting
318+// one another.
319+type PopupCapable interface {
320+ SetOnReadyToShow(fn func())
321+ SetOnClose(fn func())
322+ Show()
323+}
324+
325+// OAuthCallbackCapable is implemented by WebViews that support OAuth auto-close callbacks.
326+// It is an optional capability; callers should type-assert port.WebView to this interface
327+// before use and degrade gracefully when it is absent.
328+type OAuthCallbackCapable interface {
329+ // AddCloseCallback registers fn to be called when the WebView is programmatically closed.
330+ // Multiple callbacks may be registered; each will be called in registration order.
331+ AddCloseCallback(fn func())
332+ // AddNavigationCallback registers fn to be called whenever the URI changes or a
333+ // committed load is detected. The uri parameter contains the new URL.
334+ // Multiple callbacks may be registered; each will be called in registration order.
335+ AddNavigationCallback(fn func(uri string))
336+ // Close triggers a programmatic close of the WebView (equivalent to window.close()).
337+ Close()
338+}
339+
340+// TextInputTargetProvider is an optional capability for WebViews that support
341+// text input method integration.
342+type TextInputTargetProvider interface {
343+ TextInputTarget() TextInputTarget
344+}
M internal/application/usecase/handle_permission_test.go +91 -21
@@ -9,7 +9,6 @@ import (
99 portmocks "github.com/bnema/dumber/internal/application/port/mocks"
1010 "github.com/bnema/dumber/internal/application/usecase"
1111 "github.com/bnema/dumber/internal/domain/entity"
12- repomocks "github.com/bnema/dumber/internal/domain/repository/mocks"
1312 "github.com/rs/zerolog"
1413 "github.com/stretchr/testify/assert"
1514 "github.com/stretchr/testify/mock"
@@ -22,7 +21,7 @@ func permissionLoggerFromContext(ctx context.Context) *zerolog.Logger {
2221
2322 func TestHandlePermissionUseCase_AutoAllowDisplayCapture(t *testing.T) {
2423 ctx := testContext()
25- permRepo := repomocks.NewMockPermissionRepository(t)
24+ permRepo := portmocks.NewMockPermissionRepository(t)
2625 dialog := portmocks.NewMockPermissionDialogPresenter(t)
2726
2827 uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
@@ -49,7 +48,7 @@ func TestHandlePermissionUseCase_AutoAllowDisplayCapture(t *testing.T) {
4948
5049 func TestHandlePermissionUseCase_AutoAllowDeviceInfo(t *testing.T) {
5150 ctx := testContext()
52- permRepo := repomocks.NewMockPermissionRepository(t)
51+ permRepo := portmocks.NewMockPermissionRepository(t)
5352 dialog := portmocks.NewMockPermissionDialogPresenter(t)
5453
5554 uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
@@ -73,7 +72,7 @@ func TestHandlePermissionUseCase_AutoAllowDeviceInfo(t *testing.T) {
7372
7473 func TestHandlePermissionUseCase_StoredPermissionGranted(t *testing.T) {
7574 ctx := testContext()
76- permRepo := repomocks.NewMockPermissionRepository(t)
75+ permRepo := portmocks.NewMockPermissionRepository(t)
7776 dialog := portmocks.NewMockPermissionDialogPresenter(t)
7877
7978 uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
@@ -103,7 +102,7 @@ func TestHandlePermissionUseCase_StoredPermissionGranted(t *testing.T) {
103102
104103 func TestHandlePermissionUseCase_StoredPermissionDenied(t *testing.T) {
105104 ctx := testContext()
106- permRepo := repomocks.NewMockPermissionRepository(t)
105+ permRepo := portmocks.NewMockPermissionRepository(t)
107106 dialog := portmocks.NewMockPermissionDialogPresenter(t)
108107
109108 uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
@@ -133,7 +132,7 @@ func TestHandlePermissionUseCase_StoredPermissionDenied(t *testing.T) {
133132
134133 func TestHandlePermissionUseCase_ShowDialogForPrompt(t *testing.T) {
135134 ctx := testContext()
136- permRepo := repomocks.NewMockPermissionRepository(t)
135+ permRepo := portmocks.NewMockPermissionRepository(t)
137136 dialog := portmocks.NewMockPermissionDialogPresenter(t)
138137
139138 uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
@@ -166,7 +165,7 @@ func TestHandlePermissionUseCase_ShowDialogForPrompt(t *testing.T) {
166165
167166 func TestHandlePermissionUseCase_PersistAllowed(t *testing.T) {
168167 ctx := testContext()
169- permRepo := repomocks.NewMockPermissionRepository(t)
168+ permRepo := portmocks.NewMockPermissionRepository(t)
170169 dialog := portmocks.NewMockPermissionDialogPresenter(t)
171170
172171 uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
@@ -205,7 +204,7 @@ func TestHandlePermissionUseCase_PersistAllowed(t *testing.T) {
205204
206205 func TestHandlePermissionUseCase_PersistDenied(t *testing.T) {
207206 ctx := testContext()
208- permRepo := repomocks.NewMockPermissionRepository(t)
207+ permRepo := portmocks.NewMockPermissionRepository(t)
209208 dialog := portmocks.NewMockPermissionDialogPresenter(t)
210209
211210 uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
@@ -243,7 +242,7 @@ func TestHandlePermissionUseCase_PersistDenied(t *testing.T) {
243242
244243 func TestHandlePermissionUseCase_EmptyOrigin(t *testing.T) {
245244 ctx := testContext()
246- permRepo := repomocks.NewMockPermissionRepository(t)
245+ permRepo := portmocks.NewMockPermissionRepository(t)
247246 dialog := portmocks.NewMockPermissionDialogPresenter(t)
248247
249248 uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
@@ -264,7 +263,7 @@ func TestHandlePermissionUseCase_EmptyOrigin(t *testing.T) {
264263
265264 func TestHandlePermissionUseCase_CombinedPermissions(t *testing.T) {
266265 ctx := testContext()
267- permRepo := repomocks.NewMockPermissionRepository(t)
266+ permRepo := portmocks.NewMockPermissionRepository(t)
268267 dialog := portmocks.NewMockPermissionDialogPresenter(t)
269268
270269 uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
@@ -300,7 +299,7 @@ func TestHandlePermissionUseCase_CombinedPermissions(t *testing.T) {
300299
301300 func TestHandlePermissionUseCase_QueryPermissionState_AutoAllow(t *testing.T) {
302301 ctx := testContext()
303- permRepo := repomocks.NewMockPermissionRepository(t)
302+ permRepo := portmocks.NewMockPermissionRepository(t)
304303 dialog := portmocks.NewMockPermissionDialogPresenter(t)
305304
306305 uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
@@ -314,7 +313,7 @@ func TestHandlePermissionUseCase_QueryPermissionState_AutoAllow(t *testing.T) {
314313
315314 func TestHandlePermissionUseCase_QueryPermissionState_AutoAllowManualOverride(t *testing.T) {
316315 ctx := testContext()
317- permRepo := repomocks.NewMockPermissionRepository(t)
316+ permRepo := portmocks.NewMockPermissionRepository(t)
318317 dialog := portmocks.NewMockPermissionDialogPresenter(t)
319318
320319 uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
@@ -333,7 +332,7 @@ func TestHandlePermissionUseCase_QueryPermissionState_AutoAllowManualOverride(t
333332
334333 func TestHandlePermissionUseCase_QueryPermissionState_Stored(t *testing.T) {
335334 ctx := testContext()
336- permRepo := repomocks.NewMockPermissionRepository(t)
335+ permRepo := portmocks.NewMockPermissionRepository(t)
337336 dialog := portmocks.NewMockPermissionDialogPresenter(t)
338337
339338 uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
@@ -352,7 +351,7 @@ func TestHandlePermissionUseCase_QueryPermissionState_Stored(t *testing.T) {
352351
353352 func TestHandlePermissionUseCase_QueryPermissionState_NoRecord(t *testing.T) {
354353 ctx := testContext()
355- permRepo := repomocks.NewMockPermissionRepository(t)
354+ permRepo := portmocks.NewMockPermissionRepository(t)
356355 dialog := portmocks.NewMockPermissionDialogPresenter(t)
357356
358357 uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
@@ -367,7 +366,7 @@ func TestHandlePermissionUseCase_QueryPermissionState_NoRecord(t *testing.T) {
367366
368367 func TestHandlePermissionUseCase_QueryPermissionState_EmptyOriginReturnsPrompt(t *testing.T) {
369368 ctx := testContext()
370- permRepo := repomocks.NewMockPermissionRepository(t)
369+ permRepo := portmocks.NewMockPermissionRepository(t)
371370 dialog := portmocks.NewMockPermissionDialogPresenter(t)
372371
373372 uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
@@ -380,7 +379,7 @@ func TestHandlePermissionUseCase_QueryPermissionState_EmptyOriginReturnsPrompt(t
380379
381380 func TestHandlePermissionUseCase_NonPersistableNotSaved(t *testing.T) {
382381 ctx := testContext()
383- permRepo := repomocks.NewMockPermissionRepository(t)
382+ permRepo := portmocks.NewMockPermissionRepository(t)
384383 dialog := portmocks.NewMockPermissionDialogPresenter(t)
385384
386385 uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
@@ -407,7 +406,7 @@ func TestHandlePermissionUseCase_NonPersistableNotSaved(t *testing.T) {
407406
408407 func TestHandlePermissionUseCase_NoDialogPresenter(t *testing.T) {
409408 ctx := testContext()
410- permRepo := repomocks.NewMockPermissionRepository(t)
409+ permRepo := portmocks.NewMockPermissionRepository(t)
411410
412411 // Create use case with nil dialog presenter
413412 uc := usecase.NewHandlePermissionUseCase(permRepo, nil, permissionLoggerFromContext)
@@ -431,7 +430,7 @@ func TestHandlePermissionUseCase_NoDialogPresenter(t *testing.T) {
431430
432431 func TestHandlePermissionUseCase_DisplayOverrideDenied(t *testing.T) {
433432 ctx := testContext()
434- permRepo := repomocks.NewMockPermissionRepository(t)
433+ permRepo := portmocks.NewMockPermissionRepository(t)
435434 dialog := portmocks.NewMockPermissionDialogPresenter(t)
436435
437436 uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
@@ -459,7 +458,7 @@ func TestHandlePermissionUseCase_DisplayOverrideDenied(t *testing.T) {
459458
460459 func TestHandlePermissionUseCase_GetSetResetManualDecision(t *testing.T) {
461460 ctx := testContext()
462- permRepo := repomocks.NewMockPermissionRepository(t)
461+ permRepo := portmocks.NewMockPermissionRepository(t)
463462 uc := usecase.NewHandlePermissionUseCase(permRepo, nil, permissionLoggerFromContext)
464463
465464 permRepo.EXPECT().Set(mock.Anything, mock.AnythingOfType("*entity.PermissionRecord")).
@@ -499,7 +498,7 @@ func TestHandlePermissionUseCase_GetSetResetManualDecision(t *testing.T) {
499498
500499 func TestHandlePermissionUseCase_SetManualPermissionDecision_NonPersistable(t *testing.T) {
501500 ctx := testContext()
502- permRepo := repomocks.NewMockPermissionRepository(t)
501+ permRepo := portmocks.NewMockPermissionRepository(t)
503502 uc := usecase.NewHandlePermissionUseCase(permRepo, nil, permissionLoggerFromContext)
504503
505504 err := uc.SetManualPermissionDecision(
@@ -517,7 +516,7 @@ func TestHandlePermissionUseCase_SetManualPermissionDecision_NonPersistable(t *t
517516
518517 func TestHandlePermissionUseCase_UsesInjectedLoggerFactory(t *testing.T) {
519518 ctx := testContext()
520- permRepo := repomocks.NewMockPermissionRepository(t)
519+ permRepo := portmocks.NewMockPermissionRepository(t)
521520 dialog := portmocks.NewMockPermissionDialogPresenter(t)
522521
523522 called := false
@@ -534,3 +533,74 @@ func TestHandlePermissionUseCase_UsesInjectedLoggerFactory(t *testing.T) {
534533
535534 assert.True(t, called)
536535 }
536+
537+func TestHandlePermissionUseCase_WebsiteDataAccess_ShowsDialogAndPersists(t *testing.T) {
538+ ctx := testContext()
539+ permRepo := portmocks.NewMockPermissionRepository(t)
540+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
541+
542+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
543+
544+ // No stored permission
545+ permRepo.EXPECT().Get(mock.Anything, "https://accounts.google.com", entity.PermissionTypeWebsiteDataAccess).
546+ Return(nil, nil)
547+
548+ allowed := false
549+ callback := usecase.PermissionCallback{
550+ Allow: func() { allowed = true },
551+ Deny: func() {},
552+ }
553+
554+ // Dialog shows and user clicks "Always Allow"
555+ dialog.EXPECT().ShowPermissionDialog(
556+ mock.Anything,
557+ "https://accounts.google.com",
558+ []entity.PermissionType{entity.PermissionTypeWebsiteDataAccess},
559+ mock.Anything,
560+ ).Run(func(_ context.Context, _ string, _ []entity.PermissionType, cb func(port.PermissionDialogResult)) {
561+ cb(port.PermissionDialogResult{Allowed: true, Persistent: true})
562+ })
563+
564+ // Should persist the granted permission
565+ permRepo.EXPECT().Set(mock.Anything, mock.AnythingOfType("*entity.PermissionRecord")).
566+ Run(func(_ context.Context, r *entity.PermissionRecord) {
567+ assert.Equal(t, "https://accounts.google.com", r.Origin)
568+ assert.Equal(t, entity.PermissionTypeWebsiteDataAccess, r.Type)
569+ assert.Equal(t, entity.PermissionGranted, r.Decision)
570+ }).Return(nil)
571+
572+ uc.HandlePermissionRequest(ctx, "https://accounts.google.com", []entity.PermissionType{
573+ entity.PermissionTypeWebsiteDataAccess,
574+ }, callback)
575+
576+ assert.True(t, allowed)
577+}
578+
579+func TestHandlePermissionUseCase_WebsiteDataAccess_UsesStoredGrant(t *testing.T) {
580+ ctx := testContext()
581+ permRepo := portmocks.NewMockPermissionRepository(t)
582+ dialog := portmocks.NewMockPermissionDialogPresenter(t)
583+
584+ uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext)
585+
586+ // Stored granted permission
587+ permRepo.EXPECT().Get(mock.Anything, "https://accounts.google.com", entity.PermissionTypeWebsiteDataAccess).
588+ Return(&entity.PermissionRecord{
589+ Origin: "https://accounts.google.com",
590+ Type: entity.PermissionTypeWebsiteDataAccess,
591+ Decision: entity.PermissionGranted,
592+ }, nil)
593+
594+ allowed := false
595+ callback := usecase.PermissionCallback{
596+ Allow: func() { allowed = true },
597+ Deny: func() {},
598+ }
599+
600+ uc.HandlePermissionRequest(ctx, "https://accounts.google.com", []entity.PermissionType{
601+ entity.PermissionTypeWebsiteDataAccess,
602+ }, callback)
603+
604+ assert.True(t, allowed, "should use stored granted permission without dialog")
605+ dialog.AssertNotCalled(t, "ShowPermissionDialog")
606+}
M internal/application/usecase/insert_accent.go +3 -0
@@ -11,6 +11,9 @@ import (
1111 "github.com/bnema/dumber/internal/logging"
1212 )
1313
14+// Compile-time check: InsertAccentUseCase must satisfy port.AccentKeyHandler.
15+var _ port.AccentKeyHandler = (*InsertAccentUseCase)(nil)
16+
1417 // LongPressDelay is the duration a key must be held to trigger the accent picker.
1518 const LongPressDelay = 400 * time.Millisecond
1619
M internal/application/usecase/keybindings.go +6 -0
@@ -7,6 +7,12 @@ import (
77 "github.com/bnema/dumber/internal/application/port"
88 )
99
10+// Compile-time checks: keybinding use cases must satisfy their port interfaces.
11+var _ port.KeybindingsGetter = (*GetKeybindingsUseCase)(nil)
12+var _ port.KeybindingSetter = (*SetKeybindingUseCase)(nil)
13+var _ port.KeybindingResetter = (*ResetKeybindingUseCase)(nil)
14+var _ port.AllKeybindingsResetter = (*ResetAllKeybindingsUseCase)(nil)
15+
1016 // validModes defines the valid keybinding modes.
1117 var validModes = map[string]bool{
1218 "global": true,
M internal/application/usecase/manage_favorites.go +4 -0
@@ -8,12 +8,16 @@ import (
88 "sync"
99 "time"
1010
11+ "github.com/bnema/dumber/internal/application/port"
1112 "github.com/bnema/dumber/internal/domain/entity"
1213 "github.com/bnema/dumber/internal/domain/repository"
1314 domainurl "github.com/bnema/dumber/internal/domain/url"
1415 "github.com/bnema/dumber/internal/logging"
1516 )
1617
18+// Compile-time check: ManageFavoritesUseCase must satisfy port.HomepageFavorites.
19+var _ port.HomepageFavorites = (*ManageFavoritesUseCase)(nil)
20+
1721 // ManageFavoritesUseCase handles favorite, folder, and tag operations.
1822 type ManageFavoritesUseCase struct {
1923 favoriteRepo repository.FavoriteRepository
M internal/application/usecase/prepare_download.go +7 -17
@@ -9,24 +9,14 @@ import (
99 "github.com/bnema/dumber/internal/logging"
1010 )
1111
12-// PrepareDownloadInput contains the inputs for preparing a download destination.
13-type PrepareDownloadInput struct {
14- // SuggestedFilename is the filename suggested by WebKit (from Content-Disposition or other sources).
15- SuggestedFilename string
16- // Response provides additional metadata (MIME type, URI) for filename resolution.
17- // May be nil if not available.
18- Response port.DownloadResponse
19- // DownloadDir is the directory where downloads should be saved.
20- DownloadDir string
21-}
12+// PrepareDownloadInput is an alias for port.DownloadPrepareInput.
13+type PrepareDownloadInput = port.DownloadPrepareInput
2214
23-// PrepareDownloadOutput contains the resolved download destination.
24-type PrepareDownloadOutput struct {
25- // Filename is the sanitized, safe filename to use.
26- Filename string
27- // DestinationPath is the full path where the download should be saved.
28- DestinationPath string
29-}
15+// PrepareDownloadOutput is an alias for port.DownloadPrepareOutput.
16+type PrepareDownloadOutput = port.DownloadPrepareOutput
17+
18+// Compile-time check: PrepareDownloadUseCase must satisfy port.DownloadPreparer.
19+var _ port.DownloadPreparer = (*PrepareDownloadUseCase)(nil)
3020
3121 // PrepareDownloadUseCase orchestrates filename resolution for downloads.
3222 // It combines multiple sources (suggested name, response headers, URI) to
M internal/application/usecase/search_history.go +8 -9
@@ -5,11 +5,15 @@ import (
55 "fmt"
66 "time"
77
8+ "github.com/bnema/dumber/internal/application/port"
89 "github.com/bnema/dumber/internal/domain/entity"
910 "github.com/bnema/dumber/internal/domain/repository"
1011 "github.com/bnema/dumber/internal/logging"
1112 )
1213
14+// Compile-time check: SearchHistoryUseCase must satisfy port.HomepageHistory.
15+var _ port.HomepageHistory = (*SearchHistoryUseCase)(nil)
16+
1317 // SearchHistoryUseCase handles history search and retrieval operations.
1418 type SearchHistoryUseCase struct {
1519 historyRepo repository.HistoryRepository
@@ -22,16 +26,11 @@ func NewSearchHistoryUseCase(historyRepo repository.HistoryRepository) *SearchHi
2226 }
2327 }
2428
25-// SearchInput contains search parameters.
26-type SearchInput struct {
27- Query string
28- Limit int
29-}
29+// SearchInput is an alias for port.HistorySearchInput.
30+type SearchInput = port.HistorySearchInput
3031
31-// SearchOutput contains search results.
32-type SearchOutput struct {
33- Matches []entity.HistoryMatch
34-}
32+// SearchOutput is an alias for port.HistorySearchOutput.
33+type SearchOutput = port.HistorySearchOutput
3534
3635 // Search performs a full-text search on history entries using SQLite FTS5.
3736 // Returns only entries that actually match the query terms.
M internal/bootstrap/crash_report_unix.go +2 -2
@@ -14,8 +14,8 @@ func collectCoreDumpDiagnostics() unexpectedCloseCoreDump {
1414 }
1515 var limit unix.Rlimit
1616 if err := unix.Getrlimit(unix.RLIMIT_CORE, &limit); err != nil {
17- d.RLimitCoreSoft = "unknown"
18- d.RLimitCoreHard = "unknown"
17+ d.RLimitCoreSoft = string(SessionExitUnknown)
18+ d.RLimitCoreHard = string(SessionExitUnknown)
1919 return d
2020 }
2121 d.RLimitCoreSoft = formatRlimitCore(limit.Cur)
A internal/bootstrap/engine.go +106 -0
@@ -0,0 +1,106 @@
1+package bootstrap
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/infrastructure/config"
10+ "github.com/bnema/dumber/internal/infrastructure/webkit"
11+ "github.com/bnema/dumber/internal/infrastructure/webkit/handlers"
12+ "github.com/bnema/dumber/internal/ui/theme"
13+ "github.com/rs/zerolog"
14+)
15+
16+// EngineInput holds the input for BuildEngine.
17+type EngineInput struct {
18+ Ctx context.Context
19+ Config *config.Config
20+ DataDir string
21+ CacheDir string
22+ ThemeManager *theme.Manager
23+ ColorResolver port.ColorSchemeResolver
24+ Logger zerolog.Logger
25+}
26+
27+// BuildEngine constructs a port.Engine for the engine type specified in cfg.Engine.Type.
28+// Currently only "webkit" is supported; "cef" and other types return an error.
29+func BuildEngine(input EngineInput) (port.Engine, error) {
30+ cfg := input.Config
31+ engineType := cfg.Engine.Type
32+ if engineType == "" {
33+ engineType = "webkit"
34+ }
35+ switch engineType {
36+ case "webkit":
37+ opts := port.EngineOptions{
38+ DataDir: input.DataDir,
39+ CacheDir: input.CacheDir,
40+ CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy),
41+ }
42+ wkCfg := webkit.EngineConfigFromConfig(cfg.Engine.WebKit)
43+
44+ // Pre-build keybindings handler for handler registration.
45+ keybindingsHandler, err := buildKeybindingsHandler()
46+ if err != nil {
47+ return nil, fmt.Errorf("failed to build keybindings handler: %w", err)
48+ }
49+
50+ // Pre-build config save function for handler registration.
51+ saveConfigFunc, err := buildSaveConfigFunc()
52+ if err != nil {
53+ return nil, fmt.Errorf("failed to build save config func: %w", err)
54+ }
55+
56+ return webkit.NewEngine(
57+ input.Ctx, cfg, opts, wkCfg,
58+ input.ThemeManager, input.ColorResolver, input.Logger,
59+ func(ctx context.Context, router *webkit.MessageRouter, deps port.HandlerDependencies) error {
60+ return handlers.RegisterAll(ctx, router, handlers.Config{
61+ HistoryUC: deps.HistoryUC,
62+ FavoritesUC: deps.FavoritesUC,
63+ Clipboard: deps.Clipboard,
64+ AutoCopyConfig: deps.AutoCopyConfig,
65+ SaveConfig: saveConfigFunc,
66+ KeybindingsHandler: keybindingsHandler,
67+ OnClipboardCopied: deps.OnClipboardCopied,
68+ })
69+ },
70+ handlers.RegisterAccentHandlers,
71+ )
72+ case "cef":
73+ return nil, fmt.Errorf("CEF engine not yet implemented")
74+ default:
75+ return nil, fmt.Errorf("unknown engine type: %q", engineType)
76+ }
77+}
78+
79+// buildKeybindingsHandler constructs the keybindings handler using the config manager.
80+func buildKeybindingsHandler() (*handlers.KeybindingsHandler, error) {
81+ mgr := config.GetManager()
82+ if mgr == nil {
83+ return nil, fmt.Errorf("config manager not initialized")
84+ }
85+
86+ gateway := config.NewKeybindingsGateway(mgr)
87+
88+ return handlers.NewKeybindingsHandler(
89+ usecase.NewGetKeybindingsUseCase(gateway),
90+ usecase.NewSetKeybindingUseCase(gateway, gateway),
91+ usecase.NewResetKeybindingUseCase(gateway),
92+ usecase.NewResetAllKeybindingsUseCase(gateway),
93+ ), nil
94+}
95+
96+// buildSaveConfigFunc constructs the config save function using the config manager.
97+// It returns an error immediately if the config manager is not initialized, consistent
98+// with buildKeybindingsHandler.
99+func buildSaveConfigFunc() (func(context.Context, port.WebUIConfig) error, error) {
100+ mgr := config.GetManager()
101+ if mgr == nil {
102+ return nil, fmt.Errorf("config manager not initialized")
103+ }
104+ uc := usecase.NewSaveWebUIConfigUseCase(config.NewWebUIConfigGateway(mgr))
105+ return uc.Execute, nil
106+}
A internal/bootstrap/engine_test.go +25 -0
@@ -0,0 +1,25 @@
1+package bootstrap
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestBuildEngine_UnknownType(t *testing.T) {
12+ cfg := config.DefaultConfig()
13+ cfg.Engine.Type = "unknown"
14+ _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background()})
15+ require.Error(t, err)
16+ require.Contains(t, err.Error(), "unknown engine type")
17+}
18+
19+func TestBuildEngine_CEF_NotImplemented(t *testing.T) {
20+ cfg := config.DefaultConfig()
21+ cfg.Engine.Type = "cef"
22+ _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background()})
23+ require.Error(t, err)
24+ require.Contains(t, err.Error(), "not yet implemented")
25+}
M internal/bootstrap/gui.go +28 -18
@@ -125,7 +125,10 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
125125 var themeManager *theme.Manager
126126 go func() {
127127 defer wg.Done()
128- themeManager = theme.NewManager(input.Ctx, input.Config, resolver)
128+ themeManager = theme.NewManager(
129+ input.Ctx, &input.Config.Appearance, input.Config.DefaultUIScale,
130+ &input.Config.Workspace.Styling, resolver,
131+ )
129132 }()
130133
131134 wg.Wait()
@@ -199,7 +202,7 @@ func CheckRuntimeRequirements(ctx context.Context, cfg *config.Config) error {
199202 probe := deps.NewPkgConfigProbe()
200203 checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe)
201204 runtimeOut, err := checkRuntimeUC.Execute(ctx, usecase.CheckRuntimeDependenciesInput{
202- Prefix: cfg.Runtime.Prefix,
205+ Prefix: cfg.Engine.WebKit.Prefix,
203206 })
204207 if err != nil {
205208 return fmt.Errorf("runtime check execution failed: %w", err)
@@ -263,7 +266,7 @@ func OpenDatabase(ctx context.Context) (*DatabaseResult, error) {
263266 // CreateLazyDatabase creates a lazy database provider that defers initialization.
264267 // The database is initialized on first access, allowing the UI to render faster.
265268 //
266-// Currently unused: the application uses OpenDatabase with RunParallelDBWebKit for
269+// Currently unused: the application uses OpenDatabase with RunParallelDBEngine for
267270 // eager initialization. This function is kept for potential future use when lazy
268271 // initialization past first paint becomes beneficial.
269272 func CreateLazyDatabase() (*sqlite.LazyDB, error) {
@@ -274,19 +277,19 @@ func CreateLazyDatabase() (*sqlite.LazyDB, error) {
274277 return sqlite.NewLazyDB(dbPath), nil
275278 }
276279
277-// ParallelDBWebKitResult holds results from parallel DB and WebKit initialization.
278-type ParallelDBWebKitResult struct {
280+// ParallelDBEngineResult holds results from parallel DB and engine initialization.
281+type ParallelDBEngineResult struct {
279282 DB *sql.DB
280283 DBCleanup func()
281- Stack WebKitStack
284+ Engine port.Engine
282285 }
283286
284-// ParallelDBWebKitInput holds inputs for parallel DB and WebKit initialization.
285-type ParallelDBWebKitInput struct {
287+// ParallelDBEngineInput holds inputs for parallel DB and engine initialization.
288+type ParallelDBEngineInput struct {
286289 Ctx context.Context
287290 Config *config.Config
288- DataDir string // For WebKit context
289- CacheDir string // For WebKit cache
291+ DataDir string // For engine context
292+ CacheDir string // For engine cache
290293 ThemeManager *theme.Manager
291294 ColorResolver port.ColorSchemeResolver
292295 }
@@ -300,10 +303,10 @@ type dbInitResult struct {
300303 err error
301304 }
302305
303-// RunParallelDBWebKit initializes database in background while WebKit runs on main thread.
304-// Database init happens in goroutine (pure Go/WASM), WebKit must stay on main thread (GTK).
305-// This saves ~150ms by overlapping DB migrations with WebKit context creation.
306-func RunParallelDBWebKit(input ParallelDBWebKitInput) (*ParallelDBWebKitResult, error) {
306+// RunParallelDBEngine initializes database in background while the engine runs on main thread.
307+// Database init happens in goroutine (pure Go/WASM), engine must stay on main thread (GTK).
308+// This saves ~150ms by overlapping DB migrations with engine context creation.
309+func RunParallelDBEngine(input ParallelDBEngineInput) (*ParallelDBEngineResult, error) {
307310 log := logging.FromContext(input.Ctx)
308311
309312 dbCh := make(chan dbInitResult, 1)
@@ -321,8 +324,8 @@ func RunParallelDBWebKit(input ParallelDBWebKitInput) (*ParallelDBWebKitResult,
321324 }
322325 }()
323326
324- // WebKit stack on main thread (GTK requirement)
325- stack := BuildWebKitStack(WebKitStackInput{
327+ // Engine on main thread (GTK requirement)
328+ engine, err := BuildEngine(EngineInput{
326329 Ctx: input.Ctx,
327330 Config: input.Config,
328331 DataDir: input.DataDir,
@@ -331,6 +334,13 @@ func RunParallelDBWebKit(input ParallelDBWebKitInput) (*ParallelDBWebKitResult,
331334 ColorResolver: input.ColorResolver,
332335 Logger: *log,
333336 })
337+ if err != nil {
338+ dbRes := <-dbCh
339+ if dbRes.err == nil && dbRes.cleanup != nil {
340+ dbRes.cleanup()
341+ }
342+ return nil, fmt.Errorf("engine initialization: %w", err)
343+ }
334344
335345 // Wait for database
336346 dbRes := <-dbCh
@@ -339,9 +349,9 @@ func RunParallelDBWebKit(input ParallelDBWebKitInput) (*ParallelDBWebKitResult,
339349 return nil, fmt.Errorf("database initialization: %w", dbRes.err)
340350 }
341351
342- return &ParallelDBWebKitResult{
352+ return &ParallelDBEngineResult{
343353 DB: dbRes.db,
344354 DBCleanup: dbRes.cleanup,
345- Stack: stack,
355+ Engine: engine,
346356 }, nil
347357 }
D internal/bootstrap/webkit_stack_test.go +0 -55
@@ -1,55 +0,0 @@
1-package bootstrap
2-
3-import (
4- "testing"
5-
6- "github.com/bnema/dumber/internal/application/port"
7- "github.com/bnema/dumber/internal/infrastructure/config"
8- "github.com/jwijenbergh/puregotk/v4/gdk"
9- "github.com/stretchr/testify/assert"
10- "github.com/stretchr/testify/require"
11-)
12-
13-func TestBuildWebKitContextOptions_MapsPrivacyAndMemory(t *testing.T) {
14- cfg := config.DefaultConfig()
15- cfg.Privacy.CookiePolicy = config.CookiePolicyNever
16- cfg.Privacy.ITPEnabled = false
17-
18- perf := &config.ResolvedPerformanceSettings{
19- WebProcessMemoryLimitMB: 1024,
20- WebProcessMemoryPollIntervalSec: 11,
21- WebProcessMemoryConservativeThreshold: 0.25,
22- WebProcessMemoryStrictThreshold: 0.5,
23- NetworkProcessMemoryLimitMB: 512,
24- NetworkProcessMemoryPollIntervalSec: 13,
25- }
26-
27- opts := buildWebKitContextOptions(cfg, "/tmp/data", "/tmp/cache", perf)
28-
29- assert.Equal(t, "/tmp/data", opts.DataDir)
30- assert.Equal(t, "/tmp/cache", opts.CacheDir)
31- assert.Equal(t, port.WebKitCookiePolicyNever, opts.CookiePolicy)
32- assert.False(t, opts.ITPEnabled)
33-
34- require.NotNil(t, opts.WebProcessMemory)
35- assert.Equal(t, 1024, opts.WebProcessMemory.MemoryLimitMB)
36- assert.InDelta(t, 11.0, opts.WebProcessMemory.PollIntervalSec, 1e-9)
37- assert.InDelta(t, 0.25, opts.WebProcessMemory.ConservativeThreshold, 1e-9)
38- assert.InDelta(t, 0.5, opts.WebProcessMemory.StrictThreshold, 1e-9)
39-
40- require.NotNil(t, opts.NetworkProcessMemory)
41- assert.Equal(t, 512, opts.NetworkProcessMemory.MemoryLimitMB)
42- assert.InDelta(t, 13.0, opts.NetworkProcessMemory.PollIntervalSec, 1e-9)
43-}
44-
45-func TestBuildWebKitContextOptions_DefaultPrivacyWhenConfigNil(t *testing.T) {
46- opts := buildWebKitContextOptions(nil, "/tmp/data", "/tmp/cache", &config.ResolvedPerformanceSettings{})
47-
48- assert.Equal(t, port.WebKitCookiePolicyNoThirdParty, opts.CookiePolicy)
49- assert.True(t, opts.ITPEnabled)
50-}
51-
52-func TestShouldPrewarmFirstWebView(t *testing.T) {
53- assert.False(t, shouldPrewarmFirstWebView(nil))
54- assert.True(t, shouldPrewarmFirstWebView(&gdk.Display{}))
55-}
M internal/cli/cmd/config.go +11 -0
@@ -247,6 +247,17 @@ func runConfigMigrate(_ *cobra.Command, _ []string) error {
247247 return nil
248248 }
249249
250+ // Structural migration: move old [rendering]/[performance]/[privacy]/[runtime]
251+ // sections to [engine]/[engine.webkit] if needed.
252+ engineMigrated, engineErr := config.MigrateToEngineConfig(configFile)
253+ if engineErr != nil {
254+ fmt.Println(renderer.RenderError(fmt.Errorf("engine config migration failed: %w", engineErr)))
255+ return nil
256+ }
257+ if engineMigrated {
258+ fmt.Print(renderer.RenderEngineMigrationSuccess())
259+ }
260+
250261 // Detect changes (includes renames, additions, and removals)
251262 ctx := context.Background()
252263 detectResult, err := uc.DetectChanges(ctx, usecase.DetectChangesInput{})
M internal/cli/cmd/doctor.go +1 -1
@@ -59,7 +59,7 @@ func runDoctor(_ *cobra.Command, _ []string) error {
5959 probe := deps.NewPkgConfigProbe()
6060 runtimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe)
6161 runtimeOut, err := runtimeUC.Execute(app.Ctx(), usecase.CheckRuntimeDependenciesInput{
62- Prefix: app.Config.Runtime.Prefix,
62+ Prefix: app.Config.Engine.WebKit.Prefix,
6363 })
6464 if err != nil {
6565 return err
M internal/cli/styles/config.go +11 -0
@@ -247,6 +247,17 @@ func (r *ConfigRenderer) RenderNoConfigFile(path string) string {
247247 )
248248 }
249249
250+// RenderEngineMigrationSuccess renders the success message after migrating legacy
251+// sections to [engine]/[engine.webkit].
252+func (r *ConfigRenderer) RenderEngineMigrationSuccess() string {
253+ iconStyle := lipgloss.NewStyle().Foreground(r.theme.Success)
254+
255+ return fmt.Sprintf(
256+ " %s Migrated legacy sections to [engine]/[engine.webkit]\n",
257+ iconStyle.Render(IconCheck),
258+ )
259+}
260+
250261 // RenderOpening renders a brief "opening config" message for `dumber config open`.
251262 func (r *ConfigRenderer) RenderOpening(path, editor string) string {
252263 iconStyle := r.theme.Subtle
A internal/domain/entity/config_types.go +199 -0
@@ -0,0 +1,199 @@
1+package entity
2+
3+// Config value types shared between infrastructure/config and the UI layer.
4+// Keeping these in domain/entity allows UI packages to use them without
5+// importing infrastructure/config.
6+
7+// AppearanceConfig holds visual appearance settings.
8+type AppearanceConfig struct {
9+ SansFont string `mapstructure:"sans_font" yaml:"sans_font" toml:"sans_font" json:"sans_font"`
10+ SerifFont string `mapstructure:"serif_font" yaml:"serif_font" toml:"serif_font" json:"serif_font"`
11+ MonospaceFont string `mapstructure:"monospace_font" yaml:"monospace_font" toml:"monospace_font" json:"monospace_font"`
12+ DefaultFontSize int `mapstructure:"default_font_size" yaml:"default_font_size" toml:"default_font_size" json:"default_font_size"`
13+ LightPalette ColorPalette `mapstructure:"light_palette" yaml:"light_palette" toml:"light_palette" json:"light_palette"`
14+ DarkPalette ColorPalette `mapstructure:"dark_palette" yaml:"dark_palette" toml:"dark_palette" json:"dark_palette"`
15+ ColorScheme string `mapstructure:"color_scheme" yaml:"color_scheme" toml:"color_scheme" json:"color_scheme"`
16+}
17+
18+// ColorPalette defines the color scheme for a theme variant (light/dark).
19+type ColorPalette struct {
20+ Background string `mapstructure:"background" yaml:"background" toml:"background" json:"background"`
21+ Surface string `mapstructure:"surface" yaml:"surface" toml:"surface" json:"surface"`
22+ SurfaceVariant string `mapstructure:"surface_variant" yaml:"surface_variant" toml:"surface_variant" json:"surface_variant"`
23+ Text string `mapstructure:"text" yaml:"text" toml:"text" json:"text"`
24+ Muted string `mapstructure:"muted" yaml:"muted" toml:"muted" json:"muted"`
25+ Accent string `mapstructure:"accent" yaml:"accent" toml:"accent" json:"accent"`
26+ Border string `mapstructure:"border" yaml:"border" toml:"border" json:"border"`
27+}
28+
29+// ActionBinding maps a config action name to key bindings.
30+type ActionBinding struct {
31+ Keys []string `mapstructure:"keys" yaml:"keys" toml:"keys" json:"keys"`
32+ Desc string `mapstructure:"desc" yaml:"desc" toml:"desc" json:"desc,omitempty"`
33+}
34+
35+// PaneModeConfig holds pane mode shortcut configuration.
36+type PaneModeConfig struct {
37+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
38+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
39+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
40+}
41+
42+// keyBindingsFromActions converts an action binding map to a key→action map.
43+// This is the canonical implementation shared by all GetKeyBindings methods.
44+func keyBindingsFromActions(actions map[string]ActionBinding) map[string]string {
45+ keyToAction := make(map[string]string)
46+ for action, binding := range actions {
47+ for _, key := range binding.Keys {
48+ keyToAction[key] = action
49+ }
50+ }
51+ return keyToAction
52+}
53+
54+// GetKeyBindings returns a map from key string to action name.
55+func (p *PaneModeConfig) GetKeyBindings() map[string]string {
56+ return keyBindingsFromActions(p.Actions)
57+}
58+
59+// TabModeConfig holds tab mode shortcut configuration.
60+type TabModeConfig struct {
61+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
62+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
63+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
64+}
65+
66+// GetKeyBindings returns a map from key string to action name.
67+func (t *TabModeConfig) GetKeyBindings() map[string]string {
68+ return keyBindingsFromActions(t.Actions)
69+}
70+
71+// ResizeModeConfig holds resize mode shortcut configuration.
72+type ResizeModeConfig struct {
73+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
74+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
75+ StepPercent float64 `mapstructure:"step_percent" yaml:"step_percent" toml:"step_percent" json:"step_percent"`
76+ MinPanePercent float64 `mapstructure:"min_pane_percent" yaml:"min_pane_percent" toml:"min_pane_percent" json:"min_pane_percent"` //nolint:lll // struct tags must stay on one line
77+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
78+}
79+
80+// GetKeyBindings returns a map from key string to action name.
81+func (r *ResizeModeConfig) GetKeyBindings() map[string]string {
82+ return keyBindingsFromActions(r.Actions)
83+}
84+
85+// SessionModeConfig holds session mode shortcut configuration.
86+type SessionModeConfig struct {
87+ ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
88+ TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
89+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
90+}
91+
92+// GetKeyBindings returns a map from key string to action name.
93+func (s *SessionModeConfig) GetKeyBindings() map[string]string {
94+ return keyBindingsFromActions(s.Actions)
95+}
96+
97+// SessionConfig holds session persistence and restoration settings.
98+type SessionConfig struct {
99+ AutoRestore bool `mapstructure:"auto_restore" yaml:"auto_restore" toml:"auto_restore" json:"auto_restore"`
100+
101+ SnapshotIntervalMs int `mapstructure:"snapshot_interval_ms" yaml:"snapshot_interval_ms" toml:"snapshot_interval_ms" json:"snapshot_interval_ms"` //nolint:lll // struct tags must stay on one line
102+
103+ MaxExitedSessions int `mapstructure:"max_exited_sessions" yaml:"max_exited_sessions" toml:"max_exited_sessions" json:"max_exited_sessions"` //nolint:lll // struct tags must stay on one line
104+
105+ MaxExitedSessionAgeDays int `mapstructure:"max_exited_session_age_days" yaml:"max_exited_session_age_days" toml:"max_exited_session_age_days" json:"max_exited_session_age_days"` //nolint:lll // struct tags must stay on one line
106+
107+ SessionMode SessionModeConfig `mapstructure:"session_mode" yaml:"session_mode" toml:"session_mode" json:"session_mode"`
108+}
109+
110+// GlobalShortcutsConfig holds workspace-level global shortcut bindings.
111+type GlobalShortcutsConfig struct {
112+ Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
113+}
114+
115+// GetKeyBindings returns a map from key string to action name.
116+func (g *GlobalShortcutsConfig) GetKeyBindings() map[string]string {
117+ return keyBindingsFromActions(g.Actions)
118+}
119+
120+// FloatingPaneProfile defines a named floating pane preset.
121+type FloatingPaneProfile struct {
122+ Keys []string `mapstructure:"keys" yaml:"keys" toml:"keys" json:"keys"`
123+ URL string `mapstructure:"url" yaml:"url" toml:"url" json:"url"`
124+ Desc string `mapstructure:"desc" yaml:"desc" toml:"desc" json:"desc,omitempty"`
125+}
126+
127+// FloatingPaneConfig holds floating pane layout and profile settings.
128+type FloatingPaneConfig struct {
129+ WidthPct float64 `mapstructure:"width_pct" yaml:"width_pct" toml:"width_pct" json:"width_pct"`
130+ HeightPct float64 `mapstructure:"height_pct" yaml:"height_pct" toml:"height_pct" json:"height_pct"`
131+ Profiles map[string]FloatingPaneProfile `mapstructure:"profiles" yaml:"profiles" toml:"profiles" json:"profiles"`
132+}
133+
134+// WorkspaceStylingConfig defines visual styling for workspace elements.
135+type WorkspaceStylingConfig struct {
136+ BorderWidth int `mapstructure:"border_width" yaml:"border_width" toml:"border_width" json:"border_width"`
137+ BorderColor string `mapstructure:"border_color" yaml:"border_color" toml:"border_color" json:"border_color"`
138+ ModeBorderWidth int `mapstructure:"mode_border_width" yaml:"mode_border_width" toml:"mode_border_width" json:"mode_border_width"` //nolint:lll // struct tags must stay on one line
139+
140+ PaneModeColor string `mapstructure:"pane_mode_color" yaml:"pane_mode_color" toml:"pane_mode_color" json:"pane_mode_color"` //nolint:lll // struct tags must stay on one line
141+ TabModeColor string `mapstructure:"tab_mode_color" yaml:"tab_mode_color" toml:"tab_mode_color" json:"tab_mode_color"` //nolint:lll // struct tags must stay on one line
142+ SessionModeColor string `mapstructure:"session_mode_color" yaml:"session_mode_color" toml:"session_mode_color" json:"session_mode_color"` //nolint:lll // struct tags must stay on one line
143+ ResizeModeColor string `mapstructure:"resize_mode_color" yaml:"resize_mode_color" toml:"resize_mode_color" json:"resize_mode_color"` //nolint:lll // struct tags must stay on one line
144+
145+ ModeIndicatorToasterEnabled bool `mapstructure:"mode_indicator_toaster_enabled" yaml:"mode_indicator_toaster_enabled" toml:"mode_indicator_toaster_enabled" json:"mode_indicator_toaster_enabled"` //nolint:lll // struct tags must stay on one line
146+
147+ TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line
148+}
149+
150+// PopupBehavior defines how a popup is placed in the workspace.
151+type PopupBehavior string
152+
153+const (
154+ PopupBehaviorSplit PopupBehavior = "split"
155+ PopupBehaviorStacked PopupBehavior = "stacked"
156+ PopupBehaviorTabbed PopupBehavior = "tabbed"
157+ PopupBehaviorWindowed PopupBehavior = "windowed"
158+)
159+
160+// PopupBehaviorConfig controls how popup windows are handled.
161+type PopupBehaviorConfig struct {
162+ Behavior PopupBehavior `mapstructure:"behavior" yaml:"behavior" toml:"behavior" json:"behavior"`
163+
164+ Placement string `mapstructure:"placement" yaml:"placement" toml:"placement" json:"placement"`
165+
166+ OpenInNewPane bool `mapstructure:"open_in_new_pane" yaml:"open_in_new_pane" toml:"open_in_new_pane" json:"open_in_new_pane"`
167+
168+ FollowPaneContext bool `mapstructure:"follow_pane_context" yaml:"follow_pane_context" toml:"follow_pane_context" json:"follow_pane_context"` //nolint:lll // struct tags must stay on one line
169+
170+ BlankTargetBehavior string `mapstructure:"blank_target_behavior" yaml:"blank_target_behavior" toml:"blank_target_behavior" json:"blank_target_behavior"` //nolint:lll // struct tags must stay on one line
171+
172+ EnableSmartDetection bool `mapstructure:"enable_smart_detection" yaml:"enable_smart_detection" toml:"enable_smart_detection" json:"enable_smart_detection"` //nolint:lll // struct tags must stay on one line
173+
174+ OAuthAutoClose bool `mapstructure:"oauth_auto_close" yaml:"oauth_auto_close" toml:"oauth_auto_close" json:"oauth_auto_close"`
175+}
176+
177+// WorkspaceConfig holds all workspace layout and behavior settings.
178+type WorkspaceConfig struct {
179+ NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url" json:"new_pane_url"`
180+ PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"`
181+ TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"`
182+ ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"`
183+ Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`
184+ FloatingPane FloatingPaneConfig `mapstructure:"floating_pane" yaml:"floating_pane" toml:"floating_pane" json:"floating_pane"`
185+
186+ TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`
187+ HideTabBarWhenSingleTab bool `mapstructure:"hide_tab_bar_when_single_tab" yaml:"hide_tab_bar_when_single_tab" toml:"hide_tab_bar_when_single_tab" json:"hide_tab_bar_when_single_tab"` //nolint:lll // struct tags must stay on one line
188+ SwitchToTabOnMove bool `mapstructure:"switch_to_tab_on_move" yaml:"switch_to_tab_on_move" toml:"switch_to_tab_on_move" json:"switch_to_tab_on_move"` //nolint:lll // struct tags must stay on one line
189+
190+ Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"`
191+ Styling WorkspaceStylingConfig `mapstructure:"styling" yaml:"styling" toml:"styling" json:"styling"`
192+}
193+
194+// UpdateConfig holds auto-update behavior settings.
195+type UpdateConfig struct {
196+ EnableOnStartup bool `mapstructure:"enable_on_startup" yaml:"enable_on_startup" toml:"enable_on_startup"`
197+ AutoDownload bool `mapstructure:"auto_download" yaml:"auto_download" toml:"auto_download"`
198+ NotifyOnNewSettings bool `mapstructure:"notify_on_new_settings" yaml:"notify_on_new_settings" toml:"notify_on_new_settings"`
199+}
M internal/domain/repository/mocks/mocks.go +0 -289
@@ -2979,295 +2979,6 @@ func (_c *MockHistoryRepository_Search_Call) RunAndReturn(run func(ctx context.C
29792979 return _c
29802980 }
29812981
2982-// NewMockPermissionRepository creates a new instance of MockPermissionRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
2983-// The first argument is typically a *testing.T value.
2984-func NewMockPermissionRepository(t interface {
2985- mock.TestingT
2986- Cleanup(func())
2987-}) *MockPermissionRepository {
2988- mock := &MockPermissionRepository{}
2989- mock.Mock.Test(t)
2990-
2991- t.Cleanup(func() { mock.AssertExpectations(t) })
2992-
2993- return mock
2994-}
2995-
2996-// MockPermissionRepository is an autogenerated mock type for the PermissionRepository type
2997-type MockPermissionRepository struct {
2998- mock.Mock
2999-}
3000-
3001-type MockPermissionRepository_Expecter struct {
3002- mock *mock.Mock
3003-}
3004-
3005-func (_m *MockPermissionRepository) EXPECT() *MockPermissionRepository_Expecter {
3006- return &MockPermissionRepository_Expecter{mock: &_m.Mock}
3007-}
3008-
3009-// Delete provides a mock function for the type MockPermissionRepository
3010-func (_mock *MockPermissionRepository) Delete(ctx context.Context, origin string, permType entity.PermissionType) error {
3011- ret := _mock.Called(ctx, origin, permType)
3012-
3013- if len(ret) == 0 {
3014- panic("no return value specified for Delete")
3015- }
3016-
3017- var r0 error
3018- if returnFunc, ok := ret.Get(0).(func(context.Context, string, entity.PermissionType) error); ok {
3019- r0 = returnFunc(ctx, origin, permType)
3020- } else {
3021- r0 = ret.Error(0)
3022- }
3023- return r0
3024-}
3025-
3026-// MockPermissionRepository_Delete_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Delete'
3027-type MockPermissionRepository_Delete_Call struct {
3028- *mock.Call
3029-}
3030-
3031-// Delete is a helper method to define mock.On call
3032-// - ctx context.Context
3033-// - origin string
3034-// - permType entity.PermissionType
3035-func (_e *MockPermissionRepository_Expecter) Delete(ctx interface{}, origin interface{}, permType interface{}) *MockPermissionRepository_Delete_Call {
3036- return &MockPermissionRepository_Delete_Call{Call: _e.mock.On("Delete", ctx, origin, permType)}
3037-}
3038-
3039-func (_c *MockPermissionRepository_Delete_Call) Run(run func(ctx context.Context, origin string, permType entity.PermissionType)) *MockPermissionRepository_Delete_Call {
3040- _c.Call.Run(func(args mock.Arguments) {
3041- var arg0 context.Context
3042- if args[0] != nil {
3043- arg0 = args[0].(context.Context)
3044- }
3045- var arg1 string
3046- if args[1] != nil {
3047- arg1 = args[1].(string)
3048- }
3049- var arg2 entity.PermissionType
3050- if args[2] != nil {
3051- arg2 = args[2].(entity.PermissionType)
3052- }
3053- run(
3054- arg0,
3055- arg1,
3056- arg2,
3057- )
3058- })
3059- return _c
3060-}
3061-
3062-func (_c *MockPermissionRepository_Delete_Call) Return(err error) *MockPermissionRepository_Delete_Call {
3063- _c.Call.Return(err)
3064- return _c
3065-}
3066-
3067-func (_c *MockPermissionRepository_Delete_Call) RunAndReturn(run func(ctx context.Context, origin string, permType entity.PermissionType) error) *MockPermissionRepository_Delete_Call {
3068- _c.Call.Return(run)
3069- return _c
3070-}
3071-
3072-// Get provides a mock function for the type MockPermissionRepository
3073-func (_mock *MockPermissionRepository) Get(ctx context.Context, origin string, permType entity.PermissionType) (*entity.PermissionRecord, error) {
3074- ret := _mock.Called(ctx, origin, permType)
3075-
3076- if len(ret) == 0 {
3077- panic("no return value specified for Get")
3078- }
3079-
3080- var r0 *entity.PermissionRecord
3081- var r1 error
3082- if returnFunc, ok := ret.Get(0).(func(context.Context, string, entity.PermissionType) (*entity.PermissionRecord, error)); ok {
3083- return returnFunc(ctx, origin, permType)
3084- }
3085- if returnFunc, ok := ret.Get(0).(func(context.Context, string, entity.PermissionType) *entity.PermissionRecord); ok {
3086- r0 = returnFunc(ctx, origin, permType)
3087- } else {
3088- if ret.Get(0) != nil {
3089- r0 = ret.Get(0).(*entity.PermissionRecord)
3090- }
3091- }
3092- if returnFunc, ok := ret.Get(1).(func(context.Context, string, entity.PermissionType) error); ok {
3093- r1 = returnFunc(ctx, origin, permType)
3094- } else {
3095- r1 = ret.Error(1)
3096- }
3097- return r0, r1
3098-}
3099-
3100-// MockPermissionRepository_Get_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Get'
3101-type MockPermissionRepository_Get_Call struct {
3102- *mock.Call
3103-}
3104-
3105-// Get is a helper method to define mock.On call
3106-// - ctx context.Context
3107-// - origin string
3108-// - permType entity.PermissionType
3109-func (_e *MockPermissionRepository_Expecter) Get(ctx interface{}, origin interface{}, permType interface{}) *MockPermissionRepository_Get_Call {
3110- return &MockPermissionRepository_Get_Call{Call: _e.mock.On("Get", ctx, origin, permType)}
3111-}
3112-
3113-func (_c *MockPermissionRepository_Get_Call) Run(run func(ctx context.Context, origin string, permType entity.PermissionType)) *MockPermissionRepository_Get_Call {
3114- _c.Call.Run(func(args mock.Arguments) {
3115- var arg0 context.Context
3116- if args[0] != nil {
3117- arg0 = args[0].(context.Context)
3118- }
3119- var arg1 string
3120- if args[1] != nil {
3121- arg1 = args[1].(string)
3122- }
3123- var arg2 entity.PermissionType
3124- if args[2] != nil {
3125- arg2 = args[2].(entity.PermissionType)
3126- }
3127- run(
3128- arg0,
3129- arg1,
3130- arg2,
3131- )
3132- })
3133- return _c
3134-}
3135-
3136-func (_c *MockPermissionRepository_Get_Call) Return(permissionRecord *entity.PermissionRecord, err error) *MockPermissionRepository_Get_Call {
3137- _c.Call.Return(permissionRecord, err)
3138- return _c
3139-}
3140-
3141-func (_c *MockPermissionRepository_Get_Call) RunAndReturn(run func(ctx context.Context, origin string, permType entity.PermissionType) (*entity.PermissionRecord, error)) *MockPermissionRepository_Get_Call {
3142- _c.Call.Return(run)
3143- return _c
3144-}
3145-
3146-// GetAll provides a mock function for the type MockPermissionRepository
3147-func (_mock *MockPermissionRepository) GetAll(ctx context.Context, origin string) ([]*entity.PermissionRecord, error) {
3148- ret := _mock.Called(ctx, origin)
3149-
3150- if len(ret) == 0 {
3151- panic("no return value specified for GetAll")
3152- }
3153-
3154- var r0 []*entity.PermissionRecord
3155- var r1 error
3156- if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]*entity.PermissionRecord, error)); ok {
3157- return returnFunc(ctx, origin)
3158- }
3159- if returnFunc, ok := ret.Get(0).(func(context.Context, string) []*entity.PermissionRecord); ok {
3160- r0 = returnFunc(ctx, origin)
3161- } else {
3162- if ret.Get(0) != nil {
3163- r0 = ret.Get(0).([]*entity.PermissionRecord)
3164- }
3165- }
3166- if returnFunc, ok := ret.Get(1).(func(context.Context, string) error); ok {
3167- r1 = returnFunc(ctx, origin)
3168- } else {
3169- r1 = ret.Error(1)
3170- }
3171- return r0, r1
3172-}
3173-
3174-// MockPermissionRepository_GetAll_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'GetAll'
3175-type MockPermissionRepository_GetAll_Call struct {
3176- *mock.Call
3177-}
3178-
3179-// GetAll is a helper method to define mock.On call
3180-// - ctx context.Context
3181-// - origin string
3182-func (_e *MockPermissionRepository_Expecter) GetAll(ctx interface{}, origin interface{}) *MockPermissionRepository_GetAll_Call {
3183- return &MockPermissionRepository_GetAll_Call{Call: _e.mock.On("GetAll", ctx, origin)}
3184-}
3185-
3186-func (_c *MockPermissionRepository_GetAll_Call) Run(run func(ctx context.Context, origin string)) *MockPermissionRepository_GetAll_Call {
3187- _c.Call.Run(func(args mock.Arguments) {
3188- var arg0 context.Context
3189- if args[0] != nil {
3190- arg0 = args[0].(context.Context)
3191- }
3192- var arg1 string
3193- if args[1] != nil {
3194- arg1 = args[1].(string)
3195- }
3196- run(
3197- arg0,
3198- arg1,
3199- )
3200- })
3201- return _c
3202-}
3203-
3204-func (_c *MockPermissionRepository_GetAll_Call) Return(permissionRecords []*entity.PermissionRecord, err error) *MockPermissionRepository_GetAll_Call {
3205- _c.Call.Return(permissionRecords, err)
3206- return _c
3207-}
3208-
3209-func (_c *MockPermissionRepository_GetAll_Call) RunAndReturn(run func(ctx context.Context, origin string) ([]*entity.PermissionRecord, error)) *MockPermissionRepository_GetAll_Call {
3210- _c.Call.Return(run)
3211- return _c
3212-}
3213-
3214-// Set provides a mock function for the type MockPermissionRepository
3215-func (_mock *MockPermissionRepository) Set(ctx context.Context, record *entity.PermissionRecord) error {
3216- ret := _mock.Called(ctx, record)
3217-
3218- if len(ret) == 0 {
3219- panic("no return value specified for Set")
3220- }
3221-
3222- var r0 error
3223- if returnFunc, ok := ret.Get(0).(func(context.Context, *entity.PermissionRecord) error); ok {
3224- r0 = returnFunc(ctx, record)
3225- } else {
3226- r0 = ret.Error(0)
3227- }
3228- return r0
3229-}
3230-
3231-// MockPermissionRepository_Set_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'Set'
3232-type MockPermissionRepository_Set_Call struct {
3233- *mock.Call
3234-}
3235-
3236-// Set is a helper method to define mock.On call
3237-// - ctx context.Context
3238-// - record *entity.PermissionRecord
3239-func (_e *MockPermissionRepository_Expecter) Set(ctx interface{}, record interface{}) *MockPermissionRepository_Set_Call {
3240- return &MockPermissionRepository_Set_Call{Call: _e.mock.On("Set", ctx, record)}
3241-}
3242-
3243-func (_c *MockPermissionRepository_Set_Call) Run(run func(ctx context.Context, record *entity.PermissionRecord)) *MockPermissionRepository_Set_Call {
3244- _c.Call.Run(func(args mock.Arguments) {
3245- var arg0 context.Context
3246- if args[0] != nil {
3247- arg0 = args[0].(context.Context)
3248- }
3249- var arg1 *entity.PermissionRecord
3250- if args[1] != nil {
3251- arg1 = args[1].(*entity.PermissionRecord)
3252- }
3253- run(
3254- arg0,
3255- arg1,
3256- )
3257- })
3258- return _c
3259-}
3260-
3261-func (_c *MockPermissionRepository_Set_Call) Return(err error) *MockPermissionRepository_Set_Call {
3262- _c.Call.Return(err)
3263- return _c
3264-}
3265-
3266-func (_c *MockPermissionRepository_Set_Call) RunAndReturn(run func(ctx context.Context, record *entity.PermissionRecord) error) *MockPermissionRepository_Set_Call {
3267- _c.Call.Return(run)
3268- return _c
3269-}
3270-
32712982 // NewMockSessionRepository creates a new instance of MockSessionRepository. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
32722983 // The first argument is typically a *testing.T value.
32732984 func NewMockSessionRepository(t interface {
M internal/infrastructure/config/defaults.go +16 -42
@@ -142,22 +142,19 @@ func DefaultConfig() *Config {
142142 Debug: DebugConfig{
143143 EnableDevTools: true,
144144 },
145- Rendering: RenderingConfig{
146- Mode: RenderingModeGPU,
147- DisableDMABufRenderer: false,
148- ForceCompositingMode: false,
149- DisableCompositingMode: false,
150- GSKRenderer: GSKRendererAuto, // Let GTK choose - Vulkan can conflict with WebKit's DMA-BUF
151- DisableMipmaps: false,
152- PreferGL: false,
153- DrawCompositingIndicators: false,
154- ShowFPS: false,
155- SampleMemory: false,
156- DebugFrames: false,
157- },
158- Privacy: PrivacyConfig{
159- CookiePolicy: CookiePolicyNoThirdParty,
160- ITPEnabled: true,
145+ Engine: EngineConfig{
146+ Type: "webkit",
147+ Profile: ProfileDefault,
148+ PoolPrewarmCount: defaultWebViewPoolPrewarmCount,
149+ ZoomCacheSize: defaultZoomCacheSize,
150+ CookiePolicy: CookiePolicyNoThirdParty,
151+ WebKit: WebKitEngineConfig{
152+ ITPEnabled: true,
153+ SkiaCPUPaintingThreads: defaultSkiaCPUPaintingThreads,
154+ SkiaGPUPaintingThreads: defaultSkiaGPUPaintingThreads,
155+ GSKRenderer: GSKRendererAuto,
156+ GLRenderingMode: GLRenderingModeAuto,
157+ },
161158 },
162159 DefaultWebpageZoom: 1.2, // 120% default zoom for better readability
163160 DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200%
@@ -293,32 +290,9 @@ func DefaultConfig() *Config {
293290 HardwareDecodingMode: HardwareDecodingAuto, // auto allows sw fallback
294291 PreferAV1: false, // Don't force codec preference, let site choose
295292 ShowDiagnosticsOnStartup: false, // Disabled - diagnostics can be noisy
296- ForceVSync: false, // Let compositor handle VSync
297- GLRenderingMode: GLRenderingModeAuto, // GStreamer picks best GL API
298- GStreamerDebugLevel: 0, // Disabled by default
299- },
300- Runtime: RuntimeConfig{
301- Prefix: "",
302- },
303- Performance: PerformanceConfig{
304- Profile: ProfileDefault, // Use WebKit defaults by default
305- ZoomCacheSize: defaultZoomCacheSize,
306- WebViewPoolPrewarmCount: defaultWebViewPoolPrewarmCount,
307- // Skia threading - balanced defaults (unset = use WebKit defaults)
308- // These only apply when Profile is "custom"
309- SkiaCPUPaintingThreads: defaultSkiaCPUPaintingThreads,
310- SkiaGPUPaintingThreads: defaultSkiaGPUPaintingThreads,
311- SkiaEnableCPURendering: false,
312- // Web process memory pressure - all unset by default
313- WebProcessMemoryLimitMB: 0,
314- WebProcessMemoryPollIntervalSec: 0,
315- WebProcessMemoryConservativeThreshold: 0,
316- WebProcessMemoryStrictThreshold: 0,
317- // Network process memory pressure - all unset by default
318- NetworkProcessMemoryLimitMB: 0,
319- NetworkProcessMemoryPollIntervalSec: 0,
320- NetworkProcessMemoryConservativeThreshold: 0,
321- NetworkProcessMemoryStrictThreshold: 0,
293+ // GStreamer fields (ForceVSync, GLRenderingMode, GStreamerDebugLevel)
294+ // moved to [engine.webkit] — zero values here prevent them from being
295+ // written back when marshaling the Config struct.
322296 },
323297 Update: UpdateConfig{
324298 EnableOnStartup: true, // Check for updates on startup by default
M internal/infrastructure/config/defaults_test.go +10 -4
@@ -6,12 +6,18 @@ import (
66 "github.com/stretchr/testify/assert"
77 )
88
9-func TestDefaultConfig_RuntimeLoggingProfile(t *testing.T) {
9+func TestDefaultConfig_CoreDefaults(t *testing.T) {
1010 cfg := DefaultConfig()
1111 assert.Equal(t, "info", cfg.Logging.Level)
1212 assert.False(t, cfg.Logging.CaptureGTKLogs)
13- assert.Equal(t, ProfileDefault, cfg.Performance.Profile)
14- assert.Equal(t, CookiePolicyNoThirdParty, cfg.Privacy.CookiePolicy)
15- assert.True(t, cfg.Privacy.ITPEnabled)
1613 assert.False(t, cfg.Media.ShowDiagnosticsOnStartup)
14+
15+ // Engine defaults (replaces old Performance/Privacy sections)
16+ assert.Equal(t, "webkit", cfg.Engine.Type)
17+ assert.Equal(t, ProfileDefault, cfg.Engine.Profile)
18+ assert.Equal(t, CookiePolicyNoThirdParty, cfg.Engine.CookiePolicy)
19+ assert.True(t, cfg.Engine.WebKit.ITPEnabled)
20+
21+ // Old sections (Rendering, Privacy, Performance, Runtime) have been removed from Config.
22+ // Their values now live under cfg.Engine / cfg.Engine.WebKit (validated above).
1723 }
A internal/infrastructure/config/engine.go +82 -0
@@ -0,0 +1,82 @@
1+package config
2+
3+// EngineConfig holds engine selection and universal engine options.
4+type EngineConfig struct {
5+ Type string `mapstructure:"type" toml:"type" yaml:"type"`
6+ PoolPrewarmCount int `mapstructure:"pool_prewarm_count" toml:"pool_prewarm_count" yaml:"pool_prewarm_count"`
7+ ZoomCacheSize int `mapstructure:"zoom_cache_size" toml:"zoom_cache_size" yaml:"zoom_cache_size"`
8+ Profile PerformanceProfile `mapstructure:"profile" toml:"profile" yaml:"profile"`
9+ CookiePolicy CookiePolicy `mapstructure:"cookie_policy" toml:"cookie_policy" yaml:"cookie_policy"`
10+ WebKit WebKitEngineConfig `mapstructure:"webkit" toml:"webkit" yaml:"webkit"`
11+}
12+
13+// WebKitEngineConfig holds WebKit-specific engine options.
14+type WebKitEngineConfig struct {
15+ // Skia rendering threads
16+ SkiaCPUPaintingThreads int `mapstructure:"skia_cpu_painting_threads" toml:"skia_cpu_painting_threads" yaml:"skia_cpu_painting_threads"`
17+ SkiaGPUPaintingThreads int `mapstructure:"skia_gpu_painting_threads" toml:"skia_gpu_painting_threads" yaml:"skia_gpu_painting_threads"`
18+ SkiaEnableCPURendering bool `mapstructure:"skia_enable_cpu_rendering" toml:"skia_enable_cpu_rendering" yaml:"skia_enable_cpu_rendering"`
19+
20+ // WebKit compositor
21+ DisableDMABufRenderer bool `mapstructure:"disable_dmabuf_renderer" toml:"disable_dmabuf_renderer" yaml:"disable_dmabuf_renderer"`
22+ ForceCompositingMode bool `mapstructure:"force_compositing_mode" toml:"force_compositing_mode" yaml:"force_compositing_mode"`
23+ DisableCompositingMode bool `mapstructure:"disable_compositing_mode" toml:"disable_compositing_mode" yaml:"disable_compositing_mode"`
24+
25+ // GTK/GSK
26+ GSKRenderer GSKRendererMode `mapstructure:"gsk_renderer" toml:"gsk_renderer" yaml:"gsk_renderer"`
27+ DisableMipmaps bool `mapstructure:"disable_mipmaps" toml:"disable_mipmaps" yaml:"disable_mipmaps"`
28+ PreferGL bool `mapstructure:"prefer_gl" toml:"prefer_gl" yaml:"prefer_gl"`
29+
30+ // Debug
31+ ShowFPS bool `mapstructure:"show_fps" toml:"show_fps" yaml:"show_fps"`
32+ SampleMemory bool `mapstructure:"sample_memory" toml:"sample_memory" yaml:"sample_memory"`
33+ DebugFrames bool `mapstructure:"debug_frames" toml:"debug_frames" yaml:"debug_frames"`
34+ DrawCompositingIndicators bool `mapstructure:"draw_compositing_indicators" toml:"draw_compositing_indicators" yaml:"draw_compositing_indicators"` //nolint:lll // struct tags exceed lll limit
35+
36+ // Privacy (WebKit-specific)
37+ ITPEnabled bool `mapstructure:"itp_enabled" toml:"itp_enabled" yaml:"itp_enabled"`
38+
39+ // GStreamer
40+ ForceVSync bool `mapstructure:"force_vsync" toml:"force_vsync" yaml:"force_vsync"`
41+ GLRenderingMode GLRenderingMode `mapstructure:"gl_rendering_mode" toml:"gl_rendering_mode" yaml:"gl_rendering_mode"`
42+ GStreamerDebugLevel int `mapstructure:"gstreamer_debug_level" toml:"gstreamer_debug_level" yaml:"gstreamer_debug_level"`
43+
44+ // Runtime prefix for WebKitGTK libraries
45+ Prefix string `mapstructure:"prefix" toml:"prefix" yaml:"prefix"`
46+
47+ // Web process memory pressure
48+ WebProcessMemoryLimitMB int `mapstructure:"web_process_memory_limit_mb" toml:"web_process_memory_limit_mb" yaml:"web_process_memory_limit_mb"` //nolint:lll // struct tags exceed lll limit
49+ WebProcessMemoryPollIntervalSec float64 `mapstructure:"web_process_memory_poll_interval_sec" toml:"web_process_memory_poll_interval_sec" yaml:"web_process_memory_poll_interval_sec"` //nolint:lll // struct tags exceed lll limit
50+ WebProcessMemoryConservativeThreshold float64 `mapstructure:"web_process_memory_conservative_threshold" toml:"web_process_memory_conservative_threshold" yaml:"web_process_memory_conservative_threshold"` //nolint:lll // struct tags exceed lll limit
51+ WebProcessMemoryStrictThreshold float64 `mapstructure:"web_process_memory_strict_threshold" toml:"web_process_memory_strict_threshold" yaml:"web_process_memory_strict_threshold"` //nolint:lll // struct tags exceed lll limit
52+
53+ // Network process memory pressure
54+ NetworkProcessMemoryLimitMB int `mapstructure:"network_process_memory_limit_mb" toml:"network_process_memory_limit_mb" yaml:"network_process_memory_limit_mb"` //nolint:lll // struct tags exceed lll limit
55+ NetworkProcessMemoryPollIntervalSec float64 `mapstructure:"network_process_memory_poll_interval_sec" toml:"network_process_memory_poll_interval_sec" yaml:"network_process_memory_poll_interval_sec"` //nolint:lll // struct tags exceed lll limit
56+ NetworkProcessMemoryConservativeThreshold float64 `mapstructure:"network_process_memory_conservative_threshold" toml:"network_process_memory_conservative_threshold" yaml:"network_process_memory_conservative_threshold"` //nolint:lll // struct tags exceed lll limit
57+ NetworkProcessMemoryStrictThreshold float64 `mapstructure:"network_process_memory_strict_threshold" toml:"network_process_memory_strict_threshold" yaml:"network_process_memory_strict_threshold"` //nolint:lll // struct tags exceed lll limit
58+}
59+
60+// PerformanceConfigFromEngine constructs a PerformanceConfig from the
61+// new [engine] config section, for use with ResolvePerformanceProfile.
62+func PerformanceConfigFromEngine(e *EngineConfig) PerformanceConfig {
63+ if e == nil {
64+ return PerformanceConfig{}
65+ }
66+ return PerformanceConfig{
67+ Profile: e.Profile,
68+ ZoomCacheSize: e.ZoomCacheSize,
69+ WebViewPoolPrewarmCount: e.PoolPrewarmCount,
70+ SkiaCPUPaintingThreads: e.WebKit.SkiaCPUPaintingThreads,
71+ SkiaGPUPaintingThreads: e.WebKit.SkiaGPUPaintingThreads,
72+ SkiaEnableCPURendering: e.WebKit.SkiaEnableCPURendering,
73+ WebProcessMemoryLimitMB: e.WebKit.WebProcessMemoryLimitMB,
74+ WebProcessMemoryPollIntervalSec: e.WebKit.WebProcessMemoryPollIntervalSec,
75+ WebProcessMemoryConservativeThreshold: e.WebKit.WebProcessMemoryConservativeThreshold,
76+ WebProcessMemoryStrictThreshold: e.WebKit.WebProcessMemoryStrictThreshold,
77+ NetworkProcessMemoryLimitMB: e.WebKit.NetworkProcessMemoryLimitMB,
78+ NetworkProcessMemoryPollIntervalSec: e.WebKit.NetworkProcessMemoryPollIntervalSec,
79+ NetworkProcessMemoryConservativeThreshold: e.WebKit.NetworkProcessMemoryConservativeThreshold,
80+ NetworkProcessMemoryStrictThreshold: e.WebKit.NetworkProcessMemoryStrictThreshold,
81+ }
82+}
M internal/infrastructure/config/loader.go +103 -105
@@ -87,6 +87,10 @@ func (m *Manager) Load() error {
8787 return err
8888 }
8989
90+ if err := m.checkLegacyFormat(); err != nil {
91+ return err
92+ }
93+
9094 config, err := m.unmarshalConfig()
9195 if err != nil {
9296 return err
@@ -151,6 +155,29 @@ func (m *Manager) transformLegacyActionBindings() {
151155 }
152156 }
153157
158+// checkLegacyFormat detects old config format and returns an error directing user to migrate.
159+func (m *Manager) checkLegacyFormat() error {
160+ // Check if old sections exist by looking for known keys.
161+ // IsSet works for legacy keys because they do not have defaults, while
162+ // engine.type has a default and must be checked with InConfig.
163+ hasOldSections := m.viper.IsSet("rendering.mode") ||
164+ m.viper.IsSet("rendering.disable_dmabuf_renderer") ||
165+ m.viper.IsSet("performance.profile") ||
166+ m.viper.IsSet("privacy.cookie_policy") ||
167+ m.viper.IsSet("runtime.prefix")
168+
169+ hasEngineSection := m.viper.InConfig("engine.type")
170+
171+ if hasOldSections && !hasEngineSection {
172+ return fmt.Errorf(
173+ "config format outdated: [rendering], [performance], [privacy] sections " +
174+ "have moved to [engine]/[engine.webkit] — " +
175+ "run \"dumber config migrate\" to update your config file",
176+ )
177+ }
178+ return nil
179+}
180+
154181 func (m *Manager) unmarshalConfig() (*Config, error) {
155182 config := &Config{}
156183 if err := m.viper.Unmarshal(config); err != nil {
@@ -177,52 +204,54 @@ func ensureDatabasePath(config *Config) error {
177204 }
178205
179206 func normalizeConfig(config *Config) {
180- normalizeRendering(config)
181- normalizePrivacy(config)
182207 normalizeAppearance(config)
183208 normalizeMedia(config)
184-
185- config.Runtime.Prefix = strings.TrimSpace(config.Runtime.Prefix)
186- normalizePerformanceProfile(config)
209+ normalizeEngineConfig(config)
187210 }
188211
189-func normalizeRendering(config *Config) {
190- switch strings.ToLower(string(config.Rendering.Mode)) {
191- case "", string(RenderingModeAuto):
192- config.Rendering.Mode = RenderingModeAuto
193- case string(RenderingModeGPU):
194- config.Rendering.Mode = RenderingModeGPU
195- case string(RenderingModeCPU):
196- config.Rendering.Mode = RenderingModeCPU
197- default:
198- config.Rendering.Mode = RenderingModeAuto
199- }
200-
201- switch strings.ToLower(string(config.Rendering.GSKRenderer)) {
212+func normalizeEngineConfig(config *Config) {
213+ // Normalize GSK renderer (engine.webkit)
214+ switch strings.ToLower(string(config.Engine.WebKit.GSKRenderer)) {
202215 case "", string(GSKRendererAuto):
203- config.Rendering.GSKRenderer = GSKRendererAuto
216+ config.Engine.WebKit.GSKRenderer = GSKRendererAuto
204217 case string(GSKRendererOpenGL):
205- config.Rendering.GSKRenderer = GSKRendererOpenGL
218+ config.Engine.WebKit.GSKRenderer = GSKRendererOpenGL
206219 case string(GSKRendererVulkan):
207- config.Rendering.GSKRenderer = GSKRendererVulkan
220+ config.Engine.WebKit.GSKRenderer = GSKRendererVulkan
208221 case string(GSKRendererCairo):
209- config.Rendering.GSKRenderer = GSKRendererCairo
222+ config.Engine.WebKit.GSKRenderer = GSKRendererCairo
210223 default:
211- config.Rendering.GSKRenderer = GSKRendererAuto
224+ config.Engine.WebKit.GSKRenderer = GSKRendererAuto
212225 }
213-}
214226
215-func normalizePrivacy(config *Config) {
216- switch strings.ToLower(string(config.Privacy.CookiePolicy)) {
227+ // Normalize cookie policy (engine)
228+ switch strings.ToLower(string(config.Engine.CookiePolicy)) {
217229 case "", string(CookiePolicyNoThirdParty):
218- config.Privacy.CookiePolicy = CookiePolicyNoThirdParty
230+ config.Engine.CookiePolicy = CookiePolicyNoThirdParty
219231 case string(CookiePolicyAlways):
220- config.Privacy.CookiePolicy = CookiePolicyAlways
232+ config.Engine.CookiePolicy = CookiePolicyAlways
221233 case string(CookiePolicyNever):
222- config.Privacy.CookiePolicy = CookiePolicyNever
234+ config.Engine.CookiePolicy = CookiePolicyNever
235+ default:
236+ config.Engine.CookiePolicy = CookiePolicyNoThirdParty
237+ }
238+
239+ // Normalize performance profile (engine)
240+ switch strings.ToLower(string(config.Engine.Profile)) {
241+ case "", string(ProfileDefault):
242+ config.Engine.Profile = ProfileDefault
243+ case string(ProfileLite):
244+ config.Engine.Profile = ProfileLite
245+ case string(ProfileMax):
246+ config.Engine.Profile = ProfileMax
247+ case string(ProfileCustom):
248+ config.Engine.Profile = ProfileCustom
223249 default:
224- config.Privacy.CookiePolicy = CookiePolicyNoThirdParty
250+ config.Engine.Profile = ProfileDefault
225251 }
252+
253+ // Trim runtime prefix (engine.webkit)
254+ config.Engine.WebKit.Prefix = strings.TrimSpace(config.Engine.WebKit.Prefix)
226255 }
227256
228257 func normalizeAppearance(config *Config) {
@@ -249,34 +278,6 @@ func normalizeMedia(config *Config) {
249278 default:
250279 config.Media.HardwareDecodingMode = HardwareDecodingAuto
251280 }
252-
253- switch strings.ToLower(string(config.Media.GLRenderingMode)) {
254- case "", string(GLRenderingModeAuto):
255- config.Media.GLRenderingMode = GLRenderingModeAuto
256- case string(GLRenderingModeGLES2):
257- config.Media.GLRenderingMode = GLRenderingModeGLES2
258- case string(GLRenderingModeGL3):
259- config.Media.GLRenderingMode = GLRenderingModeGL3
260- case string(GLRenderingModeNone):
261- config.Media.GLRenderingMode = GLRenderingModeNone
262- default:
263- config.Media.GLRenderingMode = GLRenderingModeAuto
264- }
265-}
266-
267-func normalizePerformanceProfile(config *Config) {
268- switch strings.ToLower(string(config.Performance.Profile)) {
269- case "", string(ProfileDefault):
270- config.Performance.Profile = ProfileDefault
271- case string(ProfileLite):
272- config.Performance.Profile = ProfileLite
273- case string(ProfileMax):
274- config.Performance.Profile = ProfileMax
275- case string(ProfileCustom):
276- config.Performance.Profile = ProfileCustom
277- default:
278- config.Performance.Profile = ProfileDefault
279- }
280281 }
281282
282283 // Get returns the current configuration (thread-safe).
@@ -401,17 +402,15 @@ func (m *Manager) setDefaults() {
401402 m.setLoggingDefaults(defaults)
402403 m.setDebugDefaults(defaults)
403404 m.setAppearanceDefaults(defaults)
404- m.setRenderingDefaults(defaults)
405- m.setPrivacyDefaults(defaults)
405+ m.setEngineDefaults(defaults)
406+ m.setZoomAndScaleDefaults(defaults)
406407 m.setWorkspaceDefaults(defaults)
407408 m.setContentFilteringDefaults(defaults)
408409 m.setClipboardDefaults(defaults)
409410 m.setOmniboxDefaults(defaults)
410411 m.setMediaDefaults(defaults)
411- m.setRuntimeDefaults(defaults)
412412 m.setSessionDefaults(defaults)
413413 m.setUpdateDefaults(defaults)
414- m.setPerformanceDefaults(defaults)
415414 m.setDownloadsDefaults(defaults)
416415 }
417416
@@ -461,27 +460,11 @@ func (m *Manager) setAppearanceDefaults(defaults *Config) {
461460 m.viper.SetDefault("appearance.color_scheme", defaults.Appearance.ColorScheme)
462461 }
463462
464-func (m *Manager) setRenderingDefaults(defaults *Config) {
465- m.viper.SetDefault("rendering.mode", string(defaults.Rendering.Mode))
466- m.viper.SetDefault("rendering.disable_dmabuf_renderer", defaults.Rendering.DisableDMABufRenderer)
467- m.viper.SetDefault("rendering.force_compositing_mode", defaults.Rendering.ForceCompositingMode)
468- m.viper.SetDefault("rendering.disable_compositing_mode", defaults.Rendering.DisableCompositingMode)
469- m.viper.SetDefault("rendering.gsk_renderer", string(defaults.Rendering.GSKRenderer))
470- m.viper.SetDefault("rendering.disable_mipmaps", defaults.Rendering.DisableMipmaps)
471- m.viper.SetDefault("rendering.prefer_gl", defaults.Rendering.PreferGL)
472- m.viper.SetDefault("rendering.draw_compositing_indicators", defaults.Rendering.DrawCompositingIndicators)
473- m.viper.SetDefault("rendering.show_fps", defaults.Rendering.ShowFPS)
474- m.viper.SetDefault("rendering.sample_memory", defaults.Rendering.SampleMemory)
475- m.viper.SetDefault("rendering.debug_frames", defaults.Rendering.DebugFrames)
463+func (m *Manager) setZoomAndScaleDefaults(defaults *Config) {
476464 m.viper.SetDefault("default_webpage_zoom", defaults.DefaultWebpageZoom)
477465 m.viper.SetDefault("default_ui_scale", defaults.DefaultUIScale)
478466 }
479467
480-func (m *Manager) setPrivacyDefaults(defaults *Config) {
481- m.viper.SetDefault("privacy.cookie_policy", string(defaults.Privacy.CookiePolicy))
482- m.viper.SetDefault("privacy.itp_enabled", defaults.Privacy.ITPEnabled)
483-}
484-
485468 func (m *Manager) setWorkspaceDefaults(defaults *Config) {
486469 m.viper.SetDefault("workspace.new_pane_url", defaults.Workspace.NewPaneURL)
487470 m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut)
@@ -535,17 +518,14 @@ func (m *Manager) setOmniboxDefaults(defaults *Config) {
535518 }
536519
537520 func (m *Manager) setMediaDefaults(defaults *Config) {
521+ // GStreamer fields (force_vsync, gl_rendering_mode, gstreamer_debug_level)
522+ // have moved to [engine.webkit] — only non-migrated media fields remain here.
538523 m.viper.SetDefault("media.hardware_decoding", string(defaults.Media.HardwareDecodingMode))
539524 m.viper.SetDefault("media.prefer_av1", defaults.Media.PreferAV1)
540525 m.viper.SetDefault("media.show_diagnostics", defaults.Media.ShowDiagnosticsOnStartup)
541- m.viper.SetDefault("media.force_vsync", defaults.Media.ForceVSync)
542- m.viper.SetDefault("media.gl_rendering_mode", string(defaults.Media.GLRenderingMode))
543- m.viper.SetDefault("media.gstreamer_debug_level", defaults.Media.GStreamerDebugLevel)
544526 }
545527
546-func (m *Manager) setRuntimeDefaults(defaults *Config) {
547- m.viper.SetDefault("runtime.prefix", defaults.Runtime.Prefix)
548-}
528+// setRuntimeDefaults removed — runtime.prefix moved to [engine.webkit].
549529
550530 func (m *Manager) setSessionDefaults(defaults *Config) {
551531 m.viper.SetDefault("session.auto_restore", defaults.Session.AutoRestore)
@@ -563,31 +543,49 @@ func (m *Manager) setUpdateDefaults(defaults *Config) {
563543 m.viper.SetDefault("update.notify_on_new_settings", defaults.Update.NotifyOnNewSettings)
564544 }
565545
566-func (m *Manager) setPerformanceDefaults(defaults *Config) {
567- m.viper.SetDefault("performance.profile", string(defaults.Performance.Profile))
568- m.viper.SetDefault("performance.zoom_cache_size", defaults.Performance.ZoomCacheSize)
569- m.viper.SetDefault("performance.webview_pool_prewarm_count", defaults.Performance.WebViewPoolPrewarmCount)
570- // Skia threading (only used when profile = "custom")
571- m.viper.SetDefault("performance.skia_cpu_painting_threads", defaults.Performance.SkiaCPUPaintingThreads)
572- m.viper.SetDefault("performance.skia_gpu_painting_threads", defaults.Performance.SkiaGPUPaintingThreads)
573- m.viper.SetDefault("performance.skia_enable_cpu_rendering", defaults.Performance.SkiaEnableCPURendering)
574- // Web process memory pressure
575- m.viper.SetDefault("performance.web_process_memory_limit_mb", defaults.Performance.WebProcessMemoryLimitMB)
576- m.viper.SetDefault("performance.web_process_memory_poll_interval_sec", defaults.Performance.WebProcessMemoryPollIntervalSec)
577- m.viper.SetDefault("performance.web_process_memory_conservative_threshold", defaults.Performance.WebProcessMemoryConservativeThreshold)
578- m.viper.SetDefault("performance.web_process_memory_strict_threshold", defaults.Performance.WebProcessMemoryStrictThreshold)
579- // Network process memory pressure
580- m.viper.SetDefault("performance.network_process_memory_limit_mb", defaults.Performance.NetworkProcessMemoryLimitMB)
581- m.viper.SetDefault("performance.network_process_memory_poll_interval_sec", defaults.Performance.NetworkProcessMemoryPollIntervalSec)
582- netConservativeThreshold := defaults.Performance.NetworkProcessMemoryConservativeThreshold
583- m.viper.SetDefault("performance.network_process_memory_conservative_threshold", netConservativeThreshold)
584- m.viper.SetDefault("performance.network_process_memory_strict_threshold", defaults.Performance.NetworkProcessMemoryStrictThreshold)
585-}
546+// setPerformanceDefaults removed — all fields moved to [engine]/[engine.webkit].
586547
587548 func (m *Manager) setDownloadsDefaults(defaults *Config) {
588549 m.viper.SetDefault("downloads.path", defaults.Downloads.Path)
589550 }
590551
552+func (m *Manager) setEngineDefaults(defaults *Config) {
553+ e := defaults.Engine
554+ m.viper.SetDefault("engine.type", e.Type)
555+ m.viper.SetDefault("engine.profile", string(e.Profile))
556+ m.viper.SetDefault("engine.pool_prewarm_count", e.PoolPrewarmCount)
557+ m.viper.SetDefault("engine.zoom_cache_size", e.ZoomCacheSize)
558+ m.viper.SetDefault("engine.cookie_policy", string(e.CookiePolicy))
559+
560+ wk := e.WebKit
561+ m.viper.SetDefault("engine.webkit.itp_enabled", wk.ITPEnabled)
562+ m.viper.SetDefault("engine.webkit.skia_cpu_painting_threads", wk.SkiaCPUPaintingThreads)
563+ m.viper.SetDefault("engine.webkit.skia_gpu_painting_threads", wk.SkiaGPUPaintingThreads)
564+ m.viper.SetDefault("engine.webkit.skia_enable_cpu_rendering", wk.SkiaEnableCPURendering)
565+ m.viper.SetDefault("engine.webkit.disable_dmabuf_renderer", wk.DisableDMABufRenderer)
566+ m.viper.SetDefault("engine.webkit.force_compositing_mode", wk.ForceCompositingMode)
567+ m.viper.SetDefault("engine.webkit.disable_compositing_mode", wk.DisableCompositingMode)
568+ m.viper.SetDefault("engine.webkit.gsk_renderer", string(wk.GSKRenderer))
569+ m.viper.SetDefault("engine.webkit.disable_mipmaps", wk.DisableMipmaps)
570+ m.viper.SetDefault("engine.webkit.prefer_gl", wk.PreferGL)
571+ m.viper.SetDefault("engine.webkit.draw_compositing_indicators", wk.DrawCompositingIndicators)
572+ m.viper.SetDefault("engine.webkit.show_fps", wk.ShowFPS)
573+ m.viper.SetDefault("engine.webkit.sample_memory", wk.SampleMemory)
574+ m.viper.SetDefault("engine.webkit.debug_frames", wk.DebugFrames)
575+ m.viper.SetDefault("engine.webkit.force_vsync", wk.ForceVSync)
576+ m.viper.SetDefault("engine.webkit.gl_rendering_mode", string(wk.GLRenderingMode))
577+ m.viper.SetDefault("engine.webkit.gstreamer_debug_level", wk.GStreamerDebugLevel)
578+ m.viper.SetDefault("engine.webkit.prefix", wk.Prefix)
579+ m.viper.SetDefault("engine.webkit.web_process_memory_limit_mb", wk.WebProcessMemoryLimitMB)
580+ m.viper.SetDefault("engine.webkit.web_process_memory_poll_interval_sec", wk.WebProcessMemoryPollIntervalSec)
581+ m.viper.SetDefault("engine.webkit.web_process_memory_conservative_threshold", wk.WebProcessMemoryConservativeThreshold)
582+ m.viper.SetDefault("engine.webkit.web_process_memory_strict_threshold", wk.WebProcessMemoryStrictThreshold)
583+ m.viper.SetDefault("engine.webkit.network_process_memory_limit_mb", wk.NetworkProcessMemoryLimitMB)
584+ m.viper.SetDefault("engine.webkit.network_process_memory_poll_interval_sec", wk.NetworkProcessMemoryPollIntervalSec)
585+ m.viper.SetDefault("engine.webkit.network_process_memory_conservative_threshold", wk.NetworkProcessMemoryConservativeThreshold)
586+ m.viper.SetDefault("engine.webkit.network_process_memory_strict_threshold", wk.NetworkProcessMemoryStrictThreshold)
587+}
588+
591589 // New returns a new default configuration instance.
592590 // This is a convenience function for getting default config without the full manager.
593591 func New() *Config {
A internal/infrastructure/config/loader_legacy_test.go +77 -0
@@ -0,0 +1,77 @@
1+package config
2+
3+import (
4+ "testing"
5+
6+ "github.com/spf13/viper"
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestCheckLegacyFormat_OldSectionsNoEngine_ReturnsError(t *testing.T) {
11+ m := &Manager{viper: viper.New()}
12+ m.viper.Set("rendering.mode", "gpu")
13+
14+ err := m.checkLegacyFormat()
15+
16+ require.Error(t, err)
17+ require.Contains(t, err.Error(), "dumber config migrate")
18+}
19+
20+func TestCheckLegacyFormat_OldSectionsWithEngine_ReturnsError(t *testing.T) {
21+ m := &Manager{viper: viper.New()}
22+ m.viper.Set("rendering.mode", "gpu")
23+ m.viper.Set("engine.type", "webkit")
24+
25+ err := m.checkLegacyFormat()
26+
27+ require.Error(t, err)
28+ require.Contains(t, err.Error(), "dumber config migrate")
29+}
30+
31+func TestCheckLegacyFormat_FreshConfig_ReturnsNoError(t *testing.T) {
32+ m := &Manager{viper: viper.New()}
33+
34+ err := m.checkLegacyFormat()
35+
36+ require.NoError(t, err)
37+}
38+
39+func TestCheckLegacyFormat_DisableDmabuf_ReturnsError(t *testing.T) {
40+ m := &Manager{viper: viper.New()}
41+ m.viper.Set("rendering.disable_dmabuf_renderer", true)
42+
43+ err := m.checkLegacyFormat()
44+
45+ require.Error(t, err)
46+ require.Contains(t, err.Error(), "dumber config migrate")
47+}
48+
49+func TestCheckLegacyFormat_PerformanceProfile_ReturnsError(t *testing.T) {
50+ m := &Manager{viper: viper.New()}
51+ m.viper.Set("performance.profile", "lite")
52+
53+ err := m.checkLegacyFormat()
54+
55+ require.Error(t, err)
56+ require.Contains(t, err.Error(), "dumber config migrate")
57+}
58+
59+func TestCheckLegacyFormat_PrivacyCookiePolicy_ReturnsError(t *testing.T) {
60+ m := &Manager{viper: viper.New()}
61+ m.viper.Set("privacy.cookie_policy", "always")
62+
63+ err := m.checkLegacyFormat()
64+
65+ require.Error(t, err)
66+ require.Contains(t, err.Error(), "dumber config migrate")
67+}
68+
69+func TestCheckLegacyFormat_RuntimePrefix_ReturnsError(t *testing.T) {
70+ m := &Manager{viper: viper.New()}
71+ m.viper.Set("runtime.prefix", "/usr")
72+
73+ err := m.checkLegacyFormat()
74+
75+ require.Error(t, err)
76+ require.Contains(t, err.Error(), "dumber config migrate")
77+}
M internal/infrastructure/config/loader_test.go +22 -6
@@ -7,19 +7,35 @@ import (
77 "github.com/stretchr/testify/assert"
88 )
99
10-func TestSetPrivacyDefaults(t *testing.T) {
10+func TestSetEngineDefaults(t *testing.T) {
1111 mgr := &Manager{viper: viper.New()}
1212 mgr.setDefaults()
1313
14- assert.Equal(t, "no_third_party", mgr.viper.GetString("privacy.cookie_policy"))
15- assert.True(t, mgr.viper.GetBool("privacy.itp_enabled"))
14+ // Universal engine fields
15+ assert.Equal(t, "webkit", mgr.viper.GetString("engine.type"))
16+ assert.Equal(t, "default", mgr.viper.GetString("engine.profile"))
17+ assert.Equal(t, "no_third_party", mgr.viper.GetString("engine.cookie_policy"))
18+ assert.Equal(t, 4, mgr.viper.GetInt("engine.pool_prewarm_count"))
19+ assert.Equal(t, 256, mgr.viper.GetInt("engine.zoom_cache_size"))
20+
21+ // WebKit-specific engine fields
22+ assert.True(t, mgr.viper.GetBool("engine.webkit.itp_enabled"))
23+ assert.Equal(t, "auto", mgr.viper.GetString("engine.webkit.gsk_renderer"))
24+ assert.Equal(t, "auto", mgr.viper.GetString("engine.webkit.gl_rendering_mode"))
25+ assert.False(t, mgr.viper.GetBool("engine.webkit.disable_dmabuf_renderer"))
26+
27+ // Old sections should NOT be registered as defaults
28+ assert.Empty(t, mgr.viper.GetString("privacy.cookie_policy"))
29+ assert.Empty(t, mgr.viper.GetString("rendering.mode"))
30+ assert.Empty(t, mgr.viper.GetString("performance.profile"))
31+ assert.Empty(t, mgr.viper.GetString("runtime.prefix"))
1632 }
1733
18-func TestNormalizeConfig_PrivacyCookiePolicy(t *testing.T) {
34+func TestNormalizeConfig_EngineCookiePolicy(t *testing.T) {
1935 cfg := DefaultConfig()
20- cfg.Privacy.CookiePolicy = CookiePolicy("INVALID")
36+ cfg.Engine.CookiePolicy = CookiePolicy("INVALID")
2137
2238 normalizeConfig(cfg)
2339
24- assert.Equal(t, CookiePolicyNoThirdParty, cfg.Privacy.CookiePolicy)
40+ assert.Equal(t, CookiePolicyNoThirdParty, cfg.Engine.CookiePolicy)
2541 }
A internal/infrastructure/config/migrate_engine.go +232 -0
@@ -0,0 +1,232 @@
1+package config
2+
3+import (
4+ "fmt"
5+ "github.com/pelletier/go-toml/v2"
6+ "io/fs"
7+ "os"
8+)
9+
10+// configFilePermissions is the file permission used when writing migrated config files.
11+const configFilePermissions fs.FileMode = 0o644
12+
13+// engineMigrationMapping defines old key -> new key mappings for the engine migration.
14+// Keys with empty new key ("") are dropped (not migrated).
15+// The source section is the first part of the old key.
16+type engineFieldMapping struct {
17+ oldSection string
18+ oldField string
19+ newSection string // "engine" or "engine.webkit"
20+ newField string // empty means drop
21+}
22+
23+// engineUniversalMappings are fields that go into [engine] (not [engine.webkit]).
24+var engineUniversalMappings = []engineFieldMapping{
25+ // [performance] -> [engine]
26+ {oldSection: "performance", oldField: "profile", newSection: "engine", newField: "profile"},
27+ {oldSection: "performance", oldField: "zoom_cache_size", newSection: "engine", newField: "zoom_cache_size"},
28+ {oldSection: "performance", oldField: "webview_pool_prewarm_count", newSection: "engine", newField: "pool_prewarm_count"},
29+ // [privacy] -> [engine]
30+ {oldSection: "privacy", oldField: "cookie_policy", newSection: "engine", newField: "cookie_policy"},
31+}
32+
33+// engineWebkitMappings are fields that go into [engine.webkit].
34+var engineWebkitMappings = []engineFieldMapping{
35+ // [privacy] -> [engine.webkit]
36+ {oldSection: "privacy", oldField: "itp_enabled", newSection: "engine.webkit", newField: "itp_enabled"},
37+
38+ // [rendering] -> [engine.webkit] (rendering.mode is dropped)
39+ {oldSection: "rendering", oldField: "disable_dmabuf_renderer", newSection: "engine.webkit", newField: "disable_dmabuf_renderer"},
40+ {oldSection: "rendering", oldField: "force_compositing_mode", newSection: "engine.webkit", newField: "force_compositing_mode"},
41+ {oldSection: "rendering", oldField: "disable_compositing_mode", newSection: "engine.webkit", newField: "disable_compositing_mode"},
42+ {oldSection: "rendering", oldField: "gsk_renderer", newSection: "engine.webkit", newField: "gsk_renderer"},
43+ {oldSection: "rendering", oldField: "disable_mipmaps", newSection: "engine.webkit", newField: "disable_mipmaps"},
44+ {oldSection: "rendering", oldField: "prefer_gl", newSection: "engine.webkit", newField: "prefer_gl"},
45+ {oldSection: "rendering", oldField: "draw_compositing_indicators", newSection: "engine.webkit", newField: "draw_compositing_indicators"},
46+ {oldSection: "rendering", oldField: "show_fps", newSection: "engine.webkit", newField: "show_fps"},
47+ {oldSection: "rendering", oldField: "sample_memory", newSection: "engine.webkit", newField: "sample_memory"},
48+ {oldSection: "rendering", oldField: "debug_frames", newSection: "engine.webkit", newField: "debug_frames"},
49+
50+ // [performance] -> [engine.webkit] (webkit-specific) — field names are identical
51+ {oldSection: "performance", oldField: "skia_cpu_painting_threads",
52+ newSection: "engine.webkit", newField: "skia_cpu_painting_threads"},
53+ {oldSection: "performance", oldField: "skia_gpu_painting_threads",
54+ newSection: "engine.webkit", newField: "skia_gpu_painting_threads"},
55+ {oldSection: "performance", oldField: "skia_enable_cpu_rendering",
56+ newSection: "engine.webkit", newField: "skia_enable_cpu_rendering"},
57+ {oldSection: "performance", oldField: "web_process_memory_limit_mb",
58+ newSection: "engine.webkit", newField: "web_process_memory_limit_mb"},
59+ {oldSection: "performance", oldField: "web_process_memory_poll_interval_sec",
60+ newSection: "engine.webkit", newField: "web_process_memory_poll_interval_sec"},
61+ {oldSection: "performance", oldField: "web_process_memory_conservative_threshold",
62+ newSection: "engine.webkit", newField: "web_process_memory_conservative_threshold"},
63+ {oldSection: "performance", oldField: "web_process_memory_strict_threshold",
64+ newSection: "engine.webkit", newField: "web_process_memory_strict_threshold"},
65+ {oldSection: "performance", oldField: "network_process_memory_limit_mb",
66+ newSection: "engine.webkit", newField: "network_process_memory_limit_mb"},
67+ {oldSection: "performance", oldField: "network_process_memory_poll_interval_sec",
68+ newSection: "engine.webkit", newField: "network_process_memory_poll_interval_sec"},
69+ {oldSection: "performance", oldField: "network_process_memory_conservative_threshold",
70+ newSection: "engine.webkit", newField: "network_process_memory_conservative_threshold"},
71+ {oldSection: "performance", oldField: "network_process_memory_strict_threshold",
72+ newSection: "engine.webkit", newField: "network_process_memory_strict_threshold"},
73+
74+ // [media] GStreamer fields -> [engine.webkit]
75+ {oldSection: "media", oldField: "force_vsync", newSection: "engine.webkit", newField: "force_vsync"},
76+ {oldSection: "media", oldField: "gl_rendering_mode", newSection: "engine.webkit", newField: "gl_rendering_mode"},
77+ {oldSection: "media", oldField: "gstreamer_debug_level", newSection: "engine.webkit", newField: "gstreamer_debug_level"},
78+
79+ // [runtime] -> [engine.webkit]
80+ {oldSection: "runtime", oldField: "prefix", newSection: "engine.webkit", newField: "prefix"},
81+}
82+
83+// oldSectionsToRemove are the sections that are fully removed after migration
84+// (media is not here because it has fields that stay).
85+var oldSectionsToRemove = []string{"rendering", "performance", "privacy", "runtime"}
86+
87+// MigrateToEngineConfig restructures old top-level config sections into [engine]/[engine.webkit].
88+// Returns true if migration was performed, false if already migrated or no old sections found.
89+func MigrateToEngineConfig(configFile string) (bool, error) {
90+ raw, err := readRawTOML(configFile)
91+ if err != nil {
92+ return false, err
93+ }
94+
95+ if _, exists := raw["engine"]; exists {
96+ for _, section := range []string{"rendering", "performance", "privacy", "runtime"} {
97+ if _, hasLegacy := raw[section]; hasLegacy {
98+ return false, fmt.Errorf(
99+ "mixed old/new config: [engine] coexists with legacy [%s]; "+
100+ "remove [rendering], [performance], [privacy], [runtime]",
101+ section,
102+ )
103+ }
104+ }
105+ // Check for deprecated media keys that moved to [engine.webkit].
106+ if mediaRaw, ok := raw["media"].(map[string]any); ok {
107+ for _, key := range []string{"force_vsync", "gl_rendering_mode", "gstreamer_debug_level"} {
108+ if _, has := mediaRaw[key]; has {
109+ return false, fmt.Errorf(
110+ "mixed old/new config: [engine] coexists with deprecated "+
111+ "media.%s; remove it from [media] (now in [engine.webkit])",
112+ key,
113+ )
114+ }
115+ }
116+ }
117+ return false, nil // already migrated
118+ }
119+
120+ if !hasOldEngineSections(raw) {
121+ return false, nil
122+ }
123+
124+ applyEngineMappings(raw)
125+
126+ out, marshalErr := toml.Marshal(raw)
127+ if marshalErr != nil {
128+ return false, marshalErr
129+ }
130+
131+ if err := atomicWriteFile(configFile, string(out), configFilePermissions); err != nil {
132+ return false, err
133+ }
134+
135+ return true, nil
136+}
137+
138+// readRawTOML reads a TOML file into a generic map.
139+func readRawTOML(path string) (map[string]any, error) {
140+ data, err := os.ReadFile(path)
141+ if err != nil {
142+ return nil, err
143+ }
144+ var raw map[string]any
145+ if err := toml.Unmarshal(data, &raw); err != nil {
146+ return nil, err
147+ }
148+ return raw, nil
149+}
150+
151+// hasOldEngineSections checks if the raw config has old sections that need migration.
152+func hasOldEngineSections(raw map[string]any) bool {
153+ for _, section := range []string{"rendering", "performance", "privacy", "runtime"} {
154+ if _, exists := raw[section]; exists {
155+ return true
156+ }
157+ }
158+ if mediaAny, exists := raw["media"]; exists {
159+ if media, ok := mediaAny.(map[string]any); ok {
160+ for _, f := range []string{"force_vsync", "gl_rendering_mode", "gstreamer_debug_level"} {
161+ if _, has := media[f]; has {
162+ return true
163+ }
164+ }
165+ }
166+ }
167+ return false
168+}
169+
170+// applyEngineMappings builds [engine]/[engine.webkit] from old sections and cleans up.
171+func applyEngineMappings(raw map[string]any) {
172+ engineMap := make(map[string]any)
173+ webkitMap := make(map[string]any)
174+
175+ for _, m := range engineUniversalMappings {
176+ if val := getSectionField(raw, m.oldSection, m.oldField); val != nil {
177+ engineMap[m.newField] = val
178+ }
179+ }
180+ for _, m := range engineWebkitMappings {
181+ if val := getSectionField(raw, m.oldSection, m.oldField); val != nil {
182+ webkitMap[m.newField] = val
183+ }
184+ }
185+
186+ engineMap["type"] = "webkit"
187+ if len(webkitMap) > 0 {
188+ engineMap["webkit"] = webkitMap
189+ }
190+ raw["engine"] = engineMap
191+
192+ for _, section := range oldSectionsToRemove {
193+ delete(raw, section)
194+ }
195+
196+ stripMigratedMediaFields(raw)
197+}
198+
199+// stripMigratedMediaFields removes GStreamer fields from [media] while keeping the rest.
200+func stripMigratedMediaFields(raw map[string]any) {
201+ mediaAny, exists := raw["media"]
202+ if !exists {
203+ return
204+ }
205+ media, ok := mediaAny.(map[string]any)
206+ if !ok {
207+ return
208+ }
209+ delete(media, "force_vsync")
210+ delete(media, "gl_rendering_mode")
211+ delete(media, "gstreamer_debug_level")
212+ if len(media) == 0 {
213+ delete(raw, "media")
214+ }
215+}
216+
217+// getSectionField retrieves a field value from a named section in the raw config map.
218+func getSectionField(raw map[string]any, section, field string) any {
219+ sectionAny, exists := raw[section]
220+ if !exists {
221+ return nil
222+ }
223+ sectionMap, ok := sectionAny.(map[string]any)
224+ if !ok {
225+ return nil
226+ }
227+ val, exists := sectionMap[field]
228+ if !exists {
229+ return nil
230+ }
231+ return val
232+}
A internal/infrastructure/config/migrate_engine_test.go +284 -0
@@ -0,0 +1,284 @@
1+package config
2+
3+import (
4+ "os"
5+ "path/filepath"
6+ "testing"
7+
8+ "github.com/pelletier/go-toml/v2"
9+ "github.com/stretchr/testify/assert"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+const oldFormatConfig = `
14+[performance]
15+profile = "balanced"
16+zoom_cache_size = 50
17+webview_pool_prewarm_count = 2
18+skia_cpu_painting_threads = 4
19+skia_gpu_painting_threads = 2
20+skia_enable_cpu_rendering = false
21+web_process_memory_limit_mb = 512
22+web_process_memory_poll_interval_sec = 5.0
23+web_process_memory_conservative_threshold = 0.7
24+web_process_memory_strict_threshold = 0.9
25+network_process_memory_limit_mb = 256
26+network_process_memory_poll_interval_sec = 10.0
27+network_process_memory_conservative_threshold = 0.6
28+network_process_memory_strict_threshold = 0.85
29+
30+[privacy]
31+cookie_policy = "accept_all"
32+itp_enabled = true
33+
34+[rendering]
35+mode = "auto"
36+disable_dmabuf_renderer = false
37+force_compositing_mode = true
38+disable_compositing_mode = false
39+gsk_renderer = "gl"
40+disable_mipmaps = false
41+prefer_gl = true
42+draw_compositing_indicators = false
43+show_fps = false
44+sample_memory = false
45+debug_frames = false
46+
47+[media]
48+hardware_decoding = true
49+prefer_av1 = false
50+show_diagnostics = false
51+force_vsync = true
52+gl_rendering_mode = "auto"
53+gstreamer_debug_level = 2
54+
55+[runtime]
56+prefix = "/usr/local"
57+`
58+
59+func TestMigrateToEngineConfig_OldFormat(t *testing.T) {
60+ tmpDir := t.TempDir()
61+ configFile := filepath.Join(tmpDir, "config.toml")
62+
63+ err := os.WriteFile(configFile, []byte(oldFormatConfig), 0o644)
64+ require.NoError(t, err)
65+
66+ migrated, err := MigrateToEngineConfig(configFile)
67+ require.NoError(t, err)
68+ assert.True(t, migrated, "should report migration performed")
69+
70+ // Read the migrated config
71+ data, err := os.ReadFile(configFile)
72+ require.NoError(t, err)
73+
74+ var raw map[string]any
75+ err = toml.Unmarshal(data, &raw)
76+ require.NoError(t, err)
77+
78+ // [engine] section must exist
79+ engineAny, ok := raw["engine"]
80+ require.True(t, ok, "engine section must exist")
81+ engine, ok := engineAny.(map[string]any)
82+ require.True(t, ok, "engine must be a map")
83+
84+ // engine.type defaults to "webkit"
85+ assert.Equal(t, "webkit", engine["type"])
86+
87+ // [performance] -> [engine] universal fields
88+ assert.Equal(t, "balanced", engine["profile"])
89+ assert.Equal(t, int64(50), engine["zoom_cache_size"])
90+ assert.Equal(t, int64(2), engine["pool_prewarm_count"])
91+
92+ // [privacy] -> [engine] universal field
93+ assert.Equal(t, "accept_all", engine["cookie_policy"])
94+
95+ // [engine.webkit] section
96+ webkitAny, ok := engine["webkit"]
97+ require.True(t, ok, "engine.webkit section must exist")
98+ webkit, ok := webkitAny.(map[string]any)
99+ require.True(t, ok, "engine.webkit must be a map")
100+
101+ // [privacy] -> [engine.webkit]
102+ assert.Equal(t, true, webkit["itp_enabled"])
103+
104+ // [rendering] -> [engine.webkit] (excluding mode which is dropped)
105+ assert.Equal(t, false, webkit["disable_dmabuf_renderer"])
106+ assert.Equal(t, true, webkit["force_compositing_mode"])
107+ assert.Equal(t, false, webkit["disable_compositing_mode"])
108+ assert.Equal(t, "gl", webkit["gsk_renderer"])
109+ assert.Equal(t, false, webkit["disable_mipmaps"])
110+ assert.Equal(t, true, webkit["prefer_gl"])
111+ assert.Equal(t, false, webkit["draw_compositing_indicators"])
112+ assert.Equal(t, false, webkit["show_fps"])
113+ assert.Equal(t, false, webkit["sample_memory"])
114+ assert.Equal(t, false, webkit["debug_frames"])
115+ // rendering.mode must be dropped (not present in webkit)
116+ assert.NotContains(t, webkit, "mode")
117+
118+ // [performance] -> [engine.webkit] (webkit-specific)
119+ assert.Equal(t, int64(4), webkit["skia_cpu_painting_threads"])
120+ assert.Equal(t, int64(2), webkit["skia_gpu_painting_threads"])
121+ assert.Equal(t, false, webkit["skia_enable_cpu_rendering"])
122+ assert.Equal(t, int64(512), webkit["web_process_memory_limit_mb"])
123+ assert.InDelta(t, 5.0, webkit["web_process_memory_poll_interval_sec"], 0.001)
124+ assert.InDelta(t, 0.7, webkit["web_process_memory_conservative_threshold"], 0.001)
125+ assert.InDelta(t, 0.9, webkit["web_process_memory_strict_threshold"], 0.001)
126+ assert.Equal(t, int64(256), webkit["network_process_memory_limit_mb"])
127+ assert.InDelta(t, 10.0, webkit["network_process_memory_poll_interval_sec"], 0.001)
128+ assert.InDelta(t, 0.6, webkit["network_process_memory_conservative_threshold"], 0.001)
129+ assert.InDelta(t, 0.85, webkit["network_process_memory_strict_threshold"], 0.001)
130+
131+ // [media] GStreamer fields -> [engine.webkit]
132+ assert.Equal(t, true, webkit["force_vsync"])
133+ assert.Equal(t, "auto", webkit["gl_rendering_mode"])
134+ assert.Equal(t, int64(2), webkit["gstreamer_debug_level"])
135+
136+ // [runtime] -> [engine.webkit]
137+ assert.Equal(t, "/usr/local", webkit["prefix"])
138+
139+ // Old top-level sections must be removed
140+ assert.NotContains(t, raw, "performance")
141+ assert.NotContains(t, raw, "rendering")
142+ assert.NotContains(t, raw, "runtime")
143+
144+ // [privacy] should be removed (all fields migrated)
145+ assert.NotContains(t, raw, "privacy")
146+}
147+
148+func TestMigrateToEngineConfig_AlreadyMigrated(t *testing.T) {
149+ tmpDir := t.TempDir()
150+ configFile := filepath.Join(tmpDir, "config.toml")
151+
152+ alreadyMigrated := `
153+[engine]
154+type = "webkit"
155+profile = "balanced"
156+
157+[engine.webkit]
158+itp_enabled = true
159+`
160+ err := os.WriteFile(configFile, []byte(alreadyMigrated), 0o644)
161+ require.NoError(t, err)
162+
163+ migrated, err := MigrateToEngineConfig(configFile)
164+ require.NoError(t, err)
165+ assert.False(t, migrated, "should not migrate when already migrated")
166+
167+ // Content should be unchanged
168+ data, err := os.ReadFile(configFile)
169+ require.NoError(t, err)
170+ assert.Equal(t, alreadyMigrated, string(data))
171+}
172+
173+func TestMigrateToEngineConfig_NoOldSections(t *testing.T) {
174+ tmpDir := t.TempDir()
175+ configFile := filepath.Join(tmpDir, "config.toml")
176+
177+ noOldSections := `
178+[history]
179+max_entries = 1000
180+
181+[logging]
182+level = "info"
183+`
184+ err := os.WriteFile(configFile, []byte(noOldSections), 0o644)
185+ require.NoError(t, err)
186+
187+ migrated, err := MigrateToEngineConfig(configFile)
188+ require.NoError(t, err)
189+ assert.False(t, migrated, "should not migrate when no old sections exist")
190+
191+ // Content should be unchanged
192+ data, err := os.ReadFile(configFile)
193+ require.NoError(t, err)
194+ assert.Equal(t, noOldSections, string(data))
195+}
196+
197+func TestMigrateToEngineConfig_PreservesNonMigratedMediaFields(t *testing.T) {
198+ tmpDir := t.TempDir()
199+ configFile := filepath.Join(tmpDir, "config.toml")
200+
201+ content := `
202+[media]
203+hardware_decoding = true
204+prefer_av1 = false
205+show_diagnostics = true
206+force_vsync = true
207+gl_rendering_mode = "auto"
208+gstreamer_debug_level = 3
209+
210+[rendering]
211+disable_dmabuf_renderer = false
212+`
213+ err := os.WriteFile(configFile, []byte(content), 0o644)
214+ require.NoError(t, err)
215+
216+ migrated, err := MigrateToEngineConfig(configFile)
217+ require.NoError(t, err)
218+ assert.True(t, migrated)
219+
220+ data, err := os.ReadFile(configFile)
221+ require.NoError(t, err)
222+
223+ var raw map[string]any
224+ err = toml.Unmarshal(data, &raw)
225+ require.NoError(t, err)
226+
227+ // media section must still exist with non-migrated fields
228+ mediaAny, ok := raw["media"]
229+ require.True(t, ok, "media section must still exist")
230+ media, ok := mediaAny.(map[string]any)
231+ require.True(t, ok)
232+
233+ assert.Equal(t, true, media["hardware_decoding"])
234+ assert.Equal(t, false, media["prefer_av1"])
235+ assert.Equal(t, true, media["show_diagnostics"])
236+
237+ // Migrated media fields should be removed from media
238+ assert.NotContains(t, media, "force_vsync")
239+ assert.NotContains(t, media, "gl_rendering_mode")
240+ assert.NotContains(t, media, "gstreamer_debug_level")
241+
242+ // Migrated fields should be in engine.webkit
243+ engineAny, ok := raw["engine"]
244+ require.True(t, ok, "engine section must exist")
245+ engine, ok := engineAny.(map[string]any)
246+ require.True(t, ok, "engine section must be a map")
247+ webkitAny, ok := engine["webkit"]
248+ require.True(t, ok, "engine.webkit section must exist")
249+ webkit, ok := webkitAny.(map[string]any)
250+ require.True(t, ok, "engine.webkit section must be a map")
251+ assert.Equal(t, true, webkit["force_vsync"])
252+ assert.Equal(t, "auto", webkit["gl_rendering_mode"])
253+ assert.Equal(t, int64(3), webkit["gstreamer_debug_level"])
254+}
255+
256+func TestMigrateToEngineConfig_EngineTypeDefault(t *testing.T) {
257+ tmpDir := t.TempDir()
258+ configFile := filepath.Join(tmpDir, "config.toml")
259+
260+ // Minimal old-format config to trigger migration
261+ content := `
262+[rendering]
263+show_fps = true
264+`
265+ err := os.WriteFile(configFile, []byte(content), 0o644)
266+ require.NoError(t, err)
267+
268+ migrated, err := MigrateToEngineConfig(configFile)
269+ require.NoError(t, err)
270+ assert.True(t, migrated)
271+
272+ data, err := os.ReadFile(configFile)
273+ require.NoError(t, err)
274+
275+ var raw map[string]any
276+ err = toml.Unmarshal(data, &raw)
277+ require.NoError(t, err)
278+
279+ engineAny, ok := raw["engine"]
280+ require.True(t, ok, "raw[\"engine\"] should exist")
281+ engine, ok := engineAny.(map[string]any)
282+ require.True(t, ok, "raw[\"engine\"] should be map[string]any, got %T", engineAny)
283+ assert.Equal(t, "webkit", engine["type"], "engine.type should default to webkit")
284+}
M internal/infrastructure/config/schema.go +79 -356
@@ -1,5 +1,70 @@
11 package config
22
3+import "github.com/bnema/dumber/internal/domain/entity"
4+
5+// Type aliases for config value types now defined in domain/entity.
6+// These aliases keep all existing config-package callers working without
7+// any changes to their import paths.
8+
9+// AppearanceConfig holds UI/rendering preferences.
10+type AppearanceConfig = entity.AppearanceConfig
11+
12+// ColorPalette contains semantic color tokens for light/dark themes.
13+type ColorPalette = entity.ColorPalette
14+
15+// ActionBinding defines a keybinding with optional description.
16+type ActionBinding = entity.ActionBinding
17+
18+// PaneModeConfig defines modal behavior for pane management.
19+type PaneModeConfig = entity.PaneModeConfig
20+
21+// TabModeConfig defines modal behavior for tab management (Zellij-style).
22+type TabModeConfig = entity.TabModeConfig
23+
24+// ResizeModeConfig defines modal behavior for resizing panes (Zellij-style).
25+type ResizeModeConfig = entity.ResizeModeConfig
26+
27+// SessionModeConfig defines modal behavior for session management.
28+type SessionModeConfig = entity.SessionModeConfig
29+
30+// SessionConfig holds session persistence settings.
31+type SessionConfig = entity.SessionConfig
32+
33+// GlobalShortcutsConfig defines global shortcuts (always active, not modal).
34+type GlobalShortcutsConfig = entity.GlobalShortcutsConfig
35+
36+// FloatingPaneProfile defines a shortcut profile that opens a URL in the floating pane.
37+type FloatingPaneProfile = entity.FloatingPaneProfile
38+
39+// FloatingPaneConfig defines persistent floating pane behavior.
40+type FloatingPaneConfig = entity.FloatingPaneConfig
41+
42+// WorkspaceStylingConfig defines visual styling for workspace panes.
43+type WorkspaceStylingConfig = entity.WorkspaceStylingConfig
44+
45+// PopupBehavior defines how popup windows should be opened.
46+type PopupBehavior = entity.PopupBehavior
47+
48+const (
49+ // PopupBehaviorSplit opens popups in a split pane (default)
50+ PopupBehaviorSplit = entity.PopupBehaviorSplit
51+ // PopupBehaviorStacked opens popups in a stacked pane
52+ PopupBehaviorStacked = entity.PopupBehaviorStacked
53+ // PopupBehaviorTabbed opens popups as a new tab
54+ PopupBehaviorTabbed = entity.PopupBehaviorTabbed
55+ // PopupBehaviorWindowed opens popups in a new workspace window
56+ PopupBehaviorWindowed = entity.PopupBehaviorWindowed
57+)
58+
59+// PopupBehaviorConfig defines handling for popup windows.
60+type PopupBehaviorConfig = entity.PopupBehaviorConfig
61+
62+// WorkspaceConfig captures layout, pane, and tab behavior preferences.
63+type WorkspaceConfig = entity.WorkspaceConfig
64+
65+// UpdateConfig holds automatic update settings.
66+type UpdateConfig = entity.UpdateConfig
67+
368 // Config represents the complete configuration for dumber.
469 type Config struct {
570 Database DatabaseConfig `mapstructure:"database" yaml:"database" toml:"database"`
@@ -25,31 +90,16 @@ type Config struct {
2590 Clipboard ClipboardConfig `mapstructure:"clipboard" yaml:"clipboard" toml:"clipboard"`
2691 // Omnibox controls the omnibox behavior (initial history display)
2792 Omnibox OmniboxConfig `mapstructure:"omnibox" yaml:"omnibox" toml:"omnibox"`
28- // Rendering controls WebKit, GTK and compositor behavior.
29- Rendering RenderingConfig `mapstructure:"rendering" yaml:"rendering" toml:"rendering"`
30- // Privacy controls cookie acceptance and tracking prevention.
31- Privacy PrivacyConfig `mapstructure:"privacy" yaml:"privacy" toml:"privacy"`
3293 // Media controls video playback and hardware acceleration
3394 Media MediaConfig `mapstructure:"media" yaml:"media" toml:"media"`
34- // Runtime configures optional runtime overrides (e.g., /opt prefix for WebKitGTK/GTK).
35- Runtime RuntimeConfig `mapstructure:"runtime" yaml:"runtime" toml:"runtime"`
36- // Performance holds internal performance tuning options (not exposed in UI).
37- Performance PerformanceConfig `mapstructure:"performance" yaml:"performance" toml:"performance"`
3895 // Update controls automatic update checking and downloading.
3996 Update UpdateConfig `mapstructure:"update" yaml:"update" toml:"update"`
4097 // Downloads configures file download behavior.
4198 Downloads DownloadsConfig `mapstructure:"downloads" yaml:"downloads" toml:"downloads"`
99+ // Engine holds engine selection and unified engine options.
100+ Engine EngineConfig `mapstructure:"engine" toml:"engine" yaml:"engine"`
42101 }
43102
44-// RenderingMode selects GPU vs CPU rendering.
45-type RenderingMode string
46-
47-const (
48- RenderingModeAuto RenderingMode = "auto"
49- RenderingModeGPU RenderingMode = "gpu"
50- RenderingModeCPU RenderingMode = "cpu"
51-)
52-
53103 // CookiePolicy controls cookie acceptance behavior.
54104 type CookiePolicy string
55105
@@ -72,60 +122,6 @@ const (
72122 GSKRendererCairo GSKRendererMode = "cairo"
73123 )
74124
75-// RenderingConfig controls WebKit and GTK/GSK rendering behavior.
76-type RenderingConfig struct {
77- // Mode controls GPU/CPU rendering selection for WebKit (auto, gpu, cpu).
78- Mode RenderingMode `mapstructure:"mode" yaml:"mode" toml:"mode"`
79-
80- // DisableDMABufRenderer disables DMA-BUF accelerated renderer.
81- // Env: WEBKIT_DISABLE_DMABUF_RENDERER
82- DisableDMABufRenderer bool `mapstructure:"disable_dmabuf_renderer" yaml:"disable_dmabuf_renderer" toml:"disable_dmabuf_renderer"`
83-
84- // ForceCompositingMode forces accelerated compositing.
85- // Env: WEBKIT_FORCE_COMPOSITING_MODE
86- ForceCompositingMode bool `mapstructure:"force_compositing_mode" yaml:"force_compositing_mode" toml:"force_compositing_mode"`
87-
88- // DisableCompositingMode disables accelerated compositing.
89- // Env: WEBKIT_DISABLE_COMPOSITING_MODE
90- DisableCompositingMode bool `mapstructure:"disable_compositing_mode" yaml:"disable_compositing_mode" toml:"disable_compositing_mode"`
91-
92- // GSKRenderer overrides GTK Scene Kit renderer selection.
93- // Env: GSK_RENDERER
94- GSKRenderer GSKRendererMode `mapstructure:"gsk_renderer" yaml:"gsk_renderer" toml:"gsk_renderer"`
95-
96- // DisableMipmaps disables mipmap generation.
97- // Env: GSK_GPU_DISABLE=mipmap
98- DisableMipmaps bool `mapstructure:"disable_mipmaps" yaml:"disable_mipmaps" toml:"disable_mipmaps"`
99-
100- // PreferGL forces OpenGL over GLES.
101- // Env: GDK_DEBUG=gl-prefer-gl
102- PreferGL bool `mapstructure:"prefer_gl" yaml:"prefer_gl" toml:"prefer_gl"`
103-
104- // DrawCompositingIndicators shows compositing layer borders and repaint counters.
105- DrawCompositingIndicators bool `mapstructure:"draw_compositing_indicators" yaml:"draw_compositing_indicators" toml:"draw_compositing_indicators"` //nolint:lll // struct tags exceed lll limit
106-
107- // ShowFPS displays WebKit FPS counter.
108- // Env: WEBKIT_SHOW_FPS
109- ShowFPS bool `mapstructure:"show_fps" yaml:"show_fps" toml:"show_fps"`
110-
111- // SampleMemory enables memory sampling.
112- // Env: WEBKIT_SAMPLE_MEMORY
113- SampleMemory bool `mapstructure:"sample_memory" yaml:"sample_memory" toml:"sample_memory"`
114-
115- // DebugFrames enables frame timing debug output.
116- // Env: GDK_DEBUG=frames
117- DebugFrames bool `mapstructure:"debug_frames" yaml:"debug_frames" toml:"debug_frames"`
118-}
119-
120-// PrivacyConfig controls cookie behavior and tracking prevention.
121-type PrivacyConfig struct {
122- // CookiePolicy controls cookie acceptance behavior.
123- // Values: "always", "no_third_party", "never"
124- CookiePolicy CookiePolicy `mapstructure:"cookie_policy" yaml:"cookie_policy" toml:"cookie_policy"`
125- // ITPEnabled enables Intelligent Tracking Prevention.
126- ITPEnabled bool `mapstructure:"itp_enabled" yaml:"itp_enabled" toml:"itp_enabled"`
127-}
128-
129125 // HardwareDecodingMode controls video hardware acceleration.
130126 type HardwareDecodingMode string
131127
@@ -185,20 +181,19 @@ type MediaConfig struct {
185181 PreferAV1 bool `mapstructure:"prefer_av1" yaml:"prefer_av1" toml:"prefer_av1"`
186182 // ShowDiagnosticsOnStartup shows media capability warnings at startup
187183 ShowDiagnosticsOnStartup bool `mapstructure:"show_diagnostics" yaml:"show_diagnostics" toml:"show_diagnostics"`
188- // ForceVSync forces vertical sync for video playback (may help with tearing)
189- ForceVSync bool `mapstructure:"force_vsync" yaml:"force_vsync" toml:"force_vsync"`
190- // GLRenderingMode controls OpenGL API selection for video rendering
184+ // ForceVSync forces vertical sync for video playback (may help with tearing).
185+ //
186+ // Deprecated: moved to [engine.webkit]. Kept for read compatibility during migration.
187+ ForceVSync bool `mapstructure:"force_vsync" yaml:"force_vsync" toml:"-"`
188+ // GLRenderingMode controls OpenGL API selection for video rendering.
191189 // Values: "auto" (default), "gles2", "gl3", "none"
192- GLRenderingMode GLRenderingMode `mapstructure:"gl_rendering_mode" yaml:"gl_rendering_mode" toml:"gl_rendering_mode"`
193- // GStreamerDebugLevel sets GStreamer debug verbosity (0=off, 1-5=increasing verbosity)
194- GStreamerDebugLevel int `mapstructure:"gstreamer_debug_level" yaml:"gstreamer_debug_level" toml:"gstreamer_debug_level"`
195-}
196-
197-// RuntimeConfig holds optional runtime overrides.
198-type RuntimeConfig struct {
199- // Prefix points to a custom runtime prefix (e.g., /opt/webkitgtk).
200- // This is used to influence pkg-config lookup and dynamic library loading.
201- Prefix string `mapstructure:"prefix" yaml:"prefix" toml:"prefix"`
190+ //
191+ // Deprecated: moved to [engine.webkit]. Kept for read compatibility during migration.
192+ GLRenderingMode GLRenderingMode `mapstructure:"gl_rendering_mode" yaml:"gl_rendering_mode" toml:"-"`
193+ // GStreamerDebugLevel sets GStreamer debug verbosity (0=off, 1-5=increasing verbosity).
194+ //
195+ // Deprecated: moved to [engine.webkit]. Kept for read compatibility during migration.
196+ GStreamerDebugLevel int `mapstructure:"gstreamer_debug_level" yaml:"gstreamer_debug_level" toml:"-"`
202197 }
203198
204199 // DatabaseConfig holds database-related configuration.
@@ -326,31 +321,6 @@ type LoggingConfig struct {
326321 CaptureGTKLogs bool `mapstructure:"capture_gtk_logs" yaml:"capture_gtk_logs" toml:"capture_gtk_logs" json:"capture_gtk_logs"`
327322 }
328323
329-// AppearanceConfig holds UI/rendering preferences.
330-type AppearanceConfig struct {
331- // Default fonts for pages that do not specify fonts.
332- SansFont string `mapstructure:"sans_font" yaml:"sans_font" toml:"sans_font"`
333- SerifFont string `mapstructure:"serif_font" yaml:"serif_font" toml:"serif_font"`
334- MonospaceFont string `mapstructure:"monospace_font" yaml:"monospace_font" toml:"monospace_font"`
335- // Default font size in CSS pixels (approx).
336- DefaultFontSize int `mapstructure:"default_font_size" yaml:"default_font_size" toml:"default_font_size"`
337- LightPalette ColorPalette `mapstructure:"light_palette" yaml:"light_palette" toml:"light_palette"`
338- DarkPalette ColorPalette `mapstructure:"dark_palette" yaml:"dark_palette" toml:"dark_palette"`
339- // ColorScheme controls the initial theme preference: "prefer-dark", "prefer-light", or "default" (follows system)
340- ColorScheme string `mapstructure:"color_scheme" yaml:"color_scheme" toml:"color_scheme"`
341-}
342-
343-// ColorPalette contains semantic color tokens for light/dark themes.
344-type ColorPalette struct {
345- Background string `mapstructure:"background" yaml:"background" toml:"background" json:"background"`
346- Surface string `mapstructure:"surface" yaml:"surface" toml:"surface" json:"surface"`
347- SurfaceVariant string `mapstructure:"surface_variant" yaml:"surface_variant" toml:"surface_variant" json:"surface_variant"`
348- Text string `mapstructure:"text" yaml:"text" toml:"text" json:"text"`
349- Muted string `mapstructure:"muted" yaml:"muted" toml:"muted" json:"muted"`
350- Accent string `mapstructure:"accent" yaml:"accent" toml:"accent" json:"accent"`
351- Border string `mapstructure:"border" yaml:"border" toml:"border" json:"border"`
352-}
353-
354324 // ContentFilteringConfig holds content filtering and ad blocking preferences
355325 type ContentFilteringConfig struct {
356326 // Enabled controls whether ad blocking is active (default: true)
@@ -386,256 +356,9 @@ type DebugConfig struct {
386356 EnableDevTools bool `mapstructure:"enable_devtools" yaml:"enable_devtools" toml:"enable_devtools"`
387357 }
388358
389-// UpdateConfig holds automatic update settings.
390-type UpdateConfig struct {
391- // EnableOnStartup enables checking for updates when the browser starts.
392- // Default: true
393- EnableOnStartup bool `mapstructure:"enable_on_startup" yaml:"enable_on_startup" toml:"enable_on_startup"`
394- // AutoDownload automatically downloads updates in the background.
395- // When enabled, updates are applied on browser exit.
396- // Default: false
397- AutoDownload bool `mapstructure:"auto_download" yaml:"auto_download" toml:"auto_download"`
398- // NotifyOnNewSettings shows a toast notification on startup when new config settings are available.
399- // Default: true
400- NotifyOnNewSettings bool `mapstructure:"notify_on_new_settings" yaml:"notify_on_new_settings" toml:"notify_on_new_settings"`
401-}
402-
403359 // DownloadsConfig holds file download preferences.
404360 type DownloadsConfig struct {
405361 // Path is the directory where downloads are saved.
406362 // Empty string means use XDG_DOWNLOAD_DIR or ~/Downloads.
407363 Path string `mapstructure:"path" yaml:"path" toml:"path"`
408364 }
409-
410-// WorkspaceConfig captures layout, pane, and tab behavior preferences.
411-type WorkspaceConfig struct {
412- // NewPaneURL is the URL loaded when creating a new empty pane/tab.
413- // Default: "about:blank"
414- NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url"`
415- // PaneMode defines modal pane behavior and bindings.
416- PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"`
417- // TabMode defines modal tab behavior and bindings (Alt+T).
418- TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"`
419- // ResizeMode defines modal pane resizing behavior and bindings (Ctrl+N).
420- ResizeMode ResizeModeConfig `mapstructure:"resize_mode" yaml:"resize_mode" toml:"resize_mode" json:"resize_mode"`
421- // Shortcuts holds global (non-modal) keyboard shortcuts.
422- Shortcuts GlobalShortcutsConfig `mapstructure:"shortcuts" yaml:"shortcuts" toml:"shortcuts" json:"shortcuts"`
423- // FloatingPane configures workspace-level floating pane behavior.
424- FloatingPane FloatingPaneConfig `mapstructure:"floating_pane" yaml:"floating_pane" toml:"floating_pane" json:"floating_pane"`
425- // TabBarPosition determines tab bar placement: "top" or "bottom".
426- TabBarPosition string `mapstructure:"tab_bar_position" yaml:"tab_bar_position" toml:"tab_bar_position" json:"tab_bar_position"`
427- // HideTabBarWhenSingleTab hides the tab bar when only one tab exists.
428- HideTabBarWhenSingleTab bool `mapstructure:"hide_tab_bar_when_single_tab" yaml:"hide_tab_bar_when_single_tab" toml:"hide_tab_bar_when_single_tab" json:"hide_tab_bar_when_single_tab"` //nolint:lll // struct tags must stay on one line
429- // SwitchToTabOnMove controls whether moving a pane to another tab switches focus to that tab.
430- // Default: true
431- SwitchToTabOnMove bool `mapstructure:"switch_to_tab_on_move" yaml:"switch_to_tab_on_move" toml:"switch_to_tab_on_move" json:"switch_to_tab_on_move"` //nolint:lll // struct tags must stay on one line
432- // Popups configures default popup placement rules.
433- Popups PopupBehaviorConfig `mapstructure:"popups" yaml:"popups" toml:"popups" json:"popups"`
434- // Styling configures workspace visual appearance.
435- Styling WorkspaceStylingConfig `mapstructure:"styling" yaml:"styling" toml:"styling" json:"styling"`
436-}
437-
438-// ActionBinding defines a keybinding with optional description.
439-type ActionBinding struct {
440- Keys []string `mapstructure:"keys" yaml:"keys" toml:"keys" json:"keys"`
441- Desc string `mapstructure:"desc" yaml:"desc" toml:"desc" json:"desc,omitempty"`
442-}
443-
444-// PaneModeConfig defines modal behavior for pane management.
445-type PaneModeConfig struct {
446- ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
447- TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
448- Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
449-}
450-
451-// GetKeyBindings returns an inverted map for O(1) key→action lookup.
452-// This is built from the action→keys structure in the config.
453-func (p *PaneModeConfig) GetKeyBindings() map[string]string {
454- keyToAction := make(map[string]string)
455- for action, binding := range p.Actions {
456- for _, key := range binding.Keys {
457- keyToAction[key] = action
458- }
459- }
460- return keyToAction
461-}
462-
463-// TabModeConfig defines modal behavior for tab management (Zellij-style).
464-type TabModeConfig struct {
465- ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
466- TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
467- Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
468-}
469-
470-// GetKeyBindings returns an inverted map for O(1) key→action lookup.
471-// This is built from the action→keys structure in the config.
472-func (t *TabModeConfig) GetKeyBindings() map[string]string {
473- keyToAction := make(map[string]string)
474- for action, binding := range t.Actions {
475- for _, key := range binding.Keys {
476- keyToAction[key] = action
477- }
478- }
479- return keyToAction
480-}
481-
482-// ResizeModeConfig defines modal behavior for resizing panes (Zellij-style).
483-type ResizeModeConfig struct {
484- ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
485- TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
486- StepPercent float64 `mapstructure:"step_percent" yaml:"step_percent" toml:"step_percent" json:"step_percent"`
487- MinPanePercent float64 `mapstructure:"min_pane_percent" yaml:"min_pane_percent" toml:"min_pane_percent" json:"min_pane_percent"` //nolint:lll // struct tags must stay on one line
488- Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
489-}
490-
491-// GetKeyBindings returns an inverted map for O(1) key→action lookup.
492-// This is built from the action→keys structure in the config.
493-func (r *ResizeModeConfig) GetKeyBindings() map[string]string {
494- keyToAction := make(map[string]string)
495- for action, binding := range r.Actions {
496- for _, key := range binding.Keys {
497- keyToAction[key] = action
498- }
499- }
500- return keyToAction
501-}
502-
503-// GlobalShortcutsConfig defines global shortcuts (always active, not modal).
504-type GlobalShortcutsConfig struct {
505- Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
506-}
507-
508-// FloatingPaneConfig defines persistent floating pane behavior.
509-type FloatingPaneConfig struct {
510- WidthPct float64 `mapstructure:"width_pct" yaml:"width_pct" toml:"width_pct" json:"width_pct"`
511- HeightPct float64 `mapstructure:"height_pct" yaml:"height_pct" toml:"height_pct" json:"height_pct"`
512- Profiles map[string]FloatingPaneProfile `mapstructure:"profiles" yaml:"profiles" toml:"profiles" json:"profiles"`
513-}
514-
515-// FloatingPaneProfile defines a shortcut profile that opens a URL in the floating pane.
516-type FloatingPaneProfile struct {
517- Keys []string `mapstructure:"keys" yaml:"keys" toml:"keys" json:"keys"`
518- URL string `mapstructure:"url" yaml:"url" toml:"url" json:"url"`
519- Desc string `mapstructure:"desc" yaml:"desc" toml:"desc" json:"desc,omitempty"`
520-}
521-
522-// GetKeyBindings returns an inverted map for O(1) key→action lookup.
523-func (g *GlobalShortcutsConfig) GetKeyBindings() map[string]string {
524- keyToAction := make(map[string]string)
525- for action, binding := range g.Actions {
526- for _, key := range binding.Keys {
527- keyToAction[key] = action
528- }
529- }
530- return keyToAction
531-}
532-
533-// PopupBehavior defines how popup windows should be opened
534-type PopupBehavior string
535-
536-const (
537- // PopupBehaviorSplit opens popups in a split pane (default)
538- PopupBehaviorSplit PopupBehavior = "split"
539- // PopupBehaviorStacked opens popups in a stacked pane
540- PopupBehaviorStacked PopupBehavior = "stacked"
541- // PopupBehaviorTabbed opens popups as a new tab
542- PopupBehaviorTabbed PopupBehavior = "tabbed"
543- // PopupBehaviorWindowed opens popups in a new workspace window
544- PopupBehaviorWindowed PopupBehavior = "windowed"
545-)
546-
547-// PopupBehaviorConfig defines handling for popup windows.
548-type PopupBehaviorConfig struct {
549- // Behavior determines how popups are opened (split/stacked/tabbed/windowed)
550- Behavior PopupBehavior `mapstructure:"behavior" yaml:"behavior" toml:"behavior" json:"behavior"`
551-
552- // Placement specifies direction for split behavior ("right", "left", "top", "bottom")
553- // Only used when Behavior is "split"
554- Placement string `mapstructure:"placement" yaml:"placement" toml:"placement" json:"placement"`
555-
556- // OpenInNewPane controls whether popups are opened in workspace or blocked
557- OpenInNewPane bool `mapstructure:"open_in_new_pane" yaml:"open_in_new_pane" toml:"open_in_new_pane" json:"open_in_new_pane"`
558-
559- // FollowPaneContext determines if popup placement follows parent pane context
560- FollowPaneContext bool `mapstructure:"follow_pane_context" yaml:"follow_pane_context" toml:"follow_pane_context" json:"follow_pane_context"` //nolint:lll // struct tags must stay on one line
561-
562- // BlankTargetBehavior determines how target="_blank" links are opened
563- // Accepted values: "split", "stacked" (default), "tabbed"
564- // This is separate from Behavior which controls JavaScript popups
565- BlankTargetBehavior string `mapstructure:"blank_target_behavior" yaml:"blank_target_behavior" toml:"blank_target_behavior" json:"blank_target_behavior"` //nolint:lll // struct tags must stay on one line
566-
567- // EnableSmartDetection uses WebKitWindowProperties to detect popup vs tab intents
568- EnableSmartDetection bool `mapstructure:"enable_smart_detection" yaml:"enable_smart_detection" toml:"enable_smart_detection" json:"enable_smart_detection"` //nolint:lll // struct tags must stay on one line
569-
570- // OAuthAutoClose enables auto-closing OAuth popups after successful auth redirects
571- OAuthAutoClose bool `mapstructure:"oauth_auto_close" yaml:"oauth_auto_close" toml:"oauth_auto_close" json:"oauth_auto_close"`
572-}
573-
574-// WorkspaceStylingConfig defines visual styling for workspace panes.
575-type WorkspaceStylingConfig struct {
576- // BorderWidth in pixels for active pane borders (overlay)
577- BorderWidth int `mapstructure:"border_width" yaml:"border_width" toml:"border_width" json:"border_width"`
578- // BorderColor for focused panes (CSS color value or theme variable)
579- BorderColor string `mapstructure:"border_color" yaml:"border_color" toml:"border_color" json:"border_color"`
580-
581- // ModeBorderWidth in pixels for all mode indicator borders (pane, tab, session, resize)
582- ModeBorderWidth int `mapstructure:"mode_border_width" yaml:"mode_border_width" toml:"mode_border_width" json:"mode_border_width"` //nolint:lll // struct tags must stay on one line
583-
584- // PaneModeColor for pane mode indicator (border and toaster). Defaults to "#4A90E2" (blue)
585- PaneModeColor string `mapstructure:"pane_mode_color" yaml:"pane_mode_color" toml:"pane_mode_color" json:"pane_mode_color"` //nolint:lll // struct tags must stay on one line
586- // TabModeColor for tab mode indicator (border and toaster). Defaults to "#FFA500" (orange)
587- TabModeColor string `mapstructure:"tab_mode_color" yaml:"tab_mode_color" toml:"tab_mode_color" json:"tab_mode_color"` //nolint:lll // struct tags must stay on one line
588- // SessionModeColor for session mode indicator (border and toaster). Defaults to "#9B59B6" (purple)
589- SessionModeColor string `mapstructure:"session_mode_color" yaml:"session_mode_color" toml:"session_mode_color" json:"session_mode_color"` //nolint:lll // struct tags must stay on one line
590- // ResizeModeColor for resize mode indicator (border and toaster). Defaults to "#00D4AA" (teal)
591- ResizeModeColor string `mapstructure:"resize_mode_color" yaml:"resize_mode_color" toml:"resize_mode_color" json:"resize_mode_color"` //nolint:lll // struct tags must stay on one line
592-
593- // ModeIndicatorToasterEnabled shows a toaster notification when modal modes are active.
594- // Default: true
595- ModeIndicatorToasterEnabled bool `mapstructure:"mode_indicator_toaster_enabled" yaml:"mode_indicator_toaster_enabled" toml:"mode_indicator_toaster_enabled" json:"mode_indicator_toaster_enabled"` //nolint:lll // struct tags must stay on one line
596-
597- // TransitionDuration in milliseconds for border animations
598- TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" toml:"transition_duration" json:"transition_duration"` //nolint:lll // struct tags must stay on one line
599-}
600-
601-// SessionConfig holds session persistence settings.
602-type SessionConfig struct {
603- // AutoRestore automatically restores the last session on startup.
604- // Default: false
605- AutoRestore bool `mapstructure:"auto_restore" yaml:"auto_restore" toml:"auto_restore" json:"auto_restore"`
606-
607- // SnapshotIntervalMs is the minimum interval between snapshots in milliseconds.
608- // Default: 5000
609- SnapshotIntervalMs int `mapstructure:"snapshot_interval_ms" yaml:"snapshot_interval_ms" toml:"snapshot_interval_ms" json:"snapshot_interval_ms"` //nolint:lll // struct tags must stay on one line
610-
611- // MaxExitedSessions is the maximum number of exited sessions to keep.
612- // Default: 50
613- MaxExitedSessions int `mapstructure:"max_exited_sessions" yaml:"max_exited_sessions" toml:"max_exited_sessions" json:"max_exited_sessions"` //nolint:lll // struct tags must stay on one line
614-
615- // MaxExitedSessionAgeDays is the maximum age in days for exited sessions.
616- // Sessions older than this will be automatically deleted on startup.
617- // Default: 7
618- MaxExitedSessionAgeDays int `mapstructure:"max_exited_session_age_days" yaml:"max_exited_session_age_days" toml:"max_exited_session_age_days" json:"max_exited_session_age_days"` //nolint:lll // struct tags must stay on one line
619-
620- // SessionMode defines modal behavior for session management (Ctrl+O).
621- SessionMode SessionModeConfig `mapstructure:"session_mode" yaml:"session_mode" toml:"session_mode" json:"session_mode"`
622-}
623-
624-// SessionModeConfig defines modal behavior for session management.
625-type SessionModeConfig struct {
626- ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line
627- TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"`
628- Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"`
629-}
630-
631-// GetKeyBindings returns an inverted map for O(1) key→action lookup.
632-// This is built from the action→keys structure in the config.
633-func (s *SessionModeConfig) GetKeyBindings() map[string]string {
634- keyToAction := make(map[string]string)
635- for action, binding := range s.Actions {
636- for _, key := range binding.Keys {
637- keyToAction[key] = action
638- }
639- }
640- return keyToAction
641-}
M internal/infrastructure/config/schema_provider.go +51 -82
@@ -712,81 +712,73 @@ func (*SchemaProvider) getClipboardKeys(defaults *Config) []entity.ConfigKeyInfo
712712 func (*SchemaProvider) getRenderingKeys(defaults *Config) []entity.ConfigKeyInfo {
713713 return []entity.ConfigKeyInfo{
714714 {
715- Key: "rendering.mode",
715+ Key: "engine.webkit.gsk_renderer",
716716 Type: "string",
717- Default: string(defaults.Rendering.Mode),
718- Description: "GPU/CPU rendering selection for WebKit",
719- Values: []string{"auto", "gpu", "cpu"},
720- Section: SectionRendering,
721- },
722- {
723- Key: "rendering.gsk_renderer",
724- Type: "string",
725- Default: string(defaults.Rendering.GSKRenderer),
717+ Default: string(defaults.Engine.WebKit.GSKRenderer),
726718 Description: "GTK Scene Kit renderer selection",
727719 Values: []string{"auto", "opengl", "vulkan", "cairo"},
728720 Section: SectionRendering,
729721 },
730722 {
731- Key: "rendering.disable_dmabuf_renderer",
723+ Key: "engine.webkit.disable_dmabuf_renderer",
732724 Type: "bool",
733- Default: fmt.Sprintf("%t", defaults.Rendering.DisableDMABufRenderer),
725+ Default: fmt.Sprintf("%t", defaults.Engine.WebKit.DisableDMABufRenderer),
734726 Description: "Disable DMA-BUF accelerated renderer",
735727 Section: SectionRendering,
736728 },
737729 {
738- Key: "rendering.force_compositing_mode",
730+ Key: "engine.webkit.force_compositing_mode",
739731 Type: "bool",
740- Default: fmt.Sprintf("%t", defaults.Rendering.ForceCompositingMode),
732+ Default: fmt.Sprintf("%t", defaults.Engine.WebKit.ForceCompositingMode),
741733 Description: "Force accelerated compositing",
742734 Section: SectionRendering,
743735 },
744736 {
745- Key: "rendering.disable_compositing_mode",
737+ Key: "engine.webkit.disable_compositing_mode",
746738 Type: "bool",
747- Default: fmt.Sprintf("%t", defaults.Rendering.DisableCompositingMode),
739+ Default: fmt.Sprintf("%t", defaults.Engine.WebKit.DisableCompositingMode),
748740 Description: "Disable accelerated compositing",
749741 Section: SectionRendering,
750742 },
751743 {
752- Key: "rendering.disable_mipmaps",
744+ Key: "engine.webkit.disable_mipmaps",
753745 Type: "bool",
754- Default: fmt.Sprintf("%t", defaults.Rendering.DisableMipmaps),
746+ Default: fmt.Sprintf("%t", defaults.Engine.WebKit.DisableMipmaps),
755747 Description: "Disable mipmap generation",
756748 Section: SectionRendering,
757749 },
758750 {
759- Key: "rendering.prefer_gl",
751+ Key: "engine.webkit.prefer_gl",
760752 Type: "bool",
761- Default: fmt.Sprintf("%t", defaults.Rendering.PreferGL),
753+ Default: fmt.Sprintf("%t", defaults.Engine.WebKit.PreferGL),
762754 Description: "Force OpenGL over GLES",
763755 Section: SectionRendering,
764756 },
765757 {
766- Key: "rendering.draw_compositing_indicators",
758+ Key: "engine.webkit.draw_compositing_indicators",
767759 Type: "bool",
768- Default: fmt.Sprintf("%t", defaults.Rendering.DrawCompositingIndicators),
760+ Default: fmt.Sprintf("%t", defaults.Engine.WebKit.DrawCompositingIndicators),
769761 Description: "Show compositing layer borders",
770762 Section: SectionRendering,
771763 },
772764 {
773- Key: "rendering.show_fps",
765+ Key: "engine.webkit.show_fps",
774766 Type: "bool",
775- Default: fmt.Sprintf("%t", defaults.Rendering.ShowFPS),
767+ Default: fmt.Sprintf("%t", defaults.Engine.WebKit.ShowFPS),
776768 Description: "Display WebKit FPS counter",
777769 Section: SectionRendering,
778770 },
779771 {
780- Key: "rendering.sample_memory",
772+ Key: "engine.webkit.sample_memory",
781773 Type: "bool",
782- Default: fmt.Sprintf("%t", defaults.Rendering.SampleMemory),
774+ Default: fmt.Sprintf("%t", defaults.Engine.WebKit.SampleMemory),
783775 Description: "Enable memory sampling",
784776 Section: SectionRendering,
785777 },
786778 {
787- Key: "rendering.debug_frames",
779+ Key: "engine.webkit.debug_frames",
788780 Type: "bool",
789- Default: fmt.Sprintf("%t", defaults.Rendering.DebugFrames),
781+ Default: fmt.Sprintf("%t", defaults.Engine.WebKit.DebugFrames),
790782 Description: "Enable frame timing debug output",
791783 Section: SectionRendering,
792784 },
@@ -803,14 +795,6 @@ func (*SchemaProvider) getMediaKeys(defaults *Config) []entity.ConfigKeyInfo {
803795 Values: []string{"auto", "force", "disable"},
804796 Section: SectionMedia,
805797 },
806- {
807- Key: "media.gl_rendering_mode",
808- Type: "string",
809- Default: string(defaults.Media.GLRenderingMode),
810- Description: "OpenGL API selection for video rendering",
811- Values: []string{"auto", "gles2", "gl3", "none"},
812- Section: SectionMedia,
813- },
814798 {
815799 Key: "media.prefer_av1",
816800 Type: "bool",
@@ -825,21 +809,6 @@ func (*SchemaProvider) getMediaKeys(defaults *Config) []entity.ConfigKeyInfo {
825809 Description: "Show media capability warnings at startup",
826810 Section: SectionMedia,
827811 },
828- {
829- Key: "media.force_vsync",
830- Type: "bool",
831- Default: fmt.Sprintf("%t", defaults.Media.ForceVSync),
832- Description: "Force vertical sync for video playback",
833- Section: SectionMedia,
834- },
835- {
836- Key: "media.gstreamer_debug_level",
837- Type: "int",
838- Default: fmt.Sprintf("%d", defaults.Media.GStreamerDebugLevel),
839- Description: "GStreamer debug verbosity (0=off, 1-5)",
840- Range: "0-5",
841- Section: SectionMedia,
842- },
843812 }
844813 }
845814
@@ -884,112 +853,112 @@ func (*SchemaProvider) getDebugKeys(defaults *Config) []entity.ConfigKeyInfo {
884853 func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyInfo {
885854 return []entity.ConfigKeyInfo{
886855 {
887- Key: "performance.profile",
856+ Key: "engine.profile",
888857 Type: "string",
889- Default: string(defaults.Performance.Profile),
858+ Default: string(defaults.Engine.Profile),
890859 Description: "Performance profile preset (custom enables manual tuning)",
891860 Values: []string{"default", "lite", "max", "custom"},
892861 Section: SectionPerformance,
893862 },
894863 {
895- Key: "performance.zoom_cache_size",
864+ Key: "engine.zoom_cache_size",
896865 Type: "int",
897- Default: fmt.Sprintf("%d", defaults.Performance.ZoomCacheSize),
866+ Default: fmt.Sprintf("%d", defaults.Engine.ZoomCacheSize),
898867 Description: "Number of domain zoom levels to cache",
899868 Range: ">=0",
900869 Section: SectionPerformance,
901870 },
902871 {
903- Key: "performance.webview_pool_prewarm_count",
872+ Key: "engine.pool_prewarm_count",
904873 Type: "int",
905- Default: fmt.Sprintf("%d", defaults.Performance.WebViewPoolPrewarmCount),
874+ Default: fmt.Sprintf("%d", defaults.Engine.PoolPrewarmCount),
906875 Description: "WebViews to pre-create at startup",
907876 Range: ">=0",
908877 Section: SectionPerformance,
909878 },
910879 {
911- Key: "performance.skia_cpu_painting_threads",
880+ Key: "engine.webkit.skia_cpu_painting_threads",
912881 Type: "int",
913- Default: fmt.Sprintf("%d", defaults.Performance.SkiaCPUPaintingThreads),
882+ Default: fmt.Sprintf("%d", defaults.Engine.WebKit.SkiaCPUPaintingThreads),
914883 Description: "Skia CPU rendering threads (0=unset, custom profile only)",
915884 Range: ">=0",
916885 Section: SectionPerformance,
917886 },
918887 {
919- Key: "performance.skia_gpu_painting_threads",
888+ Key: "engine.webkit.skia_gpu_painting_threads",
920889 Type: "int",
921- Default: fmt.Sprintf("%d", defaults.Performance.SkiaGPUPaintingThreads),
890+ Default: fmt.Sprintf("%d", defaults.Engine.WebKit.SkiaGPUPaintingThreads),
922891 Description: "Skia GPU rendering threads (-1=unset, 0=disable, custom profile only)",
923892 Range: ">=-1",
924893 Section: SectionPerformance,
925894 },
926895 {
927- Key: "performance.skia_enable_cpu_rendering",
896+ Key: "engine.webkit.skia_enable_cpu_rendering",
928897 Type: "bool",
929- Default: fmt.Sprintf("%t", defaults.Performance.SkiaEnableCPURendering),
898+ Default: fmt.Sprintf("%t", defaults.Engine.WebKit.SkiaEnableCPURendering),
930899 Description: "Force CPU rendering (custom profile only)",
931900 Section: SectionPerformance,
932901 },
933902 {
934- Key: "performance.web_process_memory_limit_mb",
903+ Key: "engine.webkit.web_process_memory_limit_mb",
935904 Type: "int",
936- Default: fmt.Sprintf("%d", defaults.Performance.WebProcessMemoryLimitMB),
905+ Default: fmt.Sprintf("%d", defaults.Engine.WebKit.WebProcessMemoryLimitMB),
937906 Description: "Web process memory limit in MB (0=unset, custom profile only)",
938907 Range: ">=0",
939908 Section: SectionPerformance,
940909 },
941910 {
942- Key: "performance.web_process_memory_poll_interval_sec",
911+ Key: "engine.webkit.web_process_memory_poll_interval_sec",
943912 Type: "float64",
944- Default: fmt.Sprintf("%.1f", defaults.Performance.WebProcessMemoryPollIntervalSec),
913+ Default: fmt.Sprintf("%.1f", defaults.Engine.WebKit.WebProcessMemoryPollIntervalSec),
945914 Description: "Memory check interval in seconds (0=default 30s, custom profile only)",
946915 Range: ">=0",
947916 Section: SectionPerformance,
948917 },
949918 {
950- Key: "performance.web_process_memory_conservative_threshold",
919+ Key: "engine.webkit.web_process_memory_conservative_threshold",
951920 Type: "float64",
952- Default: fmt.Sprintf("%.2f", defaults.Performance.WebProcessMemoryConservativeThreshold),
921+ Default: fmt.Sprintf("%.2f", defaults.Engine.WebKit.WebProcessMemoryConservativeThreshold),
953922 Description: "Conservative memory cleanup threshold (0-1, custom profile only)",
954923 Range: "0-1",
955924 Section: SectionPerformance,
956925 },
957926 {
958- Key: "performance.web_process_memory_strict_threshold",
927+ Key: "engine.webkit.web_process_memory_strict_threshold",
959928 Type: "float64",
960- Default: fmt.Sprintf("%.2f", defaults.Performance.WebProcessMemoryStrictThreshold),
929+ Default: fmt.Sprintf("%.2f", defaults.Engine.WebKit.WebProcessMemoryStrictThreshold),
961930 Description: "Strict memory cleanup threshold (0-1, custom profile only)",
962931 Range: "0-1",
963932 Section: SectionPerformance,
964933 },
965934 {
966- Key: "performance.network_process_memory_limit_mb",
935+ Key: "engine.webkit.network_process_memory_limit_mb",
967936 Type: "int",
968- Default: fmt.Sprintf("%d", defaults.Performance.NetworkProcessMemoryLimitMB),
937+ Default: fmt.Sprintf("%d", defaults.Engine.WebKit.NetworkProcessMemoryLimitMB),
969938 Description: "Network process memory limit in MB (0=unset, custom profile only)",
970939 Range: ">=0",
971940 Section: SectionPerformance,
972941 },
973942 {
974- Key: "performance.network_process_memory_poll_interval_sec",
943+ Key: "engine.webkit.network_process_memory_poll_interval_sec",
975944 Type: "float64",
976- Default: fmt.Sprintf("%.1f", defaults.Performance.NetworkProcessMemoryPollIntervalSec),
945+ Default: fmt.Sprintf("%.1f", defaults.Engine.WebKit.NetworkProcessMemoryPollIntervalSec),
977946 Description: "Network memory check interval in seconds (custom profile only)",
978947 Range: ">=0",
979948 Section: SectionPerformance,
980949 },
981950 {
982- Key: "performance.network_process_memory_conservative_threshold",
951+ Key: "engine.webkit.network_process_memory_conservative_threshold",
983952 Type: "float64",
984- Default: fmt.Sprintf("%.2f", defaults.Performance.NetworkProcessMemoryConservativeThreshold),
953+ Default: fmt.Sprintf("%.2f", defaults.Engine.WebKit.NetworkProcessMemoryConservativeThreshold),
985954 Description: "Network conservative memory threshold (0-1, custom profile only)",
986955 Range: "0-1",
987956 Section: SectionPerformance,
988957 },
989958 {
990- Key: "performance.network_process_memory_strict_threshold",
959+ Key: "engine.webkit.network_process_memory_strict_threshold",
991960 Type: "float64",
992- Default: fmt.Sprintf("%.2f", defaults.Performance.NetworkProcessMemoryStrictThreshold),
961+ Default: fmt.Sprintf("%.2f", defaults.Engine.WebKit.NetworkProcessMemoryStrictThreshold),
993962 Description: "Network strict memory threshold (0-1, custom profile only)",
994963 Range: "0-1",
995964 Section: SectionPerformance,
@@ -997,12 +966,12 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn
997966 }
998967 }
999968
1000-func (*SchemaProvider) getRuntimeKeys(_ *Config) []entity.ConfigKeyInfo {
969+func (*SchemaProvider) getRuntimeKeys(defaults *Config) []entity.ConfigKeyInfo {
1001970 return []entity.ConfigKeyInfo{
1002971 {
1003- Key: "runtime.prefix",
972+ Key: "engine.webkit.prefix",
1004973 Type: "string",
1005- Default: "(empty)",
974+ Default: defaults.Engine.WebKit.Prefix,
1006975 Description: "Custom runtime prefix for WebKitGTK/GTK",
1007976 Section: SectionRuntime,
1008977 },
M internal/infrastructure/config/validation.go +21 -29
@@ -379,30 +379,21 @@ func validateOmnibox(config *Config) []string {
379379 func validateRendering(config *Config) []string {
380380 var validationErrors []string
381381
382- switch config.Rendering.Mode {
383- case RenderingModeAuto, RenderingModeGPU, RenderingModeCPU, "":
384- default:
385- validationErrors = append(
386- validationErrors,
387- fmt.Sprintf("rendering.mode must be one of: auto, gpu, cpu (got: %s)", config.Rendering.Mode),
388- )
389- }
390-
391- switch config.Rendering.GSKRenderer {
382+ switch config.Engine.WebKit.GSKRenderer {
392383 case GSKRendererAuto, GSKRendererOpenGL, GSKRendererVulkan, GSKRendererCairo, "":
393384 default:
394385 validationErrors = append(
395386 validationErrors,
396387 fmt.Sprintf(
397- "rendering.gsk_renderer must be one of: auto, opengl, vulkan, cairo (got: %s)",
398- config.Rendering.GSKRenderer,
388+ "engine.webkit.gsk_renderer must be one of: auto, opengl, vulkan, cairo (got: %s)",
389+ config.Engine.WebKit.GSKRenderer,
399390 ),
400391 )
401392 }
402393
403- if config.Rendering.ForceCompositingMode && config.Rendering.DisableCompositingMode {
394+ if config.Engine.WebKit.ForceCompositingMode && config.Engine.WebKit.DisableCompositingMode {
404395 validationErrors = append(validationErrors,
405- "rendering.force_compositing_mode and rendering.disable_compositing_mode cannot both be true",
396+ "engine.webkit.force_compositing_mode and engine.webkit.disable_compositing_mode cannot both be true",
406397 )
407398 }
408399
@@ -410,13 +401,13 @@ func validateRendering(config *Config) []string {
410401 }
411402
412403 func validatePrivacy(config *Config) []string {
413- switch config.Privacy.CookiePolicy {
404+ switch config.Engine.CookiePolicy {
414405 case CookiePolicyAlways, CookiePolicyNoThirdParty, CookiePolicyNever, "":
415406 return nil
416407 default:
417408 return []string{fmt.Sprintf(
418- "privacy.cookie_policy must be one of: always, no_third_party, never (got: %s)",
419- config.Privacy.CookiePolicy,
409+ "engine.cookie_policy must be one of: always, no_third_party, never (got: %s)",
410+ config.Engine.CookiePolicy,
420411 )}
421412 }
422413 }
@@ -451,39 +442,40 @@ func validatePerformanceProfile(config *Config) []string {
451442 var validationErrors []string
452443
453444 // Validate profile name
454- if !IsValidPerformanceProfile(config.Performance.Profile) {
445+ if !IsValidPerformanceProfile(config.Engine.Profile) {
455446 validationErrors = append(validationErrors, fmt.Sprintf(
456- "performance.profile must be one of: default, lite, max, custom (got: %s)",
457- config.Performance.Profile,
447+ "engine.profile must be one of: default, lite, max, custom (got: %s)",
448+ config.Engine.Profile,
458449 ))
459450 }
460451
461452 // When profile is not "custom", warn if individual fields are set
462453 // (they will be ignored in favor of profile-computed values)
463- if config.Performance.Profile != ProfileCustom && config.Performance.Profile != "" {
464- if HasCustomPerformanceFields(&config.Performance) {
454+ if config.Engine.Profile != ProfileCustom && config.Engine.Profile != "" {
455+ perfCfg := PerformanceConfigFromEngine(&config.Engine)
456+ if HasCustomPerformanceFields(&perfCfg) {
465457 validationErrors = append(validationErrors,
466- "performance tuning fields (skia_*, *_memory_*) are ignored when profile is not 'custom'; "+
458+ "engine performance tuning fields (skia_*, *_memory_*) are ignored when profile is not 'custom'; "+
467459 "set profile = \"custom\" to use individual field values",
468460 )
469461 }
470462 }
471463
472464 // Validate memory pressure threshold ordering: WebKit requires conservative < strict
473- webCons := config.Performance.WebProcessMemoryConservativeThreshold
474- webStrict := config.Performance.WebProcessMemoryStrictThreshold
465+ webCons := config.Engine.WebKit.WebProcessMemoryConservativeThreshold
466+ webStrict := config.Engine.WebKit.WebProcessMemoryStrictThreshold
475467 if webCons > 0 && webStrict > 0 && webCons >= webStrict {
476468 validationErrors = append(validationErrors,
477- "performance.web_process_memory_conservative_threshold must be less than "+
469+ "engine.webkit.web_process_memory_conservative_threshold must be less than "+
478470 "web_process_memory_strict_threshold when both are set",
479471 )
480472 }
481473
482- netCons := config.Performance.NetworkProcessMemoryConservativeThreshold
483- netStrict := config.Performance.NetworkProcessMemoryStrictThreshold
474+ netCons := config.Engine.WebKit.NetworkProcessMemoryConservativeThreshold
475+ netStrict := config.Engine.WebKit.NetworkProcessMemoryStrictThreshold
484476 if netCons > 0 && netStrict > 0 && netCons >= netStrict {
485477 validationErrors = append(validationErrors,
486- "performance.network_process_memory_conservative_threshold must be less than "+
478+ "engine.webkit.network_process_memory_conservative_threshold must be less than "+
487479 "network_process_memory_strict_threshold when both are set",
488480 )
489481 }
M internal/infrastructure/config/validation_test.go +3 -3
@@ -7,7 +7,7 @@ import (
77 "github.com/stretchr/testify/require"
88 )
99
10-func TestValidateConfig_PrivacyCookiePolicy(t *testing.T) {
10+func TestValidateConfig_EngineCookiePolicy(t *testing.T) {
1111 tests := []struct {
1212 name string
1313 cookiePolicy CookiePolicy
@@ -22,12 +22,12 @@ func TestValidateConfig_PrivacyCookiePolicy(t *testing.T) {
2222 for _, tt := range tests {
2323 t.Run(tt.name, func(t *testing.T) {
2424 cfg := DefaultConfig()
25- cfg.Privacy.CookiePolicy = tt.cookiePolicy
25+ cfg.Engine.CookiePolicy = tt.cookiePolicy
2626
2727 err := validateConfig(cfg)
2828 if tt.wantErr {
2929 require.Error(t, err)
30- assert.Contains(t, err.Error(), "privacy.cookie_policy")
30+ assert.Contains(t, err.Error(), "engine.cookie_policy")
3131 return
3232 }
3333 require.NoError(t, err)
M internal/infrastructure/config/webui_gateway.go +6 -6
@@ -80,16 +80,16 @@ func (g *WebUIConfigGateway) SaveWebUIConfig(ctx context.Context, cfg port.WebUI
8080
8181 // Performance profile (requires restart to take effect)
8282 if cfg.Performance.Profile != "" {
83- current.Performance.Profile = PerformanceProfile(cfg.Performance.Profile)
83+ current.Engine.Profile = PerformanceProfile(cfg.Performance.Profile)
8484 }
8585
8686 // Custom performance fields (only used when profile is "custom")
8787 if cfg.Performance.Profile == string(ProfileCustom) {
88- current.Performance.SkiaCPUPaintingThreads = clampInt(cfg.Performance.Custom.SkiaCPUThreads, 0, maxSkiaCPUThreads)
89- current.Performance.SkiaGPUPaintingThreads = clampInt(cfg.Performance.Custom.SkiaGPUThreads, -1, maxSkiaGPUThreads)
90- current.Performance.WebProcessMemoryLimitMB = clampInt(cfg.Performance.Custom.WebProcessMemoryMB, 0, maxWebProcessMemoryMB)
91- current.Performance.NetworkProcessMemoryLimitMB = clampInt(cfg.Performance.Custom.NetworkProcessMemoryMB, 0, maxNetworkProcessMemoryMB)
92- current.Performance.WebViewPoolPrewarmCount = clampInt(cfg.Performance.Custom.WebViewPoolPrewarm, 0, maxWebViewPoolPrewarm)
88+ current.Engine.WebKit.SkiaCPUPaintingThreads = clampInt(cfg.Performance.Custom.SkiaCPUThreads, 0, maxSkiaCPUThreads)
89+ current.Engine.WebKit.SkiaGPUPaintingThreads = clampInt(cfg.Performance.Custom.SkiaGPUThreads, -1, maxSkiaGPUThreads)
90+ current.Engine.WebKit.WebProcessMemoryLimitMB = clampInt(cfg.Performance.Custom.WebProcessMemoryMB, 0, maxWebProcessMemoryMB)
91+ current.Engine.WebKit.NetworkProcessMemoryLimitMB = clampInt(cfg.Performance.Custom.NetworkProcessMemoryMB, 0, maxNetworkProcessMemoryMB)
92+ current.Engine.PoolPrewarmCount = clampInt(cfg.Performance.Custom.WebViewPoolPrewarm, 0, maxWebViewPoolPrewarm)
9393 }
9494
9595 return g.mgr.Save(current)
M internal/infrastructure/env/manager.go +8 -6
@@ -78,7 +78,7 @@ func (m *Manager) DetectGPUVendor(ctx context.Context) port.GPUVendor {
7878
7979 // ApplyEnvironment sets all rendering environment variables.
8080 // Must be called before GTK/WebKit initialization.
81-func (m *Manager) ApplyEnvironment(ctx context.Context, settings port.RenderingEnvSettings) error {
81+func (m *Manager) ApplyEnvironment(ctx context.Context, settings RenderingSettings) error {
8282 log := logging.FromContext(ctx)
8383 m.appliedVars = make(map[string]string)
8484
@@ -97,7 +97,7 @@ func (m *Manager) ApplyEnvironment(ctx context.Context, settings port.RenderingE
9797 return nil
9898 }
9999
100-func (m *Manager) applyGStreamerEnv(settings port.RenderingEnvSettings) {
100+func (m *Manager) applyGStreamerEnv(settings RenderingSettings) {
101101 // VSync
102102 if settings.ForceVSync {
103103 m.setEnv("__GL_SYNC_TO_VBLANK", "1")
@@ -149,7 +149,7 @@ func (m *Manager) applyGPUSpecificEnv() {
149149 }
150150 }
151151
152-func (m *Manager) applyWebKitEnv(settings port.RenderingEnvSettings) {
152+func (m *Manager) applyWebKitEnv(settings RenderingSettings) {
153153 if settings.DisableDMABufRenderer {
154154 m.setEnv("WEBKIT_DISABLE_DMABUF_RENDERER", "1")
155155 }
@@ -161,7 +161,7 @@ func (m *Manager) applyWebKitEnv(settings port.RenderingEnvSettings) {
161161 }
162162 }
163163
164-func (m *Manager) applyGTKEnv(settings port.RenderingEnvSettings) {
164+func (m *Manager) applyGTKEnv(settings RenderingSettings) {
165165 renderer := strings.ToLower(strings.TrimSpace(settings.GSKRenderer))
166166 if renderer != "" && renderer != "auto" {
167167 m.setEnv("GSK_RENDERER", renderer)
@@ -187,7 +187,7 @@ func (m *Manager) applyGTKEnv(settings port.RenderingEnvSettings) {
187187 }
188188 }
189189
190-func (m *Manager) applyDebugEnv(settings port.RenderingEnvSettings) {
190+func (m *Manager) applyDebugEnv(settings RenderingSettings) {
191191 if settings.ShowFPS {
192192 m.setEnv("WEBKIT_SHOW_FPS", "1")
193193 }
@@ -196,7 +196,7 @@ func (m *Manager) applyDebugEnv(settings port.RenderingEnvSettings) {
196196 }
197197 }
198198
199-func (m *Manager) applySkiaEnv(settings port.RenderingEnvSettings) {
199+func (m *Manager) applySkiaEnv(settings RenderingSettings) {
200200 // CPU painting threads: only set if > 0 and env var not already set
201201 if settings.SkiaCPUPaintingThreads > 0 && os.Getenv("WEBKIT_SKIA_CPU_PAINTING_THREADS") == "" {
202202 m.setEnv("WEBKIT_SKIA_CPU_PAINTING_THREADS", fmt.Sprintf("%d", settings.SkiaCPUPaintingThreads))
@@ -249,4 +249,6 @@ func (*Manager) detectFromDRM() port.GPUVendor {
249249 return port.GPUVendorUnknown
250250 }
251251
252+// Ensure Manager implements port.RenderingEnvManager (DetectGPUVendor, GetAppliedVars).
253+// ApplyEnvironment takes the infra-local RenderingSettings type instead of a port type.
252254 var _ port.RenderingEnvManager = (*Manager)(nil)
M internal/infrastructure/env/manager_test.go +10 -11
@@ -6,7 +6,6 @@ import (
66 "os/exec"
77 "testing"
88
9- "github.com/bnema/dumber/internal/application/port"
109 "github.com/bnema/dumber/internal/infrastructure/env"
1110 "github.com/stretchr/testify/assert"
1211 "github.com/stretchr/testify/require"
@@ -25,14 +24,14 @@ func TestManager_ApplyEnvironment_SkiaEnv(t *testing.T) {
2524
2625 tests := []struct {
2726 name string
28- settings port.RenderingEnvSettings
27+ settings env.RenderingSettings
2928 presetEnv map[string]string
3029 expectedVars map[string]string
3130 unexpectedVars []string
3231 }{
3332 {
3433 name: "CPU threads set when > 0",
35- settings: port.RenderingEnvSettings{
34+ settings: env.RenderingSettings{
3635 SkiaCPUPaintingThreads: 4,
3736 SkiaGPUPaintingThreads: -1, // unset
3837 },
@@ -43,7 +42,7 @@ func TestManager_ApplyEnvironment_SkiaEnv(t *testing.T) {
4342 },
4443 {
4544 name: "GPU threads set when >= 0",
46- settings: port.RenderingEnvSettings{
45+ settings: env.RenderingSettings{
4746 SkiaCPUPaintingThreads: 0, // unset
4847 SkiaGPUPaintingThreads: 2,
4948 },
@@ -54,7 +53,7 @@ func TestManager_ApplyEnvironment_SkiaEnv(t *testing.T) {
5453 },
5554 {
5655 name: "GPU threads 0 disables GPU tile painting",
57- settings: port.RenderingEnvSettings{
56+ settings: env.RenderingSettings{
5857 SkiaGPUPaintingThreads: 0,
5958 },
6059 expectedVars: map[string]string{
@@ -63,7 +62,7 @@ func TestManager_ApplyEnvironment_SkiaEnv(t *testing.T) {
6362 },
6463 {
6564 name: "CPU rendering enabled",
66- settings: port.RenderingEnvSettings{
65+ settings: env.RenderingSettings{
6766 SkiaEnableCPURendering: true,
6867 },
6968 expectedVars: map[string]string{
@@ -72,14 +71,14 @@ func TestManager_ApplyEnvironment_SkiaEnv(t *testing.T) {
7271 },
7372 {
7473 name: "CPU rendering disabled (default)",
75- settings: port.RenderingEnvSettings{
74+ settings: env.RenderingSettings{
7675 SkiaEnableCPURendering: false,
7776 },
7877 unexpectedVars: []string{"WEBKIT_SKIA_ENABLE_CPU_RENDERING"},
7978 },
8079 {
8180 name: "does not override existing CPU threads env var",
82- settings: port.RenderingEnvSettings{
81+ settings: env.RenderingSettings{
8382 SkiaCPUPaintingThreads: 8,
8483 },
8584 presetEnv: map[string]string{
@@ -91,7 +90,7 @@ func TestManager_ApplyEnvironment_SkiaEnv(t *testing.T) {
9190 },
9291 {
9392 name: "does not override existing GPU threads env var",
94- settings: port.RenderingEnvSettings{
93+ settings: env.RenderingSettings{
9594 SkiaGPUPaintingThreads: 4,
9695 },
9796 presetEnv: map[string]string{
@@ -103,7 +102,7 @@ func TestManager_ApplyEnvironment_SkiaEnv(t *testing.T) {
103102 },
104103 {
105104 name: "does not override existing CPU rendering env var",
106- settings: port.RenderingEnvSettings{
105+ settings: env.RenderingSettings{
107106 SkiaEnableCPURendering: true,
108107 },
109108 presetEnv: map[string]string{
@@ -115,7 +114,7 @@ func TestManager_ApplyEnvironment_SkiaEnv(t *testing.T) {
115114 },
116115 {
117116 name: "all unset by default",
118- settings: port.RenderingEnvSettings{
117+ settings: env.RenderingSettings{
119118 SkiaCPUPaintingThreads: 0, // unset
120119 SkiaGPUPaintingThreads: -1, // unset
121120 SkiaEnableCPURendering: false,
A internal/infrastructure/env/settings.go +27 -0
@@ -0,0 +1,27 @@
1+package env
2+
3+// RenderingSettings holds all rendering environment variable settings.
4+// This was previously port.RenderingEnvSettings but moved here because
5+// these settings are engine-specific (WebKit/GTK/GStreamer).
6+type RenderingSettings struct {
7+ // GStreamer
8+ ForceVSync bool
9+ GLRenderingMode string
10+ GStreamerDebugLevel int
11+ // WebKit compositor
12+ DisableDMABufRenderer bool
13+ ForceCompositingMode bool
14+ DisableCompositingMode bool
15+ // GTK/GSK
16+ GSKRenderer string
17+ DisableMipmaps bool
18+ PreferGL bool
19+ // Debug
20+ ShowFPS bool
21+ SampleMemory bool
22+ DebugFrames bool
23+ // Skia
24+ SkiaCPUPaintingThreads int
25+ SkiaGPUPaintingThreads int
26+ SkiaEnableCPURendering bool
27+}
M internal/infrastructure/filtering/manager.go +4 -0
@@ -12,10 +12,14 @@ import (
1212 "sync/atomic"
1313 "time"
1414
15+ "github.com/bnema/dumber/internal/application/port"
1516 "github.com/bnema/dumber/internal/logging"
1617 "github.com/bnema/puregotk-webkit/webkit"
1718 )
1819
20+// Compile-time check that Manager satisfies port.FilterManager.
21+var _ port.FilterManager = (*Manager)(nil)
22+
1923 const (
2024 storeDirPerm = 0o755
2125 jsonDirPerm = 0o755
M internal/infrastructure/filtering/types.go +15 -16
@@ -1,31 +1,30 @@
11 package filtering
22
3-import "time"
3+import (
4+ "time"
45
5-// FilterState represents the current state of the content filter system.
6-type FilterState string
6+ "github.com/bnema/dumber/internal/application/port"
7+)
8+
9+// FilterState is an alias for port.FilterState to avoid breaking existing references.
10+type FilterState = port.FilterState
11+
12+// FilterStatus is an alias for port.FilterStatus so filtering.Manager satisfies port.FilterManager.
13+type FilterStatus = port.FilterStatus
714
815 const (
916 // StateUninitialized means the filter manager has not been initialized yet.
10- StateUninitialized FilterState = "uninitialized"
17+ StateUninitialized FilterState = port.FilterStateUninitialized
1118 // StateLoading means filters are being downloaded or compiled.
12- StateLoading FilterState = "loading"
19+ StateLoading FilterState = port.FilterStateLoading
1320 // StateActive means filters are loaded and active.
14- StateActive FilterState = "active"
21+ StateActive FilterState = port.FilterStateActive
1522 // StateDisabled means filtering is disabled by configuration.
16- StateDisabled FilterState = "disabled"
23+ StateDisabled FilterState = port.FilterStateDisabled
1724 // StateError means an error occurred during filter loading.
18- StateError FilterState = "error"
25+ StateError FilterState = port.FilterStateError
1926 )
2027
21-// FilterStatus represents the current status of the content filter system.
22-// Used for UI toast notifications and status reporting.
23-type FilterStatus struct {
24- State FilterState
25- Message string
26- Version string
27-}
28-
2928 // Manifest represents the manifest.json from ublock-webkit-filters releases.
3029 // It contains metadata about the filter lists and their rule counts.
3130 type Manifest struct {
M internal/infrastructure/snapshot/service.go +3 -0
@@ -19,6 +19,9 @@ const (
1919 fkRetryDelay = 100 * time.Millisecond
2020 )
2121
22+// Compile-time interface check.
23+var _ port.SnapshotService = (*Service)(nil)
24+
2225 // Service handles debounced session state snapshots.
2326 type Service struct {
2427 snapshotUC *usecase.SnapshotSessionUseCase
M internal/infrastructure/textinput/gtk_entry_target.go +5 -1
@@ -14,8 +14,12 @@ type GTKEntryTarget struct {
1414 entry *gtk.SearchEntry
1515 }
1616
17-// Compile-time interface check.
17+// Compile-time interface checks.
1818 var _ port.TextInputTarget = (*GTKEntryTarget)(nil)
19+var _ port.EntryInputTarget = (*GTKEntryTarget)(nil)
20+
21+// IsGTKEntryTarget implements port.EntryInputTarget.
22+func (*GTKEntryTarget) IsGTKEntryTarget() bool { return true }
1923
2024 // NewGTKEntryTarget creates a new GTK entry target for a SearchEntry.
2125 func NewGTKEntryTarget(entry *gtk.SearchEntry) *GTKEntryTarget {
M internal/infrastructure/webkit/context.go +14 -13
@@ -6,7 +6,6 @@ import (
66 "path/filepath"
77 "sync"
88
9- "github.com/bnema/dumber/internal/application/port"
109 "github.com/bnema/dumber/internal/logging"
1110 "github.com/bnema/puregotk-webkit/webkit"
1211 "github.com/rs/zerolog"
@@ -36,17 +35,17 @@ type WebKitContext struct {
3635 // The dataDir and cacheDir are used for cookie storage, cache, and other persistent data.
3736 // This MUST be called before creating any WebViews to ensure they use persistent storage.
3837 func NewWebKitContext(ctx context.Context, dataDir, cacheDir string) (*WebKitContext, error) {
39- return NewWebKitContextWithOptions(ctx, port.WebKitContextOptions{
38+ return NewWebKitContextWithOptions(ctx, webKitContextOptions{
4039 DataDir: dataDir,
4140 CacheDir: cacheDir,
42- CookiePolicy: port.WebKitCookiePolicyNoThirdParty,
41+ CookiePolicy: cookiePolicyNoThirdParty,
4342 ITPEnabled: true,
4443 })
4544 }
4645
4746 // NewWebKitContextWithOptions creates and initializes a WebKitContext with the given options.
4847 // This allows configuring memory pressure settings for web and network processes.
49-func NewWebKitContextWithOptions(ctx context.Context, opts port.WebKitContextOptions) (*WebKitContext, error) {
48+func NewWebKitContextWithOptions(ctx context.Context, opts webKitContextOptions) (*WebKitContext, error) {
5049 log := logging.FromContext(ctx).With().Str("component", "webkit-context").Logger()
5150
5251 if opts.DataDir == "" {
@@ -114,7 +113,7 @@ func NewWebKitContextWithOptions(ctx context.Context, opts port.WebKitContextOpt
114113 }
115114
116115 // initNetworkSession creates and configures the persistent network session.
117-func (c *WebKitContext) initNetworkSession(opts port.WebKitContextOptions) error {
116+func (c *WebKitContext) initNetworkSession(opts webKitContextOptions) error {
118117 // Create persistent network session
119118 session := webkit.NewNetworkSession(&c.dataDir, &c.cacheDir)
120119 if session == nil {
@@ -188,16 +187,18 @@ func (c *WebKitContext) initNetworkSession(opts port.WebKitContextOptions) error
188187 return nil
189188 }
190189
191-func mapCookiePolicy(policy port.WebKitCookiePolicy) (webkit.CookieAcceptPolicy, string) {
190+func mapCookiePolicy(policy cookiePolicy) (webkit.CookieAcceptPolicy, string) {
192191 switch policy {
193- case port.WebKitCookiePolicyAlways:
194- return webkit.CookiePolicyAcceptAlwaysValue, string(port.WebKitCookiePolicyAlways)
195- case port.WebKitCookiePolicyNever:
196- return webkit.CookiePolicyAcceptNeverValue, string(port.WebKitCookiePolicyNever)
197- case port.WebKitCookiePolicyNoThirdParty, "":
198- return webkit.CookiePolicyAcceptNoThirdPartyValue, string(port.WebKitCookiePolicyNoThirdParty)
192+ case cookiePolicyAlways:
193+ return webkit.CookiePolicyAcceptAlwaysValue, string(cookiePolicyAlways)
194+ case cookiePolicyNever:
195+ return webkit.CookiePolicyAcceptNeverValue, string(cookiePolicyNever)
196+ case cookiePolicyNoThirdParty:
197+ return webkit.CookiePolicyAcceptNoThirdPartyValue, string(cookiePolicyNoThirdParty)
198+ case "":
199+ return webkit.CookiePolicyAcceptNoThirdPartyValue, ""
199200 default:
200- return webkit.CookiePolicyAcceptNoThirdPartyValue, string(port.WebKitCookiePolicyNoThirdParty)
201+ return webkit.CookiePolicyAcceptNoThirdPartyValue, string(cookiePolicyNoThirdParty)
201202 }
202203 }
203204
A internal/infrastructure/webkit/context_options.go +56 -0
@@ -0,0 +1,56 @@
1+package webkit
2+
3+import "github.com/bnema/dumber/internal/application/port"
4+
5+// cookiePolicy controls cookie acceptance behavior for the WebKit NetworkSession.
6+// This is a webkit-internal type; values match port.CookiePolicy string values.
7+type cookiePolicy = port.CookiePolicy
8+
9+const (
10+ cookiePolicyAlways cookiePolicy = port.CookiePolicyAlways
11+ cookiePolicyNoThirdParty cookiePolicy = port.CookiePolicyNoThirdParty
12+ cookiePolicyNever cookiePolicy = port.CookiePolicyNever
13+)
14+
15+// webKitContextOptions configures WebKitContext creation.
16+// This is a webkit-specific options struct that extends EngineOptions with
17+// WebKit-specific fields (e.g. ITPEnabled).
18+type webKitContextOptions struct {
19+ // DataDir is the directory for persistent data (cookies, storage, etc.).
20+ DataDir string
21+
22+ // CacheDir is the directory for cache data.
23+ CacheDir string
24+
25+ // CookiePolicy controls cookie acceptance behavior.
26+ // Empty value means runtime defaults are used.
27+ CookiePolicy cookiePolicy
28+
29+ // ITPEnabled controls Intelligent Tracking Prevention for NetworkSession.
30+ // False means ITP remains disabled unless enabled explicitly.
31+ ITPEnabled bool
32+
33+ // WebProcessMemory configures memory pressure for web processes.
34+ // nil means use WebKit defaults.
35+ WebProcessMemory *port.MemoryPressureConfig
36+
37+ // NetworkProcessMemory configures memory pressure for the network process.
38+ // nil means use WebKit defaults.
39+ NetworkProcessMemory *port.MemoryPressureConfig
40+}
41+
42+// IsWebProcessMemoryConfigured returns true if web process memory settings are configured.
43+func (o *webKitContextOptions) IsWebProcessMemoryConfigured() bool {
44+ if o == nil || o.WebProcessMemory == nil {
45+ return false
46+ }
47+ return o.WebProcessMemory.IsConfigured()
48+}
49+
50+// IsNetworkProcessMemoryConfigured returns true if network process memory settings are configured.
51+func (o *webKitContextOptions) IsNetworkProcessMemoryConfigured() bool {
52+ if o == nil || o.NetworkProcessMemory == nil {
53+ return false
54+ }
55+ return o.NetworkProcessMemory.IsConfigured()
56+}
M internal/infrastructure/webkit/download_handler.go +14 -15
@@ -7,7 +7,6 @@ import (
77 "sync"
88
99 "github.com/bnema/dumber/internal/application/port"
10- "github.com/bnema/dumber/internal/application/usecase"
1110 downloadutil "github.com/bnema/dumber/internal/domain/download"
1211 "github.com/bnema/dumber/internal/logging"
1312 "github.com/bnema/puregotk-webkit/webkit"
@@ -20,26 +19,26 @@ const (
2019
2120 // DownloadHandler manages WebKit downloads and notifies the UI layer.
2221 type DownloadHandler struct {
23- downloadPath string
24- eventHandler port.DownloadEventHandler
25- prepareDownloadUC *usecase.PrepareDownloadUseCase
26- mu sync.RWMutex
22+ downloadPath string
23+ eventHandler port.DownloadEventHandler
24+ preparer port.DownloadPreparer
25+ mu sync.RWMutex
2726 }
2827
2928 // NewDownloadHandler creates a new download handler.
30-// Panics if prepareDownloadUC is nil (fail fast on misconfiguration).
29+// Panics if preparer is nil (fail fast on misconfiguration).
3130 func NewDownloadHandler(
3231 downloadPath string,
3332 handler port.DownloadEventHandler,
34- prepareDownloadUC *usecase.PrepareDownloadUseCase,
33+ preparer port.DownloadPreparer,
3534 ) *DownloadHandler {
36- if prepareDownloadUC == nil {
37- panic("prepareDownloadUC is required")
35+ if preparer == nil {
36+ panic("preparer is required")
3837 }
3938 return &DownloadHandler{
40- downloadPath: downloadPath,
41- eventHandler: handler,
42- prepareDownloadUC: prepareDownloadUC,
39+ downloadPath: downloadPath,
40+ eventHandler: handler,
41+ preparer: preparer,
4342 }
4443 }
4544
@@ -57,7 +56,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
5756 h.mu.RLock()
5857 downloadPath := h.downloadPath
5958 eventHandler := h.eventHandler
60- prepareDownloadUC := h.prepareDownloadUC
59+ preparer := h.preparer
6160 h.mu.RUnlock()
6261
6362 // Track download state in a dedicated struct to ensure proper isolation.
@@ -80,8 +79,8 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D
8079 response = &uriResponseAdapter{resp: resp}
8180 }
8281
83- // Use PrepareDownloadUseCase to resolve filename
84- output := prepareDownloadUC.Execute(ctx, usecase.PrepareDownloadInput{
82+ // Use DownloadPreparer to resolve filename
83+ output := preparer.Execute(ctx, port.DownloadPrepareInput{
8584 SuggestedFilename: suggestedFilename,
8685 Response: response,
8786 DownloadDir: downloadPath,
M internal/infrastructure/webkit/download_handler_test.go +1 -1
@@ -16,7 +16,7 @@ func TestNewDownloadHandler(t *testing.T) {
1616 assert.NotNil(t, handler)
1717 assert.Equal(t, "/tmp/downloads", handler.downloadPath)
1818 assert.Nil(t, handler.eventHandler)
19- assert.NotNil(t, handler.prepareDownloadUC)
19+ assert.NotNil(t, handler.preparer)
2020 })
2121
2222 t.Run("creates handler with custom path", func(t *testing.T) {
A internal/infrastructure/webkit/engine.go +157 -0
@@ -0,0 +1,157 @@
1+package webkit
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/bnema/dumber/internal/infrastructure/filtering"
10+ "github.com/rs/zerolog"
11+)
12+
13+// Engine implements port.Engine for the WebKit browser engine.
14+type Engine struct {
15+ ctx context.Context
16+ wkCtx *WebKitContext
17+ settings *SettingsManager
18+ injector *ContentInjector
19+ messageRouter *MessageRouter
20+ pool *WebViewPool
21+ factory *WebViewFactory
22+ filterManager *filtering.Manager
23+ schemeHandler *DumbSchemeHandler
24+ schemePath string
25+ logger zerolog.Logger
26+
27+ // Handler registrars - injected at construction to avoid import cycles.
28+ handlerRegistrar func(ctx context.Context, router *MessageRouter, deps port.HandlerDependencies) error
29+ accentHandlerRegistrar func(ctx context.Context, router *MessageRouter, handler port.AccentKeyHandler) error
30+}
31+
32+// Compile-time check that Engine implements port.Engine.
33+var _ port.Engine = (*Engine)(nil)
34+
35+// Factory returns the WebViewFactory wrapped as a port.WebViewFactory.
36+func (e *Engine) Factory() port.WebViewFactory {
37+ return &webViewFactoryAdapter{factory: e.factory}
38+}
39+
40+// Pool returns the WebViewPool wrapped as a port.WebViewPool.
41+func (e *Engine) Pool() port.WebViewPool {
42+ return &webViewPoolAdapter{pool: e.pool, ctx: e.ctx}
43+}
44+
45+// ContentInjector returns the ContentInjector implementing port.ContentInjector.
46+func (e *Engine) ContentInjector() port.ContentInjector {
47+ return e.injector
48+}
49+
50+// InternalSchemePath returns the URI scheme used for internal app resources.
51+func (e *Engine) InternalSchemePath() string {
52+ return e.schemePath
53+}
54+
55+// Close releases all resources held by the engine.
56+func (e *Engine) Close() error {
57+ if e.pool != nil {
58+ e.pool.Close(e.ctx)
59+ }
60+ return nil
61+}
62+
63+// SettingsApplier returns a port.SettingsApplier adapter for the SettingsManager.
64+func (e *Engine) SettingsApplier() port.SettingsApplier {
65+ return &settingsApplierAdapter{settings: e.settings}
66+}
67+
68+// FilterApplier returns a port.FilterApplier adapter, or nil if filtering is disabled.
69+func (e *Engine) FilterApplier() port.FilterApplier {
70+ if e.filterManager == nil {
71+ return nil
72+ }
73+ return &filterApplierAdapter{manager: e.filterManager}
74+}
75+
76+// FaviconDatabase returns a port.FaviconDatabase adapter for async favicon lookups.
77+func (e *Engine) FaviconDatabase() port.FaviconDatabase {
78+ return &faviconDatabaseAdapter{wkCtx: e.wkCtx}
79+}
80+
81+// InternalFilterManager returns the FilterManager for content filter lifecycle.
82+// This is on the concrete *Engine type (not the port.Engine interface) because
83+// FilterManager is a webkit-specific concern used only during dependency wiring.
84+func (e *Engine) InternalFilterManager() port.FilterManager { return e.filterManager }
85+
86+// SetHandlerContext sets the base context for message handler dispatch.
87+func (e *Engine) SetHandlerContext(ctx context.Context) {
88+ if e.messageRouter != nil {
89+ e.messageRouter.SetBaseContext(ctx)
90+ }
91+}
92+
93+// RegisterHandlers registers all WebUI message bridge handlers.
94+func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependencies) error {
95+ if e.messageRouter == nil {
96+ return fmt.Errorf("message router not initialized")
97+ }
98+ if e.handlerRegistrar == nil {
99+ return fmt.Errorf("handler registrar not configured")
100+ }
101+ return e.handlerRegistrar(ctx, e.messageRouter, deps)
102+}
103+
104+// RegisterAccentHandlers registers accent/dead-key input handlers.
105+func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.AccentKeyHandler) error {
106+ if e.messageRouter == nil {
107+ return fmt.Errorf("message router not initialized")
108+ }
109+ if e.accentHandlerRegistrar == nil {
110+ return fmt.Errorf("accent handler registrar not configured")
111+ }
112+ return e.accentHandlerRegistrar(ctx, e.messageRouter, handler)
113+}
114+
115+// ConfigureDownloads sets up download handling.
116+func (e *Engine) ConfigureDownloads(
117+ ctx context.Context, downloadPath string,
118+ eventHandler port.DownloadEventHandler, preparer port.DownloadPreparer,
119+) error {
120+ if e.wkCtx == nil {
121+ return fmt.Errorf("webkit context not initialized")
122+ }
123+ handler := NewDownloadHandler(downloadPath, eventHandler, preparer)
124+ e.wkCtx.SetDownloadHandler(ctx, handler)
125+ return nil
126+}
127+
128+// OnToolkitReady refreshes pooled WebViews after toolkit init.
129+func (e *Engine) OnToolkitReady(ctx context.Context) error {
130+ if e.pool != nil {
131+ e.pool.RefreshScripts(ctx)
132+ }
133+ return nil
134+}
135+
136+// UpdateAppearance updates default background color for pool and factory.
137+func (e *Engine) UpdateAppearance(_ context.Context, r, g, b, alpha float64) error {
138+ if e.pool != nil {
139+ e.pool.SetBackgroundColor(float32(r), float32(g), float32(b), float32(alpha))
140+ }
141+ if e.factory != nil {
142+ e.factory.SetBackgroundColor(float32(r), float32(g), float32(b), float32(alpha))
143+ }
144+ return nil
145+}
146+
147+// UpdateSettings applies runtime config changes to engine settings.
148+func (e *Engine) UpdateSettings(ctx context.Context, update port.EngineSettingsUpdate) error {
149+ cfg, ok := update.Raw.(*config.Config)
150+ if !ok {
151+ return fmt.Errorf("UpdateSettings: expected *config.Config, got %T", update.Raw)
152+ }
153+ if e.settings != nil {
154+ e.settings.UpdateFromConfig(ctx, cfg)
155+ }
156+ return nil
157+}
A internal/infrastructure/webkit/engine_adapters.go +149 -0
@@ -0,0 +1,149 @@
1+package webkit
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/infrastructure/filtering"
9+ "github.com/bnema/dumber/internal/logging"
10+ "github.com/jwijenbergh/puregotk/v4/gio"
11+)
12+
13+// --- WebViewFactory adapter ---
14+
15+// webViewFactoryAdapter bridges *WebViewFactory to port.WebViewFactory.
16+// WebViewFactory.Create and CreateRelated return *WebView, not port.WebView,
17+// so this adapter wraps the return values.
18+type webViewFactoryAdapter struct {
19+ factory *WebViewFactory
20+}
21+
22+func (a *webViewFactoryAdapter) Create(ctx context.Context) (port.WebView, error) {
23+ wv, err := a.factory.Create(ctx)
24+ if err != nil {
25+ return nil, err
26+ }
27+ return wv, nil
28+}
29+
30+func (a *webViewFactoryAdapter) CreateRelated(ctx context.Context, parentID port.WebViewID) (port.WebView, error) {
31+ wv, err := a.factory.CreateRelated(ctx, parentID)
32+ if err != nil {
33+ return nil, err
34+ }
35+ return wv, nil
36+}
37+
38+// --- WebViewPool adapter ---
39+
40+// webViewPoolAdapter bridges *WebViewPool to port.WebViewPool.
41+// WebViewPool methods use *WebView and require a context; this adapter adapts
42+// the signatures to match the port interface.
43+type webViewPoolAdapter struct {
44+ pool *WebViewPool
45+ ctx context.Context
46+}
47+
48+func (a *webViewPoolAdapter) Acquire(ctx context.Context) (port.WebView, error) {
49+ wv, err := a.pool.Acquire(ctx)
50+ if err != nil {
51+ return nil, err
52+ }
53+ return wv, nil
54+}
55+
56+func (a *webViewPoolAdapter) Release(wv port.WebView) {
57+ if wv == nil {
58+ return
59+ }
60+ wwv, ok := wv.(*WebView)
61+ if !ok {
62+ logging.FromContext(a.ctx).Warn().
63+ Str("concrete_type", fmt.Sprintf("%T", wv)).
64+ Msg("webViewPoolAdapter.Release: unexpected type, cannot release to pool")
65+ return
66+ }
67+ a.pool.Release(a.ctx, wwv)
68+}
69+
70+func (a *webViewPoolAdapter) Prewarm(count int) {
71+ a.pool.Prewarm(a.ctx, count)
72+}
73+
74+func (a *webViewPoolAdapter) PrewarmAsync(ctx context.Context, count int) {
75+ a.pool.PrewarmAsync(ctx, count)
76+}
77+
78+func (a *webViewPoolAdapter) Size() int {
79+ return a.pool.Size()
80+}
81+
82+func (a *webViewPoolAdapter) Close() {
83+ a.pool.Close(a.ctx)
84+}
85+
86+// --- SettingsApplier adapter ---
87+
88+// settingsApplierAdapter bridges *SettingsManager to port.SettingsApplier.
89+type settingsApplierAdapter struct {
90+ settings *SettingsManager
91+}
92+
93+func (a *settingsApplierAdapter) ApplyToAll(ctx context.Context, webviews []port.WebView) {
94+ for _, wv := range webviews {
95+ if wwv, ok := wv.(*WebView); ok && !wwv.IsDestroyed() {
96+ a.settings.ApplyToWebView(ctx, wwv.Widget())
97+ }
98+ }
99+}
100+
101+// --- FilterApplier adapter ---
102+
103+// filterApplierAdapter bridges *filtering.Manager to port.FilterApplier.
104+type filterApplierAdapter struct {
105+ manager *filtering.Manager
106+}
107+
108+func (a *filterApplierAdapter) ApplyToAll(ctx context.Context, webviews []port.WebView) {
109+ for _, wv := range webviews {
110+ if wwv, ok := wv.(*WebView); ok && !wwv.IsDestroyed() {
111+ a.manager.ApplyTo(ctx, wwv.UserContentManager())
112+ }
113+ }
114+}
115+
116+// --- FaviconDatabase adapter ---
117+
118+// faviconDatabaseAdapter bridges *WebKitContext to port.FaviconDatabase.
119+// The WebKitContext exposes the favicon database; async lookup is a stub until
120+// the coordinator layer is migrated to use this port.
121+type faviconDatabaseAdapter struct {
122+ wkCtx *WebKitContext
123+}
124+
125+func (a *faviconDatabaseAdapter) GetFaviconAsync(pageURL string, callback func(port.Texture)) {
126+ db := a.wkCtx.FaviconDatabase()
127+ if db == nil {
128+ callback(nil)
129+ return
130+ }
131+
132+ asyncCb := gio.AsyncReadyCallback(func(_ uintptr, resultPtr uintptr, _ uintptr) {
133+ if resultPtr == 0 {
134+ callback(nil)
135+ return
136+ }
137+
138+ result := &gio.AsyncResultBase{Ptr: resultPtr}
139+ texture, err := db.GetFaviconFinish(result)
140+ if err != nil || texture == nil {
141+ callback(nil)
142+ return
143+ }
144+
145+ callback(texture)
146+ })
147+
148+ db.GetFavicon(pageURL, nil, &asyncCb, 0)
149+}
A internal/infrastructure/webkit/engine_config.go +74 -0
@@ -0,0 +1,74 @@
1+package webkit
2+
3+import "github.com/bnema/dumber/internal/infrastructure/config"
4+
5+// WebKitEngineConfig holds all WebKit-specific engine configuration.
6+// This is the infrastructure-side version (no TOML tags).
7+type WebKitEngineConfig struct {
8+ // Skia
9+ SkiaCPUPaintingThreads int
10+ SkiaGPUPaintingThreads int
11+ SkiaEnableCPURendering bool
12+ // WebKit compositor
13+ DisableDMABufRenderer bool
14+ ForceCompositingMode bool
15+ DisableCompositingMode bool
16+ // GTK/GSK
17+ GSKRenderer string
18+ DisableMipmaps bool
19+ PreferGL bool
20+ // Debug
21+ ShowFPS bool
22+ SampleMemory bool
23+ DebugFrames bool
24+ DrawCompositingIndicators bool
25+ // Privacy
26+ ITPEnabled bool
27+ // GStreamer
28+ ForceVSync bool
29+ GLRenderingMode string
30+ GStreamerDebugLevel int
31+ // Runtime
32+ Prefix string
33+ // Memory pressure (all fields from config)
34+ WebProcessMemoryLimitMB int
35+ WebProcessMemoryPollIntervalSec float64
36+ WebProcessMemoryConservativeThreshold float64
37+ WebProcessMemoryStrictThreshold float64
38+ NetworkProcessMemoryLimitMB int
39+ NetworkProcessMemoryPollIntervalSec float64
40+ NetworkProcessMemoryConservativeThreshold float64
41+ NetworkProcessMemoryStrictThreshold float64
42+}
43+
44+// EngineConfigFromConfig converts config.WebKitEngineConfig to the infra type.
45+func EngineConfigFromConfig(cfg config.WebKitEngineConfig) WebKitEngineConfig {
46+ return WebKitEngineConfig{
47+ SkiaCPUPaintingThreads: cfg.SkiaCPUPaintingThreads,
48+ SkiaGPUPaintingThreads: cfg.SkiaGPUPaintingThreads,
49+ SkiaEnableCPURendering: cfg.SkiaEnableCPURendering,
50+ DisableDMABufRenderer: cfg.DisableDMABufRenderer,
51+ ForceCompositingMode: cfg.ForceCompositingMode,
52+ DisableCompositingMode: cfg.DisableCompositingMode,
53+ GSKRenderer: string(cfg.GSKRenderer),
54+ DisableMipmaps: cfg.DisableMipmaps,
55+ PreferGL: cfg.PreferGL,
56+ ShowFPS: cfg.ShowFPS,
57+ SampleMemory: cfg.SampleMemory,
58+ DebugFrames: cfg.DebugFrames,
59+ DrawCompositingIndicators: cfg.DrawCompositingIndicators,
60+ ITPEnabled: cfg.ITPEnabled,
61+ ForceVSync: cfg.ForceVSync,
62+ GLRenderingMode: string(cfg.GLRenderingMode),
63+ GStreamerDebugLevel: cfg.GStreamerDebugLevel,
64+ Prefix: cfg.Prefix,
65+ WebProcessMemoryLimitMB: cfg.WebProcessMemoryLimitMB,
66+ WebProcessMemoryPollIntervalSec: cfg.WebProcessMemoryPollIntervalSec,
67+ WebProcessMemoryConservativeThreshold: cfg.WebProcessMemoryConservativeThreshold,
68+ WebProcessMemoryStrictThreshold: cfg.WebProcessMemoryStrictThreshold,
69+ NetworkProcessMemoryLimitMB: cfg.NetworkProcessMemoryLimitMB,
70+ NetworkProcessMemoryPollIntervalSec: cfg.NetworkProcessMemoryPollIntervalSec,
71+ NetworkProcessMemoryConservativeThreshold: cfg.NetworkProcessMemoryConservativeThreshold,
72+ NetworkProcessMemoryStrictThreshold: cfg.NetworkProcessMemoryStrictThreshold,
73+ }
74+}
R internal/bootstrap/webkit_stack.go → internal/infrastructure/webkit/engine_init.go +166 -184 renamed, 54% similar
@@ -1,4 +1,4 @@
1-package bootstrap
1+package webkit
22
33 import (
44 "context"
@@ -10,75 +10,124 @@ import (
1010 "github.com/bnema/dumber/internal/infrastructure/config"
1111 "github.com/bnema/dumber/internal/infrastructure/env"
1212 "github.com/bnema/dumber/internal/infrastructure/filtering"
13- "github.com/bnema/dumber/internal/infrastructure/webkit"
1413 "github.com/bnema/dumber/internal/logging"
1514 "github.com/bnema/dumber/internal/ui/theme"
1615 "github.com/jwijenbergh/puregotk/v4/gdk"
1716 "github.com/rs/zerolog"
1817 )
1918
20-// WebKitStackInput holds the input for BuildWebKitStack.
21-type WebKitStackInput struct {
22- Ctx context.Context
23- Config *config.Config
24- DataDir string
25- CacheDir string
26- ThemeManager *theme.Manager
27- ColorResolver port.ColorSchemeResolver
28- Logger zerolog.Logger
29-}
30-
31-type WebKitStack struct {
32- Context *webkit.WebKitContext
33- Settings *webkit.SettingsManager
34- Injector *webkit.ContentInjector
35- MessageRouter *webkit.MessageRouter
36- Pool *webkit.WebViewPool
37- FilterManager *filtering.Manager
38-}
39-
40-func BuildWebKitStack(input WebKitStackInput) WebKitStack {
41- ctx := input.Ctx
42- cfg := input.Config
43- logger := input.Logger
44-
45- perfSettings := surveyHardwareAndResolveProfile(ctx, cfg, logger)
46-
47- configureRenderingEnvironment(ctx, cfg, &perfSettings, logger)
19+// NewEngine creates and initializes all WebKit engine components.
20+// It absorbs the initialization logic previously found in bootstrap.BuildWebKitStack.
21+func NewEngine(
22+ ctx context.Context,
23+ cfg *config.Config,
24+ opts port.EngineOptions,
25+ wkCfg WebKitEngineConfig,
26+ themeManager *theme.Manager,
27+ colorResolver port.ColorSchemeResolver,
28+ logger zerolog.Logger,
29+ handlerRegistrar func(ctx context.Context, router *MessageRouter, deps port.HandlerDependencies) error,
30+ accentHandlerRegistrar func(ctx context.Context, router *MessageRouter, handler port.AccentKeyHandler) error,
31+) (*Engine, error) {
32+ // --- Hardware survey and performance profile resolution ---
33+ perfSettings := engineSurveyHardwareAndResolveProfile(ctx, cfg, logger)
34+
35+ // --- Configure rendering environment (must be before GTK/WebKit init) ---
36+ engineConfigureRenderingEnvironment(ctx, cfg, wkCfg, &perfSettings, logger)
4837 logging.Trace().Mark("render_env")
4938
50- wkOpts := buildWebKitContextOptions(cfg, input.DataDir, input.CacheDir, &perfSettings)
39+ // --- Build webKitContextOptions from opts + wkCfg + perfSettings ---
40+ wkOpts := engineBuildContextOptions(opts, wkCfg, &perfSettings)
5141 logger.Info().
5242 Str("cookie_policy", string(wkOpts.CookiePolicy)).
5343 Bool("itp_enabled", wkOpts.ITPEnabled).
5444 Msg("webkit privacy configuration")
55- wkCtx, err := webkit.NewWebKitContextWithOptions(ctx, wkOpts)
45+
46+ wkCtx, err := NewWebKitContextWithOptions(ctx, wkOpts)
5647 if err != nil {
57- logger.Fatal().Err(err).Msg("failed to initialize WebKit context")
48+ return nil, err
5849 }
5950 logging.Trace().Mark("webkit_context")
6051
61- filterManager := initFilterManager(ctx, cfg, input.DataDir, logger)
52+ // --- Filter manager ---
53+ filterManager := engineInitFilterManager(ctx, cfg, opts.DataDir, logger)
6254
63- schemeHandler := webkit.NewDumbSchemeHandler(ctx)
55+ // --- Scheme handler ---
56+ schemeHandler := NewDumbSchemeHandler(ctx)
6457 schemeHandler.SetAssets(assets.WebUIAssets)
6558 schemeHandler.RegisterWithContext(wkCtx)
6659
67- settings, injector, messageRouter := initWebKitComponents(ctx, cfg, input.ThemeManager, input.ColorResolver, logger)
60+ // --- Settings, injector, message router ---
61+ settings := NewSettingsManager(ctx, cfg)
62+ injector := NewContentInjector(colorResolver)
6863
69- pool := initWebViewPool(ctx, wkCtx, settings, injector, messageRouter, input.ThemeManager, filterManager, &perfSettings, logger)
64+ injector.SetAutoCopyConfigGetter(func() bool {
65+ return config.Get().Clipboard.AutoCopyOnSelection
66+ })
7067
71- return WebKitStack{
72- Context: wkCtx,
73- Settings: settings,
74- Injector: injector,
75- MessageRouter: messageRouter,
76- Pool: pool,
77- FilterManager: filterManager,
68+ prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector)
69+ themeCSSText := themeManager.GetWebUIThemeCSS()
70+ if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil {
71+ logger.Warn().Err(err).Msg("failed to prepare WebUI theme CSS")
72+ }
73+
74+ messageRouter := NewMessageRouter(ctx)
75+ logging.Trace().Mark("settings_manager")
76+
77+ // --- WebView pool ---
78+ poolCfg := DefaultPoolConfig()
79+ if perfSettings.WebViewPoolPrewarmCount > 0 {
80+ poolCfg.PrewarmCount = perfSettings.WebViewPoolPrewarmCount
81+ }
82+ pool := NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter)
83+
84+ bgR, bgG, bgB, bgA := themeManager.GetBackgroundRGBA()
85+ pool.SetBackgroundColor(bgR, bgG, bgB, bgA)
86+
87+ if filterManager != nil {
88+ pool.SetFilterApplier(filterManager)
89+ }
90+
91+ if gdk.DisplayGetDefault() != nil {
92+ if err := pool.PrewarmFirst(ctx); err != nil {
93+ logger.Warn().Err(err).Msg("failed to prewarm first webview, first tab may be slower")
94+ }
95+ } else {
96+ logger.Warn().Msg("skipping first webview prewarm: no GDK display available yet")
7897 }
98+ logging.Trace().Mark("pool_prewarm_first")
99+
100+ // --- WebView factory ---
101+ factory := NewWebViewFactory(wkCtx, settings, pool, injector, messageRouter)
102+ factory.SetBackgroundColor(bgR, bgG, bgB, bgA)
103+ if filterManager != nil {
104+ factory.SetFilterApplier(filterManager)
105+ }
106+
107+ // --- Assemble Engine ---
108+ engine := &Engine{
109+ ctx: ctx,
110+ wkCtx: wkCtx,
111+ settings: settings,
112+ injector: injector,
113+ messageRouter: messageRouter,
114+ pool: pool,
115+ factory: factory,
116+ filterManager: filterManager,
117+ schemeHandler: schemeHandler,
118+ schemePath: "dumb://",
119+ logger: logger,
120+ handlerRegistrar: handlerRegistrar,
121+ accentHandlerRegistrar: accentHandlerRegistrar,
122+ }
123+
124+ return engine, nil
79125 }
80126
81-func surveyHardwareAndResolveProfile(ctx context.Context, cfg *config.Config, logger zerolog.Logger) config.ResolvedPerformanceSettings {
127+// engineSurveyHardwareAndResolveProfile surveys hardware and resolves the performance profile.
128+func engineSurveyHardwareAndResolveProfile(
129+ ctx context.Context, cfg *config.Config, logger zerolog.Logger,
130+) config.ResolvedPerformanceSettings {
82131 hwSurveyor := env.NewHardwareSurveyor()
83132 hwInfo := hwSurveyor.Survey(ctx)
84133 logging.Trace().Mark("hardware_survey")
@@ -90,10 +139,11 @@ func surveyHardwareAndResolveProfile(ctx context.Context, cfg *config.Config, lo
90139 Uint64("vram_mb", hwInfo.VRAM/(1024*1024)).
91140 Msg("hardware survey completed")
92141
93- perfSettings := config.ResolvePerformanceProfile(&cfg.Performance, &hwInfo)
142+ perfCfg := config.PerformanceConfigFromEngine(&cfg.Engine)
143+ perfSettings := config.ResolvePerformanceProfile(&perfCfg, &hwInfo)
94144 logging.Trace().Mark("performance_profile")
95145 logger.Info().
96- Str("profile", string(cfg.Performance.Profile)).
146+ Str("profile", string(cfg.Engine.Profile)).
97147 Int("skia_cpu_threads", perfSettings.SkiaCPUPaintingThreads).
98148 Int("skia_gpu_threads", perfSettings.SkiaGPUPaintingThreads).
99149 Int("webview_pool_prewarm", perfSettings.WebViewPoolPrewarmCount).
@@ -102,26 +152,72 @@ func surveyHardwareAndResolveProfile(ctx context.Context, cfg *config.Config, lo
102152 return perfSettings
103153 }
104154
105-func buildWebKitContextOptions(
155+// engineConfigureRenderingEnvironment sets up the rendering environment.
156+// Environment variables must be set before GTK/WebKit/GStreamer initializes.
157+func engineConfigureRenderingEnvironment(
158+ ctx context.Context,
106159 cfg *config.Config,
107- dataDir, cacheDir string,
160+ wkCfg WebKitEngineConfig,
108161 perfSettings *config.ResolvedPerformanceSettings,
109-) port.WebKitContextOptions {
110- cookiePolicy := port.WebKitCookiePolicyNoThirdParty
111- itpEnabled := true
112- if cfg != nil {
113- cookiePolicy = port.WebKitCookiePolicy(cfg.Privacy.CookiePolicy)
114- itpEnabled = cfg.Privacy.ITPEnabled
162+ logger zerolog.Logger,
163+) {
164+ // Install GLib log handler FIRST if configured (must be before any GTK/GLib calls).
165+ if cfg.Logging.CaptureGTKLogs {
166+ enableDebug := cfg.Logging.Level == "debug" || cfg.Logging.Level == "trace"
167+ logging.InstallGLibLogHandler(ctx, logger, enableDebug)
168+ }
169+
170+ renderEnv := env.NewManager()
171+ gpuVendor := renderEnv.DetectGPUVendor(ctx)
172+ renderSettings := env.RenderingSettings{
173+ // GStreamer settings
174+ ForceVSync: wkCfg.ForceVSync,
175+ GLRenderingMode: wkCfg.GLRenderingMode,
176+ GStreamerDebugLevel: wkCfg.GStreamerDebugLevel,
177+ // WebKit compositor settings
178+ DisableDMABufRenderer: wkCfg.DisableDMABufRenderer,
179+ ForceCompositingMode: wkCfg.ForceCompositingMode,
180+ DisableCompositingMode: wkCfg.DisableCompositingMode,
181+ // GTK/GSK settings
182+ GSKRenderer: wkCfg.GSKRenderer,
183+ DisableMipmaps: wkCfg.DisableMipmaps,
184+ PreferGL: wkCfg.PreferGL,
185+ // Debug settings
186+ ShowFPS: wkCfg.ShowFPS,
187+ SampleMemory: wkCfg.SampleMemory,
188+ DebugFrames: wkCfg.DebugFrames,
189+ // Skia rendering thread settings (from resolved performance profile)
190+ SkiaCPUPaintingThreads: perfSettings.SkiaCPUPaintingThreads,
191+ SkiaGPUPaintingThreads: perfSettings.SkiaGPUPaintingThreads,
192+ SkiaEnableCPURendering: perfSettings.SkiaEnableCPURendering,
115193 }
194+ if err := renderEnv.ApplyEnvironment(ctx, renderSettings); err != nil {
195+ logger.Warn().Err(err).Msg("failed to apply rendering environment")
196+ }
197+ logger.Info().
198+ Str("gpu", string(gpuVendor)).
199+ Interface("vars", renderEnv.GetAppliedVars()).
200+ Msg("rendering environment configured")
201+}
116202
117- wkOpts := port.WebKitContextOptions{
118- DataDir: dataDir,
119- CacheDir: cacheDir,
120- CookiePolicy: cookiePolicy,
121- ITPEnabled: itpEnabled,
203+// engineBuildContextOptions builds webKitContextOptions from EngineOptions, wkCfg and perfSettings.
204+func engineBuildContextOptions(
205+ opts port.EngineOptions,
206+ wkCfg WebKitEngineConfig,
207+ perfSettings *config.ResolvedPerformanceSettings,
208+) webKitContextOptions {
209+ cp := opts.CookiePolicy // empty preserves runtime default per port contract
210+
211+ wkOpts := webKitContextOptions{
212+ DataDir: opts.DataDir,
213+ CacheDir: opts.CacheDir,
214+ CookiePolicy: cp,
215+ ITPEnabled: wkCfg.ITPEnabled,
122216 }
123217
124- if hasWebProcessMemoryConfig(perfSettings) {
218+ if opts.WebProcessMemory != nil {
219+ wkOpts.WebProcessMemory = opts.WebProcessMemory
220+ } else if engineHasWebProcessMemoryConfig(perfSettings) {
125221 wkOpts.WebProcessMemory = &port.MemoryPressureConfig{
126222 MemoryLimitMB: perfSettings.WebProcessMemoryLimitMB,
127223 PollIntervalSec: perfSettings.WebProcessMemoryPollIntervalSec,
@@ -130,7 +226,9 @@ func buildWebKitContextOptions(
130226 }
131227 }
132228
133- if hasNetworkProcessMemoryConfig(perfSettings) {
229+ if opts.NetworkProcessMemory != nil {
230+ wkOpts.NetworkProcessMemory = opts.NetworkProcessMemory
231+ } else if engineHasNetworkProcessMemoryConfig(perfSettings) {
134232 wkOpts.NetworkProcessMemory = &port.MemoryPressureConfig{
135233 MemoryLimitMB: perfSettings.NetworkProcessMemoryLimitMB,
136234 PollIntervalSec: perfSettings.NetworkProcessMemoryPollIntervalSec,
@@ -142,7 +240,8 @@ func buildWebKitContextOptions(
142240 return wkOpts
143241 }
144242
145-func initFilterManager(ctx context.Context, cfg *config.Config, dataDir string, logger zerolog.Logger) *filtering.Manager {
243+// engineInitFilterManager creates and initializes the content filter manager.
244+func engineInitFilterManager(ctx context.Context, cfg *config.Config, dataDir string, logger zerolog.Logger) *filtering.Manager {
146245 filterStoreDir := filepath.Join(dataDir, "filters", "store")
147246 filterJSONDir := filepath.Join(dataDir, "filters", "json")
148247 filterManager, err := filtering.NewManager(filtering.ManagerConfig{
@@ -162,135 +261,18 @@ func initFilterManager(ctx context.Context, cfg *config.Config, dataDir string,
162261 return filterManager
163262 }
164263
165-func initWebKitComponents(
166- ctx context.Context,
167- cfg *config.Config,
168- themeManager *theme.Manager,
169- colorResolver port.ColorSchemeResolver,
170- logger zerolog.Logger,
171-) (*webkit.SettingsManager, *webkit.ContentInjector, *webkit.MessageRouter) {
172- settings := webkit.NewSettingsManager(ctx, cfg)
173- injector := webkit.NewContentInjector(colorResolver)
174-
175- injector.SetAutoCopyConfigGetter(func() bool {
176- return config.Get().Clipboard.AutoCopyOnSelection
177- })
178-
179- prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(injector)
180- themeCSSText := themeManager.GetWebUIThemeCSS()
181- if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: themeCSSText}); err != nil {
182- logger.Warn().Err(err).Msg("failed to prepare WebUI theme CSS")
183- }
184-
185- messageRouter := webkit.NewMessageRouter(ctx)
186- logging.Trace().Mark("settings_manager")
187-
188- return settings, injector, messageRouter
189-}
190-
191-func initWebViewPool(
192- ctx context.Context,
193- wkCtx *webkit.WebKitContext,
194- settings *webkit.SettingsManager,
195- injector *webkit.ContentInjector,
196- messageRouter *webkit.MessageRouter,
197- themeManager *theme.Manager,
198- filterManager *filtering.Manager,
199- perfSettings *config.ResolvedPerformanceSettings,
200- logger zerolog.Logger,
201-) *webkit.WebViewPool {
202- poolCfg := webkit.DefaultPoolConfig()
203- if perfSettings.WebViewPoolPrewarmCount > 0 {
204- poolCfg.PrewarmCount = perfSettings.WebViewPoolPrewarmCount
205- }
206- pool := webkit.NewWebViewPool(ctx, wkCtx, settings, poolCfg, injector, messageRouter)
207-
208- bgR, bgG, bgB, bgA := themeManager.GetBackgroundRGBA()
209- pool.SetBackgroundColor(bgR, bgG, bgB, bgA)
210-
211- if filterManager != nil {
212- pool.SetFilterApplier(filterManager)
213- }
214-
215- if shouldPrewarmFirstWebView(gdk.DisplayGetDefault()) {
216- if err := pool.PrewarmFirst(ctx); err != nil {
217- logger.Warn().Err(err).Msg("failed to prewarm first webview, first tab may be slower")
218- }
219- } else {
220- logger.Warn().Msg("skipping first webview prewarm: no GDK display available yet")
221- }
222- logging.Trace().Mark("pool_prewarm_first")
223-
224- return pool
225-}
226-
227-func shouldPrewarmFirstWebView(display *gdk.Display) bool {
228- return display != nil
229-}
230-
231-// hasWebProcessMemoryConfig returns true if any web process memory setting is configured.
232-func hasWebProcessMemoryConfig(p *config.ResolvedPerformanceSettings) bool {
264+// engineHasWebProcessMemoryConfig returns true if any web process memory setting is configured.
265+func engineHasWebProcessMemoryConfig(p *config.ResolvedPerformanceSettings) bool {
233266 return p.WebProcessMemoryLimitMB > 0 ||
234267 p.WebProcessMemoryPollIntervalSec > 0 ||
235268 p.WebProcessMemoryConservativeThreshold > 0 ||
236269 p.WebProcessMemoryStrictThreshold > 0
237270 }
238271
239-// hasNetworkProcessMemoryConfig returns true if any network process memory setting is configured.
240-func hasNetworkProcessMemoryConfig(p *config.ResolvedPerformanceSettings) bool {
272+// engineHasNetworkProcessMemoryConfig returns true if any network process memory setting is configured.
273+func engineHasNetworkProcessMemoryConfig(p *config.ResolvedPerformanceSettings) bool {
241274 return p.NetworkProcessMemoryLimitMB > 0 ||
242275 p.NetworkProcessMemoryPollIntervalSec > 0 ||
243276 p.NetworkProcessMemoryConservativeThreshold > 0 ||
244277 p.NetworkProcessMemoryStrictThreshold > 0
245278 }
246-
247-// configureRenderingEnvironment sets up the rendering environment before WebKit/GTK initialization.
248-// Environment variables must be set before GTK/WebKit/GStreamer initializes.
249-func configureRenderingEnvironment(
250- ctx context.Context,
251- cfg *config.Config,
252- perfSettings *config.ResolvedPerformanceSettings,
253- logger zerolog.Logger,
254-) {
255- // Install GLib log handler FIRST if configured (must be before any GTK/GLib calls)
256- if cfg.Logging.CaptureGTKLogs {
257- enableDebug := cfg.Logging.Level == "debug" || cfg.Logging.Level == "trace"
258- logging.InstallGLibLogHandler(ctx, logger, enableDebug)
259- }
260-
261- renderEnv := env.NewManager()
262- gpuVendor := renderEnv.DetectGPUVendor(ctx)
263- renderSettings := port.RenderingEnvSettings{
264- // GStreamer settings
265- ForceVSync: cfg.Media.ForceVSync,
266- GLRenderingMode: string(cfg.Media.GLRenderingMode),
267- GStreamerDebugLevel: cfg.Media.GStreamerDebugLevel,
268-
269- // WebKit compositor settings
270- DisableDMABufRenderer: cfg.Rendering.DisableDMABufRenderer,
271- ForceCompositingMode: cfg.Rendering.ForceCompositingMode,
272- DisableCompositingMode: cfg.Rendering.DisableCompositingMode,
273-
274- // GTK/GSK settings
275- GSKRenderer: string(cfg.Rendering.GSKRenderer),
276- DisableMipmaps: cfg.Rendering.DisableMipmaps,
277- PreferGL: cfg.Rendering.PreferGL,
278-
279- // Debug settings
280- ShowFPS: cfg.Rendering.ShowFPS,
281- SampleMemory: cfg.Rendering.SampleMemory,
282- DebugFrames: cfg.Rendering.DebugFrames,
283-
284- // Skia rendering thread settings (from resolved profile)
285- SkiaCPUPaintingThreads: perfSettings.SkiaCPUPaintingThreads,
286- SkiaGPUPaintingThreads: perfSettings.SkiaGPUPaintingThreads,
287- SkiaEnableCPURendering: perfSettings.SkiaEnableCPURendering,
288- }
289- if err := renderEnv.ApplyEnvironment(ctx, renderSettings); err != nil {
290- logger.Warn().Err(err).Msg("failed to apply rendering environment")
291- }
292- logger.Info().
293- Str("gpu", string(gpuVendor)).
294- Interface("vars", renderEnv.GetAppliedVars()).
295- Msg("rendering environment configured")
296-}
A internal/infrastructure/webkit/engine_runtime_test.go +53 -0
@@ -0,0 +1,53 @@
1+package webkit
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestEngine_UpdateSettings_WrongType(t *testing.T) {
13+ e := &Engine{}
14+ err := e.UpdateSettings(context.Background(), port.EngineSettingsUpdate{Raw: "wrong"})
15+ require.Error(t, err)
16+ require.Contains(t, err.Error(), "expected *config.Config")
17+}
18+
19+func TestEngine_UpdateSettings_NilSettings(t *testing.T) {
20+ // settings is nil — should not panic, just be a no-op.
21+ e := &Engine{}
22+ // Use a nil *config.Config to pass the type assertion.
23+ var cfg *config.Config
24+ err := e.UpdateSettings(context.Background(), port.EngineSettingsUpdate{Raw: cfg})
25+ require.NoError(t, err)
26+}
27+
28+func TestEngine_RegisterHandlers_NilRouter(t *testing.T) {
29+ e := &Engine{}
30+ err := e.RegisterHandlers(context.Background(), port.HandlerDependencies{})
31+ require.Error(t, err)
32+ require.Contains(t, err.Error(), "message router not initialized")
33+}
34+
35+func TestEngine_RegisterAccentHandlers_NilRouter(t *testing.T) {
36+ e := &Engine{}
37+ err := e.RegisterAccentHandlers(context.Background(), nil)
38+ require.Error(t, err)
39+ require.Contains(t, err.Error(), "message router not initialized")
40+}
41+
42+func TestEngine_ConfigureDownloads_NilContext(t *testing.T) {
43+ e := &Engine{}
44+ err := e.ConfigureDownloads(context.Background(), "/tmp", nil, nil)
45+ require.Error(t, err)
46+ require.Contains(t, err.Error(), "webkit context not initialized")
47+}
48+
49+func TestEngine_Close_NilPool(t *testing.T) {
50+ e := &Engine{}
51+ err := e.Close()
52+ require.NoError(t, err)
53+}
M internal/infrastructure/webkit/factory.go +1 -1
@@ -103,7 +103,7 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie
103103
104104 // Set background color to match theme (eliminates white flash)
105105 if r, g, b, a := f.bg.get(); a > 0 {
106- wv.SetBackgroundColor(r, g, b, a)
106+ wv.SetBackgroundColor(float64(r), float64(g), float64(b), float64(a))
107107 }
108108
109109 // Keep hidden until content is painted
M internal/infrastructure/webkit/handlers/clipboard.go +10 -12
@@ -5,24 +5,23 @@ import (
55 "encoding/json"
66
77 "github.com/bnema/dumber/internal/application/port"
8- "github.com/bnema/dumber/internal/infrastructure/config"
98 "github.com/bnema/dumber/internal/infrastructure/webkit"
109 "github.com/bnema/dumber/internal/logging"
1110 )
1211
1312 // ClipboardHandler handles clipboard-related messages from webviews.
1413 type ClipboardHandler struct {
15- clipboard port.Clipboard
16- configGetter func() *config.Config
17- onCopied func(textLen int) // Called after successful auto-copy (for toast notification)
14+ clipboard port.Clipboard
15+ autoCopyConfig port.AutoCopyConfig
16+ onCopied func(textLen int) // Called after successful auto-copy (for toast notification)
1817 }
1918
2019 // NewClipboardHandler creates a new ClipboardHandler.
21-func NewClipboardHandler(clipboard port.Clipboard, configGetter func() *config.Config, onCopied func(textLen int)) *ClipboardHandler {
20+func NewClipboardHandler(clipboard port.Clipboard, autoCopyConfig port.AutoCopyConfig, onCopied func(textLen int)) *ClipboardHandler {
2221 return &ClipboardHandler{
23- clipboard: clipboard,
24- configGetter: configGetter,
25- onCopied: onCopied,
22+ clipboard: clipboard,
23+ autoCopyConfig: autoCopyConfig,
24+ onCopied: onCopied,
2625 }
2726 }
2827
@@ -38,8 +37,7 @@ func (h *ClipboardHandler) HandleAutoCopySelection() webkit.MessageHandler {
3837 log := logging.FromContext(ctx)
3938
4039 // Check if feature is enabled
41- cfg := h.configGetter()
42- if cfg == nil || !cfg.Clipboard.AutoCopyOnSelection {
40+ if h.autoCopyConfig == nil || !h.autoCopyConfig.IsAutoCopyEnabled() {
4341 return nil, nil
4442 }
4543
@@ -75,10 +73,10 @@ func RegisterClipboardHandlers(
7573 ctx context.Context,
7674 router *webkit.MessageRouter,
7775 clipboard port.Clipboard,
78- configGetter func() *config.Config,
76+ autoCopyConfig port.AutoCopyConfig,
7977 onCopied func(textLen int),
8078 ) error {
81- handler := NewClipboardHandler(clipboard, configGetter, onCopied)
79+ handler := NewClipboardHandler(clipboard, autoCopyConfig, onCopied)
8280
8381 // Register auto_copy_selection handler
8482 if err := router.RegisterHandler("auto_copy_selection", handler.HandleAutoCopySelection()); err != nil {
M internal/infrastructure/webkit/handlers/config.go +18 -19
@@ -6,18 +6,22 @@ import (
66 "fmt"
77
88 "github.com/bnema/dumber/internal/application/port"
9- "github.com/bnema/dumber/internal/application/usecase"
10- "github.com/bnema/dumber/internal/infrastructure/config"
119 "github.com/bnema/dumber/internal/infrastructure/webkit"
1210 "github.com/bnema/dumber/internal/logging"
1311 )
1412
1513 // ConfigHandler handles configuration-related messages.
16-type ConfigHandler struct{}
14+type ConfigHandler struct {
15+ saveConfig func(context.Context, port.WebUIConfig) error
16+}
1717
1818 // NewConfigHandler creates a new ConfigHandler.
19-func NewConfigHandler() *ConfigHandler {
20- return &ConfigHandler{}
19+// saveConfig is called to persist config changes (typically usecase.SaveWebUIConfigUseCase.Execute).
20+func NewConfigHandler(saveConfig func(context.Context, port.WebUIConfig) error) *ConfigHandler {
21+ if saveConfig == nil {
22+ panic("NewConfigHandler: saveConfig must not be nil")
23+ }
24+ return &ConfigHandler{saveConfig: saveConfig}
2125 }
2226
2327 // Handle processes the save_config message.
@@ -25,6 +29,9 @@ func (h *ConfigHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload
2529 if h == nil {
2630 return nil, fmt.Errorf("config handler is nil")
2731 }
32+ if h.saveConfig == nil {
33+ return nil, fmt.Errorf("config handler: saveConfig not initialized")
34+ }
2835 log := logging.FromContext(ctx).With().Str("handler", "config").Logger()
2936
3037 var payloadCfg port.WebUIConfig
@@ -34,13 +41,8 @@ func (h *ConfigHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload
3441 }
3542
3643 log.Info().Msg("saving appearance configuration from webui")
37- mgr := config.GetManager()
38- if mgr == nil {
39- return nil, fmt.Errorf("config manager not initialized")
40- }
4144
42- uc := usecase.NewSaveWebUIConfigUseCase(config.NewWebUIConfigGateway(mgr))
43- if err := uc.Execute(ctx, payloadCfg); err != nil {
45+ if err := h.saveConfig(ctx, payloadCfg); err != nil {
4446 log.Error().Err(err).Msg("failed to save config")
4547 return nil, fmt.Errorf("failed to save config: %w", err)
4648 }
@@ -49,17 +51,14 @@ func (h *ConfigHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload
4951 }
5052
5153 // RegisterConfigHandlers registers configuration handlers with the router.
52-func RegisterConfigHandlers(ctx context.Context, router *webkit.MessageRouter) error {
53- handler := NewConfigHandler()
54-
55- // worldName empty means main world (since dumb:// pages run in main world)
56- // callback/errorCallback can be used if we want to notify JS of success/fail
57- // In Svelte we use fetch() for GET, but for POST we use message bridge
58- // Actually, if we use message bridge, we might want response callbacks.
54+func RegisterConfigHandlers(
55+ ctx context.Context, router *webkit.MessageRouter,
56+ saveConfig func(context.Context, port.WebUIConfig) error,
57+) error {
58+ handler := NewConfigHandler(saveConfig)
5959
6060 log := logging.FromContext(ctx).With().Str("component", "handlers").Logger()
6161
62- // Message type: save_config
6362 if err := router.RegisterHandlerWithCallbacks(
6463 "save_config",
6564 "__dumber_config_saved",
M internal/infrastructure/webkit/handlers/homepage/favorites.go +3 -3
@@ -4,7 +4,7 @@ import (
44 "context"
55 "encoding/json"
66
7- "github.com/bnema/dumber/internal/application/usecase"
7+ "github.com/bnema/dumber/internal/application/port"
88 "github.com/bnema/dumber/internal/domain/entity"
99 "github.com/bnema/dumber/internal/infrastructure/webkit"
1010 "github.com/bnema/dumber/internal/logging"
@@ -12,11 +12,11 @@ import (
1212
1313 // FavoritesHandlers handles favorite-related messages from the homepage.
1414 type FavoritesHandlers struct {
15- favoritesUC *usecase.ManageFavoritesUseCase
15+ favoritesUC port.HomepageFavorites
1616 }
1717
1818 // NewFavoritesHandlers creates a new FavoritesHandlers instance.
19-func NewFavoritesHandlers(favoritesUC *usecase.ManageFavoritesUseCase) *FavoritesHandlers {
19+func NewFavoritesHandlers(favoritesUC port.HomepageFavorites) *FavoritesHandlers {
2020 return &FavoritesHandlers{favoritesUC: favoritesUC}
2121 }
2222
M internal/infrastructure/webkit/handlers/homepage/folders.go +8 -5
@@ -4,7 +4,7 @@ import (
44 "context"
55 "encoding/json"
66
7- "github.com/bnema/dumber/internal/application/usecase"
7+ "github.com/bnema/dumber/internal/application/port"
88 "github.com/bnema/dumber/internal/domain/entity"
99 "github.com/bnema/dumber/internal/infrastructure/webkit"
1010 "github.com/bnema/dumber/internal/logging"
@@ -12,11 +12,11 @@ import (
1212
1313 // FolderHandlers handles folder-related messages from the homepage.
1414 type FolderHandlers struct {
15- favoritesUC *usecase.ManageFavoritesUseCase
15+ favoritesUC port.HomepageFavorites
1616 }
1717
1818 // NewFolderHandlers creates a new FolderHandlers instance.
19-func NewFolderHandlers(favoritesUC *usecase.ManageFavoritesUseCase) *FolderHandlers {
19+func NewFolderHandlers(favoritesUC port.HomepageFavorites) *FolderHandlers {
2020 return &FolderHandlers{favoritesUC: favoritesUC}
2121 }
2222
@@ -67,9 +67,12 @@ func (h *FolderHandlers) HandleCreate() webkit.MessageHandler {
6767 return NewErrorResponse(req.RequestID, err), nil
6868 }
6969
70- // Set icon if provided
70+ // Set icon if provided and persist it
7171 if req.Icon != nil && *req.Icon != "" {
7272 folder.Icon = *req.Icon
73+ if err := h.favoritesUC.UpdateFolder(ctx, folder.ID, folder.Name, folder.Icon); err != nil {
74+ return NewErrorResponse(req.RequestID, err), nil
75+ }
7376 }
7477
7578 return NewSuccessResponse(req.RequestID, folder), nil
@@ -114,7 +117,7 @@ type updateFolderRequest struct {
114117 }
115118
116119 // HandleUpdate handles folder_update messages.
117-// NOTE: This requires UpdateFolder() method to be added to ManageFavoritesUseCase.
120+// It delegates to the port.HomepageFavorites interface's UpdateFolder method.
118121 func (h *FolderHandlers) HandleUpdate() webkit.MessageHandler {
119122 return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) {
120123 log := logging.FromContext(ctx)
M internal/infrastructure/webkit/handlers/homepage/history.go +4 -4
@@ -5,7 +5,7 @@ import (
55 "encoding/json"
66 "time"
77
8- "github.com/bnema/dumber/internal/application/usecase"
8+ "github.com/bnema/dumber/internal/application/port"
99 "github.com/bnema/dumber/internal/domain/entity"
1010 "github.com/bnema/dumber/internal/infrastructure/webkit"
1111 "github.com/bnema/dumber/internal/logging"
@@ -13,11 +13,11 @@ import (
1313
1414 // HistoryHandlers handles history-related messages from the homepage.
1515 type HistoryHandlers struct {
16- historyUC *usecase.SearchHistoryUseCase
16+ historyUC port.HomepageHistory
1717 }
1818
1919 // NewHistoryHandlers creates a new HistoryHandlers instance.
20-func NewHistoryHandlers(historyUC *usecase.SearchHistoryUseCase) *HistoryHandlers {
20+func NewHistoryHandlers(historyUC port.HomepageHistory) *HistoryHandlers {
2121 return &HistoryHandlers{historyUC: historyUC}
2222 }
2323
@@ -75,7 +75,7 @@ func (h *HistoryHandlers) HandleSearchFTS() webkit.MessageHandler {
7575 Str("query", req.Query).
7676 Msg("handling history_search_fts")
7777
78- output, err := h.historyUC.Search(ctx, usecase.SearchInput{
78+ output, err := h.historyUC.Search(ctx, port.HistorySearchInput{
7979 Query: req.Query,
8080 Limit: req.Limit,
8181 })
M internal/infrastructure/webkit/handlers/homepage/registry.go +3 -3
@@ -4,15 +4,15 @@ import (
44 "context"
55 "fmt"
66
7- "github.com/bnema/dumber/internal/application/usecase"
7+ "github.com/bnema/dumber/internal/application/port"
88 "github.com/bnema/dumber/internal/infrastructure/webkit"
99 "github.com/bnema/dumber/internal/logging"
1010 )
1111
1212 // Config holds dependencies for homepage handlers.
1313 type Config struct {
14- HistoryUC *usecase.SearchHistoryUseCase
15- FavoritesUC *usecase.ManageFavoritesUseCase
14+ HistoryUC port.HomepageHistory
15+ FavoritesUC port.HomepageFavorites
1616 }
1717
1818 // RegisterHandlers registers all homepage message handlers with the router.
M internal/infrastructure/webkit/handlers/homepage/tags.go +3 -3
@@ -4,7 +4,7 @@ import (
44 "context"
55 "encoding/json"
66
7- "github.com/bnema/dumber/internal/application/usecase"
7+ "github.com/bnema/dumber/internal/application/port"
88 "github.com/bnema/dumber/internal/domain/entity"
99 "github.com/bnema/dumber/internal/infrastructure/webkit"
1010 "github.com/bnema/dumber/internal/logging"
@@ -12,11 +12,11 @@ import (
1212
1313 // TagHandlers handles tag-related messages from the homepage.
1414 type TagHandlers struct {
15- favoritesUC *usecase.ManageFavoritesUseCase
15+ favoritesUC port.HomepageFavorites
1616 }
1717
1818 // NewTagHandlers creates a new TagHandlers instance.
19-func NewTagHandlers(favoritesUC *usecase.ManageFavoritesUseCase) *TagHandlers {
19+func NewTagHandlers(favoritesUC port.HomepageFavorites) *TagHandlers {
2020 return &TagHandlers{favoritesUC: favoritesUC}
2121 }
2222
M internal/infrastructure/webkit/handlers/keybindings.go +8 -9
@@ -6,25 +6,24 @@ import (
66 "fmt"
77
88 "github.com/bnema/dumber/internal/application/port"
9- "github.com/bnema/dumber/internal/application/usecase"
109 "github.com/bnema/dumber/internal/infrastructure/webkit"
1110 "github.com/bnema/dumber/internal/logging"
1211 )
1312
1413 // KeybindingsHandler handles keybinding-related messages.
1514 type KeybindingsHandler struct {
16- getUC *usecase.GetKeybindingsUseCase
17- setUC *usecase.SetKeybindingUseCase
18- resetUC *usecase.ResetKeybindingUseCase
19- resetAllUC *usecase.ResetAllKeybindingsUseCase
15+ getUC port.KeybindingsGetter
16+ setUC port.KeybindingSetter
17+ resetUC port.KeybindingResetter
18+ resetAllUC port.AllKeybindingsResetter
2019 }
2120
2221 // NewKeybindingsHandler creates a new KeybindingsHandler.
2322 func NewKeybindingsHandler(
24- getUC *usecase.GetKeybindingsUseCase,
25- setUC *usecase.SetKeybindingUseCase,
26- resetUC *usecase.ResetKeybindingUseCase,
27- resetAllUC *usecase.ResetAllKeybindingsUseCase,
23+ getUC port.KeybindingsGetter,
24+ setUC port.KeybindingSetter,
25+ resetUC port.KeybindingResetter,
26+ resetAllUC port.AllKeybindingsResetter,
2827 ) *KeybindingsHandler {
2928 return &KeybindingsHandler{
3029 getUC: getUC,
M internal/infrastructure/webkit/handlers/register.go +22 -42
@@ -7,26 +7,27 @@ import (
77 "unicode/utf8"
88
99 "github.com/bnema/dumber/internal/application/port"
10- "github.com/bnema/dumber/internal/application/usecase"
11- "github.com/bnema/dumber/internal/infrastructure/config"
1210 "github.com/bnema/dumber/internal/infrastructure/webkit"
1311 "github.com/bnema/dumber/internal/infrastructure/webkit/handlers/homepage"
1412 )
1513
1614 // AccentKeyHandler is implemented by the InsertAccentUseCase to receive
1715 // key press/release events forwarded from WebView JS via the message bridge.
18-type AccentKeyHandler interface {
19- OnKeyPressed(ctx context.Context, char rune, shiftHeld bool) bool
20- OnKeyReleased(ctx context.Context, char rune)
21-}
16+//
17+// Deprecated: use port.AccentKeyHandler instead. This alias is kept for compatibility.
18+type AccentKeyHandler = port.AccentKeyHandler
2219
2320 // Config holds all dependencies for message handlers.
2421 type Config struct {
25- HistoryUC *usecase.SearchHistoryUseCase
26- FavoritesUC *usecase.ManageFavoritesUseCase
27- Clipboard port.Clipboard
28- ConfigGetter func() *config.Config
29- OnClipboardCopied func(textLen int) // Called when auto-copy completes (for toast notification)
22+ HistoryUC port.HomepageHistory
23+ FavoritesUC port.HomepageFavorites
24+ Clipboard port.Clipboard
25+ AutoCopyConfig port.AutoCopyConfig
26+ SaveConfig func(context.Context, port.WebUIConfig) error // Pre-built by bootstrap (usecase.SaveWebUIConfigUseCase.Execute)
27+ // KeybindingsHandler is required (not optional like other handlers) because the WebUI
28+ // always needs keybinding read/write support regardless of configuration.
29+ KeybindingsHandler *KeybindingsHandler // Pre-built by bootstrap
30+ OnClipboardCopied func(textLen int) // Called when auto-copy completes (for toast notification)
3031 }
3132
3233 // RegisterAll registers all message handlers with the router.
@@ -41,32 +42,28 @@ func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config)
4142 }
4243 }
4344
44- // Configuration handlers (always available)
45- if err := RegisterConfigHandlers(ctx, router); err != nil {
46- return err
45+ // Configuration handlers
46+ if cfg.SaveConfig != nil {
47+ if err := RegisterConfigHandlers(ctx, router, cfg.SaveConfig); err != nil {
48+ return err
49+ }
4750 }
4851
4952 // Keybindings handlers (always available)
50- keybindingsHandler, err := createKeybindingsHandler()
51- if err != nil {
52- return fmt.Errorf("failed to create keybindings handler: %w", err)
53+ if cfg.KeybindingsHandler == nil {
54+ return fmt.Errorf("KeybindingsHandler is required")
5355 }
54- if err := RegisterKeybindingsHandlers(ctx, router, keybindingsHandler); err != nil {
56+ if err := RegisterKeybindingsHandlers(ctx, router, cfg.KeybindingsHandler); err != nil {
5557 return err
5658 }
5759
5860 // Clipboard handlers (for auto-copy on selection feature)
59- if cfg.Clipboard != nil && cfg.ConfigGetter != nil {
60- if err := RegisterClipboardHandlers(ctx, router, cfg.Clipboard, cfg.ConfigGetter, cfg.OnClipboardCopied); err != nil {
61+ if cfg.Clipboard != nil && cfg.AutoCopyConfig != nil {
62+ if err := RegisterClipboardHandlers(ctx, router, cfg.Clipboard, cfg.AutoCopyConfig, cfg.OnClipboardCopied); err != nil {
6163 return err
6264 }
6365 }
6466
65- // Future handler groups go here:
66- // - settings handlers
67- // - downloads handlers
68- // - etc.
69-
7067 return nil
7168 }
7269
@@ -117,20 +114,3 @@ func RegisterAccentHandlers(_ context.Context, router *webkit.MessageRouter, han
117114
118115 return nil
119116 }
120-
121-// createKeybindingsHandler wires the gateway and use cases for keybindings.
122-func createKeybindingsHandler() (*KeybindingsHandler, error) {
123- mgr := config.GetManager()
124- if mgr == nil {
125- return nil, fmt.Errorf("config manager not initialized")
126- }
127-
128- gateway := config.NewKeybindingsGateway(mgr)
129-
130- return NewKeybindingsHandler(
131- usecase.NewGetKeybindingsUseCase(gateway),
132- usecase.NewSetKeybindingUseCase(gateway, gateway),
133- usecase.NewResetKeybindingUseCase(gateway),
134- usecase.NewResetAllKeybindingsUseCase(gateway),
135- ), nil
136-}
M internal/infrastructure/webkit/injector.go +25 -0
@@ -413,3 +413,28 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
413413
414414 log.Debug().Bool("prefers_dark", prefersDark).Bool("auto_copy", autoCopyEnabled).Msg("scripts injected")
415415 }
416+
417+// RefreshScripts clears and re-injects user scripts for a single WebView.
418+// Implements port.ContentInjector.
419+// Returns nil for "not applicable" cases (nil wv, wrong engine type, nil UCM).
420+func (ci *ContentInjector) RefreshScripts(ctx context.Context, wv port.WebView) error {
421+ log := logging.FromContext(ctx).With().Str("component", "content-injector").Logger()
422+ if wv == nil {
423+ log.Debug().Msg("RefreshScripts: nil webview")
424+ return nil
425+ }
426+ wkWV, ok := wv.(*WebView)
427+ if !ok {
428+ log.Debug().Msg("RefreshScripts: webview is not *webkit.WebView")
429+ return nil
430+ }
431+ ucm := wkWV.UserContentManager()
432+ if ucm == nil {
433+ log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("RefreshScripts: nil UserContentManager")
434+ return nil
435+ }
436+ ucm.RemoveAllScripts()
437+ ucm.RemoveAllStyleSheets()
438+ ci.InjectScripts(ctx, ucm, wv.ID())
439+ return nil
440+}
M internal/infrastructure/webkit/messaging.go +2 -2
@@ -276,7 +276,7 @@ func (r *MessageRouter) syncWebViewID(wv *WebView) {
276276 return
277277 }
278278 script := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(wv.ID()))
279- wv.RunJavaScript(r.baseCtx, script, "")
279+ wv.RunJavaScriptInWorld(r.baseCtx, script, "")
280280 }
281281
282282 func (r *MessageRouter) markWebViewIDSynced(id WebViewID) bool {
@@ -329,6 +329,6 @@ func (r *MessageRouter) dispatchResponse(ctx context.Context, webviewID WebViewI
329329 string(data),
330330 )
331331
332- wv.RunJavaScript(ctx, script, world)
332+ wv.RunJavaScriptInWorld(ctx, script, world)
333333 return nil
334334 }
M internal/infrastructure/webkit/pool.go +1 -1
@@ -159,7 +159,7 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) {
159159 case wv := <-p.pool:
160160 if wv != nil && !wv.IsDestroyed() {
161161 if r, g, b, a := p.bg.get(); a > 0 {
162- wv.SetBackgroundColor(r, g, b, a)
162+ wv.SetBackgroundColor(float64(r), float64(g), float64(b), float64(a))
163163 }
164164 wv.inner.AddCssClass("webview-themed")
165165 // Keep pooled WebViews hidden until we explicitly reveal them.
M internal/infrastructure/webkit/scheme_handler.go +8 -7
@@ -347,7 +347,8 @@ func (h *DumbSchemeHandler) buildConfigResponse(cfg *config.Config) *SchemeRespo
347347 }
348348
349349 // Resolve performance profile with hardware info
350- resolved := config.ResolvePerformanceProfile(&cfg.Performance, hw)
350+ perfCfg := config.PerformanceConfigFromEngine(&cfg.Engine)
351+ resolved := config.ResolvePerformanceProfile(&perfCfg, hw)
351352
352353 resp := configPayload{
353354 DefaultUIScale: cfg.DefaultUIScale,
@@ -363,13 +364,13 @@ func (h *DumbSchemeHandler) buildConfigResponse(cfg *config.Config) *SchemeRespo
363364 DarkPalette: cfg.Appearance.DarkPalette,
364365 },
365366 Performance: configPerformancePayload{
366- Profile: string(cfg.Performance.Profile),
367+ Profile: string(cfg.Engine.Profile),
367368 Custom: configCustomPerformance{
368- SkiaCPUThreads: cfg.Performance.SkiaCPUPaintingThreads,
369- SkiaGPUThreads: cfg.Performance.SkiaGPUPaintingThreads,
370- WebProcessMemoryMB: cfg.Performance.WebProcessMemoryLimitMB,
371- NetworkProcessMemoryMB: cfg.Performance.NetworkProcessMemoryLimitMB,
372- WebViewPoolPrewarm: cfg.Performance.WebViewPoolPrewarmCount,
369+ SkiaCPUThreads: cfg.Engine.WebKit.SkiaCPUPaintingThreads,
370+ SkiaGPUThreads: cfg.Engine.WebKit.SkiaGPUPaintingThreads,
371+ WebProcessMemoryMB: cfg.Engine.WebKit.WebProcessMemoryLimitMB,
372+ NetworkProcessMemoryMB: cfg.Engine.WebKit.NetworkProcessMemoryLimitMB,
373+ WebViewPoolPrewarm: cfg.Engine.PoolPrewarmCount,
373374 },
374375 Resolved: configResolvedPerformance{
375376 SkiaCPUThreads: resolved.SkiaCPUPaintingThreads,
M internal/infrastructure/webkit/settings.go +4 -21
@@ -51,7 +51,6 @@ func (sm *SettingsManager) applySettings(ctx context.Context, settings *webkit.S
5151 log := logging.FromContext(ctx)
5252 applyJavaScriptSettings(settings)
5353 applyFontSettings(settings, cfg)
54- applyRenderingSettings(settings, cfg)
5554 applyDebugSettings(settings, cfg)
5655 applyBrowsingSettings(settings)
5756 applyMediaSettings(settings, cfg, log)
@@ -65,7 +64,6 @@ func (sm *SettingsManager) applySettings(ctx context.Context, settings *webkit.S
6564
6665 log.Debug().
6766 Str("sans_font", cfg.Appearance.SansFont).
68- Str("rendering_mode", string(cfg.Rendering.Mode)).
6967 Bool("developer_extras", cfg.Debug.EnableDevTools).
7068 Bool("webrtc_enabled", webrtcEnabled).
7169 Bool("media_stream_enabled", mediaStreamEnabled).
@@ -93,21 +91,10 @@ func applyFontSettings(settings *webkit.Settings, cfg *config.Config) {
9391 }
9492 }
9593
96-func applyRenderingSettings(settings *webkit.Settings, cfg *config.Config) {
97- switch cfg.Rendering.Mode {
98- case config.RenderingModeGPU:
99- settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyAlwaysValue)
100- case config.RenderingModeCPU:
101- settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyNeverValue)
102- case config.RenderingModeAuto:
103- settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyAlwaysValue)
104- }
105-}
106-
10794 func applyDebugSettings(settings *webkit.Settings, cfg *config.Config) {
10895 settings.SetEnableDeveloperExtras(cfg.Debug.EnableDevTools)
10996 settings.SetEnableWriteConsoleMessagesToStdout(cfg.Logging.CaptureConsole)
110- settings.SetDrawCompositingIndicators(cfg.Rendering.DrawCompositingIndicators)
97+ settings.SetDrawCompositingIndicators(cfg.Engine.WebKit.DrawCompositingIndicators)
11198 }
11299
113100 func applyBrowsingSettings(settings *webkit.Settings) {
@@ -178,18 +165,14 @@ func (sm *SettingsManager) UpdateFromConfig(ctx context.Context, cfg *config.Con
178165 // ApplyToWebView applies current settings to an existing WebView.
179166 // This can be used to update a WebView's settings after config hot-reload.
180167 func (sm *SettingsManager) ApplyToWebView(ctx context.Context, wv *webkit.WebView) {
181- if wv == nil {
168+ if sm == nil {
182169 return
183170 }
184-
185- settings := sm.CreateSettings(ctx)
186- if settings == nil {
171+ if wv == nil {
187172 return
188173 }
189174
190- // WebView.GetSettings() returns the current settings - we can modify them directly
191- // For a full replacement, we'd need webkit_web_view_set_settings which may not be available
192- // Instead, we apply to the existing settings object
175+ // Apply to the existing settings object attached to the WebView
193176 existingSettings := wv.GetSettings()
194177 if existingSettings != nil {
195178 sm.mu.RLock()
M internal/infrastructure/webkit/webview.go +204 -17
@@ -24,8 +24,11 @@ import (
2424 "github.com/rs/zerolog"
2525 )
2626
27-// Compile-time interface check: WebView must implement port.WebView.
27+// Compile-time interface checks.
2828 var _ port.WebView = (*WebView)(nil)
29+var _ port.DevToolsOpener = (*WebView)(nil)
30+var _ port.Printer = (*WebView)(nil)
31+var _ port.OAuthCallbackCapable = (*WebView)(nil)
2932
3033 // WebViewID is an alias to port.WebViewID for clean architecture compliance.
3134 // Infrastructure layer uses the type defined in the application port.
@@ -995,11 +998,14 @@ func (wv *WebView) connectPermissionRequestSignal() {
995998 }
996999
9971000 // Determine permission types from the request
998- permTypes := wv.determinePermissionTypes(ctx, requestPtr)
1001+ permTypes, originOverride := wv.determinePermissionTypes(ctx, requestPtr)
9991002 if len(permTypes) == 0 {
10001003 wv.logger.Warn().Msg("permission request with unknown type, denying")
10011004 return false
10021005 }
1006+ if originOverride != "" {
1007+ origin = originOverride
1008+ }
10031009
10041010 // Ref the request object to prevent use-after-free
10051011 // The request may outlive the signal handler if we show a dialog
@@ -1045,13 +1051,13 @@ func (wv *WebView) connectPermissionRequestSignal() {
10451051 // We use GObject property accessors for audio/video request flags. Display detection
10461052 // uses the dedicated WebKit API because this WebKit build does not expose an
10471053 // "is-for-display-device" GObject property.
1048-func (wv *WebView) determinePermissionTypes(ctx context.Context, requestPtr uintptr) []string {
1054+func (wv *WebView) determinePermissionTypes(ctx context.Context, requestPtr uintptr) (types []string, originOverride string) {
10491055 requestKind := detectPermissionRequestKind(ctx, requestPtr)
10501056 switch requestKind {
10511057 case permissionRequestKindUserMedia:
10521058 userMediaReq := webkit.UserMediaPermissionRequestNewFromInternalPtr(requestPtr)
10531059 if userMediaReq == nil {
1054- return nil
1060+ return nil, ""
10551061 }
10561062
10571063 // Use GObject property accessors — more reliable than the C function wrappers
@@ -1066,9 +1072,34 @@ func (wv *WebView) determinePermissionTypes(ctx context.Context, requestPtr uint
10661072 Bool("is_display", isDisplay).
10671073 Msg("permission request type detection")
10681074
1069- return classifyPermissionRequestTypes(ctx, requestKind, isAudio, isVideo, isDisplay)
1075+ return classifyPermissionRequestTypes(ctx, requestKind, isAudio, isVideo, isDisplay), ""
10701076 case permissionRequestKindDeviceInfo:
1071- return classifyPermissionRequestTypes(ctx, requestKind, false, false, false)
1077+ return classifyPermissionRequestTypes(ctx, requestKind, false, false, false), ""
1078+ case permissionRequestKindWebsiteDataAccess:
1079+ wdaReq := webkit.WebsiteDataAccessPermissionRequestNewFromInternalPtr(requestPtr)
1080+ if wdaReq == nil {
1081+ return nil, ""
1082+ }
1083+ currentDomain := wdaReq.GetCurrentDomain()
1084+ requestingDomain := wdaReq.GetRequestingDomain()
1085+ wv.logger.Debug().
1086+ Str("current_domain", currentDomain).
1087+ Str("requesting_domain", requestingDomain).
1088+ Msg("website data access permission request")
1089+ override := ""
1090+ if requestingDomain != "" {
1091+ candidate := "https://" + requestingDomain
1092+ if normalized, err := urlutil.ExtractOrigin(candidate); err == nil {
1093+ override = normalized
1094+ } else {
1095+ wv.logger.Warn().
1096+ Str("requesting_domain", requestingDomain).
1097+ Err(err).
1098+ Msg("website data access: failed to normalize requesting domain, using raw")
1099+ override = candidate
1100+ }
1101+ }
1102+ return classifyPermissionRequestTypes(ctx, requestKind, false, false, false), override
10721103 default:
10731104 if requestPtr != 0 {
10741105 typeName := permissionRequestTypeName(ctx, requestPtr)
@@ -1080,7 +1111,7 @@ func (wv *WebView) determinePermissionTypes(ctx context.Context, requestPtr uint
10801111 }
10811112 // Unknown permission type - could be clipboard, notifications, geolocation, etc.
10821113 // For now, return empty to trigger denial. Future phases will add these types.
1083- return nil
1114+ return nil, ""
10841115 }
10851116 }
10861117
@@ -1090,6 +1121,7 @@ const (
10901121 permissionRequestKindUnknown permissionRequestKind = iota
10911122 permissionRequestKindUserMedia
10921123 permissionRequestKindDeviceInfo
1124+ permissionRequestKindWebsiteDataAccess
10931125 )
10941126
10951127 func detectPermissionRequestKind(ctx context.Context, requestPtr uintptr) permissionRequestKind {
@@ -1103,6 +1135,11 @@ func detectPermissionRequestKind(ctx context.Context, requestPtr uintptr) permis
11031135 return permissionRequestKindDeviceInfo
11041136 }
11051137 }
1138+ if gtype, ok := safeGLibType(ctx, webkit.WebsiteDataAccessPermissionRequestGLibType); ok {
1139+ if isPermissionRequestType(ctx, requestPtr, gtype) {
1140+ return permissionRequestKindWebsiteDataAccess
1141+ }
1142+ }
11061143 return permissionRequestKindUnknown
11071144 }
11081145
@@ -1137,6 +1174,8 @@ func classifyPermissionRequestTypes(
11371174 return classifyUserMediaPermissionTypes(ctx, isAudio, isVideo, isDisplay)
11381175 case permissionRequestKindDeviceInfo:
11391176 return []string{"device_info"}
1177+ case permissionRequestKindWebsiteDataAccess:
1178+ return []string{"website_data_access"}
11401179 default:
11411180 return nil
11421181 }
@@ -1187,6 +1226,14 @@ func (wv *WebView) allowPermissionRequest(requestPtr uintptr) {
11871226 return
11881227 }
11891228
1229+ // Try WebsiteDataAccessPermissionRequest
1230+ wdaReq := webkit.WebsiteDataAccessPermissionRequestNewFromInternalPtr(requestPtr)
1231+ if wdaReq != nil {
1232+ wdaReq.Allow()
1233+ wv.logger.Debug().Msg("website data access permission request allowed")
1234+ return
1235+ }
1236+
11901237 wv.logger.Warn().Uint64("request_ptr", uint64(requestPtr)).Msg("permission request: unknown type, cannot allow")
11911238 }
11921239
@@ -1208,6 +1255,14 @@ func (wv *WebView) denyPermissionRequest(requestPtr uintptr) {
12081255 return
12091256 }
12101257
1258+ // Try WebsiteDataAccessPermissionRequest
1259+ wdaReq := webkit.WebsiteDataAccessPermissionRequestNewFromInternalPtr(requestPtr)
1260+ if wdaReq != nil {
1261+ wdaReq.Deny()
1262+ wv.logger.Debug().Msg("website data access permission request denied")
1263+ return
1264+ }
1265+
12111266 wv.logger.Warn().Uint64("request_ptr", uint64(requestPtr)).Msg("permission request: unknown type, cannot deny")
12121267 }
12131268
@@ -1397,9 +1452,22 @@ func (wv *WebView) Title() string {
13971452 return wv.title
13981453 }
13991454
1400-// Favicon returns the current page favicon as a GdkTexture.
1455+// Favicon returns the current page favicon as a Texture.
14011456 // Returns nil if no favicon is available.
1402-func (wv *WebView) Favicon() *gdk.Texture {
1457+func (wv *WebView) Favicon() port.Texture {
1458+ if wv.destroyed.Load() {
1459+ return nil
1460+ }
1461+ tex := wv.inner.GetFavicon()
1462+ if tex == nil {
1463+ return nil
1464+ }
1465+ return tex
1466+}
1467+
1468+// FaviconGdk returns the current page favicon as a *gdk.Texture.
1469+// Used by internal WebKit code that needs the concrete type.
1470+func (wv *WebView) FaviconGdk() *gdk.Texture {
14031471 if wv.destroyed.Load() {
14041472 return nil
14051473 }
@@ -1444,18 +1512,18 @@ func (wv *WebView) GetZoomLevel() float64 {
14441512 return wv.inner.GetZoomLevel()
14451513 }
14461514
1447-// SetBackgroundColor sets the WebView background color.
1515+// SetBackgroundColor sets the WebView background color (port.WebView interface).
14481516 // This color is shown before content is painted, eliminating white flash.
14491517 // Values are in range 0.0-1.0 for red, green, blue, alpha.
1450-func (wv *WebView) SetBackgroundColor(r, g, b, a float32) {
1518+func (wv *WebView) SetBackgroundColor(r, g, b, a float64) {
14511519 if wv.destroyed.Load() {
14521520 return
14531521 }
14541522 rgba := &gdk.RGBA{
1455- Red: r,
1456- Green: g,
1457- Blue: b,
1458- Alpha: a,
1523+ Red: float32(r),
1524+ Green: float32(g),
1525+ Blue: float32(b),
1526+ Alpha: float32(a),
14591527 }
14601528 wv.inner.SetBackgroundColor(rgba)
14611529 }
@@ -1466,6 +1534,19 @@ func (wv *WebView) ResetBackgroundToDefault() {
14661534 wv.SetBackgroundColor(1.0, 1.0, 1.0, 1.0)
14671535 }
14681536
1537+// NativeWidget returns the underlying GTK widget pointer for UI embedding.
1538+// Implements port.NativeWidgetProvider.
1539+func (wv *WebView) NativeWidget() uintptr {
1540+ if wv.destroyed.Load() {
1541+ return 0
1542+ }
1543+ w := wv.Widget()
1544+ if w == nil {
1545+ return 0
1546+ }
1547+ return w.Widget.GoPointer()
1548+}
1549+
14691550 // Show makes the WebView widget visible.
14701551 // This should be called after the WebView is ready to be displayed.
14711552 func (wv *WebView) Show() {
@@ -1475,6 +1556,30 @@ func (wv *WebView) Show() {
14751556 wv.inner.SetVisible(true)
14761557 }
14771558
1559+// SetOnReadyToShow sets the callback invoked when the popup WebView is ready to display.
1560+// It implements port.PopupCapable.
1561+func (wv *WebView) SetOnReadyToShow(fn func()) {
1562+ wv.OnReadyToShow = fn
1563+}
1564+
1565+// SetOnClose composes fn with any existing OnClose handler so multiple callers
1566+// can each register a close callback without overwriting one another.
1567+// It implements port.PopupCapable.
1568+func (wv *WebView) SetOnClose(fn func()) {
1569+ existing := wv.OnClose
1570+ if existing == nil {
1571+ wv.OnClose = fn
1572+ return
1573+ }
1574+ if fn == nil {
1575+ return
1576+ }
1577+ wv.OnClose = func() {
1578+ existing()
1579+ fn()
1580+ }
1581+}
1582+
14781583 // State returns the current WebView state as a snapshot.
14791584 func (wv *WebView) State() port.WebViewState {
14801585 return port.WebViewState{
@@ -1502,6 +1607,10 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
15021607 wv.OnLinkHover = nil
15031608 wv.OnWebProcessTerminated = nil
15041609 wv.OnPermissionRequest = nil
1610+ wv.OnLinkMiddleClick = nil
1611+ wv.OnEnterFullscreen = nil
1612+ wv.OnLeaveFullscreen = nil
1613+ wv.OnAudioStateChanged = nil
15051614 return
15061615 }
15071616
@@ -1549,6 +1658,10 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
15491658 wv.OnWebProcessTerminated = nil
15501659 }
15511660 wv.OnPermissionRequest = callbacks.OnPermissionRequest
1661+ wv.OnLinkMiddleClick = callbacks.OnLinkMiddleClick
1662+ wv.OnEnterFullscreen = callbacks.OnEnterFullscreen
1663+ wv.OnLeaveFullscreen = callbacks.OnLeaveFullscreen
1664+ wv.OnAudioStateChanged = callbacks.OnAudioStateChanged
15521665 }
15531666
15541667 // ShowDevTools opens the WebKit inspector/developer tools.
@@ -1580,6 +1693,20 @@ func (wv *WebView) Print() error {
15801693 return nil
15811694 }
15821695
1696+// OpenDevTools implements port.DevToolsOpener.
1697+func (wv *WebView) OpenDevTools() {
1698+ if err := wv.ShowDevTools(); err != nil {
1699+ wv.logger.Error().Err(err).Uint64("id", uint64(wv.id)).Msg("failed to open dev tools")
1700+ }
1701+}
1702+
1703+// PrintPage implements port.Printer.
1704+func (wv *WebView) PrintPage() {
1705+ if err := wv.Print(); err != nil {
1706+ wv.logger.Error().Err(err).Uint64("id", uint64(wv.id)).Msg("failed to open print dialog")
1707+ }
1708+}
1709+
15831710 // IsDestroyed returns true if the WebView has been destroyed.
15841711 func (wv *WebView) IsDestroyed() bool {
15851712 return wv.destroyed.Load()
@@ -1606,6 +1733,60 @@ func (wv *WebView) Close() {
16061733 }
16071734 }
16081735
1736+// AddCloseCallback implements port.OAuthCallbackCapable.
1737+// It composes fn with any existing OnClose handler so multiple callers can register
1738+// close callbacks without overwriting one another.
1739+func (wv *WebView) AddCloseCallback(fn func()) {
1740+ if fn == nil {
1741+ return
1742+ }
1743+ if wv.OnClose == nil {
1744+ wv.OnClose = fn
1745+ return
1746+ }
1747+ existing := wv.OnClose
1748+ wv.OnClose = func() {
1749+ existing()
1750+ fn()
1751+ }
1752+}
1753+
1754+// AddNavigationCallback implements port.OAuthCallbackCapable.
1755+// fn is invoked when the URI changes (OnURIChanged) and when a page load is committed
1756+// (OnLoadChanged with LoadCommitted), covering both redirect-based and postMessage OAuth flows.
1757+func (wv *WebView) AddNavigationCallback(fn func(uri string)) {
1758+ if fn == nil {
1759+ return
1760+ }
1761+ // Compose with existing OnURIChanged.
1762+ if wv.OnURIChanged == nil {
1763+ wv.OnURIChanged = fn
1764+ } else {
1765+ existing := wv.OnURIChanged
1766+ wv.OnURIChanged = func(uri string) {
1767+ existing(uri)
1768+ fn(uri)
1769+ }
1770+ }
1771+
1772+ // Compose with existing OnLoadChanged to also fire on committed loads.
1773+ if wv.OnLoadChanged == nil {
1774+ wv.OnLoadChanged = func(event LoadEvent) {
1775+ if event == LoadCommitted {
1776+ fn(wv.URI())
1777+ }
1778+ }
1779+ } else {
1780+ existing := wv.OnLoadChanged
1781+ wv.OnLoadChanged = func(event LoadEvent) {
1782+ existing(event)
1783+ if event == LoadCommitted {
1784+ fn(wv.URI())
1785+ }
1786+ }
1787+ }
1788+}
1789+
16091790 // DisconnectSignals disconnects all GLib signal handlers from the WebView.
16101791 // This must be called before releasing the WebView to the pool or destroying it
16111792 // to prevent callbacks from firing on freed/pooled WebViews.
@@ -1880,10 +2061,16 @@ func (wv *WebView) shouldLogRunJSError(domain, signature string, nonFatal bool,
18802061 return shouldLog, count
18812062 }
18822063
1883-// RunJavaScript executes script in the specified world (empty for main world).
2064+// RunJavaScript executes script in the main world (port.WebView interface).
2065+// This is fire-and-forget: it does not block and errors are logged asynchronously.
2066+func (wv *WebView) RunJavaScript(ctx context.Context, script string) {
2067+ wv.RunJavaScriptInWorld(ctx, script, "")
2068+}
2069+
2070+// RunJavaScriptInWorld executes script in the specified world (empty for main world).
18842071 // This is fire-and-forget: it does not block and errors are logged asynchronously.
18852072 // Safe to call from any context including GTK signal handlers.
1886-func (wv *WebView) RunJavaScript(ctx context.Context, script, worldName string) {
2073+func (wv *WebView) RunJavaScriptInWorld(ctx context.Context, script, worldName string) {
18872074 if wv.destroyed.Load() {
18882075 return
18892076 }
M internal/infrastructure/webkit/webview_permission_types_test.go +5 -0
@@ -87,6 +87,11 @@ func TestClassifyPermissionRequestTypes(t *testing.T) {
8787 kind: permissionRequestKindUnknown,
8888 expected: nil,
8989 },
90+ {
91+ name: "website data access request",
92+ kind: permissionRequestKindWebsiteDataAccess,
93+ expected: []string{"website_data_access"},
94+ },
9095 }
9196
9297 for _, tt := range tests {
M internal/ui/adapter/favicon.go +69 -50
@@ -5,12 +5,10 @@ import (
55 "context"
66 "sync"
77
8+ "github.com/bnema/dumber/internal/application/port"
89 domainurl "github.com/bnema/dumber/internal/domain/url"
9- "github.com/bnema/dumber/internal/infrastructure/favicon"
10- "github.com/bnema/dumber/internal/infrastructure/webkit"
1110 "github.com/bnema/dumber/internal/logging"
1211 "github.com/jwijenbergh/puregotk/v4/gdk"
13- "github.com/jwijenbergh/puregotk/v4/gio"
1412 "github.com/jwijenbergh/puregotk/v4/glib"
1513 "github.com/rs/zerolog"
1614 )
@@ -18,24 +16,47 @@ import (
1816 // FaviconAdapter bridges the domain FaviconService to GTK by providing
1917 // gdk.Texture conversion and WebKit FaviconDatabase integration.
2018 type FaviconAdapter struct {
21- service *favicon.Service
22- faviconDB *webkit.FaviconDatabase
19+ service port.FaviconService
20+ faviconDB port.FaviconDatabase
2321 textureCache map[string]*gdk.Texture
2422 mu sync.RWMutex
2523 warnMu sync.Mutex
2624 warnCounts map[string]int
25+
26+ // App-logo / internal-URL helpers (injected to decouple from infrastructure/favicon)
27+ isInternalURL func(url string) bool
28+ internalDomain string
29+ normalizedIconSize int
30+ getLogoBytes func() []byte
2731 }
2832
2933 type warningLogFunc func(log *zerolog.Logger, err error)
3034
35+// FaviconAdapterConfig holds optional configuration for FaviconAdapter.
36+// Zero values disable the corresponding features (internal-URL logo, sized-PNG creation).
37+type FaviconAdapterConfig struct {
38+ // IsInternalURL reports whether a URL uses the internal app scheme (e.g. dumb://).
39+ IsInternalURL func(url string) bool
40+ // InternalDomain is the pseudo-domain used to cache the app logo favicon.
41+ InternalDomain string
42+ // NormalizedIconSize is the target size for dmenu/fuzzel icons (0 = skip).
43+ NormalizedIconSize int
44+ // GetLogoBytes returns the raw bytes of the app logo (nil = skip logo favicon).
45+ GetLogoBytes func() []byte
46+}
47+
3148 // NewFaviconAdapter creates a new FaviconAdapter.
32-// The faviconDB can be nil if WebKit favicon database is not available.
33-func NewFaviconAdapter(service *favicon.Service, faviconDB *webkit.FaviconDatabase) *FaviconAdapter {
49+// The faviconDB can be nil if a favicon database is not available.
50+func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDatabase, cfg FaviconAdapterConfig) *FaviconAdapter {
3451 return &FaviconAdapter{
35- service: service,
36- faviconDB: faviconDB,
37- textureCache: make(map[string]*gdk.Texture),
38- warnCounts: make(map[string]int),
52+ service: service,
53+ faviconDB: faviconDB,
54+ textureCache: make(map[string]*gdk.Texture),
55+ warnCounts: make(map[string]int),
56+ isInternalURL: cfg.IsInternalURL,
57+ internalDomain: cfg.InternalDomain,
58+ normalizedIconSize: cfg.NormalizedIconSize,
59+ getLogoBytes: cfg.GetLogoBytes,
3960 }
4061 }
4162
@@ -55,8 +76,8 @@ func (a *FaviconAdapter) GetTexture(domain string) *gdk.Texture {
5576 // For internal dumb:// URLs, returns the app logo texture.
5677 func (a *FaviconAdapter) GetTextureByURL(pageURL string) *gdk.Texture {
5778 // Handle internal dumb:// scheme URLs
58- if favicon.IsInternalURL(pageURL) {
59- return a.GetTexture(favicon.InternalDomain)
79+ if a.isInternalURL != nil && a.isInternalURL(pageURL) {
80+ return a.GetTexture(a.internalDomain)
6081 }
6182 domain := domainurl.ExtractDomain(pageURL)
6283 return a.GetTexture(domain)
@@ -73,7 +94,7 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
7394 log := logging.FromContext(ctx)
7495
7596 // Handle internal dumb:// scheme URLs - use app logo
76- if favicon.IsInternalURL(pageURL) {
97+ if a.isInternalURL != nil && a.isInternalURL(pageURL) {
7798 texture := a.getOrCreateLogoTexture()
7899 callback(texture)
79100 return
@@ -127,32 +148,24 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac
127148 }()
128149 }
129150
130- // Try WebKit FaviconDatabase if available
151+ // Try engine FaviconDatabase if available
131152 if a.faviconDB != nil {
132- asyncCb := gio.AsyncReadyCallback(func(_ uintptr, resultPtr uintptr, _ uintptr) {
133- if resultPtr == 0 {
134- log.Debug().Str("domain", domain).Msg("favicon not in webkit db, fetching via service")
153+ a.faviconDB.GetFaviconAsync(pageURL, func(tex port.Texture) {
154+ if tex == nil {
155+ log.Debug().Str("domain", domain).Msg("favicon not in engine db, fetching via service")
135156 fetchViaService()
136157 return
137158 }
138-
139- result := &gio.AsyncResultBase{Ptr: resultPtr}
140- texture, err := a.faviconDB.GetFaviconFinish(result)
141- if err != nil || texture == nil {
142- log.Debug().Str("domain", domain).Msg("favicon not in webkit db, fetching via service")
159+ // Convert port.Texture to *gdk.Texture
160+ gdkTex, ok := tex.(*gdk.Texture)
161+ if !ok {
143162 fetchViaService()
144163 return
145164 }
146-
147- // Found in WebKit database
148- a.setTexture(domain, texture)
149- invokeCallback(texture)
150-
151- // Ensure disk cache is populated (async)
152- go a.service.EnsureDiskCache(ctx, domain)
165+ a.setTexture(domain, gdkTex)
166+ invokeCallback(gdkTex)
167+ a.saveFaviconToDisk(ctx, domain, gdkTex)
153168 })
154-
155- a.faviconDB.GetFavicon(pageURL, nil, &asyncCb, 0)
156169 return
157170 }
158171
@@ -233,17 +246,19 @@ func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, t
233246 a.clearWarningDedup(savePNGWarnKey)
234247 log.Debug().Str("domain", domain).Str("path", pngPath).Msg("saved favicon PNG")
235248
236- // Create normalized sized copy for dmenu/fuzzel (async)
237- go func() {
238- if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil {
239- a.logWarningDedup(ctx, sizedPNGWarnKey, err, func(log *zerolog.Logger, warnErr error) {
240- log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create sized PNG")
241- })
242- } else {
243- a.clearWarningDedup(sizedPNGWarnKey)
244- log.Debug().Str("domain", domain).Msg("created sized PNG")
245- }
246- }()
249+ // Create normalized sized copy for dmenu/fuzzel (async, only if size is set)
250+ if a.normalizedIconSize > 0 {
251+ go func() {
252+ if err := a.service.EnsureSizedPNG(ctx, domain, a.normalizedIconSize); err != nil {
253+ a.logWarningDedup(ctx, sizedPNGWarnKey, err, func(log *zerolog.Logger, warnErr error) {
254+ log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create sized PNG")
255+ })
256+ } else {
257+ a.clearWarningDedup(sizedPNGWarnKey)
258+ log.Debug().Str("domain", domain).Msg("created sized PNG")
259+ }
260+ }()
261+ }
247262 }
248263 return false // don't repeat
249264 })
@@ -281,7 +296,7 @@ func (a *FaviconAdapter) PreloadFromCache(pageURL string) *gdk.Texture {
281296
282297 // Service returns the underlying favicon service.
283298 // Used by CLI components that need disk paths.
284-func (a *FaviconAdapter) Service() *favicon.Service {
299+func (a *FaviconAdapter) Service() port.FaviconService {
285300 return a.service
286301 }
287302
@@ -325,7 +340,7 @@ func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) {
325340 log := logging.FromContext(ctx)
326341
327342 hasPNG := a.service.HasPNGOnDisk(domain)
328- hasSized := a.service.HasPNGSizedOnDisk(domain, favicon.NormalizedIconSize)
343+ hasSized := a.service.HasPNGSizedOnDisk(domain, a.normalizedIconSize)
329344
330345 log.Debug().
331346 Str("domain", domain).
@@ -336,7 +351,7 @@ func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) {
336351 // Only create if original PNG exists but sized version doesn't
337352 if hasPNG && !hasSized {
338353 log.Debug().Str("domain", domain).Msg("creating sized PNG")
339- if err := a.service.EnsureSizedPNG(ctx, domain, favicon.NormalizedIconSize); err != nil {
354+ if err := a.service.EnsureSizedPNG(ctx, domain, a.normalizedIconSize); err != nil {
340355 a.logWarningDedup(ctx, "sized-png:"+domain, err, func(log *zerolog.Logger, warnErr error) {
341356 log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create sized PNG")
342357 })
@@ -386,18 +401,22 @@ func (a *FaviconAdapter) logWarningDedup(
386401 }
387402
388403 // getOrCreateLogoTexture returns the app logo texture, creating it if needed.
389-// The texture is cached under the InternalDomain key.
404+// The texture is cached under the internalDomain key.
390405 func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture {
406+ if a.internalDomain == "" || a.getLogoBytes == nil {
407+ return nil
408+ }
409+
391410 // Check cache first
392- if texture := a.GetTexture(favicon.InternalDomain); texture != nil {
411+ if texture := a.GetTexture(a.internalDomain); texture != nil {
393412 return texture
394413 }
395414
396415 // Create texture from logo bytes
397- logoBytes := favicon.GetLogoBytes()
416+ logoBytes := a.getLogoBytes()
398417 texture := bytesToTexture(logoBytes)
399418 if texture != nil {
400- a.setTexture(favicon.InternalDomain, texture)
419+ a.setTexture(a.internalDomain, texture)
401420 }
402421 return texture
403422 }
M internal/ui/adapter/favicon_test.go +3 -3
@@ -8,7 +8,7 @@ import (
88 )
99
1010 func TestFaviconWarningDedup_FirstAndRepeated(t *testing.T) {
11- adapter := NewFaviconAdapter(nil, nil)
11+ adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
1212
1313 first, suppressed := adapter.shouldLogWarningDedup("save-png:example.com")
1414 if !first {
@@ -36,7 +36,7 @@ func TestFaviconWarningDedup_FirstAndRepeated(t *testing.T) {
3636 }
3737
3838 func TestFaviconWarningDedup_ClearResetsState(t *testing.T) {
39- adapter := NewFaviconAdapter(nil, nil)
39+ adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
4040 key := "sized-png:example.com"
4141
4242 first, _ := adapter.shouldLogWarningDedup(key)
@@ -56,7 +56,7 @@ func TestFaviconWarningDedup_ClearResetsState(t *testing.T) {
5656 }
5757
5858 func TestFaviconWarningDedup_LogWarningDedupInvokesCallbackOnce(t *testing.T) {
59- adapter := NewFaviconAdapter(nil, nil)
59+ adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{})
6060 key := "save-png:example.com"
6161 var calls atomic.Int32
6262
M internal/ui/app.go +236 -190
@@ -14,19 +14,12 @@ import (
1414 "github.com/bnema/dumber/internal/application/usecase"
1515 "github.com/bnema/dumber/internal/domain/entity"
1616 urlutil "github.com/bnema/dumber/internal/domain/url"
17- "github.com/bnema/dumber/internal/infrastructure/config"
18- "github.com/bnema/dumber/internal/infrastructure/desktop"
19- "github.com/bnema/dumber/internal/infrastructure/filesystem"
20- "github.com/bnema/dumber/internal/infrastructure/filtering"
21- "github.com/bnema/dumber/internal/infrastructure/snapshot"
22- "github.com/bnema/dumber/internal/infrastructure/textinput"
23- "github.com/bnema/dumber/internal/infrastructure/webkit"
24-
25- "github.com/bnema/dumber/internal/infrastructure/webkit/handlers"
17+
2618 "github.com/bnema/dumber/internal/logging"
2719 "github.com/bnema/dumber/internal/ui/adapter"
2820 "github.com/bnema/dumber/internal/ui/component"
2921 "github.com/bnema/dumber/internal/ui/coordinator"
22+ "github.com/bnema/dumber/internal/ui/coordinator/content"
3023 "github.com/bnema/dumber/internal/ui/dialog"
3124 "github.com/bnema/dumber/internal/ui/dispatcher"
3225 "github.com/bnema/dumber/internal/ui/focus"
@@ -68,12 +61,11 @@ type App struct {
6861 tabsUC *usecase.ManageTabsUseCase
6962
7063 // Coordinators (new architecture)
71- contentCoord *coordinator.ContentCoordinator
72- tabCoord *coordinator.TabCoordinator
73- wsCoord *coordinator.WorkspaceCoordinator
74- navCoord *coordinator.NavigationCoordinator
75- kbDispatcher *dispatcher.KeyboardDispatcher
76- configManager *config.Manager
64+ contentCoord *content.Coordinator
65+ tabCoord *coordinator.TabCoordinator
66+ wsCoord *coordinator.WorkspaceCoordinator
67+ navCoord *coordinator.NavigationCoordinator
68+ kbDispatcher *dispatcher.KeyboardDispatcher
7769
7870 // Pane management (used by coordinators)
7971 panesUC *usecase.ManagePanesUseCase
@@ -99,12 +91,10 @@ type App struct {
9991 // Floating pane sessions keyed by tab and profile session ID.
10092 floatingSessions map[floatingSessionKey]*floatingWorkspaceSession
10193
102- // Web content (managed by ContentCoordinator)
103- pool *webkit.WebViewPool
104- webViewFactory *webkit.WebViewFactory
105- injector *webkit.ContentInjector
106- router *webkit.MessageRouter
107- settings *webkit.SettingsManager
94+ // Engine
95+ engine port.Engine
96+
97+ // Web content (managed by content.Coordinator)
10898 faviconAdapter *adapter.FaviconAdapter
10999
110100 // App-level toaster for system notifications (filter status, etc.)
@@ -119,7 +109,7 @@ type App struct {
119109 tabPicker *component.TabPicker
120110 tabPickerWidget layout.Widget
121111 tabPickerPaneID entity.PaneID
122- snapshotService *snapshot.Service
112+ snapshotService port.SnapshotService
123113
124114 // Update management
125115 updateCoord *coordinator.UpdateCoordinator
@@ -134,7 +124,7 @@ type App struct {
134124 // Accent picker for dead keys support
135125 accentPicker *component.AccentPicker
136126 insertAccentUC *usecase.InsertAccentUseCase
137- accentFocusProvider *textinput.FocusProvider
127+ accentFocusProvider port.FocusedInputProvider
138128
139129 // Deferred initialization - runs after first load_started to avoid blocking initial navigation
140130 deferredInitOnce sync.Once
@@ -148,7 +138,7 @@ type floatingWorkspaceSession struct {
148138 paneID entity.PaneID
149139 pane *component.FloatingPane
150140 paneView *component.PaneView
151- webView *webkit.WebView
141+ webView port.WebView
152142 overlay layout.OverlayWidget
153143 widget layout.Widget
154144 focusWidget layout.Widget
@@ -179,42 +169,34 @@ func New(deps *Dependencies) (*App, error) {
179169 panesUC: deps.PanesUC,
180170 workspaceViews: make(map[entity.TabID]*component.WorkspaceView),
181171 floatingSessions: make(map[floatingSessionKey]*floatingWorkspaceSession),
182- pool: deps.Pool,
183- injector: deps.Injector,
184- router: deps.MessageRouter,
185- settings: deps.Settings,
186- configManager: config.GetManager(),
172+ engine: deps.Engine,
187173 cancel: cancel,
188174 }
189- if app.router == nil {
190- app.router = webkit.NewMessageRouter(ctx)
191- }
192-
193- // Register message handlers
194- if app.router != nil {
195- if err := handlers.RegisterAll(ctx, app.router, handlers.Config{
196- HistoryUC: deps.HistoryUC,
197- FavoritesUC: deps.FavoritesUC,
198- Clipboard: deps.Clipboard,
199- ConfigGetter: config.Get,
200- // AccentHandler is registered after initAccentPicker in onActivate
201- OnClipboardCopied: func(textLen int) {
202- // Show brief toast on auto-copy (similar to zellij footer notification)
203- // Must schedule on GTK main thread since this is called from WebKit handler
204- cb := glib.SourceFunc(func(_ uintptr) bool {
205- if app.appToaster != nil {
206- app.appToaster.Show(ctx, "Copied to clipboard", component.ToastInfo,
207- component.WithDuration(component.ToastBriefDurationMs),
208- component.WithPosition(component.ToastPositionBottomRight),
209- )
210- }
211- return false
212- })
213- glib.IdleAdd(&cb, 0)
214- },
215- }); err != nil {
216- return nil, err
217- }
175+ // Register message handlers through the engine.
176+ if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{
177+ HistoryUC: deps.HistoryUC,
178+ FavoritesUC: deps.FavoritesUC,
179+ Clipboard: deps.Clipboard,
180+ AutoCopyConfig: &autoCopyConfigFn{fn: func() bool {
181+ if deps.Config == nil {
182+ return false
183+ }
184+ return deps.Config.Clipboard.AutoCopyOnSelection
185+ }},
186+ OnClipboardCopied: func(textLen int) {
187+ cb := glib.SourceFunc(func(_ uintptr) bool {
188+ if app.appToaster != nil {
189+ app.appToaster.Show(ctx, "Copied to clipboard", component.ToastInfo,
190+ component.WithDuration(component.ToastBriefDurationMs),
191+ component.WithPosition(component.ToastPositionBottomRight),
192+ )
193+ }
194+ return false
195+ })
196+ glib.IdleAdd(&cb, 0)
197+ },
198+ }); err != nil {
199+ return nil, err
218200 }
219201
220202 return app, nil
@@ -239,8 +221,8 @@ func (a *App) Run(ctx context.Context, args []string) int {
239221
240222 // Refresh scripts in pooled WebViews that were prewarmed before adw.Init().
241223 // They have the wrong dark mode preference injected; re-inject with correct value.
242- if a.pool != nil {
243- a.pool.RefreshScripts(ctx)
224+ if a.engine != nil {
225+ _ = a.engine.OnToolkitReady(ctx)
244226 }
245227 }
246228
@@ -362,33 +344,34 @@ func (a *App) applyGTKColorSchemePreference(ctx context.Context) {
362344
363345 func (a *App) setupPoolBackgroundColor(ctx context.Context) {
364346 log := logging.FromContext(ctx)
365-
366- // Set theme background color on pool to eliminate white flash.
367- // Must be done before any WebView creation so WebViews get the correct color.
368- if a.pool == nil {
369- log.Debug().Msg("webview pool not available; skipping background color setup")
347+ if a.engine == nil {
348+ log.Debug().Msg("engine not available; skipping background color setup")
370349 return
371350 }
372351 if a.deps == nil || a.deps.Theme == nil {
373352 log.Debug().Msg("theme not available; skipping webview pool background color setup")
374353 return
375354 }
376-
377355 r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
378- a.pool.SetBackgroundColor(r, g, b, alpha)
356+ if err := a.engine.UpdateAppearance(ctx, float64(r), float64(g), float64(b), float64(alpha)); err != nil {
357+ log.Warn().Err(err).Msg("failed to update engine appearance")
358+ }
379359 log.Debug().Msg("configured webview pool background color")
380360 }
381361
382362 func (a *App) prewarmWebViewPoolAsync(ctx context.Context) {
383- // Prewarm WebView pool after startup so cold-start navigation is not blocked.
384- if a.pool == nil {
363+ if a.engine == nil {
364+ return
365+ }
366+ pool := a.engine.Pool()
367+ if pool == nil {
385368 return
386369 }
387- a.pool.PrewarmAsync(ctx, 0)
370+ pool.PrewarmAsync(ctx, 0)
388371 }
389372
390373 func (a *App) createMainWindow(ctx context.Context) error {
391- mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config)
374+ mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config.Workspace.TabBarPosition)
392375 if err != nil {
393376 return err
394377 }
@@ -524,8 +507,8 @@ func (a *App) initAccentPicker(ctx context.Context) {
524507 }
525508 }
526509
527- // Create focus provider (tracks which input has focus)
528- a.accentFocusProvider = textinput.NewFocusProvider()
510+ // Use focus provider from deps (tracks which input has focus)
511+ a.accentFocusProvider = a.deps.AccentFocusProvider
529512
530513 // Create the use case with glib.IdleAdd for thread-safe GTK calls
531514 a.insertAccentUC = usecase.NewInsertAccentUseCase(
@@ -548,12 +531,16 @@ func (a *App) initAccentPicker(ctx context.Context) {
548531 func (a *App) registerAccentHandlers(ctx context.Context) {
549532 log := logging.FromContext(ctx)
550533
551- if a.router == nil || a.insertAccentUC == nil {
552- log.Debug().Msg("router or insertAccentUC not available, skipping accent handler registration")
534+ if a.insertAccentUC == nil {
535+ log.Debug().Msg("insertAccentUC not available, skipping accent handler registration")
536+ return
537+ }
538+ if a.engine == nil {
539+ log.Debug().Msg("engine not available, skipping accent handler registration")
553540 return
554541 }
555542
556- if err := handlers.RegisterAccentHandlers(ctx, a.router, a.insertAccentUC); err != nil {
543+ if err := a.engine.RegisterAccentHandlers(ctx, a.insertAccentUC); err != nil {
557544 log.Error().Err(err).Msg("failed to register accent handlers")
558545 } else {
559546 log.Info().Msg("registered accent key handlers")
@@ -563,7 +550,7 @@ func (a *App) registerAccentHandlers(ctx context.Context) {
563550 func (a *App) initDownloadHandler(ctx context.Context) {
564551 log := logging.FromContext(ctx)
565552
566- if a.deps == nil || a.deps.WebContext == nil {
553+ if a.deps == nil || a.engine == nil {
567554 log.Debug().Msg("WebContext not available, skipping download handler")
568555 return
569556 }
@@ -595,11 +582,12 @@ func (a *App) initDownloadHandler(ctx context.Context) {
595582 eventAdapter := &downloadEventAdapter{app: a}
596583
597584 // Create use case for preparing download destinations with file deduplication.
598- prepareDownloadUC := usecase.NewPrepareDownloadUseCase(filesystem.New())
585+ prepareDownloadUC := usecase.NewPrepareDownloadUseCase(a.deps.FileSystem)
599586
600- // Create and wire the download handler.
601- handler := webkit.NewDownloadHandler(downloadPath, eventAdapter, prepareDownloadUC)
602- a.deps.WebContext.SetDownloadHandler(ctx, handler)
587+ if err := a.engine.ConfigureDownloads(ctx, downloadPath, eventAdapter, prepareDownloadUC); err != nil {
588+ log.Error().Err(err).Msg("failed to configure downloads")
589+ return
590+ }
603591
604592 log.Info().Str("path", downloadPath).Msg("download handler initialized")
605593 }
@@ -629,6 +617,18 @@ func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.D
629617 glib.IdleAdd(&cb, 0)
630618 }
631619
620+// autoCopyConfigFn implements port.AutoCopyConfig using a function closure.
621+type autoCopyConfigFn struct {
622+ fn func() bool
623+}
624+
625+func (a *autoCopyConfigFn) IsAutoCopyEnabled() bool {
626+ if a.fn == nil {
627+ return false
628+ }
629+ return a.fn()
630+}
631+
632632 func (a *App) initKeyboardHandler(ctx context.Context) {
633633 log := logging.FromContext(ctx)
634634
@@ -637,7 +637,7 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
637637 }
638638
639639 // Create keyboard handler and wire to dispatcher.
640- a.keyboardHandler = input.NewKeyboardHandler(ctx, a.deps.Config)
640+ a.keyboardHandler = input.NewKeyboardHandler(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session)
641641 a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error {
642642 if action == input.ActionClosePane {
643643 if a.closeAndReleaseActiveFloatingPane(ctx) {
@@ -677,7 +677,7 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
677677 // visible after focus returns to the WebView, so visibility alone
678678 // would misroute keys.
679679 if a.accentFocusProvider != nil {
680- if _, ok := a.accentFocusProvider.GetFocusedInput().(*textinput.GTKEntryTarget); ok {
680+ if _, ok := a.accentFocusProvider.GetFocusedInput().(port.EntryInputTarget); ok {
681681 // GTK Entry focused (omnibox or find bar): Alt-modified keys go to
682682 // shortcuts (pane navigation), other keys pass through to the widget's
683683 // own capture-phase key controller (which handles accent detection)
@@ -712,7 +712,8 @@ func (a *App) initKeyboardHandler(ctx context.Context) {
712712 a.globalShortcutHandler = input.NewGlobalShortcutHandler(
713713 ctx,
714714 a.mainWindow.Window(),
715- a.deps.Config,
715+ &a.deps.Config.Workspace,
716+ &a.deps.Config.Session,
716717 a.keyboardHandler,
717718 func(ctx context.Context, action input.Action) error {
718719 return a.kbDispatcher.Dispatch(ctx, action)
@@ -785,7 +786,9 @@ func (a *App) initOmniboxConfig(ctx context.Context) {
785786 OnFocusIn: func(entry *gtk.SearchEntry) {
786787 // Set omnibox entry as the focused input for accent picker
787788 if a.accentFocusProvider != nil && entry != nil {
788- a.accentFocusProvider.SetFocusedInput(textinput.NewGTKEntryTarget(entry))
789+ if a.deps.NewGTKEntryTarget != nil {
790+ a.accentFocusProvider.SetFocusedInput(a.deps.NewGTKEntryTarget(entry))
791+ }
789792 }
790793 },
791794 OnFocusOut: func() {
@@ -821,7 +824,9 @@ func (a *App) initFindBarConfig(ctx context.Context) {
821824 OnFocusIn: func(entry *gtk.SearchEntry) {
822825 // Set find bar entry as the focused input for accent picker
823826 if a.accentFocusProvider != nil && entry != nil {
824- a.accentFocusProvider.SetFocusedInput(textinput.NewGTKEntryTarget(entry))
827+ if a.deps.NewGTKEntryTarget != nil {
828+ a.accentFocusProvider.SetFocusedInput(a.deps.NewGTKEntryTarget(entry))
829+ }
825830 }
826831 },
827832 OnFocusOut: func() {
@@ -863,8 +868,8 @@ func (a *App) initSessionManager(ctx context.Context) {
863868 )
864869 }
865870
866- // Create session spawner for restoration
867- spawner := desktop.NewSessionSpawner(ctx)
871+ // Use injected session spawner for restoration
872+ spawner := a.deps.SessionSpawner
868873
869874 // Create session manager component
870875 a.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{
@@ -877,6 +882,10 @@ func (a *App) initSessionManager(ctx context.Context) {
877882 },
878883 OnOpen: func(sessionID entity.SessionID) {
879884 log.Info().Str("session_id", string(sessionID)).Msg("session restoration requested")
885+ if spawner == nil {
886+ log.Warn().Str("session_id", string(sessionID)).Msg("session spawner not available")
887+ return
888+ }
880889 if err := spawner.SpawnWithSession(sessionID); err != nil {
881890 log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session")
882891 }
@@ -1166,7 +1175,7 @@ func (a *App) switchToTargetTabIfConfigured(ctx context.Context, out *usecase.Mo
11661175 return
11671176 }
11681177
1169- switchToTarget := config.Get().Workspace.SwitchToTabOnMove
1178+ switchToTarget := a.deps.Config.Workspace.SwitchToTabOnMove
11701179 if out.SourceTabClosed {
11711180 switchToTarget = true
11721181 }
@@ -1226,8 +1235,8 @@ func (a *App) initTabPicker(ctx context.Context) {
12261235 func (a *App) initSnapshotService(ctx context.Context) {
12271236 log := logging.FromContext(ctx)
12281237
1229- if a.deps == nil || a.deps.SnapshotUC == nil {
1230- log.Debug().Msg("snapshot use case not available, skipping snapshot service")
1238+ if a.deps == nil || a.deps.SnapshotServiceFactory == nil {
1239+ log.Debug().Msg("snapshot service factory not available, skipping")
12311240 return
12321241 }
12331242
@@ -1236,7 +1245,11 @@ func (a *App) initSnapshotService(ctx context.Context) {
12361245 intervalMs = a.deps.Config.Session.SnapshotIntervalMs
12371246 }
12381247
1239- a.snapshotService = snapshot.NewService(a.deps.SnapshotUC, a, intervalMs)
1248+ a.snapshotService = a.deps.SnapshotServiceFactory(a, intervalMs)
1249+ if a.snapshotService == nil {
1250+ log.Warn().Msg("snapshot service factory returned nil")
1251+ return
1252+ }
12401253 a.snapshotService.Start(ctx)
12411254
12421255 // Set up callback for main.go to notify when session is persisted
@@ -1259,7 +1272,8 @@ func (a *App) initUpdateCoordinator(ctx context.Context) {
12591272 a.deps.CheckUpdateUC,
12601273 a.deps.ApplyUpdateUC,
12611274 a.appToaster,
1262- a.deps.Config,
1275+ a.deps.Config.Update.EnableOnStartup,
1276+ a.deps.Config.Update.AutoDownload,
12631277 )
12641278
12651279 // Start async update check
@@ -1425,7 +1439,7 @@ func (a *App) runAfterFirstLoadStarted(fn func()) {
14251439 a.deferredInitFn = fn
14261440 }
14271441
1428-// triggerDeferredInit is called from ContentCoordinator on first load_started.
1442+// triggerDeferredInit is called from content.Coordinator on first load_started.
14291443 // It runs deferred initialization at LOW priority so navigation continues unblocked.
14301444 func (a *App) triggerDeferredInit(ctx context.Context) {
14311445 a.deferredInitOnce.Do(func() {
@@ -1482,8 +1496,10 @@ func (a *App) onShutdown(ctx context.Context) {
14821496 for _, tabID := range tabIDs {
14831497 a.releaseFloatingSessionsForTab(ctx, tabID)
14841498 }
1485- if a.deps.Pool != nil {
1486- a.deps.Pool.Close(ctx)
1499+ if a.engine != nil {
1500+ if err := a.engine.Close(); err != nil {
1501+ log.Warn().Err(err).Msg("failed to close engine")
1502+ }
14871503 }
14881504 // Close idle inhibitor to release D-Bus connection
14891505 if a.deps.IdleInhibitor != nil {
@@ -1495,27 +1511,15 @@ func (a *App) onShutdown(ctx context.Context) {
14951511 log.Info().Msg("application shutdown complete")
14961512 }
14971513
1498-// initCoordinators initializes all coordinators and wires their callbacks.
1499-func (a *App) initCoordinators(ctx context.Context) {
1500- log := logging.FromContext(ctx)
1501- log.Debug().Msg("initializing coordinators")
1502-
1503- // Helper to get active workspace and view
1504- getActiveWS := func() (*entity.Workspace, *component.WorkspaceView) {
1505- return a.activeWorkspace(), a.activeWorkspaceView()
1506- }
1507-
1508- // Create FaviconAdapter with service and WebKit FaviconDatabase
1509- var faviconDB *webkit.FaviconDatabase
1510- if a.deps.WebContext != nil {
1511- faviconDB = a.deps.WebContext.FaviconDatabase()
1512- }
1513- a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, faviconDB)
1514-
1515- // 1. Content Coordinator (no dependencies on other coordinators)
1516- a.contentCoord = coordinator.NewContentCoordinator(
1514+// initContentCoordinator creates the content coordinator and wires its optional dependencies.
1515+func (a *App) initContentCoordinator(
1516+ ctx context.Context,
1517+ getActiveWS func() (*entity.Workspace, *component.WorkspaceView),
1518+) {
1519+ a.contentCoord = content.NewCoordinator(
15171520 ctx,
1518- a.pool,
1521+ a.engine.Pool(),
1522+ a.engine.ContentInjector(),
15191523 a.widgetFactory,
15201524 a.faviconAdapter,
15211525 getActiveWS,
@@ -1528,17 +1532,50 @@ func (a *App) initCoordinators(ctx context.Context) {
15281532 a.contentCoord.SetIdleInhibitor(a.deps.IdleInhibitor)
15291533 }
15301534
1535+ // Wire engine settings and filter appliers for hot-reload and late-binding filters
1536+ if sa := a.deps.Engine.SettingsApplier(); sa != nil {
1537+ a.contentCoord.SetSettingsApplier(sa)
1538+ }
1539+ if fa := a.deps.Engine.FilterApplier(); fa != nil {
1540+ a.contentCoord.SetFilterApplier(fa)
1541+ }
1542+
1543+ // Wire external URL launcher (e.g. xdg-open for vscode://, spotify://)
1544+ if a.deps.LaunchExternalURL != nil {
1545+ a.contentCoord.SetOnLaunchExternalURL(a.deps.LaunchExternalURL)
1546+ }
1547+
15311548 // Wire deferred init trigger - runs after first navigation starts
15321549 a.contentCoord.SetOnFirstLoadStarted(func() {
15331550 a.triggerDeferredInit(ctx)
15341551 })
1552+}
1553+
1554+// initCoordinators initializes all coordinators and wires their callbacks.
1555+func (a *App) initCoordinators(ctx context.Context) {
1556+ log := logging.FromContext(ctx)
1557+ log.Debug().Msg("initializing coordinators")
1558+
1559+ // Helper to get active workspace and view
1560+ getActiveWS := func() (*entity.Workspace, *component.WorkspaceView) {
1561+ return a.activeWorkspace(), a.activeWorkspaceView()
1562+ }
1563+
1564+ // Create FaviconAdapter with service and engine FaviconDatabase.
1565+ // Skip if FaviconService is nil (e.g. in tests or when favicon support is disabled).
1566+ if a.deps.FaviconService != nil {
1567+ a.faviconAdapter = adapter.NewFaviconAdapter(a.deps.FaviconService, a.engine.FaviconDatabase(), a.deps.FaviconAdapterConfig)
1568+ }
1569+
1570+ // 1. Content Coordinator (no dependencies on other coordinators)
1571+ a.initContentCoordinator(ctx, getActiveWS)
15351572
15361573 // 2. Tab Coordinator
15371574 a.tabCoord = coordinator.NewTabCoordinator(ctx, coordinator.TabCoordinatorConfig{
1538- TabsUC: a.tabsUC,
1539- Tabs: a.tabs,
1540- MainWindow: a.mainWindow,
1541- Config: a.deps.Config,
1575+ TabsUC: a.tabsUC,
1576+ Tabs: a.tabs,
1577+ MainWindow: a.mainWindow,
1578+ HideTabBarWhenSingleTab: a.deps.Config.Workspace.HideTabBarWhenSingleTab,
15421579 })
15431580 a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) {
15441581 a.createWorkspaceView(ctx, tab)
@@ -1549,7 +1586,7 @@ func (a *App) initCoordinators(ctx context.Context) {
15491586 a.tabCoord.SetOnQuit(a.Quit)
15501587 a.tabCoord.SetOnStateChanged(a.MarkDirty)
15511588 // Wire popup tab WebView attachment
1552- a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) {
1589+ a.tabCoord.SetOnAttachPopupToTab(func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) {
15531590 a.attachPopupToTab(ctx, tabID, pane, wv)
15541591 })
15551592
@@ -1578,13 +1615,16 @@ func (a *App) initCoordinators(ctx context.Context) {
15781615
15791616 // 3. Workspace Coordinator
15801617 a.wsCoord = coordinator.NewWorkspaceCoordinator(ctx, coordinator.WorkspaceCoordinatorConfig{
1581- PanesUC: a.panesUC,
1582- FocusMgr: a.focusMgr,
1583- StackedPaneMgr: a.stackedPaneMgr,
1584- WidgetFactory: a.widgetFactory,
1585- ContentCoord: a.contentCoord,
1586- GetActiveWS: getActiveWS,
1587- GenerateID: a.generateID,
1618+ PanesUC: a.panesUC,
1619+ FocusMgr: a.focusMgr,
1620+ StackedPaneMgr: a.stackedPaneMgr,
1621+ WidgetFactory: a.widgetFactory,
1622+ ContentCoord: a.contentCoord,
1623+ GetActiveWS: getActiveWS,
1624+ GenerateID: a.generateID,
1625+ NewPaneURL: a.deps.Config.Workspace.NewPaneURL,
1626+ ResizeStepPercent: a.deps.Config.Workspace.ResizeMode.StepPercent,
1627+ ResizeMinPanePercent: a.deps.Config.Workspace.ResizeMode.MinPanePercent,
15881628 })
15891629 a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error {
15901630 return a.tabCoord.Close(ctx)
@@ -1592,31 +1632,24 @@ func (a *App) initCoordinators(ctx context.Context) {
15921632 a.wsCoord.SetOnStateChanged(a.MarkDirty)
15931633
15941634 // Wire popup handling
1595- a.webViewFactory = webkit.NewWebViewFactory(
1596- a.deps.WebContext,
1597- a.settings,
1598- a.pool,
1599- a.injector,
1600- a.router,
1601- )
1602- // Set theme background color on factory to eliminate white flash
1603- if a.deps.Theme != nil {
1635+ // Set theme background color on the engine's popup factory to eliminate white flash.
1636+ if a.engine != nil && a.deps.Theme != nil {
16041637 r, g, b, alpha := a.deps.Theme.GetBackgroundRGBA()
1605- a.webViewFactory.SetBackgroundColor(r, g, b, alpha)
1638+ _ = a.engine.UpdateAppearance(ctx, float64(r), float64(g), float64(b), float64(alpha))
16061639 }
16071640 a.contentCoord.SetPopupConfig(
1608- a.webViewFactory,
1641+ a.engine.Factory(),
16091642 &a.deps.Config.Workspace.Popups,
16101643 a.generateID,
16111644 )
1612- a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input coordinator.InsertPopupInput) error {
1645+ a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input content.InsertPopupInput) error {
16131646 return a.wsCoord.InsertPopup(ctx, input)
16141647 })
16151648 a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error {
16161649 return a.wsCoord.ClosePaneByID(ctx, paneID)
16171650 })
16181651 // Wire tabbed popup behavior to create new tabs
1619- a.wsCoord.SetOnCreatePopupTab(func(ctx context.Context, input coordinator.InsertPopupInput) error {
1652+ a.wsCoord.SetOnCreatePopupTab(func(ctx context.Context, input content.InsertPopupInput) error {
16201653 // Create a new tab with the popup pane
16211654 tab, err := a.tabCoord.CreateWithPane(ctx, input.PopupPane, input.WebView, input.TargetURI)
16221655 if err != nil {
@@ -1695,6 +1728,7 @@ func (a *App) initCoordinators(ctx context.Context) {
16951728 a.navCoord,
16961729 a.deps.ZoomUC,
16971730 a.deps.CopyURLUC,
1731+ a.deps.Config.Workspace.NewPaneURL,
16981732 )
16991733 a.wireKeyboardActions()
17001734
@@ -1748,15 +1782,15 @@ func (a *App) wireWebRTCPermissionIndicator() {
17481782 }
17491783 log := logging.FromContext(ctx)
17501784
1751- a.contentCoord.SetOnPermissionActivity(func(origin string, permTypes []entity.PermissionType, state coordinator.PermissionActivityState) {
1785+ a.contentCoord.SetOnPermissionActivity(func(origin string, permTypes []entity.PermissionType, state content.PermissionActivityState) {
17521786 a.webrtcIndicator.SetOrigin(origin)
17531787
17541788 switch state {
1755- case coordinator.PermissionActivityRequesting:
1789+ case content.PermissionActivityRequesting:
17561790 a.webrtcIndicator.MarkRequesting(permTypes)
1757- case coordinator.PermissionActivityAllowed:
1791+ case content.PermissionActivityAllowed:
17581792 a.webrtcIndicator.MarkAllowed(permTypes)
1759- case coordinator.PermissionActivityBlocked:
1793+ case content.PermissionActivityBlocked:
17601794 a.webrtcIndicator.MarkBlocked(permTypes)
17611795 }
17621796
@@ -1781,7 +1815,7 @@ func (a *App) wireWebRTCPermissionIndicator() {
17811815 // Allowed/requesting → deny future requests.
17821816 // Blocked → allow future requests.
17831817 decision := entity.PermissionDenied
1784- if state == string(coordinator.PermissionActivityBlocked) {
1818+ if state == string(content.PermissionActivityBlocked) {
17851819 decision = entity.PermissionGranted
17861820 }
17871821 if err := a.deps.PermissionUC.SetManualPermissionDecision(ctx, origin, permType, decision); err != nil {
@@ -2054,7 +2088,9 @@ func (a *App) createWorkspaceViewWithoutAttach(ctx context.Context, tab *entity.
20542088 // Set find bar config for this workspace view
20552089 wsView.SetFindBarConfig(a.findBarCfg)
20562090 // Set auto-open omnibox on new pane
2057- wsView.SetAutoOpenOnNewPane(config.Get().Omnibox.AutoOpenOnNewPane)
2091+ if a.deps.Config != nil {
2092+ wsView.SetAutoOpenOnNewPane(a.deps.Config.Omnibox.AutoOpenOnNewPane)
2093+ }
20582094
20592095 wsView.SetOnPaneFocused(func(paneID entity.PaneID) {
20602096 if a.keyboardHandler != nil && a.keyboardHandler.Mode() == input.ModeResize {
@@ -2125,13 +2161,15 @@ func (a *App) getActiveWebViewTarget() port.TextInputTarget {
21252161 return nil
21262162 }
21272163
2128- // Get the underlying webkit.WebView for the text input target
2129- return textinput.NewWebViewTarget(wv.Widget())
2164+ if provider, ok := wv.(port.TextInputTargetProvider); ok {
2165+ return provider.TextInputTarget()
2166+ }
2167+ return nil
21302168 }
21312169
21322170 // attachPopupToTab attaches a popup WebView to a newly created tab.
21332171 // This is called when a popup uses tabbed behavior.
2134-func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) {
2172+func (a *App) attachPopupToTab(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) {
21352173 log := logging.FromContext(ctx)
21362174
21372175 wsView := a.workspaceViews[tabID]
@@ -2385,11 +2423,20 @@ func (a *App) installFloatingOverlayPositioning(tabID entity.TabID, workspaceOve
23852423 gtkOverlay.ConnectGetChildPosition(&cb)
23862424 }
23872425
2388-func (a *App) currentFloatingConfig() config.FloatingPaneConfig {
2426+// currentFloatingWidthPct returns the configured floating pane width percentage.
2427+func (a *App) currentFloatingWidthPct() float64 {
23892428 if a.deps != nil && a.deps.Config != nil {
2390- return a.deps.Config.Workspace.FloatingPane
2429+ return a.deps.Config.Workspace.FloatingPane.WidthPct
23912430 }
2392- return config.Get().Workspace.FloatingPane
2431+ return 0
2432+}
2433+
2434+// currentFloatingHeightPct returns the configured floating pane height percentage.
2435+func (a *App) currentFloatingHeightPct() float64 {
2436+ if a.deps != nil && a.deps.Config != nil {
2437+ return a.deps.Config.Workspace.FloatingPane.HeightPct
2438+ }
2439+ return 0
23932440 }
23942441
23952442 func (a *App) ensureFloatingSession(
@@ -2462,10 +2509,9 @@ func (a *App) ensureFloatingSession(
24622509 wsView.AddWorkspaceOverlayWidget(pvOverlay)
24632510 configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), pvOverlay)
24642511
2465- floatingCfg := a.currentFloatingConfig()
24662512 floatingPane := component.NewFloatingPane(wsView.WorkspaceOverlayWidget(), component.FloatingPaneOptions{
2467- WidthPct: floatingCfg.WidthPct,
2468- HeightPct: floatingCfg.HeightPct,
2513+ WidthPct: a.currentFloatingWidthPct(),
2514+ HeightPct: a.currentFloatingHeightPct(),
24692515 FallbackWidth: floatingPaneFallbackWidth,
24702516 FallbackHeight: floatingPaneFallbackHeight,
24712517 OnNavigate: func(navCtx context.Context, url string) error {
@@ -3033,29 +3079,29 @@ func (a *App) SetOmniboxOnNavigate(fn func(url string)) {
30333079 func (a *App) initConfigWatcher(ctx context.Context) {
30343080 log := logging.FromContext(ctx)
30353081
3036- if a.configManager == nil {
3037- log.Debug().Msg("no config manager available, skipping watcher")
3082+ if a.deps.WatchConfig == nil || a.deps.OnConfigChange == nil {
3083+ log.Debug().Msg("no config watcher available, skipping")
30383084 return
30393085 }
30403086
30413087 // Start viper watcher
3042- if err := a.configManager.Watch(); err != nil {
3088+ if err := a.deps.WatchConfig(); err != nil {
30433089 log.Warn().Err(err).Msg("failed to start config watcher")
30443090 return
30453091 }
30463092
30473093 // Hot-reload appearance and keybindings on config change.
3048- a.configManager.OnConfigChange(func(newCfg *config.Config) {
3049- cfgCopy := newCfg
3094+ // deps.Config is updated in-place before this callback fires.
3095+ a.deps.OnConfigChange(func() {
30503096 cb := glib.SourceFunc(func(_ uintptr) bool {
3051- a.applyAppearanceConfig(ctx, cfgCopy)
3097+ a.applyAppearanceConfig(ctx)
30523098 // Reload keyboard shortcuts
30533099 if a.keyboardHandler != nil {
3054- a.keyboardHandler.ReloadShortcuts(ctx, cfgCopy)
3100+ a.keyboardHandler.ReloadShortcuts(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session)
30553101 }
30563102 // Reload global shortcuts (removes stale, re-registers from config)
30573103 if a.globalShortcutHandler != nil {
3058- a.globalShortcutHandler.ReloadShortcuts(ctx, cfgCopy)
3104+ a.globalShortcutHandler.ReloadShortcuts(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session)
30593105 }
30603106 return false
30613107 })
@@ -3065,52 +3111,52 @@ func (a *App) initConfigWatcher(ctx context.Context) {
30653111 log.Debug().Msg("config watcher initialized")
30663112 }
30673113
3068-func (a *App) applyAppearanceConfig(ctx context.Context, cfg *config.Config) {
3114+func (a *App) applyAppearanceConfig(ctx context.Context) {
30693115 log := logging.FromContext(ctx)
3070- if cfg == nil {
3116+ if a.deps == nil || a.deps.Config == nil {
30713117 return
30723118 }
3119+ cfg := a.deps.Config
30733120
3074- // Update the shared config pointer in-place so existing references see changes.
3075- if a.deps != nil && a.deps.Config != nil {
3076- *a.deps.Config = *cfg
3077- }
3078-
3079- // Update WebKit settings manager
3080- if a.settings != nil {
3081- a.settings.UpdateFromConfig(ctx, cfg)
3121+ if a.engine != nil {
3122+ if err := a.engine.UpdateSettings(ctx, port.EngineSettingsUpdate{Raw: cfg}); err != nil {
3123+ log.Warn().Err(err).Msg("failed to apply engine settings update")
3124+ }
30823125 }
30833126
30843127 // Apply settings to existing webviews via coordinator
30853128 if a.contentCoord != nil {
3086- a.contentCoord.ApplySettingsToAll(ctx, a.settings)
3129+ a.contentCoord.ApplySettingsToAll(ctx)
30873130 }
30883131
30893132 // Update GTK theme and injected WebUI theme vars
3090- if a.deps != nil && a.deps.Theme != nil {
3133+ if a.deps.Theme != nil {
30913134 var display *gdk.Display
30923135 if a.mainWindow != nil && a.mainWindow.Window() != nil {
30933136 display = a.mainWindow.Window().GetDisplay()
30943137 }
3095- a.deps.Theme.UpdateFromConfig(ctx, cfg, display)
3138+ a.deps.Theme.UpdateFromConfig(ctx, &cfg.Appearance, cfg.DefaultUIScale, &cfg.Workspace.Styling, display)
3139+
3140+ var inj port.ContentInjector
3141+ if a.engine != nil {
3142+ inj = a.engine.ContentInjector()
3143+ }
30963144
3097- // Update find highlight CSS for future navigations
3098- if a.injector != nil {
3145+ if inj != nil {
30993146 findCSS := theme.GenerateFindHighlightCSS(a.deps.Theme.GetCurrentPalette())
3100- if err := a.injector.InjectFindHighlightCSS(ctx, findCSS); err != nil {
3147+ if err := inj.InjectFindHighlightCSS(ctx, findCSS); err != nil {
31013148 log.Warn().Err(err).Msg("failed to update find highlight CSS")
31023149 }
31033150 }
31043151
3105- prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(a.injector)
3152+ prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(inj)
31063153 cssText := a.deps.Theme.GetWebUIThemeCSS()
31073154 if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: cssText}); err != nil {
31083155 log.Warn().Err(err).Msg("failed to update WebUI theme CSS")
31093156 }
31103157
3111- // Refresh injected scripts so future navigations use the latest theme.
3112- if a.contentCoord != nil && a.injector != nil {
3113- a.contentCoord.RefreshInjectedScriptsToAll(ctx, a.injector)
3158+ if a.contentCoord != nil && inj != nil {
3159+ a.contentCoord.RefreshInjectedScriptsToAll(ctx)
31143160 }
31153161
31163162 // Apply WebUI theme to already-loaded dumb:// pages
@@ -3127,7 +3173,7 @@ func (a *App) initFilteringAsync(ctx context.Context) {
31273173 return
31283174 }
31293175
3130- a.deps.FilterManager.SetStatusCallback(func(status filtering.FilterStatus) {
3176+ a.deps.FilterManager.SetStatusCallback(func(status port.FilterStatus) {
31313177 statusCopy := status // Capture for closure
31323178 cb := glib.SourceFunc(func(_ uintptr) bool {
31333179 a.showFilterStatus(ctx, statusCopy)
@@ -3139,24 +3185,24 @@ func (a *App) initFilteringAsync(ctx context.Context) {
31393185 }
31403186
31413187 // showFilterStatus displays toast notification for filter status.
3142-func (a *App) showFilterStatus(ctx context.Context, status filtering.FilterStatus) {
3188+func (a *App) showFilterStatus(ctx context.Context, status port.FilterStatus) {
31433189 log := logging.FromContext(ctx)
31443190
31453191 switch status.State {
3146- case filtering.StateLoading:
3192+ case port.FilterStateLoading:
31473193 if a.appToaster != nil {
31483194 a.appToaster.Show(ctx, status.Message, component.ToastInfo)
31493195 }
3150- case filtering.StateActive:
3196+ case port.FilterStateActive:
31513197 // Apply filters to existing webviews that were created before filters loaded
31523198 if a.contentCoord != nil && a.deps.FilterManager != nil {
3153- a.contentCoord.ApplyFiltersToAll(ctx, a.deps.FilterManager)
3199+ a.contentCoord.ApplyFiltersToAll(ctx)
31543200 log.Debug().Msg("applied filters to all existing webviews")
31553201 }
31563202 if a.appToaster != nil {
31573203 a.appToaster.Show(ctx, fmt.Sprintf("Ad blocker ready (%s)", status.Version), component.ToastInfo)
31583204 }
3159- case filtering.StateError:
3205+ case port.FilterStateError:
31603206 if a.appToaster != nil {
31613207 a.appToaster.Show(ctx, "Filter load failed: "+status.Message, component.ToastError)
31623208 }
M internal/ui/config_migration.go +6 -5
@@ -4,7 +4,6 @@ import (
44 "context"
55 "fmt"
66
7- "github.com/bnema/dumber/internal/infrastructure/config"
87 "github.com/bnema/dumber/internal/logging"
98 "github.com/bnema/dumber/internal/ui/component"
109 )
@@ -12,10 +11,12 @@ import (
1211 // checkConfigMigration checks if user config needs migration and shows a toast.
1312 func (a *App) checkConfigMigration(ctx context.Context) {
1413 log := logging.FromContext(ctx)
15- cfg := config.Get()
1614
17- migrator := config.NewMigrator()
18- result, err := migrator.CheckMigration()
15+ if a.deps.ConfigMigrator == nil {
16+ return
17+ }
18+
19+ result, err := a.deps.ConfigMigrator.CheckMigration()
1920 if err != nil {
2021 log.Debug().Err(err).Msg("config migration check failed")
2122 return
@@ -31,7 +32,7 @@ func (a *App) checkConfigMigration(ctx context.Context) {
3132 Msg("config migration available")
3233
3334 // Show toast notification if enabled
34- if cfg.Update.NotifyOnNewSettings && a.appToaster != nil {
35+ if a.deps.Config != nil && a.deps.Config.Update.NotifyOnNewSettings && a.appToaster != nil {
3536 msg := fmt.Sprintf("Config has %d new settings. Run 'dumber config migrate'", len(result.MissingKeys))
3637 a.appToaster.Show(ctx, msg, component.ToastInfo)
3738 }
D internal/ui/coordinator/content.go +0 -2316 large diff (2316 lines changed): collapsed
A internal/ui/coordinator/content/appearance.go +295 -0
@@ -0,0 +1,295 @@
1+package content
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "strings"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/entity"
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+ // Snapshot webViews under lock to avoid data race with concurrent popup create/close.
45+ c.webViewsMu.RLock()
46+ snapshot := make(map[entity.PaneID]port.WebView, len(c.webViews))
47+ for k, v := range c.webViews {
48+ snapshot[k] = v
49+ }
50+ c.webViewsMu.RUnlock()
51+
52+ for paneID, wv := range snapshot {
53+ if wv == nil || wv.IsDestroyed() {
54+ continue
55+ }
56+ if c.shouldDeferAppearance(wv) {
57+ c.queueThemeApply(paneID, prefersDark, cssText)
58+ log.Debug().Str("pane_id", string(paneID)).Msg("deferred WebUI theme apply until load committed")
59+ continue
60+ }
61+ c.applyWebUITheme(ctx, paneID, wv, script, prefersDark)
62+ }
63+}
64+
65+func buildWebUIThemeScript(prefersDark bool, cssText string) (string, error) {
66+ cssJSON, err := json.Marshal(cssText)
67+ if err != nil {
68+ return "", err
69+ }
70+
71+ script := fmt.Sprintf(`(function(){
72+ try {
73+ var cssText = %s;
74+ var prefersDark = %t;
75+
76+ // Keep the global flag in sync
77+ window.__dumber_gtk_prefers_dark = prefersDark;
78+
79+ // Update dark/light class
80+ if (prefersDark) {
81+ document.documentElement.classList.add('dark');
82+ document.documentElement.classList.remove('light');
83+ } else {
84+ document.documentElement.classList.add('light');
85+ document.documentElement.classList.remove('dark');
86+ }
87+
88+ // Update or insert theme style
89+ var style = document.querySelector('style[data-dumber-theme-vars]');
90+ if (!style) {
91+ style = document.createElement('style');
92+ style.setAttribute('data-dumber-theme-vars', '');
93+ (document.head || document.documentElement).appendChild(style);
94+ }
95+ style.textContent = cssText;
96+
97+ // Notify any running WebUI that theme changed
98+ try {
99+ window.dispatchEvent(new CustomEvent('dumber:theme-changed', {
100+ detail: { prefersDark: prefersDark }
101+ }));
102+ } catch (e) {
103+ // ignore
104+ }
105+
106+ // Keep color-scheme consistent
107+ var meta = document.querySelector('meta[name="color-scheme"]');
108+ if (!meta) {
109+ meta = document.createElement('meta');
110+ meta.name = 'color-scheme';
111+ (document.head || document.documentElement).appendChild(meta);
112+ }
113+ meta.content = prefersDark ? 'dark light' : 'light dark';
114+ } catch (e) {
115+ console.error('[dumber] failed to apply theme', e);
116+ }
117+})();`, string(cssJSON), prefersDark)
118+
119+ return script, nil
120+}
121+
122+func (c *Coordinator) applyWebUITheme(
123+ ctx context.Context,
124+ paneID entity.PaneID,
125+ wv port.WebView,
126+ script string,
127+ prefersDark bool,
128+) {
129+ if wv == nil || wv.IsDestroyed() {
130+ return
131+ }
132+ uri := wv.URI()
133+ if !strings.HasPrefix(uri, "dumb://") {
134+ return
135+ }
136+ wv.RunJavaScript(ctx, script)
137+ logging.FromContext(ctx).
138+ Debug().
139+ Str("pane_id", string(paneID)).
140+ Str("uri", uri).
141+ Bool("prefers_dark", prefersDark).
142+ Msg("applied WebUI theme")
143+}
144+
145+func (c *Coordinator) queueThemeApply(paneID entity.PaneID, prefersDark bool, cssText string) {
146+ c.appearanceMu.Lock()
147+ if c.pendingThemePanes == nil {
148+ c.pendingThemePanes = make(map[entity.PaneID]bool)
149+ }
150+ c.pendingThemePanes[paneID] = true
151+ c.pendingThemeUpdate = pendingThemeUpdate{
152+ prefersDark: prefersDark,
153+ cssText: cssText,
154+ }
155+ c.hasPendingThemeUpdate = true
156+ c.appearanceMu.Unlock()
157+}
158+
159+func (c *Coordinator) setCurrentTheme(prefersDark bool, cssText string) {
160+ c.appearanceMu.Lock()
161+ c.currentTheme = pendingThemeUpdate{
162+ prefersDark: prefersDark,
163+ cssText: cssText,
164+ }
165+ c.hasCurrentTheme = true
166+ c.appearanceMu.Unlock()
167+}
168+
169+func (c *Coordinator) getCurrentTheme() (pendingThemeUpdate, bool) {
170+ c.appearanceMu.Lock()
171+ defer c.appearanceMu.Unlock()
172+
173+ if !c.hasCurrentTheme {
174+ return pendingThemeUpdate{}, false
175+ }
176+ return c.currentTheme, true
177+}
178+
179+func (c *Coordinator) takePendingThemeApply(paneID entity.PaneID) (pendingThemeUpdate, bool) {
180+ c.appearanceMu.Lock()
181+ defer c.appearanceMu.Unlock()
182+
183+ if !c.hasPendingThemeUpdate || c.pendingThemePanes == nil || !c.pendingThemePanes[paneID] {
184+ return pendingThemeUpdate{}, false
185+ }
186+ delete(c.pendingThemePanes, paneID)
187+ update := c.pendingThemeUpdate
188+ if len(c.pendingThemePanes) == 0 {
189+ c.hasPendingThemeUpdate = false
190+ }
191+ return update, true
192+}
193+
194+func (c *Coordinator) applyPendingThemeUpdate(ctx context.Context, paneID entity.PaneID, wv port.WebView) bool {
195+ update, ok := c.takePendingThemeApply(paneID)
196+ if !ok {
197+ return false
198+ }
199+
200+ script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
201+ if err != nil {
202+ logging.FromContext(ctx).Warn().Err(err).Msg("failed to build deferred WebUI theme script")
203+ return false
204+ }
205+ c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
206+ return true
207+}
208+
209+func (c *Coordinator) applyCurrentTheme(ctx context.Context, paneID entity.PaneID, wv port.WebView) bool {
210+ update, ok := c.getCurrentTheme()
211+ if !ok || update.cssText == "" {
212+ return false
213+ }
214+
215+ script, err := buildWebUIThemeScript(update.prefersDark, update.cssText)
216+ if err != nil {
217+ logging.FromContext(ctx).Warn().Err(err).Msg("failed to build current WebUI theme script")
218+ return false
219+ }
220+ c.applyWebUITheme(ctx, paneID, wv, script, update.prefersDark)
221+ return true
222+}
223+
224+func (c *Coordinator) queueScriptRefresh(paneID entity.PaneID) {
225+ c.appearanceMu.Lock()
226+ if c.pendingScriptRefresh == nil {
227+ c.pendingScriptRefresh = make(map[entity.PaneID]bool)
228+ }
229+ c.pendingScriptRefresh[paneID] = true
230+ c.appearanceMu.Unlock()
231+}
232+
233+func (c *Coordinator) takePendingScriptRefresh(paneID entity.PaneID) bool {
234+ c.appearanceMu.Lock()
235+ defer c.appearanceMu.Unlock()
236+
237+ if c.pendingScriptRefresh == nil || !c.pendingScriptRefresh[paneID] {
238+ return false
239+ }
240+ delete(c.pendingScriptRefresh, paneID)
241+ return true
242+}
243+
244+func (c *Coordinator) refreshPendingScripts(ctx context.Context, paneID entity.PaneID, wv port.WebView) {
245+ if wv == nil || wv.IsDestroyed() || c.shouldDeferAppearance(wv) {
246+ return
247+ }
248+ if !c.takePendingScriptRefresh(paneID) {
249+ return
250+ }
251+ if c.injector == nil {
252+ return
253+ }
254+ c.refreshInjectedScripts(ctx, c.injector, paneID, wv)
255+}
256+
257+func (c *Coordinator) shouldDeferAppearance(wv port.WebView) bool {
258+ if wv == nil || wv.IsDestroyed() {
259+ return false
260+ }
261+ if wv.IsLoading() {
262+ return true
263+ }
264+ return wv.EstimatedProgress() < 1.0
265+}
266+
267+func (c *Coordinator) refreshInjectedScripts(
268+ ctx context.Context,
269+ injector port.ContentInjector,
270+ paneID entity.PaneID,
271+ wv port.WebView,
272+) {
273+ if injector == nil || wv == nil || wv.IsDestroyed() {
274+ return
275+ }
276+ if err := injector.RefreshScripts(ctx, wv); err != nil {
277+ logging.FromContext(ctx).Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to refresh injected scripts")
278+ return
279+ }
280+ logging.FromContext(ctx).Debug().Str("pane_id", string(paneID)).Msg("refreshed injected scripts for webview")
281+}
282+
283+func (c *Coordinator) clearPendingAppearance(paneID entity.PaneID) {
284+ c.appearanceMu.Lock()
285+ if c.pendingScriptRefresh != nil {
286+ delete(c.pendingScriptRefresh, paneID)
287+ }
288+ if c.pendingThemePanes != nil {
289+ delete(c.pendingThemePanes, paneID)
290+ if len(c.pendingThemePanes) == 0 {
291+ c.hasPendingThemeUpdate = false
292+ }
293+ }
294+ c.appearanceMu.Unlock()
295+}
A internal/ui/coordinator/content/bulk.go +83 -0
@@ -0,0 +1,83 @@
1+package content
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/port"
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/logging"
9+)
10+
11+// ApplyFiltersToAll applies content filters to all active webviews.
12+// Called when filters become available after webviews were already created.
13+func (c *Coordinator) ApplyFiltersToAll(ctx context.Context) {
14+ log := logging.FromContext(ctx)
15+ if c.filterApplier == nil {
16+ log.Debug().Msg("ApplyFiltersToAll: no filter applier configured")
17+ return
18+ }
19+
20+ c.webViewsMu.RLock()
21+ snapshot := make([]port.WebView, 0, len(c.webViews))
22+ for _, wv := range c.webViews {
23+ snapshot = append(snapshot, wv)
24+ }
25+ c.webViewsMu.RUnlock()
26+
27+ c.filterApplier.ApplyToAll(ctx, snapshot)
28+ log.Debug().Int("count", len(snapshot)).Msg("applied filters to all webviews")
29+}
30+
31+// ApplySettingsToAll reapplies engine settings to all active WebViews.
32+func (c *Coordinator) ApplySettingsToAll(ctx context.Context) {
33+ log := logging.FromContext(ctx)
34+ if c.settingsApplier == nil {
35+ log.Debug().Msg("ApplySettingsToAll: no settings applier configured")
36+ return
37+ }
38+
39+ c.webViewsMu.RLock()
40+ snapshot := make([]port.WebView, 0, len(c.webViews))
41+ for _, wv := range c.webViews {
42+ snapshot = append(snapshot, wv)
43+ }
44+ c.webViewsMu.RUnlock()
45+
46+ c.settingsApplier.ApplyToAll(ctx, snapshot)
47+ log.Debug().Int("count", len(snapshot)).Msg("applied settings to all webviews")
48+}
49+
50+// RefreshInjectedScriptsToAll clears and re-injects user scripts into all active WebViews.
51+//
52+// WebKit user scripts are snapshotted when added to a WebKitUserContentManager, so when
53+// appearance settings change at runtime (dark mode, palettes, UI scale), we must refresh
54+// the scripts so future navigations pick up the latest values.
55+// Script refresh is deferred for any WebView that is currently loading to avoid
56+// removing scripts mid-navigation.
57+func (c *Coordinator) RefreshInjectedScriptsToAll(ctx context.Context) {
58+ log := logging.FromContext(ctx)
59+ if c.injector == nil {
60+ return
61+ }
62+
63+ // Snapshot webViews under lock to avoid data race with concurrent popup create/close.
64+ c.webViewsMu.RLock()
65+ snapshot := make(map[entity.PaneID]port.WebView, len(c.webViews))
66+ for k, v := range c.webViews {
67+ snapshot[k] = v
68+ }
69+ c.webViewsMu.RUnlock()
70+
71+ for paneID, wv := range snapshot {
72+ if wv == nil || wv.IsDestroyed() {
73+ continue
74+ }
75+ if c.shouldDeferAppearance(wv) {
76+ c.queueScriptRefresh(paneID)
77+ log.Debug().Str("pane_id", string(paneID)).Msg("deferred script refresh until load finished")
78+ continue
79+ }
80+
81+ c.refreshInjectedScripts(ctx, c.injector, paneID, wv)
82+ }
83+}
A internal/ui/coordinator/content/callbacks.go +280 -0
@@ -0,0 +1,280 @@
1+package content
2+
3+import (
4+ "context"
5+ "net/url"
6+ "strings"
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/logging"
12+ "github.com/jwijenbergh/puregotk/v4/gdk"
13+)
14+
15+// internalSchemePath is the path used in dumb:// URIs (replaces webkit.HomePath).
16+const internalSchemePath = "home"
17+
18+func shouldRenderCrashPage(reason port.WebProcessTerminationReason) bool {
19+ switch reason {
20+ case port.WebProcessTerminationCrashed, port.WebProcessTerminationExceededMemory:
21+ return true
22+ case port.WebProcessTerminationByAPI:
23+ return false
24+ default:
25+ return true
26+ }
27+}
28+
29+func extractOriginalURIFromCrashPage(uri string) string {
30+ if uri == "" {
31+ return ""
32+ }
33+
34+ parsed, err := url.Parse(uri)
35+ if err != nil {
36+ return uri
37+ }
38+
39+ if parsed.Scheme != "dumb" || parsed.Host != internalSchemePath {
40+ return uri
41+ }
42+
43+ if strings.Trim(parsed.Path, "/") != "crash" {
44+ return uri
45+ }
46+
47+ original := strings.TrimSpace(parsed.Query().Get("url"))
48+ if original == "" {
49+ return ""
50+ }
51+ return original
52+}
53+
54+func buildCrashPageURI(originalURI string) string {
55+ if strings.TrimSpace(originalURI) == "" {
56+ return crashPageURI
57+ }
58+ query := url.Values{}
59+ query.Set("url", originalURI)
60+ return crashPageURI + "?" + query.Encode()
61+}
62+
63+// setupWebViewCallbacks configures standard callbacks and popup handling.
64+func (c *Coordinator) setupWebViewCallbacks(ctx context.Context, paneID entity.PaneID, wv port.WebView) {
65+ log := logging.FromContext(ctx)
66+
67+ // Build port callbacks for standard events
68+ callbacks := &port.WebViewCallbacks{
69+ OnTitleChanged: func(title string) {
70+ c.onTitleChanged(ctx, paneID, title)
71+ },
72+ OnLoadChanged: func(event port.LoadEvent) {
73+ switch event {
74+ case port.LoadStarted:
75+ c.onLoadStarted(paneID)
76+ case port.LoadCommitted:
77+ c.onLoadCommitted(ctx, paneID, wv)
78+ case port.LoadFinished:
79+ c.onLoadFinished(ctx, paneID, wv)
80+ }
81+ },
82+ OnProgressChanged: func(progress float64) {
83+ c.onProgressChanged(paneID, progress)
84+ },
85+ OnURIChanged: func(uri string) {
86+ c.handleURIChanged(ctx, paneID, wv, uri)
87+ },
88+ OnLinkHover: func(uri string) {
89+ c.onLinkHover(paneID, uri)
90+ },
91+ OnWebProcessTerminated: func(reason port.WebProcessTerminationReason, reasonLabel string, uri string) {
92+ originalURI := extractOriginalURIFromCrashPage(uri)
93+ if !shouldRenderCrashPage(reason) {
94+ log.Info().
95+ Str("pane_id", string(paneID)).
96+ Str("reason", reasonLabel).
97+ Str("uri", uri).
98+ Msg("web process termination handled without crash page")
99+ return
100+ }
101+
102+ crashURI := buildCrashPageURI(originalURI)
103+ log.Warn().
104+ Str("pane_id", string(paneID)).
105+ Str("reason", reasonLabel).
106+ Str("uri", uri).
107+ Str("crash_uri", crashURI).
108+ Msg("web process terminated, redirecting to crash page")
109+
110+ if err := wv.LoadURI(ctx, crashURI); err != nil {
111+ log.Error().
112+ Err(err).
113+ Str("pane_id", string(paneID)).
114+ Str("reason", reasonLabel).
115+ Str("uri", uri).
116+ Str("crash_uri", crashURI).
117+ Msg("failed to load crash page after web process termination")
118+ }
119+ },
120+ OnPermissionRequest: func(origin string, permTypes []string, allow, deny func()) bool {
121+ return c.handlePermissionRequest(ctx, origin, permTypes, allow, deny)
122+ },
123+ }
124+
125+ // Favicon changes - use Generation() from port.WebView to detect stale callbacks
126+ faviconGen := wv.Generation()
127+ callbacks.OnFaviconChanged = func(favicon port.Texture) {
128+ if wv.Generation() != faviconGen {
129+ return // WebView was reused, ignore stale signal
130+ }
131+ if gdkTexture, ok := favicon.(*gdk.Texture); ok {
132+ c.onFaviconChanged(ctx, paneID, wv, gdkTexture)
133+ }
134+ }
135+
136+ // Middle-click link handler
137+ callbacks.OnLinkMiddleClick = func(uri string) bool {
138+ return c.handleLinkMiddleClick(ctx, paneID, uri)
139+ }
140+
141+ // Fullscreen handlers for idle inhibition
142+ callbacks.OnEnterFullscreen = func() bool {
143+ if c.idleInhibitor != nil {
144+ if err := c.idleInhibitor.Inhibit(ctx, "Fullscreen video playback"); err != nil {
145+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle")
146+ }
147+ }
148+ if c.onFullscreenChanged != nil {
149+ c.onFullscreenChanged(true)
150+ }
151+ return false // Allow fullscreen
152+ }
153+
154+ callbacks.OnLeaveFullscreen = func() bool {
155+ if c.idleInhibitor != nil {
156+ if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
157+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle")
158+ }
159+ }
160+ if c.onFullscreenChanged != nil {
161+ c.onFullscreenChanged(false)
162+ }
163+ return false // Allow leaving fullscreen
164+ }
165+
166+ // Audio playback handling
167+ callbacks.OnAudioStateChanged = func(playing bool) {
168+ if c.idleInhibitor == nil {
169+ return
170+ }
171+ if playing {
172+ if err := c.idleInhibitor.Inhibit(ctx, "Media playback"); err != nil {
173+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to inhibit idle for audio")
174+ }
175+ } else {
176+ if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
177+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle for audio")
178+ }
179+ }
180+ }
181+
182+ // Add popup create handler if popup handling is configured
183+ callbacks.OnCreate = c.buildPopupCreateHandler(ctx, paneID, wv)
184+
185+ wv.SetCallbacks(callbacks)
186+}
187+
188+// handlePermissionRequest processes media permission requests from WebKit.
189+// It delegates to the permission use case which handles auto-allow, stored permissions, and dialogs.
190+func (c *Coordinator) handlePermissionRequest(
191+ ctx context.Context,
192+ origin string,
193+ permTypes []string,
194+ allow, deny func(),
195+) bool {
196+ log := logging.FromContext(ctx)
197+
198+ // Convert string permission types to entity types
199+ entityTypes := make([]entity.PermissionType, 0, len(permTypes))
200+ for _, pt := range permTypes {
201+ switch pt {
202+ case "microphone":
203+ entityTypes = append(entityTypes, entity.PermissionTypeMicrophone)
204+ case "camera":
205+ entityTypes = append(entityTypes, entity.PermissionTypeCamera)
206+ case "display":
207+ entityTypes = append(entityTypes, entity.PermissionTypeDisplay)
208+ case "device_info":
209+ entityTypes = append(entityTypes, entity.PermissionTypeDeviceInfo)
210+ case "website_data_access":
211+ entityTypes = append(entityTypes, entity.PermissionTypeWebsiteDataAccess)
212+ default:
213+ log.Warn().Str("type", pt).Msg("unknown permission type, skipping")
214+ }
215+ }
216+
217+ if len(entityTypes) == 0 {
218+ log.Warn().Str("origin", origin).Msg("permission request with no valid types, denying")
219+ deny()
220+ return true
221+ }
222+
223+ trackedTypes := filterWebRTCPermissionTypes(entityTypes)
224+ notifyActivity := func(state PermissionActivityState) {
225+ if c.onPermissionActivity == nil || len(trackedTypes) == 0 {
226+ return
227+ }
228+ c.onPermissionActivity(origin, trackedTypes, state)
229+ }
230+
231+ notifyActivity(PermissionActivityRequesting)
232+
233+ wrappedAllow := func() {
234+ notifyActivity(PermissionActivityAllowed)
235+ allow()
236+ }
237+ wrappedDeny := func() {
238+ notifyActivity(PermissionActivityBlocked)
239+ deny()
240+ }
241+
242+ // Check if permission use case is available
243+ if c.permissionUC == nil {
244+ log.Warn().Str("origin", origin).Msg("no permission use case available, auto-allowing low-risk permissions")
245+ // Auto-allow display and device_info, deny others
246+ allAutoAllow := true
247+ for _, pt := range entityTypes {
248+ if !entity.IsAutoAllow(pt) {
249+ allAutoAllow = false
250+ break
251+ }
252+ }
253+ if allAutoAllow {
254+ wrappedAllow()
255+ } else {
256+ wrappedDeny()
257+ }
258+ return true
259+ }
260+
261+ // Delegate to use case
262+ callback := usecase.PermissionCallback{
263+ Allow: wrappedAllow,
264+ Deny: wrappedDeny,
265+ }
266+
267+ c.permissionUC.HandlePermissionRequest(ctx, origin, entityTypes, callback)
268+ return true
269+}
270+
271+func filterWebRTCPermissionTypes(types []entity.PermissionType) []entity.PermissionType {
272+ filtered := make([]entity.PermissionType, 0, len(types))
273+ for _, permType := range types {
274+ switch permType {
275+ case entity.PermissionTypeMicrophone, entity.PermissionTypeCamera, entity.PermissionTypeDisplay:
276+ filtered = append(filtered, permType)
277+ }
278+ }
279+ return filtered
280+}
R internal/ui/coordinator/content_active_override_test.go → internal/ui/coordinator/content/content_active_override_test.go +16 -15 renamed, 63% similar
@@ -1,4 +1,4 @@
1-package coordinator
1+package content
22
33 import (
44 "context"
@@ -6,19 +6,20 @@ import (
66
77 "github.com/stretchr/testify/assert"
88
9+ "github.com/bnema/dumber/internal/application/port"
10+ "github.com/bnema/dumber/internal/application/port/mocks"
911 "github.com/bnema/dumber/internal/domain/entity"
10- "github.com/bnema/dumber/internal/infrastructure/webkit"
1112 "github.com/bnema/dumber/internal/ui/component"
1213 )
1314
14-func TestContentCoordinator_ActiveWebView_UsesOverride(t *testing.T) {
15+func TestCoordinator_ActiveWebView_UsesOverride(t *testing.T) {
1516 t.Parallel()
1617
17- mainWV := &webkit.WebView{}
18- floatingWV := &webkit.WebView{}
18+ mainWV := mocks.NewMockWebView(t)
19+ floatingWV := mocks.NewMockWebView(t)
1920
20- c := &ContentCoordinator{
21- webViews: map[entity.PaneID]*webkit.WebView{
21+ c := &Coordinator{
22+ webViews: map[entity.PaneID]port.WebView{
2223 "main-pane": mainWV,
2324 "floating-pane": floatingWV,
2425 },
@@ -30,13 +31,13 @@ func TestContentCoordinator_ActiveWebView_UsesOverride(t *testing.T) {
3031 assert.Equal(t, entity.PaneID("floating-pane"), c.ActivePaneID(context.Background()))
3132 }
3233
33-func TestContentCoordinator_ActiveWebView_ClearOverrideFallsBack(t *testing.T) {
34+func TestCoordinator_ActiveWebView_ClearOverrideFallsBack(t *testing.T) {
3435 t.Parallel()
3536
36- mainWV := &webkit.WebView{}
37+ mainWV := mocks.NewMockWebView(t)
3738
38- c := &ContentCoordinator{
39- webViews: map[entity.PaneID]*webkit.WebView{
39+ c := &Coordinator{
40+ webViews: map[entity.PaneID]port.WebView{
4041 "main-pane": mainWV,
4142 },
4243 }
@@ -48,16 +49,16 @@ func TestContentCoordinator_ActiveWebView_ClearOverrideFallsBack(t *testing.T) {
4849 assert.Equal(t, entity.PaneID(""), c.ActivePaneID(context.Background()))
4950 }
5051
51-func TestContentCoordinator_ActiveWebView_ClearOverrideFallsBackToWorkspace(t *testing.T) {
52+func TestCoordinator_ActiveWebView_ClearOverrideFallsBackToWorkspace(t *testing.T) {
5253 t.Parallel()
5354
5455 ctx := context.Background()
55- mainWV := &webkit.WebView{}
56+ mainWV := mocks.NewMockWebView(t)
5657 mainPane := entity.NewPane(entity.PaneID("main-pane"))
5758 ws := entity.NewWorkspace(entity.WorkspaceID("ws-1"), mainPane)
5859
59- c := &ContentCoordinator{
60- webViews: map[entity.PaneID]*webkit.WebView{
60+ c := &Coordinator{
61+ webViews: map[entity.PaneID]port.WebView{
6162 mainPane.ID: mainWV,
6263 },
6364 getActiveWS: func() (*entity.Workspace, *component.WorkspaceView) {
R internal/ui/coordinator/content_reveal_test.go → internal/ui/coordinator/content/content_reveal_test.go +14 -14 renamed, 88% similar
@@ -1,4 +1,4 @@
1-package coordinator
1+package content
22
33 import (
44 "context"
@@ -7,8 +7,8 @@ import (
77
88 "github.com/stretchr/testify/assert"
99
10+ "github.com/bnema/dumber/internal/application/port"
1011 "github.com/bnema/dumber/internal/domain/entity"
11- "github.com/bnema/dumber/internal/infrastructure/webkit"
1212 )
1313
1414 // TestPendingReveal tests the pending reveal state management.
@@ -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,9 +47,9 @@ 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),
52- webViews: make(map[entity.PaneID]*webkit.WebView),
52+ webViews: make(map[entity.PaneID]port.WebView),
5353 onWebViewShown: func(paneID entity.PaneID) {
5454 callbackCalled = true
5555 },
@@ -63,9 +63,9 @@ 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),
68- webViews: make(map[entity.PaneID]*webkit.WebView),
68+ webViews: make(map[entity.PaneID]port.WebView),
6969 }
7070 paneID := entity.PaneID("pane-1")
7171
@@ -82,9 +82,9 @@ 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),
87- webViews: make(map[entity.PaneID]*webkit.WebView),
87+ webViews: make(map[entity.PaneID]port.WebView),
8888 onWebViewShown: func(paneID entity.PaneID) {
8989 revealCount++
9090 },
@@ -109,9 +109,9 @@ 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),
114- webViews: make(map[entity.PaneID]*webkit.WebView),
114+ webViews: make(map[entity.PaneID]port.WebView),
115115 }
116116
117117 var wg sync.WaitGroup
@@ -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
R internal/ui/coordinator/content_termination_test.go → internal/ui/coordinator/content/content_termination_test.go +6 -6 renamed, 67% similar
@@ -1,17 +1,17 @@
1-package coordinator
1+package content
22
33 import (
44 "testing"
55
6- webkitlib "github.com/bnema/puregotk-webkit/webkit"
6+ "github.com/bnema/dumber/internal/application/port"
77 "github.com/stretchr/testify/assert"
88 )
99
1010 func TestShouldRenderCrashPage(t *testing.T) {
11- assert.True(t, shouldRenderCrashPage(webkitlib.WebProcessCrashedValue))
12- assert.True(t, shouldRenderCrashPage(webkitlib.WebProcessExceededMemoryLimitValue))
13- assert.False(t, shouldRenderCrashPage(webkitlib.WebProcessTerminatedByApiValue))
14- assert.True(t, shouldRenderCrashPage(webkitlib.WebProcessTerminationReason(99)))
11+ assert.True(t, shouldRenderCrashPage(port.WebProcessTerminationCrashed))
12+ assert.True(t, shouldRenderCrashPage(port.WebProcessTerminationExceededMemory))
13+ assert.False(t, shouldRenderCrashPage(port.WebProcessTerminationByAPI))
14+ assert.True(t, shouldRenderCrashPage(port.WebProcessTerminationReason(99)))
1515 }
1616
1717 func TestExtractOriginalURIFromCrashPage(t *testing.T) {
A internal/ui/coordinator/content/coordinator.go +316 -0
@@ -0,0 +1,316 @@
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+
12+ "github.com/bnema/dumber/internal/logging"
13+ "github.com/bnema/dumber/internal/ui/adapter"
14+ "github.com/bnema/dumber/internal/ui/component"
15+ "github.com/bnema/dumber/internal/ui/input"
16+ "github.com/bnema/dumber/internal/ui/layout"
17+)
18+
19+// Coordinator manages WebView lifecycle, title tracking, and content attachment.
20+type Coordinator struct {
21+ pool port.WebViewPool
22+ widgetFactory layout.WidgetFactory
23+ faviconAdapter *adapter.FaviconAdapter
24+ zoomUC *usecase.ManageZoomUseCase
25+ permissionUC *usecase.HandlePermissionUseCase
26+ injector port.ContentInjector
27+ settingsApplier port.SettingsApplier // optional: nil if engine doesn't support
28+ filterApplier port.FilterApplier // optional: nil if engine doesn't support
29+
30+ webViews map[entity.PaneID]port.WebView
31+ webViewsMu sync.RWMutex
32+
33+ activePaneOverride entity.PaneID
34+ activePaneOverrideMu sync.RWMutex
35+
36+ paneTitles map[entity.PaneID]string
37+ titleMu sync.RWMutex
38+
39+ // Track original navigation URLs to handle cross-domain redirects
40+ // e.g., google.fr → google.com: cache favicon under both domains
41+ navOrigins map[entity.PaneID]string
42+ navOriginMu sync.RWMutex
43+
44+ // Callback to get active workspace state (avoids circular dependency)
45+ getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
46+
47+ // Callback when title changes (for history persistence)
48+ onTitleUpdated func(ctx context.Context, paneID entity.PaneID, url, title string)
49+
50+ // Callback when page is committed (for history recording)
51+ onHistoryRecord func(ctx context.Context, paneID entity.PaneID, url string)
52+
53+ // Callback when pane URI changes (for session snapshots)
54+ onPaneURIUpdated func(paneID entity.PaneID, url string)
55+
56+ // Callback when active pane title changes (for window title updates)
57+ onWindowTitleChanged func(title string)
58+
59+ // Callback when media permission activity changes (requesting/allowed/blocked).
60+ onPermissionActivity func(origin string, permTypes []entity.PermissionType, state PermissionActivityState)
61+
62+ // Callback when the active pane commits a navigation (new page loading).
63+ onActiveNavigationCommitted func(uri string)
64+
65+ // Callback when the WebView becomes visible (first real commit)
66+ onWebViewShown func(paneID entity.PaneID)
67+
68+ revealMu sync.Mutex
69+ pendingReveal map[entity.PaneID]bool
70+
71+ appearanceMu sync.Mutex
72+ pendingScriptRefresh map[entity.PaneID]bool
73+ pendingThemePanes map[entity.PaneID]bool
74+ pendingThemeUpdate pendingThemeUpdate
75+ hasPendingThemeUpdate bool
76+ currentTheme pendingThemeUpdate
77+ hasCurrentTheme bool
78+
79+ // Gesture action handler for mouse button navigation
80+ gestureActionHandler input.ActionHandler
81+
82+ // Popup handling
83+ factory port.WebViewFactory
84+ popupConfig *entity.PopupBehaviorConfig
85+ pendingPopups map[port.WebViewID]*PendingPopup
86+ popupOAuth map[port.WebViewID]*popupOAuthState
87+ popupRefresh map[entity.PaneID]*time.Timer
88+ popupMu sync.RWMutex
89+
90+ // Callback to insert popup into workspace (avoids circular dependency)
91+ onInsertPopup func(ctx context.Context, input InsertPopupInput) error
92+
93+ // Callback to close a pane when popup closes
94+ onClosePane func(ctx context.Context, paneID entity.PaneID) error
95+
96+ // ID generator for popup panes
97+ generateID func() string
98+
99+ // Idle inhibitor for fullscreen video playback
100+ idleInhibitor port.IdleInhibitor
101+
102+ // Callback when fullscreen state changes (for hiding/showing tab bar)
103+ onFullscreenChanged func(entering bool)
104+
105+ // Callback when WebView gains focus (for accent picker text input targeting)
106+ onWebViewFocused func(paneID entity.PaneID, wv port.WebView)
107+
108+ // Callback for first load_started event (triggers deferred initialization)
109+ onFirstLoadStarted func()
110+ loadStartedOnce sync.Once
111+
112+ // Callback to open a URL with the system's default handler (e.g. xdg-open).
113+ // Used for external URL schemes like vscode://, spotify://, etc.
114+ onLaunchExternalURL func(uri string)
115+}
116+
117+type pendingThemeUpdate struct {
118+ prefersDark bool
119+ cssText string
120+}
121+
122+type popupOAuthState struct {
123+ ParentPaneID entity.PaneID
124+ CallbackURI string
125+ Success bool
126+ Error bool
127+ Seen bool
128+}
129+
130+// PermissionActivityState represents the visible state for media permission activity.
131+type PermissionActivityState string
132+
133+const (
134+ PermissionActivityRequesting PermissionActivityState = "requesting"
135+ PermissionActivityAllowed PermissionActivityState = "allowed"
136+ PermissionActivityBlocked PermissionActivityState = "blocked"
137+)
138+
139+// NewCoordinator creates a new Coordinator.
140+func NewCoordinator(
141+ ctx context.Context,
142+ pool port.WebViewPool,
143+ injector port.ContentInjector,
144+ widgetFactory layout.WidgetFactory,
145+ faviconAdapter *adapter.FaviconAdapter,
146+ getActiveWS func() (*entity.Workspace, *component.WorkspaceView),
147+ zoomUC *usecase.ManageZoomUseCase,
148+ permissionUC *usecase.HandlePermissionUseCase,
149+) *Coordinator {
150+ log := logging.FromContext(ctx)
151+ log.Debug().Msg("creating content coordinator")
152+
153+ return &Coordinator{
154+ pool: pool,
155+ injector: injector,
156+ widgetFactory: widgetFactory,
157+ faviconAdapter: faviconAdapter,
158+ zoomUC: zoomUC,
159+ permissionUC: permissionUC,
160+ webViews: make(map[entity.PaneID]port.WebView),
161+ paneTitles: make(map[entity.PaneID]string),
162+ navOrigins: make(map[entity.PaneID]string),
163+ pendingReveal: make(map[entity.PaneID]bool),
164+ pendingScriptRefresh: make(map[entity.PaneID]bool),
165+ pendingThemePanes: make(map[entity.PaneID]bool),
166+ getActiveWS: getActiveWS,
167+ pendingPopups: make(map[port.WebViewID]*PendingPopup),
168+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
169+ popupRefresh: make(map[entity.PaneID]*time.Timer),
170+ }
171+}
172+
173+// SetOnTitleUpdated sets the callback for title changes (for history persistence).
174+func (c *Coordinator) SetOnTitleUpdated(fn func(ctx context.Context, paneID entity.PaneID, url, title string)) {
175+ c.onTitleUpdated = fn
176+}
177+
178+// SetOnHistoryRecord sets the callback for recording history on page commit.
179+func (c *Coordinator) SetOnHistoryRecord(fn func(ctx context.Context, paneID entity.PaneID, url string)) {
180+ c.onHistoryRecord = fn
181+}
182+
183+// SetOnPermissionActivity sets a callback for WebRTC permission activity changes.
184+func (c *Coordinator) SetOnPermissionActivity(
185+ fn func(origin string, permTypes []entity.PermissionType, state PermissionActivityState),
186+) {
187+ c.onPermissionActivity = fn
188+}
189+
190+// SetOnActiveNavigationCommitted sets a callback fired when the active pane commits a navigation.
191+func (c *Coordinator) SetOnActiveNavigationCommitted(fn func(uri string)) {
192+ c.onActiveNavigationCommitted = fn
193+}
194+
195+// SetOnPaneURIUpdated sets the callback for pane URI changes (for session snapshots).
196+func (c *Coordinator) SetOnPaneURIUpdated(fn func(paneID entity.PaneID, url string)) {
197+ c.onPaneURIUpdated = fn
198+}
199+
200+// SetOnWindowTitleChanged sets the callback for active pane title changes (for window title updates).
201+func (c *Coordinator) SetOnWindowTitleChanged(fn func(title string)) {
202+ c.onWindowTitleChanged = fn
203+}
204+
205+// SetOnWebViewShown sets a callback that fires when a pane's WebView is shown.
206+func (c *Coordinator) SetOnWebViewShown(fn func(paneID entity.PaneID)) {
207+ c.onWebViewShown = fn
208+}
209+
210+// SetGestureActionHandler sets the callback for mouse button navigation gestures.
211+func (c *Coordinator) SetGestureActionHandler(handler input.ActionHandler) {
212+ c.gestureActionHandler = handler
213+}
214+
215+// SetIdleInhibitor sets the idle inhibitor for fullscreen video playback.
216+func (c *Coordinator) SetIdleInhibitor(inhibitor port.IdleInhibitor) {
217+ c.idleInhibitor = inhibitor
218+}
219+
220+// SetOnFullscreenChanged sets the callback for fullscreen state changes.
221+func (c *Coordinator) SetOnFullscreenChanged(fn func(entering bool)) {
222+ c.onFullscreenChanged = fn
223+}
224+
225+// SetOnWebViewFocused sets the callback for when a WebView gains focus.
226+func (c *Coordinator) SetOnWebViewFocused(fn func(paneID entity.PaneID, wv port.WebView)) {
227+ c.onWebViewFocused = fn
228+}
229+
230+// SetOnFirstLoadStarted sets the callback for when the first navigation starts.
231+// This is used to trigger deferred initialization after the initial load_uri()
232+// has been processed by the GTK main loop.
233+func (c *Coordinator) SetOnFirstLoadStarted(fn func()) {
234+ c.onFirstLoadStarted = fn
235+}
236+
237+// SetOnLaunchExternalURL sets the callback invoked when an external URL scheme
238+// (e.g. vscode://, spotify://) is detected and must be handed off to the system.
239+func (c *Coordinator) SetOnLaunchExternalURL(fn func(uri string)) {
240+ c.onLaunchExternalURL = fn
241+}
242+
243+// SetSettingsApplier sets the engine settings applier for config hot-reload.
244+func (c *Coordinator) SetSettingsApplier(sa port.SettingsApplier) {
245+ c.settingsApplier = sa
246+}
247+
248+// SetFilterApplier sets the content filter applier for late-binding filters.
249+func (c *Coordinator) SetFilterApplier(fa port.FilterApplier) {
250+ c.filterApplier = fa
251+}
252+
253+// ActivePaneID returns the currently active pane ID used by navigation.
254+func (c *Coordinator) ActivePaneID(ctx context.Context) entity.PaneID {
255+ if paneID, ok := c.activePaneOverrideID(); ok {
256+ return paneID
257+ }
258+ if c.getActiveWS == nil {
259+ return ""
260+ }
261+
262+ ws, _ := c.getActiveWS()
263+ if ws == nil {
264+ return ""
265+ }
266+ return ws.ActivePaneID
267+}
268+
269+// SetActivePaneOverride forces ActiveWebView/ActivePaneID to use a specific pane.
270+func (c *Coordinator) SetActivePaneOverride(paneID entity.PaneID) {
271+ c.activePaneOverrideMu.Lock()
272+ c.activePaneOverride = paneID
273+ c.activePaneOverrideMu.Unlock()
274+}
275+
276+// ClearActivePaneOverride removes any forced active pane override.
277+func (c *Coordinator) ClearActivePaneOverride() {
278+ c.activePaneOverrideMu.Lock()
279+ c.activePaneOverride = ""
280+ c.activePaneOverrideMu.Unlock()
281+}
282+
283+func (c *Coordinator) activePaneOverrideID() (entity.PaneID, bool) {
284+ c.activePaneOverrideMu.RLock()
285+ defer c.activePaneOverrideMu.RUnlock()
286+ if c.activePaneOverride == "" {
287+ return "", false
288+ }
289+ return c.activePaneOverride, true
290+}
291+
292+func (c *Coordinator) webViewCount() int {
293+ c.webViewsMu.RLock()
294+ defer c.webViewsMu.RUnlock()
295+ return len(c.webViews)
296+}
297+
298+func (c *Coordinator) getWebViewLocked(paneID entity.PaneID) port.WebView {
299+ c.webViewsMu.RLock()
300+ defer c.webViewsMu.RUnlock()
301+ return c.webViews[paneID]
302+}
303+
304+func (c *Coordinator) setWebViewLocked(paneID entity.PaneID, wv port.WebView) {
305+ c.webViewsMu.Lock()
306+ c.webViews[paneID] = wv
307+ c.webViewsMu.Unlock()
308+}
309+
310+func (c *Coordinator) deleteWebViewLocked(paneID entity.PaneID) port.WebView {
311+ c.webViewsMu.Lock()
312+ defer c.webViewsMu.Unlock()
313+ wv := c.webViews[paneID]
314+ delete(c.webViews, paneID)
315+ return wv
316+}
A internal/ui/coordinator/content/lifecycle.go +217 -0
@@ -0,0 +1,217 @@
1+package content
2+
3+import (
4+ "context"
5+ "fmt"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/entity"
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+ "github.com/jwijenbergh/puregotk/v4/gtk"
14+)
15+
16+// EnsureWebView acquires or reuses a WebView for the given pane.
17+func (c *Coordinator) EnsureWebView(ctx context.Context, paneID entity.PaneID) (port.WebView, error) {
18+ log := logging.FromContext(ctx)
19+
20+ if wv := c.getWebViewLocked(paneID); wv != nil && !wv.IsDestroyed() {
21+ return wv, nil
22+ }
23+
24+ if c.pool == nil {
25+ return nil, fmt.Errorf("webview pool not configured")
26+ }
27+
28+ // Mark tab_created on first webview (first tab)
29+ if c.webViewCount() == 0 {
30+ logging.Trace().Mark("tab_created")
31+ }
32+
33+ wv, err := c.pool.Acquire(ctx)
34+ if err != nil {
35+ return nil, err
36+ }
37+ logging.Trace().Mark("webview_acquired")
38+
39+ c.setWebViewLocked(paneID, wv)
40+ c.setupWebViewCallbacks(ctx, paneID, wv)
41+
42+ log.Debug().Str("pane_id", string(paneID)).Msg("webview acquired for pane")
43+ return wv, nil
44+}
45+
46+// ReleaseWebView returns the WebView for a pane to the pool.
47+func (c *Coordinator) ReleaseWebView(ctx context.Context, paneID entity.PaneID) {
48+ log := logging.FromContext(ctx)
49+
50+ wv := c.deleteWebViewLocked(paneID)
51+ if wv == nil {
52+ return
53+ }
54+ c.clearPendingAppearance(paneID)
55+
56+ // CRITICAL: If this webview was inhibiting idle (fullscreen or audio playing),
57+ // we must release the inhibition before destroying the webview.
58+ // Otherwise the D-Bus inhibit request stays active forever.
59+ if c.idleInhibitor != nil {
60+ if wv.IsFullscreen() {
61+ log.Debug().Str("pane_id", string(paneID)).Msg("releasing idle inhibition (was fullscreen)")
62+ if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
63+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle on release (fullscreen)")
64+ }
65+ }
66+ if wv.IsPlayingAudio() {
67+ log.Debug().Str("pane_id", string(paneID)).Msg("releasing idle inhibition (was playing audio)")
68+ if err := c.idleInhibitor.Uninhibit(ctx); err != nil {
69+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to uninhibit idle on release (audio)")
70+ }
71+ }
72+ }
73+
74+ // Clean up title tracking
75+ c.titleMu.Lock()
76+ delete(c.paneTitles, paneID)
77+ c.titleMu.Unlock()
78+
79+ // Clean up navigation origin tracking
80+ c.navOriginMu.Lock()
81+ delete(c.navOrigins, paneID)
82+ c.navOriginMu.Unlock()
83+
84+ if c.pool != nil {
85+ c.pool.Release(wv)
86+ } else {
87+ wv.Destroy()
88+ }
89+
90+ log.Debug().Str("pane_id", string(paneID)).Msg("webview released")
91+}
92+
93+// AttachToWorkspace ensures each pane in the workspace has a WebView widget attached.
94+func (c *Coordinator) AttachToWorkspace(ctx context.Context, ws *entity.Workspace, wsView *component.WorkspaceView) {
95+ log := logging.FromContext(ctx)
96+
97+ if ws == nil || wsView == nil || c.widgetFactory == nil {
98+ return
99+ }
100+
101+ for _, pane := range ws.AllPanes() {
102+ if pane == nil {
103+ continue
104+ }
105+
106+ wv, err := c.EnsureWebView(ctx, pane.ID)
107+ if err != nil {
108+ log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to ensure webview for pane")
109+ continue
110+ }
111+
112+ // Load the pane's URI if set and different from current
113+ if pane.URI != "" && pane.URI != wv.URI() {
114+ if err := wv.LoadURI(ctx, pane.URI); err != nil {
115+ log.Warn().Err(err).Str("pane_id", string(pane.ID)).Str("uri", pane.URI).Msg("failed to load pane URI")
116+ }
117+ }
118+
119+ widget := c.WrapWidget(ctx, wv)
120+ if widget == nil {
121+ continue
122+ }
123+
124+ if err := wsView.SetWebViewWidget(pane.ID, widget); err != nil {
125+ log.Warn().Err(err).Str("pane_id", string(pane.ID)).Msg("failed to attach webview widget")
126+ }
127+ logging.Trace().Mark("webview_attached")
128+ }
129+}
130+
131+// WrapWidget converts a WebView to a layout.Widget for embedding.
132+// It also attaches gesture handlers for mouse button navigation.
133+func (c *Coordinator) WrapWidget(ctx context.Context, wv port.WebView) layout.Widget {
134+ log := logging.FromContext(ctx)
135+
136+ if wv == nil || c.widgetFactory == nil {
137+ log.Debug().Msg("cannot wrap nil webview or factory")
138+ return nil
139+ }
140+
141+ // Use NativeWidgetProvider interface for GTK embedding (engine-agnostic)
142+ nwp, ok := wv.(port.NativeWidgetProvider)
143+ if !ok {
144+ log.Debug().Msg("webview does not support widget embedding")
145+ return nil
146+ }
147+
148+ ptr := nwp.NativeWidget()
149+ if ptr == 0 {
150+ return nil
151+ }
152+
153+ gtkWidget := &gtk.Widget{}
154+ gtkWidget.Ptr = ptr
155+ widget := c.widgetFactory.WrapWidget(gtkWidget)
156+
157+ // Attach gesture handler for mouse button 8/9 navigation
158+ if widget != nil {
159+ gestureHandler := input.NewGestureHandler(ctx)
160+ // Pass WebView directly to preserve user gesture context (like Epiphany)
161+ if nav, ok := wv.(input.DirectNavigator); ok {
162+ gestureHandler.SetNavigator(nav)
163+ }
164+ // Keep callback as fallback
165+ if c.gestureActionHandler != nil {
166+ gestureHandler.SetOnAction(c.gestureActionHandler)
167+ }
168+ gestureHandler.AttachTo(widget.GtkWidget())
169+ log.Debug().Msg("gesture handler attached to webview with direct navigator")
170+ }
171+
172+ return widget
173+}
174+
175+// ActiveWebView returns the WebView for the active pane.
176+func (c *Coordinator) ActiveWebView(ctx context.Context) port.WebView {
177+ log := logging.FromContext(ctx)
178+
179+ if paneID, ok := c.activePaneOverrideID(); ok {
180+ wv := c.getWebViewLocked(paneID)
181+ if wv != nil {
182+ return wv
183+ }
184+ log.Debug().Str("pane_id", string(paneID)).Msg("active pane override has no webview, falling back")
185+ }
186+ if c.getActiveWS == nil {
187+ log.Debug().Msg("no active workspace resolver")
188+ return nil
189+ }
190+
191+ ws, _ := c.getActiveWS()
192+ if ws == nil {
193+ log.Debug().Msg("no active workspace")
194+ return nil
195+ }
196+
197+ pane := ws.ActivePane()
198+ if pane == nil || pane.Pane == nil {
199+ log.Debug().Msg("no active pane")
200+ return nil
201+ }
202+
203+ return c.getWebViewLocked(pane.Pane.ID)
204+}
205+
206+// GetWebView returns the WebView for a specific pane.
207+func (c *Coordinator) GetWebView(paneID entity.PaneID) port.WebView {
208+ return c.getWebViewLocked(paneID)
209+}
210+
211+// RegisterPopupWebView registers a popup WebView that was created externally.
212+// This is used when popup tabs are created and the WebView needs to be tracked.
213+func (c *Coordinator) RegisterPopupWebView(paneID entity.PaneID, wv port.WebView) {
214+ if wv != nil && paneID != "" {
215+ c.setWebViewLocked(paneID, wv)
216+ }
217+}
A internal/ui/coordinator/content/lifecycle_test.go +257 -0
@@ -0,0 +1,257 @@
1+package content
2+
3+import (
4+ "context"
5+ "errors"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/mock"
10+ "github.com/stretchr/testify/require"
11+
12+ "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/application/port/mocks"
14+ "github.com/bnema/dumber/internal/domain/entity"
15+)
16+
17+// newMinimalCoordinator returns a Coordinator with the maps required by
18+// ReleaseWebView initialized so that calls to clearPendingAppearance,
19+// titleMu / navOriginMu locks do not panic.
20+func newMinimalCoordinator() *Coordinator {
21+ return &Coordinator{
22+ webViews: make(map[entity.PaneID]port.WebView),
23+ paneTitles: make(map[entity.PaneID]string),
24+ navOrigins: make(map[entity.PaneID]string),
25+ }
26+}
27+
28+// ---------------------------------------------------------------------------
29+// EnsureWebView
30+// ---------------------------------------------------------------------------
31+
32+func TestLifecycle_EnsureWebView_ReusesExistingNonDestroyed(t *testing.T) {
33+ t.Parallel()
34+
35+ wv := mocks.NewMockWebView(t)
36+ wv.EXPECT().IsDestroyed().Return(false)
37+
38+ pool := mocks.NewMockWebViewPool(t)
39+ // pool.Acquire must NOT be called
40+
41+ c := newMinimalCoordinator()
42+ c.pool = pool
43+ c.webViews[entity.PaneID("pane-1")] = wv
44+
45+ got, err := c.EnsureWebView(context.Background(), "pane-1")
46+
47+ require.NoError(t, err)
48+ assert.Equal(t, wv, got)
49+}
50+
51+func TestLifecycle_EnsureWebView_AcquiresFromPoolWhenNoneExists(t *testing.T) {
52+ t.Parallel()
53+
54+ newWV := mocks.NewMockWebView(t)
55+ newWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
56+ newWV.EXPECT().Generation().Return(uint64(0)).Maybe()
57+
58+ pool := mocks.NewMockWebViewPool(t)
59+ pool.EXPECT().Acquire(mock.Anything).Return(newWV, nil)
60+
61+ c := newMinimalCoordinator()
62+ c.pool = pool
63+
64+ got, err := c.EnsureWebView(context.Background(), "pane-1")
65+
66+ require.NoError(t, err)
67+ assert.Equal(t, newWV, got)
68+ assert.Equal(t, newWV, c.webViews[entity.PaneID("pane-1")])
69+}
70+
71+func TestLifecycle_EnsureWebView_AcquiresFromPoolWhenExistingIsDestroyed(t *testing.T) {
72+ t.Parallel()
73+
74+ oldWV := mocks.NewMockWebView(t)
75+ oldWV.EXPECT().IsDestroyed().Return(true)
76+
77+ newWV := mocks.NewMockWebView(t)
78+ newWV.EXPECT().SetCallbacks(mock.Anything).Maybe()
79+ newWV.EXPECT().Generation().Return(uint64(0)).Maybe()
80+
81+ pool := mocks.NewMockWebViewPool(t)
82+ pool.EXPECT().Acquire(mock.Anything).Return(newWV, nil)
83+
84+ c := newMinimalCoordinator()
85+ c.pool = pool
86+ c.webViews[entity.PaneID("pane-1")] = oldWV
87+
88+ got, err := c.EnsureWebView(context.Background(), "pane-1")
89+
90+ require.NoError(t, err)
91+ assert.Equal(t, newWV, got)
92+}
93+
94+func TestLifecycle_EnsureWebView_ErrorWhenPoolIsNil(t *testing.T) {
95+ t.Parallel()
96+
97+ c := newMinimalCoordinator()
98+ // pool remains nil
99+
100+ _, err := c.EnsureWebView(context.Background(), "pane-1")
101+
102+ require.Error(t, err)
103+ assert.Contains(t, err.Error(), "webview pool not configured")
104+}
105+
106+func TestLifecycle_EnsureWebView_ErrorWhenAcquireFails(t *testing.T) {
107+ t.Parallel()
108+
109+ acquireErr := errors.New("pool exhausted")
110+
111+ pool := mocks.NewMockWebViewPool(t)
112+ pool.EXPECT().Acquire(mock.Anything).Return(nil, acquireErr)
113+
114+ c := newMinimalCoordinator()
115+ c.pool = pool
116+
117+ _, err := c.EnsureWebView(context.Background(), "pane-1")
118+
119+ require.Error(t, err)
120+ assert.Equal(t, acquireErr, err)
121+}
122+
123+// ---------------------------------------------------------------------------
124+// ReleaseWebView
125+// ---------------------------------------------------------------------------
126+
127+func TestLifecycle_ReleaseWebView_ReleasesToPool(t *testing.T) {
128+ t.Parallel()
129+
130+ wv := mocks.NewMockWebView(t)
131+ // idleInhibitor is nil so these won't be called
132+ wv.EXPECT().IsFullscreen().Return(false).Maybe()
133+ wv.EXPECT().IsPlayingAudio().Return(false).Maybe()
134+
135+ pool := mocks.NewMockWebViewPool(t)
136+ pool.EXPECT().Release(wv)
137+
138+ c := newMinimalCoordinator()
139+ c.pool = pool
140+ c.webViews[entity.PaneID("pane-1")] = wv
141+
142+ c.ReleaseWebView(context.Background(), "pane-1")
143+
144+ _, exists := c.webViews[entity.PaneID("pane-1")]
145+ assert.False(t, exists, "webview should be removed from map after release")
146+}
147+
148+func TestLifecycle_ReleaseWebView_NoopForUnknownPane(t *testing.T) {
149+ t.Parallel()
150+
151+ pool := mocks.NewMockWebViewPool(t)
152+ // pool.Release must NOT be called
153+
154+ c := newMinimalCoordinator()
155+ c.pool = pool
156+
157+ // Should not panic
158+ c.ReleaseWebView(context.Background(), "unknown-pane")
159+}
160+
161+func TestLifecycle_ReleaseWebView_UninhibitsIdleIfFullscreen(t *testing.T) {
162+ t.Parallel()
163+
164+ wv := mocks.NewMockWebView(t)
165+ wv.EXPECT().IsFullscreen().Return(true)
166+ wv.EXPECT().IsPlayingAudio().Return(false)
167+
168+ inhibitor := mocks.NewMockIdleInhibitor(t)
169+ inhibitor.EXPECT().Uninhibit(mock.Anything).Return(nil)
170+
171+ pool := mocks.NewMockWebViewPool(t)
172+ pool.EXPECT().Release(wv)
173+
174+ c := newMinimalCoordinator()
175+ c.pool = pool
176+ c.idleInhibitor = inhibitor
177+ c.webViews[entity.PaneID("pane-1")] = wv
178+
179+ c.ReleaseWebView(context.Background(), "pane-1")
180+}
181+
182+func TestLifecycle_ReleaseWebView_UninhibitsIdleIfPlayingAudio(t *testing.T) {
183+ t.Parallel()
184+
185+ wv := mocks.NewMockWebView(t)
186+ wv.EXPECT().IsFullscreen().Return(false)
187+ wv.EXPECT().IsPlayingAudio().Return(true)
188+
189+ inhibitor := mocks.NewMockIdleInhibitor(t)
190+ inhibitor.EXPECT().Uninhibit(mock.Anything).Return(nil)
191+
192+ pool := mocks.NewMockWebViewPool(t)
193+ pool.EXPECT().Release(wv)
194+
195+ c := newMinimalCoordinator()
196+ c.pool = pool
197+ c.idleInhibitor = inhibitor
198+ c.webViews[entity.PaneID("pane-1")] = wv
199+
200+ c.ReleaseWebView(context.Background(), "pane-1")
201+}
202+
203+func TestLifecycle_ReleaseWebView_DestroysWebViewWhenPoolIsNil(t *testing.T) {
204+ t.Parallel()
205+
206+ wv := mocks.NewMockWebView(t)
207+ // idleInhibitor is nil so these won't be called
208+ wv.EXPECT().IsFullscreen().Return(false).Maybe()
209+ wv.EXPECT().IsPlayingAudio().Return(false).Maybe()
210+ wv.EXPECT().Destroy()
211+
212+ c := newMinimalCoordinator()
213+ // pool remains nil
214+ c.webViews[entity.PaneID("pane-1")] = wv
215+
216+ c.ReleaseWebView(context.Background(), "pane-1")
217+}
218+
219+// ---------------------------------------------------------------------------
220+// GetWebView / RegisterPopupWebView
221+// ---------------------------------------------------------------------------
222+
223+func TestLifecycle_GetWebView_ReturnsRegisteredWebView(t *testing.T) {
224+ t.Parallel()
225+
226+ wv := mocks.NewMockWebView(t)
227+
228+ c := newMinimalCoordinator()
229+ c.webViews[entity.PaneID("pane-1")] = wv
230+
231+ got := c.GetWebView("pane-1")
232+
233+ assert.Equal(t, wv, got)
234+}
235+
236+func TestLifecycle_RegisterPopupWebView_StoresWebView(t *testing.T) {
237+ t.Parallel()
238+
239+ wv := mocks.NewMockWebView(t)
240+
241+ c := newMinimalCoordinator()
242+
243+ c.RegisterPopupWebView("popup-1", wv)
244+
245+ assert.Equal(t, wv, c.GetWebView("popup-1"))
246+}
247+
248+func TestLifecycle_RegisterPopupWebView_IgnoresNil(t *testing.T) {
249+ t.Parallel()
250+
251+ c := newMinimalCoordinator()
252+
253+ // Should not panic
254+ c.RegisterPopupWebView("popup-1", nil)
255+
256+ assert.Nil(t, c.GetWebView("popup-1"))
257+}
A internal/ui/coordinator/content/navigation.go +351 -0
@@ -0,0 +1,351 @@
1+package content
2+
3+import (
4+ "context"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/application/usecase"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ urlutil "github.com/bnema/dumber/internal/domain/url"
11+ "github.com/bnema/dumber/internal/logging"
12+ "github.com/jwijenbergh/puregotk/v4/gtk"
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 port.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+ c.activePaneOverrideMu.RLock()
123+ override := c.activePaneOverride
124+ c.activePaneOverrideMu.RUnlock()
125+ if override != "" {
126+ if override == paneID {
127+ c.onActiveNavigationCommitted(uri)
128+ }
129+ return
130+ }
131+ ws, _ := c.getActiveWS()
132+ if ws != nil && ws.ActivePaneID == paneID {
133+ c.onActiveNavigationCommitted(uri)
134+ }
135+}
136+
137+func (c *Coordinator) shouldSkipAboutBlankAppearance(paneID entity.PaneID, wv port.WebView) bool {
138+ if wv == nil || wv.IsDestroyed() {
139+ return false
140+ }
141+ if wv.URI() != aboutBlankURI {
142+ return false
143+ }
144+ ws, _ := c.getActiveWS()
145+ if ws == nil {
146+ return false
147+ }
148+ paneNode := ws.FindPane(paneID)
149+ if paneNode == nil || paneNode.Pane == nil {
150+ return false
151+ }
152+ if paneNode.Pane.URI != "" && paneNode.Pane.URI != aboutBlankURI {
153+ return true
154+ }
155+ return false
156+}
157+
158+// onSPANavigation records history when URL changes via JavaScript (History API).
159+// This handles SPA navigation like YouTube search, where the URL changes without a page load.
160+func (c *Coordinator) onSPANavigation(ctx context.Context, paneID entity.PaneID, uri string) {
161+ // Update domain model with current URI for session snapshots
162+ c.updatePaneURI(paneID, uri)
163+
164+ // Record history for SPA navigation
165+ if c.onHistoryRecord != nil {
166+ c.onHistoryRecord(ctx, paneID, uri)
167+ }
168+}
169+
170+// updatePaneURI updates the pane's URI in the domain model.
171+// This is called on navigation so that session snapshots capture the current URL.
172+func (c *Coordinator) updatePaneURI(paneID entity.PaneID, uri string) {
173+ if c.onPaneURIUpdated != nil {
174+ c.onPaneURIUpdated(paneID, uri)
175+ }
176+}
177+
178+// onLoadStarted shows the progress bar when page loading begins.
179+func (c *Coordinator) onLoadStarted(paneID entity.PaneID) {
180+ logging.Trace().Mark("load_started")
181+
182+ // Trigger deferred initialization on first load_started.
183+ // This ensures non-critical init runs after initial navigation starts.
184+ c.loadStartedOnce.Do(func() {
185+ if c.onFirstLoadStarted != nil {
186+ c.onFirstLoadStarted()
187+ }
188+ })
189+
190+ _, wsView := c.getActiveWS()
191+ if wsView == nil {
192+ return
193+ }
194+
195+ paneView := wsView.GetPaneView(paneID)
196+ if paneView != nil {
197+ paneView.SetLoading(true)
198+ }
199+}
200+
201+// onLoadFinished hides the progress bar when page loading completes.
202+func (c *Coordinator) onLoadFinished(ctx context.Context, paneID entity.PaneID, wv port.WebView) {
203+ _, wsView := c.getActiveWS()
204+ if wsView == nil {
205+ return
206+ }
207+
208+ paneView := wsView.GetPaneView(paneID)
209+ if paneView != nil {
210+ paneView.SetLoading(false)
211+ }
212+
213+ c.revealIfPending(ctx, paneID, "", "load-finished")
214+ if c.shouldSkipAboutBlankAppearance(paneID, wv) {
215+ return
216+ }
217+ c.applyPendingThemeUpdate(ctx, paneID, wv)
218+ c.refreshPendingScripts(ctx, paneID, wv)
219+}
220+
221+// onProgressChanged updates the progress bar with current load progress.
222+func (c *Coordinator) onProgressChanged(paneID entity.PaneID, progress float64) {
223+ if progress > 0 {
224+ c.revealIfPending(context.Background(), paneID, "", "progress")
225+ }
226+
227+ _, wsView := c.getActiveWS()
228+ if wsView == nil {
229+ return
230+ }
231+
232+ paneView := wsView.GetPaneView(paneID)
233+ if paneView != nil {
234+ paneView.SetLoadProgress(progress)
235+ }
236+}
237+
238+func (c *Coordinator) markPendingReveal(paneID entity.PaneID) {
239+ c.revealMu.Lock()
240+ c.pendingReveal[paneID] = true
241+ c.revealMu.Unlock()
242+}
243+
244+func (c *Coordinator) clearPendingReveal(paneID entity.PaneID) {
245+ c.revealMu.Lock()
246+ delete(c.pendingReveal, paneID)
247+ c.revealMu.Unlock()
248+}
249+
250+func (c *Coordinator) revealIfPending(ctx context.Context, paneID entity.PaneID, uri, reason string) {
251+ c.revealMu.Lock()
252+ pending := c.pendingReveal[paneID]
253+ if pending {
254+ delete(c.pendingReveal, paneID)
255+ }
256+ c.revealMu.Unlock()
257+
258+ if !pending {
259+ return
260+ }
261+
262+ wv := c.getWebViewLocked(paneID)
263+ if wv == nil || wv.IsDestroyed() {
264+ return
265+ }
266+
267+ // Use NativeWidgetProvider to make the widget visible (engine-agnostic)
268+ if nwp, ok := wv.(port.NativeWidgetProvider); ok {
269+ if ptr := nwp.NativeWidget(); ptr != 0 {
270+ gtkWidget := &gtk.Widget{}
271+ gtkWidget.Ptr = ptr
272+ gtkWidget.SetVisible(true)
273+ logging.FromContext(ctx).
274+ Debug().
275+ Str("pane_id", string(paneID)).
276+ Str("uri", uri).
277+ Str("reason", reason).
278+ Msg("webview revealed")
279+ }
280+ }
281+
282+ // Mark first_paint and finish startup trace
283+ logging.Trace().Mark("first_paint")
284+ logging.Trace().Finish()
285+
286+ if c.onWebViewShown != nil {
287+ c.onWebViewShown(paneID)
288+ }
289+}
290+
291+// onLinkHover updates the link status overlay when hovering over links.
292+func (c *Coordinator) onLinkHover(paneID entity.PaneID, uri string) {
293+ _, wsView := c.getActiveWS()
294+ if wsView == nil {
295+ return
296+ }
297+
298+ paneView := wsView.GetPaneView(paneID)
299+ if paneView == nil {
300+ return
301+ }
302+
303+ if uri != "" {
304+ paneView.ShowLinkStatus(uri)
305+ } else {
306+ paneView.HideLinkStatus()
307+ }
308+}
309+
310+// handleURIChanged handles URI changes from WebKit, including external scheme detection
311+// and SPA navigation tracking.
312+func (c *Coordinator) handleURIChanged(ctx context.Context, paneID entity.PaneID, wv port.WebView, uri string) {
313+ if uri == "" {
314+ return
315+ }
316+
317+ log := logging.FromContext(ctx)
318+
319+ // Check for external URL schemes (vscode://, vscode-insiders://, spotify://, etc.)
320+ // These are typically triggered by JavaScript redirects (window.location)
321+ isExternal := urlutil.IsExternalScheme(uri)
322+
323+ if isExternal {
324+ log.Info().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external scheme detected, launching externally")
325+
326+ // Launch externally via injected callback
327+ if c.onLaunchExternalURL != nil {
328+ c.onLaunchExternalURL(uri)
329+ } else {
330+ log.Warn().Str("pane_id", string(paneID)).Str("uri", uri).Msg("external URL not launched: no handler registered")
331+ }
332+
333+ // Stop loading to prevent WebKit from showing an error page
334+ // The page stays on the previous URL before the JS redirect
335+ if err := wv.Stop(ctx); err != nil {
336+ log.Warn().Str("pane_id", string(paneID)).Str("uri", uri).Err(err).Msg("stop webview for external URL")
337+ }
338+
339+ // Navigate back to avoid stale URI in omnibox/history
340+ if wv.CanGoBack() {
341+ if err := wv.GoBack(ctx); err != nil {
342+ log.Warn().Str("pane_id", string(paneID)).Err(err).Msg("GoBack after external URL")
343+ }
344+ }
345+ return
346+ }
347+
348+ if !wv.IsLoading() {
349+ c.onSPANavigation(ctx, paneID, uri)
350+ }
351+}
A internal/ui/coordinator/content/oauth.go +433 -0
@@ -0,0 +1,433 @@
1+package content
2+
3+import (
4+ "context"
5+ neturl "net/url"
6+ "strings"
7+ "sync"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/application/port"
11+ "github.com/bnema/dumber/internal/domain/entity"
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+// oauthRequestPatterns are query parameters found in OAuth authorization requests.
38+var oauthRequestPatterns = []string{
39+ "response_type=",
40+ "client_id=",
41+ "redirect_uri=",
42+ "scope=",
43+ "nonce=",
44+}
45+
46+// IsOAuthURL checks if the URL is related to an OAuth flow.
47+// This includes authorization endpoints, login pages, and callback URLs.
48+//
49+// The matching is intentionally broad: both oauthFlowPatterns (path-based terms like
50+// "login", "authorize", "callback") and oauthRequestPatterns (query parameters like
51+// "client_id=" and "scope=") use simple substring matching without anchoring.
52+// This maximizes recall — we prefer false positives over missed detections — so that
53+// edge-case provider URLs and non-standard redirect paths are still caught. The
54+// trade-off is reduced precision: some unrelated URLs that happen to contain these
55+// substrings will be classified as OAuth URLs. Callers that need higher confidence
56+// should combine this with IsOAuthCallback, which checks for concrete response params.
57+func IsOAuthURL(url string) bool {
58+ if url == "" {
59+ return false
60+ }
61+ lower := strings.ToLower(url)
62+
63+ // Check for OAuth flow patterns in URL path
64+ for _, pattern := range oauthFlowPatterns {
65+ if strings.Contains(lower, pattern) {
66+ return true
67+ }
68+ }
69+
70+ // Check for OAuth request parameters
71+ for _, pattern := range oauthRequestPatterns {
72+ if strings.Contains(lower, pattern) {
73+ return true
74+ }
75+ }
76+
77+ return false
78+}
79+
80+// IsOAuthCallback checks if the URL is an OAuth callback with response parameters.
81+// This indicates the OAuth flow has completed (successfully or with error).
82+func IsOAuthCallback(url string) bool {
83+ if url == "" {
84+ return false
85+ }
86+ u, err := neturl.Parse(url)
87+ if err != nil {
88+ return false
89+ }
90+ if hasOAuthCallbackParam(u.Query()) {
91+ return true
92+ }
93+ if u.Fragment != "" {
94+ if fragVals, err := neturl.ParseQuery(u.Fragment); err == nil {
95+ return hasOAuthCallbackParam(fragVals)
96+ }
97+ }
98+ return false
99+}
100+
101+// ShouldAutoClose determines if a popup at this URL should auto-close.
102+// Returns true for OAuth callbacks that indicate flow completion.
103+//
104+// This is comprehensive detection that handles:
105+// - Success: code=, access_token=, id_token=
106+// - Errors: error=, error_description=
107+// - Various OAuth providers (Google, GitHub, Auth0, etc.)
108+func ShouldAutoClose(url string) bool {
109+ return IsOAuthCallback(url)
110+}
111+
112+// shouldForceCloseOnSafetyTimeout determines if a popup should be force-closed
113+// when the OAuth safety timeout is reached.
114+//
115+// Safety-timeout force close is disabled for stability. OAuth popups close on
116+// callback detection or manual user action.
117+func shouldForceCloseOnSafetyTimeout() bool {
118+ return false
119+}
120+
121+// IsOAuthSuccess checks if the callback indicates successful authentication.
122+// It parses query and fragment parameters (like IsOAuthCallback) to avoid
123+// false positives from substring matching.
124+func IsOAuthSuccess(rawURL string) bool {
125+ if rawURL == "" {
126+ return false
127+ }
128+ u, err := neturl.Parse(rawURL)
129+ if err != nil {
130+ return false
131+ }
132+ if hasOAuthSuccessParam(u.Query()) {
133+ return true
134+ }
135+ if u.Fragment != "" {
136+ if fragVals, err := neturl.ParseQuery(u.Fragment); err == nil {
137+ return hasOAuthSuccessParam(fragVals)
138+ }
139+ }
140+ return false
141+}
142+
143+// hasOAuthSuccessParam returns true if params contain a success token
144+// (code, access_token, id_token) and no error key.
145+func hasOAuthSuccessParam(params neturl.Values) bool {
146+ if params.Has("error") {
147+ return false
148+ }
149+ return params.Has("code") || params.Has("access_token") || params.Has("id_token")
150+}
151+
152+// IsOAuthError checks if the callback indicates an authentication error.
153+func IsOAuthError(url string) bool {
154+ if url == "" {
155+ return false
156+ }
157+ u, err := neturl.Parse(url)
158+ if err != nil {
159+ return false
160+ }
161+ if _, ok := u.Query()["error"]; ok {
162+ return true
163+ }
164+ if u.Fragment != "" {
165+ if fragVals, err := neturl.ParseQuery(u.Fragment); err == nil {
166+ if _, ok := fragVals["error"]; ok {
167+ return true
168+ }
169+ }
170+ }
171+ return false
172+}
173+
174+func hasOAuthCallbackParam(v neturl.Values) bool {
175+ for _, k := range []string{"code", "access_token", "id_token", "token_type", "refresh_token", "error", "error_description", "error_uri"} {
176+ if _, ok := v[k]; ok {
177+ return true
178+ }
179+ }
180+ return false
181+}
182+
183+func composeOnClose(existing, next func()) func() {
184+ if existing == nil {
185+ return next
186+ }
187+ if next == nil {
188+ return existing
189+ }
190+ return func() {
191+ existing()
192+ next()
193+ }
194+}
195+
196+func composeOnURIChanged(existing, next func(string)) func(string) {
197+ if existing == nil {
198+ return next
199+ }
200+ if next == nil {
201+ return existing
202+ }
203+ return func(uri string) {
204+ existing(uri)
205+ next(uri)
206+ }
207+}
208+
209+func composeOnLoadChanged(
210+ existing func(port.LoadEvent),
211+ next func(port.LoadEvent),
212+) func(port.LoadEvent) {
213+ if existing == nil {
214+ return next
215+ }
216+ if next == nil {
217+ return existing
218+ }
219+ return func(event port.LoadEvent) {
220+ existing(event)
221+ next(event)
222+ }
223+}
224+
225+// setupOAuthAutoClose monitors the popup for OAuth callback URLs and auto-closes.
226+// It uses URL pattern detection for standard OAuth callbacks (code=, access_token=, etc.)
227+// For providers using postMessage (like Google Sign-In), we rely on the provider calling
228+// window.close() which triggers WebKit's close signal.
229+// A long safety timeout (30s) catches popups that get stuck.
230+func (c *Coordinator) setupOAuthAutoClose(
231+ ctx context.Context,
232+ paneID entity.PaneID,
233+ popupID port.WebViewID,
234+ wv port.WebView,
235+) {
236+ log := logging.FromContext(ctx)
237+
238+ // Use the optional OAuthCallbackCapable capability instead of a webkit-specific assertion.
239+ oauthWV, ok := wv.(port.OAuthCallbackCapable)
240+ if !ok {
241+ log.Debug().Str("pane_id", string(paneID)).Msg("oauth auto-close: webview does not support oauth callbacks")
242+ return
243+ }
244+
245+ // Safety timeout - only triggers if popup gets stuck (provider should close via window.close)
246+ var safetyTimer *time.Timer
247+ var safetyTimerMu sync.Mutex
248+ var cancelSafetyTimerOnce sync.Once
249+ var requestCloseOnce sync.Once
250+ const oauthSafetyTimeout = 30 * time.Second
251+ const oauthCloseDelay = 500 * time.Millisecond
252+
253+ startSafetyTimer := func() {
254+ safetyTimerMu.Lock()
255+ defer safetyTimerMu.Unlock()
256+ if safetyTimer != nil {
257+ safetyTimer.Stop()
258+ }
259+ safetyTimer = time.AfterFunc(oauthSafetyTimeout, func() {
260+ cb := glib.SourceFunc(func(_ uintptr) bool {
261+ if wv != nil && !wv.IsDestroyed() {
262+ uri := wv.URI()
263+ if shouldForceCloseOnSafetyTimeout() {
264+ log.Warn().Str("pane", string(paneID)).Msg("oauth safety timeout, closing stuck popup")
265+ oauthWV.Close()
266+ return false
267+ }
268+ log.Debug().
269+ Str("pane", string(paneID)).
270+ Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
271+ Msg("oauth safety timeout reached during active auth flow, skipping forced close")
272+ }
273+ return false
274+ })
275+ glib.IdleAdd(&cb, 0)
276+ })
277+ }
278+
279+ cancelSafetyTimer := func() {
280+ cancelSafetyTimerOnce.Do(func() {
281+ safetyTimerMu.Lock()
282+ defer safetyTimerMu.Unlock()
283+ if safetyTimer != nil {
284+ safetyTimer.Stop()
285+ safetyTimer = nil
286+ }
287+ })
288+ }
289+
290+ requestOAuthClose := func(uri string, reason string) {
291+ c.capturePopupOAuthState(popupID, uri)
292+ cancelSafetyTimer()
293+ log.Info().
294+ Str("pane", string(paneID)).
295+ Str("reason", reason).
296+ Msg("oauth callback detected, closing")
297+ requestCloseOnce.Do(func() {
298+ time.AfterFunc(oauthCloseDelay, func() {
299+ cb := glib.SourceFunc(func(_ uintptr) bool {
300+ if wv != nil && !wv.IsDestroyed() {
301+ oauthWV.Close()
302+ }
303+ return false
304+ })
305+ glib.IdleAdd(&cb, 0)
306+ })
307+ })
308+ }
309+
310+ // Start safety timer immediately.
311+ startSafetyTimer()
312+
313+ // Register navigation callback to check for OAuth callbacks on URI changes and committed loads.
314+ oauthWV.AddNavigationCallback(func(uri string) {
315+ if ShouldAutoClose(uri) {
316+ requestOAuthClose(uri, "navigation")
317+ }
318+ })
319+
320+ // Cancel safety timer on any close path.
321+ oauthWV.AddCloseCallback(func() {
322+ cancelSafetyTimer()
323+ })
324+}
325+
326+func (c *Coordinator) trackOAuthPopup(popupID port.WebViewID, parentPaneID entity.PaneID) {
327+ c.popupMu.Lock()
328+ defer c.popupMu.Unlock()
329+ if c.popupOAuth == nil {
330+ c.popupOAuth = make(map[port.WebViewID]*popupOAuthState)
331+ }
332+ c.popupOAuth[popupID] = &popupOAuthState{
333+ ParentPaneID: parentPaneID,
334+ }
335+}
336+
337+func (c *Coordinator) capturePopupOAuthState(popupID port.WebViewID, uri string) {
338+ c.popupMu.Lock()
339+ defer c.popupMu.Unlock()
340+
341+ state, ok := c.popupOAuth[popupID]
342+ if !ok {
343+ return
344+ }
345+
346+ state.Seen = true
347+ state.CallbackURI = uri
348+ state.Success = IsOAuthSuccess(uri)
349+ state.Error = IsOAuthError(uri)
350+}
351+
352+func (c *Coordinator) handlePopupOAuthClose(ctx context.Context, popupID port.WebViewID) {
353+ log := logging.FromContext(ctx)
354+
355+ c.popupMu.Lock()
356+ state, ok := c.popupOAuth[popupID]
357+ if ok {
358+ delete(c.popupOAuth, popupID)
359+ }
360+ c.popupMu.Unlock()
361+
362+ if !ok || state == nil || !state.Seen {
363+ return
364+ }
365+
366+ log.Debug().
367+ Uint64("popup_id", uint64(popupID)).
368+ Str("parent_pane_id", string(state.ParentPaneID)).
369+ Bool("oauth_success", state.Success).
370+ Bool("oauth_error", state.Error).
371+ Msg("popup oauth result captured on close")
372+
373+ if !state.Success || state.ParentPaneID == "" {
374+ return
375+ }
376+
377+ c.scheduleParentPaneRefresh(ctx, state.ParentPaneID, popupID)
378+}
379+
380+func (c *Coordinator) scheduleParentPaneRefresh(
381+ ctx context.Context,
382+ parentPaneID entity.PaneID,
383+ popupID port.WebViewID,
384+) {
385+ c.popupMu.Lock()
386+ if c.popupRefresh == nil {
387+ c.popupRefresh = make(map[entity.PaneID]*time.Timer)
388+ }
389+ if existing := c.popupRefresh[parentPaneID]; existing != nil {
390+ existing.Stop()
391+ }
392+ c.popupRefresh[parentPaneID] = time.AfterFunc(oauthParentRefreshDebounce, func() {
393+ c.popupMu.Lock()
394+ delete(c.popupRefresh, parentPaneID)
395+ c.popupMu.Unlock()
396+ cb := glib.SourceFunc(func(_ uintptr) bool {
397+ c.refreshPaneAfterOAuth(ctx, parentPaneID, popupID)
398+ return false
399+ })
400+ glib.IdleAdd(&cb, 0)
401+ })
402+ c.popupMu.Unlock()
403+}
404+
405+func (c *Coordinator) refreshPaneAfterOAuth(
406+ ctx context.Context,
407+ parentPaneID entity.PaneID,
408+ popupID port.WebViewID,
409+) {
410+ log := logging.FromContext(ctx)
411+ wv := c.getWebViewLocked(parentPaneID)
412+ if wv == nil || wv.IsDestroyed() {
413+ log.Debug().
414+ Str("parent_pane_id", string(parentPaneID)).
415+ Uint64("popup_id", uint64(popupID)).
416+ Msg("skipping parent pane refresh after oauth close: parent webview unavailable")
417+ return
418+ }
419+
420+ if err := wv.Reload(ctx); err != nil {
421+ log.Warn().
422+ Err(err).
423+ Str("parent_pane_id", string(parentPaneID)).
424+ Uint64("popup_id", uint64(popupID)).
425+ Msg("failed parent pane refresh after oauth popup close")
426+ return
427+ }
428+
429+ log.Info().
430+ Str("parent_pane_id", string(parentPaneID)).
431+ Uint64("popup_id", uint64(popupID)).
432+ Msg("refreshed parent pane after oauth popup success")
433+}
R internal/ui/coordinator/oauth_test.go → internal/ui/coordinator/content/oauth_test.go +9 -18 renamed, 79% similar
@@ -1,4 +1,4 @@
1-package coordinator
1+package content
22
33 import (
44 "context"
@@ -9,7 +9,6 @@ import (
99
1010 "github.com/bnema/dumber/internal/application/port"
1111 "github.com/bnema/dumber/internal/domain/entity"
12- "github.com/bnema/dumber/internal/infrastructure/webkit"
1312 )
1413
1514 func TestComposeOnClose_Order(t *testing.T) {
@@ -40,10 +39,10 @@ func TestComposeOnLoadChanged_Order(t *testing.T) {
4039 var calls []string
4140
4241 composed := composeOnLoadChanged(
43- func(_ webkit.LoadEvent) { calls = append(calls, "existing") },
44- func(_ webkit.LoadEvent) { calls = append(calls, "next") },
42+ func(_ port.LoadEvent) { calls = append(calls, "existing") },
43+ func(_ port.LoadEvent) { calls = append(calls, "next") },
4544 )
46- composed(webkit.LoadCommitted)
45+ composed(port.LoadCommitted)
4746
4847 assert.Equal(t, []string{"existing", "next"}, calls)
4948 }
@@ -52,8 +51,8 @@ func TestHandlePopupOAuthClose_SuccessSchedulesParentRefresh(t *testing.T) {
5251 parentPaneID := entity.PaneID("parent-pane")
5352 popupID := port.WebViewID(101)
5453
55- c := &ContentCoordinator{
56- webViews: make(map[entity.PaneID]*webkit.WebView),
54+ c := &Coordinator{
55+ webViews: make(map[entity.PaneID]port.WebView),
5756 popupOAuth: make(map[port.WebViewID]*popupOAuthState),
5857 popupRefresh: make(map[entity.PaneID]*time.Timer),
5958 }
@@ -81,8 +80,8 @@ func TestHandlePopupOAuthClose_ErrorDoesNotScheduleParentRefresh(t *testing.T) {
8180 parentPaneID := entity.PaneID("parent-pane")
8281 popupID := port.WebViewID(102)
8382
84- c := &ContentCoordinator{
85- webViews: make(map[entity.PaneID]*webkit.WebView),
83+ c := &Coordinator{
84+ webViews: make(map[entity.PaneID]port.WebView),
8685 popupOAuth: make(map[port.WebViewID]*popupOAuthState),
8786 popupRefresh: make(map[entity.PaneID]*time.Timer),
8887 }
@@ -109,15 +108,7 @@ func TestIsOAuthURL_DetectsOAuthAuthorizeRequest(t *testing.T) {
109108 }
110109
111110 func TestShouldForceCloseOnSafetyTimeout(t *testing.T) {
112- t.Run("does not force close during in-progress auth challenge", func(t *testing.T) {
113- url := "https://accounts.google.com/v3/signin/challenge/dp?TL=AHU8sQabc"
114- assert.False(t, shouldForceCloseOnSafetyTimeout(url))
115- })
116-
117- t.Run("does not force close when callback is reached", func(t *testing.T) {
118- url := "http://localhost:1455/auth/callback?code=abc123&state=xyz"
119- assert.False(t, shouldForceCloseOnSafetyTimeout(url))
120- })
111+ assert.False(t, shouldForceCloseOnSafetyTimeout())
121112 }
122113
123114 func waitFor(t *testing.T, timeout time.Duration, condition func() bool) {
A internal/ui/coordinator/content/popup.go +520 -0
@@ -0,0 +1,520 @@
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/logging"
11+)
12+
13+// PopupType indicates whether the popup was triggered by a link or JavaScript.
14+type PopupType int
15+
16+const (
17+ // PopupTypeTab represents a popup from target="_blank" links.
18+ // These are typically user-initiated and open like new tabs.
19+ PopupTypeTab PopupType = iota
20+ // PopupTypePopup represents a popup from window.open() JavaScript calls.
21+ // These may have different placement behavior than tab-like popups.
22+ PopupTypePopup
23+)
24+
25+// String returns a human-readable name for the popup type.
26+func (t PopupType) String() string {
27+ switch t {
28+ case PopupTypeTab:
29+ return "tab"
30+ case PopupTypePopup:
31+ return "popup"
32+ default:
33+ return "unknown"
34+ }
35+}
36+
37+// DetectPopupType determines if this is a tab-like or JS popup based on frame name.
38+func DetectPopupType(frameName string) PopupType {
39+ // _blank is the standard indicator for "open in new tab/window"
40+ if frameName == "_blank" {
41+ return PopupTypeTab
42+ }
43+ // Empty or custom frame names indicate JS window.open()
44+ return PopupTypePopup
45+}
46+
47+// PendingPopup tracks a popup WebView that has been created but not yet
48+// inserted into the workspace. This is used during the three-phase popup
49+// lifecycle: create → ready-to-show → (insert into workspace).
50+type PendingPopup struct {
51+ // PaneID is the popup pane created during the create phase.
52+ PaneID entity.PaneID
53+
54+ // WebView is the related WebView created in the "create" phase.
55+ // It shares cookies/session with the parent for OAuth support.
56+ WebView port.WebView
57+
58+ // ParentPaneID is the pane that spawned this popup.
59+ ParentPaneID entity.PaneID
60+
61+ // ParentWebViewID is the WebView ID of the parent (for lookup).
62+ ParentWebViewID port.WebViewID
63+
64+ // TargetURI is the initial URL the popup will load.
65+ TargetURI string
66+
67+ // FrameName is the target frame name from the navigation action.
68+ // "_blank" indicates a tab-like popup, other values indicate JS popups.
69+ FrameName string
70+
71+ // IsUserGesture indicates if the popup was triggered by user action
72+ // (click) vs script-initiated (e.g., popup after timeout).
73+ IsUserGesture bool
74+
75+ // PopupType categorizes this as a tab-like or JS popup.
76+ PopupType PopupType
77+
78+ // CreatedAt is when the popup was created (for timeout handling).
79+ CreatedAt time.Time
80+}
81+
82+// InsertPopupInput contains the data needed to insert a popup into the workspace.
83+type InsertPopupInput struct {
84+ // ParentPaneID is the pane that spawned this popup.
85+ ParentPaneID entity.PaneID
86+
87+ // PopupPane is the pre-created pane entity for the popup.
88+ // It should have IsRelated=true and ParentPaneID set.
89+ PopupPane *entity.Pane
90+
91+ // WebView is the related WebView for the popup.
92+ WebView port.WebView
93+
94+ // Behavior determines how the popup is inserted (split/stacked/tabbed).
95+ Behavior entity.PopupBehavior
96+
97+ // Placement specifies direction for split behavior (right/left/top/bottom).
98+ Placement string
99+
100+ // PopupType indicates if this is a tab-like or JS popup.
101+ PopupType PopupType
102+
103+ // TargetURI is the URL to load in the popup.
104+ TargetURI string
105+}
106+
107+// GetBehavior returns the appropriate behavior based on popup type and config.
108+func GetBehavior(popupType PopupType, cfg *entity.PopupBehaviorConfig) entity.PopupBehavior {
109+ if cfg == nil {
110+ return entity.PopupBehaviorSplit // Default
111+ }
112+
113+ switch popupType {
114+ case PopupTypeTab:
115+ // Tab-like popups (_blank) use blank_target_behavior
116+ switch cfg.BlankTargetBehavior {
117+ case "split":
118+ return entity.PopupBehaviorSplit
119+ case "stacked":
120+ return entity.PopupBehaviorStacked
121+ case "tabbed":
122+ return entity.PopupBehaviorTabbed
123+ default:
124+ return entity.PopupBehaviorStacked // Default for _blank
125+ }
126+ case PopupTypePopup:
127+ // JS popups use the main behavior setting
128+ return cfg.Behavior
129+ default:
130+ return cfg.Behavior
131+ }
132+}
133+
134+// SetPopupConfig configures popup handling.
135+func (c *Coordinator) SetPopupConfig(
136+ factory port.WebViewFactory,
137+ popupConfig *entity.PopupBehaviorConfig,
138+ generateID func() string,
139+) {
140+ c.factory = factory
141+ c.popupConfig = popupConfig
142+ c.generateID = generateID
143+}
144+
145+// SetOnInsertPopup sets the callback to insert popups into the workspace.
146+func (c *Coordinator) SetOnInsertPopup(fn func(ctx context.Context, input InsertPopupInput) error) {
147+ c.onInsertPopup = fn
148+}
149+
150+// SetOnClosePane sets the callback to close a pane when its popup closes.
151+func (c *Coordinator) SetOnClosePane(fn func(ctx context.Context, paneID entity.PaneID) error) {
152+ c.onClosePane = fn
153+}
154+
155+// buildPopupCreateHandler returns the OnCreate callback for a WebView.
156+// Returns nil if popup handling is not configured.
157+func (c *Coordinator) buildPopupCreateHandler(
158+ ctx context.Context, paneID entity.PaneID, wv port.WebView,
159+) func(port.PopupRequest) port.WebView {
160+ if wv == nil {
161+ return nil
162+ }
163+
164+ log := logging.FromContext(ctx)
165+ log.Debug().Str("pane_id", string(paneID)).Msg("popup handling configured for webview")
166+
167+ return func(req port.PopupRequest) port.WebView {
168+ return c.handlePopupCreate(ctx, paneID, wv, req)
169+ }
170+}
171+
172+// createPopupPane creates a new pane entity for a popup window.
173+func (c *Coordinator) createPopupPane(
174+ popupID port.WebViewID,
175+ parentPaneID entity.PaneID,
176+ targetURI string,
177+) (entity.PaneID, *entity.Pane) {
178+ var paneID entity.PaneID
179+ if c.generateID != nil {
180+ paneID = entity.PaneID(c.generateID())
181+ } else {
182+ paneID = entity.PaneID(fmt.Sprintf("popup_%d", popupID))
183+ }
184+
185+ popupPane := entity.NewPane(paneID)
186+ popupPane.WindowType = entity.WindowPopup
187+ popupPane.IsRelated = true
188+ popupPane.ParentPaneID = &parentPaneID
189+ popupPane.URI = targetURI
190+
191+ return paneID, popupPane
192+}
193+
194+// handlePopupCreate handles the WebKit "create" signal for popup windows.
195+// Returns a new related WebView if popup is allowed, nil to block.
196+//
197+// IMPORTANT: The WebView MUST be added to a GtkWindow hierarchy BEFORE this
198+// signal handler returns. Otherwise WebKit won't establish window.opener.
199+// The WebView stays hidden until ready-to-show signal is received.
200+func (c *Coordinator) handlePopupCreate(
201+ ctx context.Context,
202+ parentPaneID entity.PaneID,
203+ parentWV port.WebView,
204+ req port.PopupRequest,
205+) port.WebView {
206+ log := logging.FromContext(ctx)
207+
208+ log.Debug().
209+ Str("parent_pane", string(parentPaneID)).
210+ Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
211+ Str("frame_name", req.FrameName).
212+ Bool("user_gesture", req.IsUserGesture).
213+ Msg("popup create request")
214+
215+ // Check if popups are enabled
216+ if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
217+ log.Debug().Msg("popups disabled by config, blocking")
218+ return nil
219+ }
220+
221+ // Check if factory is available
222+ if c.factory == nil {
223+ log.Warn().Msg("no webview factory, cannot create popup")
224+ return nil
225+ }
226+
227+ // Create related WebView for session sharing (created hidden)
228+ parentID := parentWV.ID()
229+ popupWV, err := c.factory.CreateRelated(ctx, parentID)
230+ if err != nil {
231+ log.Error().Err(err).Msg("failed to create related webview for popup")
232+ return nil
233+ }
234+
235+ // Detect popup type
236+ popupType := DetectPopupType(req.FrameName)
237+ popupID := popupWV.ID()
238+
239+ // Determine behavior from config
240+ behavior := GetBehavior(popupType, c.popupConfig)
241+ placement := "right"
242+ if c.popupConfig != nil {
243+ placement = c.popupConfig.Placement
244+ }
245+
246+ // Create popup pane entity
247+ paneID, popupPane := c.createPopupPane(popupID, parentPaneID, req.TargetURI)
248+
249+ // Check OAuth configuration
250+ hasConfig := c.popupConfig != nil
251+ oauthEnabled := hasConfig && c.popupConfig.OAuthAutoClose
252+ isOAuth := IsOAuthURL(req.TargetURI)
253+ log.Debug().
254+ Bool("has_config", hasConfig).
255+ Bool("oauth_enabled", oauthEnabled).
256+ Bool("is_oauth", isOAuth).
257+ Str("uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
258+ Msg("popup OAuth check")
259+
260+ // Insert into workspace IMMEDIATELY (WebView stays hidden)
261+ // This is required for WebKit to establish window.opener relationship
262+ if c.onInsertPopup != nil {
263+ popupInput := InsertPopupInput{
264+ ParentPaneID: parentPaneID,
265+ PopupPane: popupPane,
266+ WebView: popupWV,
267+ Behavior: behavior,
268+ Placement: placement,
269+ PopupType: popupType,
270+ TargetURI: req.TargetURI,
271+ }
272+
273+ if err := c.onInsertPopup(ctx, popupInput); err != nil {
274+ log.Error().Err(err).Msg("failed to insert popup into workspace")
275+ popupWV.Destroy()
276+ return nil
277+ }
278+ }
279+
280+ // Register WebView in our map (after successful insertion)
281+ c.setWebViewLocked(paneID, popupWV)
282+
283+ // Setup standard callbacks (after successful insertion to avoid leak)
284+ c.setupWebViewCallbacks(ctx, paneID, popupWV)
285+
286+ // Setup OAuth auto-close if configured
287+ if hasConfig && oauthEnabled && isOAuth {
288+ popupPane.AutoClose = true
289+ c.trackOAuthPopup(popupID, parentPaneID)
290+ c.setupOAuthAutoClose(ctx, paneID, popupID, popupWV)
291+ log.Debug().Str("pane_id", string(paneID)).Msg("OAuth auto-close enabled for popup")
292+ }
293+
294+ // Store pending popup for ready-to-show handling (just visibility now)
295+ pending := &PendingPopup{
296+ PaneID: paneID,
297+ WebView: popupWV,
298+ ParentPaneID: parentPaneID,
299+ ParentWebViewID: parentID,
300+ TargetURI: req.TargetURI,
301+ FrameName: req.FrameName,
302+ IsUserGesture: req.IsUserGesture,
303+ PopupType: popupType,
304+ CreatedAt: time.Now(),
305+ }
306+
307+ c.popupMu.Lock()
308+ c.pendingPopups[popupID] = pending
309+ c.popupMu.Unlock()
310+
311+ // Wire ready-to-show and close signals via the PopupCapable interface.
312+ if pc, ok := popupWV.(port.PopupCapable); ok {
313+ pc.SetOnReadyToShow(func() {
314+ c.handlePopupReadyToShow(ctx, popupID)
315+ })
316+ pc.SetOnClose(func() {
317+ c.handlePopupClose(ctx, popupID)
318+ })
319+ } else {
320+ log.Warn().Uint64("popup_id", uint64(popupID)).Msg("webview does not support popup lifecycle callbacks (PopupCapable)")
321+ }
322+
323+ log.Info().
324+ Uint64("popup_id", uint64(popupID)).
325+ Str("pane_id", string(paneID)).
326+ Str("popup_type", popupType.String()).
327+ Str("target_uri", logging.TruncateURL(req.TargetURI, logURLMaxLen)).
328+ Msg("popup inserted (hidden), awaiting ready-to-show for visibility")
329+
330+ return popupWV
331+}
332+
333+// handlePopupReadyToShow handles the WebKit "ready-to-show" signal.
334+// The popup WebView was already inserted into the workspace (hidden) during
335+// the create signal. This handler just makes it visible.
336+func (c *Coordinator) handlePopupReadyToShow(ctx context.Context, popupID port.WebViewID) {
337+ log := logging.FromContext(ctx)
338+
339+ // Get pending popup
340+ c.popupMu.Lock()
341+ pending, ok := c.pendingPopups[popupID]
342+ if ok {
343+ delete(c.pendingPopups, popupID)
344+ }
345+ c.popupMu.Unlock()
346+
347+ if !ok || pending == nil {
348+ log.Warn().Uint64("popup_id", uint64(popupID)).Msg("ready-to-show for unknown popup")
349+ return
350+ }
351+
352+ log.Debug().
353+ Uint64("popup_id", uint64(popupID)).
354+ Str("popup_type", pending.PopupType.String()).
355+ Msg("popup ready to show - making visible")
356+
357+ // Make the WebView visible now that it's ready
358+ if pending.WebView != nil {
359+ if pc, ok := pending.WebView.(port.PopupCapable); ok {
360+ pc.Show()
361+ } else {
362+ log.Warn().Uint64("popup_id", uint64(popupID)).Msg("webview does not support Show() (PopupCapable)")
363+ }
364+ }
365+
366+ log.Info().
367+ Uint64("popup_id", uint64(popupID)).
368+ Str("target_uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)).
369+ Msg("popup now visible")
370+}
371+
372+// handlePopupClose handles the WebKit "close" signal for popup windows.
373+func (c *Coordinator) handlePopupClose(ctx context.Context, popupID port.WebViewID) {
374+ log := logging.FromContext(ctx)
375+ log.Debug().Uint64("popup_id", uint64(popupID)).Msg("popup close signal received")
376+
377+ // Check if still pending (never shown)
378+ c.popupMu.Lock()
379+ pending, wasPending := c.pendingPopups[popupID]
380+ if wasPending {
381+ delete(c.pendingPopups, popupID)
382+ }
383+ c.popupMu.Unlock()
384+
385+ if wasPending && pending != nil {
386+ if c.onClosePane != nil {
387+ if err := c.onClosePane(ctx, pending.PaneID); err != nil {
388+ log.Warn().Err(err).Str("pane_id", string(pending.PaneID)).Msg("failed to close pending popup pane")
389+ }
390+ }
391+ c.handlePopupOAuthClose(ctx, popupID)
392+ c.ReleaseWebView(ctx, pending.PaneID)
393+ log.Debug().Str("pane_id", string(pending.PaneID)).Msg("cleaned up pending popup that was never shown")
394+ return
395+ }
396+
397+ // Find pane by WebView ID
398+ var paneID entity.PaneID
399+ c.webViewsMu.RLock()
400+ for pid, wv := range c.webViews {
401+ if wv != nil && wv.ID() == popupID {
402+ paneID = pid
403+ break
404+ }
405+ }
406+ c.webViewsMu.RUnlock()
407+
408+ if paneID == "" {
409+ c.handlePopupOAuthClose(ctx, popupID)
410+ log.Warn().Msg("popup close: could not find pane for webview")
411+ return
412+ }
413+
414+ // Close the pane in workspace (this removes the UI element)
415+ if c.onClosePane != nil {
416+ if err := c.onClosePane(ctx, paneID); err != nil {
417+ log.Warn().Err(err).Str("pane_id", string(paneID)).Msg("failed to close popup pane")
418+ }
419+ }
420+
421+ c.handlePopupOAuthClose(ctx, popupID)
422+
423+ // Release the WebView (this will clean up tracking)
424+ c.ReleaseWebView(ctx, paneID)
425+
426+ log.Info().Str("pane_id", string(paneID)).Msg("popup closed")
427+}
428+
429+// handleLinkMiddleClick handles middle-click / Ctrl+click on links.
430+// Opens the link in a new pane using blank_target_behavior config.
431+func (c *Coordinator) handleLinkMiddleClick(ctx context.Context, parentPaneID entity.PaneID, uri string) bool {
432+ log := logging.FromContext(ctx)
433+
434+ log.Info().
435+ Str("parent_pane", string(parentPaneID)).
436+ Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
437+ Msg("middle-click/ctrl+click on link")
438+
439+ // Check if popups are enabled
440+ if c.popupConfig != nil && !c.popupConfig.OpenInNewPane {
441+ log.Debug().Msg("popups disabled by config, ignoring middle-click")
442+ return false
443+ }
444+
445+ // Check if factory is available
446+ if c.factory == nil {
447+ log.Warn().Msg("no webview factory, cannot handle middle-click")
448+ return false
449+ }
450+
451+ // Create a new WebView (regular, not related - just opening a link)
452+ newWV, err := c.factory.Create(ctx)
453+ if err != nil {
454+ log.Error().Err(err).Msg("failed to create webview for middle-click")
455+ return false
456+ }
457+
458+ // Generate pane ID
459+ var paneID entity.PaneID
460+ if c.generateID != nil {
461+ paneID = entity.PaneID(c.generateID())
462+ } else {
463+ paneID = entity.PaneID(fmt.Sprintf("link_%d", newWV.ID()))
464+ }
465+
466+ // Create pane entity - uses blank_target_behavior
467+ newPane := entity.NewPane(paneID)
468+ newPane.WindowType = entity.WindowPopup
469+ newPane.URI = uri
470+
471+ // Register WebView
472+ c.setWebViewLocked(paneID, newWV)
473+
474+ // Setup callbacks
475+ c.setupWebViewCallbacks(ctx, paneID, newWV)
476+
477+ // Get behavior from config (same as _blank links)
478+ behavior := entity.PopupBehaviorStacked // default
479+ if c.popupConfig != nil && c.popupConfig.BlankTargetBehavior != "" {
480+ behavior = entity.PopupBehavior(c.popupConfig.BlankTargetBehavior)
481+ }
482+ placement := "right"
483+ if c.popupConfig != nil {
484+ placement = c.popupConfig.Placement
485+ }
486+
487+ // Request insertion into workspace
488+ if c.onInsertPopup != nil {
489+ popupInput := InsertPopupInput{
490+ ParentPaneID: parentPaneID,
491+ PopupPane: newPane,
492+ WebView: newWV,
493+ Behavior: behavior,
494+ Placement: placement,
495+ PopupType: PopupTypeTab, // Treat like _blank
496+ TargetURI: uri,
497+ }
498+
499+ if err := c.onInsertPopup(ctx, popupInput); err != nil {
500+ log.Error().Err(err).Msg("failed to insert middle-click pane into workspace")
501+ // Clean up on failure — use the locking helper to avoid a data race.
502+ c.deleteWebViewLocked(paneID)
503+ newWV.Destroy()
504+ return false
505+ }
506+ }
507+
508+ // Load the URI after insertion
509+ if err := newWV.LoadURI(ctx, uri); err != nil {
510+ log.Error().Err(err).Str("uri", logging.TruncateURL(uri, logURLMaxLen)).Msg("failed to load URI in new pane")
511+ }
512+
513+ log.Info().
514+ Str("pane_id", string(paneID)).
515+ Str("behavior", string(behavior)).
516+ Str("uri", logging.TruncateURL(uri, logURLMaxLen)).
517+ Msg("middle-click link opened in new pane")
518+
519+ return true
520+}
A internal/ui/coordinator/content/popup_test.go +348 -0
@@ -0,0 +1,348 @@
1+package content
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+ "github.com/stretchr/testify/require"
10+
11+ "github.com/bnema/dumber/internal/application/port"
12+ "github.com/bnema/dumber/internal/domain/entity"
13+)
14+
15+// ---------------------------------------------------------------------------
16+// DetectPopupType
17+// ---------------------------------------------------------------------------
18+
19+func TestDetectPopupType_BlankIsTab(t *testing.T) {
20+ t.Parallel()
21+
22+ assert.Equal(t, PopupTypeTab, DetectPopupType("_blank"))
23+}
24+
25+func TestDetectPopupType_EmptyIsPopup(t *testing.T) {
26+ t.Parallel()
27+
28+ assert.Equal(t, PopupTypePopup, DetectPopupType(""))
29+}
30+
31+func TestDetectPopupType_NamedFrameIsPopup(t *testing.T) {
32+ t.Parallel()
33+
34+ assert.Equal(t, PopupTypePopup, DetectPopupType("myFrame"))
35+}
36+
37+// ---------------------------------------------------------------------------
38+// PopupType.String()
39+// ---------------------------------------------------------------------------
40+
41+func TestPopupTypeString_Tab(t *testing.T) {
42+ t.Parallel()
43+
44+ assert.Equal(t, "tab", PopupTypeTab.String())
45+}
46+
47+func TestPopupTypeString_Popup(t *testing.T) {
48+ t.Parallel()
49+
50+ assert.Equal(t, "popup", PopupTypePopup.String())
51+}
52+
53+func TestPopupTypeString_Unknown(t *testing.T) {
54+ t.Parallel()
55+
56+ assert.Equal(t, "unknown", PopupType(99).String())
57+}
58+
59+// ---------------------------------------------------------------------------
60+// GetBehavior
61+// ---------------------------------------------------------------------------
62+
63+func TestGetBehavior_NilConfigReturnsSplit(t *testing.T) {
64+ t.Parallel()
65+
66+ assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypeTab, nil))
67+ assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypePopup, nil))
68+}
69+
70+func TestGetBehavior_TabPopup_DefaultConfig(t *testing.T) {
71+ t.Parallel()
72+
73+ // BlankTargetBehavior is empty → falls through to default "stacked"
74+ cfg := &entity.PopupBehaviorConfig{}
75+ assert.Equal(t, entity.PopupBehaviorStacked, GetBehavior(PopupTypeTab, cfg))
76+}
77+
78+func TestGetBehavior_TabPopup_SplitConfig(t *testing.T) {
79+ t.Parallel()
80+
81+ cfg := &entity.PopupBehaviorConfig{BlankTargetBehavior: "split"}
82+ assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypeTab, cfg))
83+}
84+
85+func TestGetBehavior_TabPopup_StackedConfig(t *testing.T) {
86+ t.Parallel()
87+
88+ cfg := &entity.PopupBehaviorConfig{BlankTargetBehavior: "stacked"}
89+ assert.Equal(t, entity.PopupBehaviorStacked, GetBehavior(PopupTypeTab, cfg))
90+}
91+
92+func TestGetBehavior_TabPopup_TabbedConfig(t *testing.T) {
93+ t.Parallel()
94+
95+ cfg := &entity.PopupBehaviorConfig{BlankTargetBehavior: "tabbed"}
96+ assert.Equal(t, entity.PopupBehaviorTabbed, GetBehavior(PopupTypeTab, cfg))
97+}
98+
99+func TestGetBehavior_JSPopup_UsesBehaviorField(t *testing.T) {
100+ t.Parallel()
101+
102+ cfg := &entity.PopupBehaviorConfig{Behavior: entity.PopupBehaviorWindowed}
103+ assert.Equal(t, entity.PopupBehaviorWindowed, GetBehavior(PopupTypePopup, cfg))
104+}
105+
106+func TestGetBehavior_JSPopup_DefaultConfig(t *testing.T) {
107+ t.Parallel()
108+
109+ // Behavior zero-value is empty string; GetBehavior returns it as-is.
110+ cfg := &entity.PopupBehaviorConfig{}
111+ assert.Equal(t, entity.PopupBehavior(""), GetBehavior(PopupTypePopup, cfg))
112+}
113+
114+func TestGetBehavior_TabPopup_BlocksWhenOpenInNewPaneFalse(t *testing.T) {
115+ t.Parallel()
116+
117+ // GetBehavior itself does not honor OpenInNewPane — that is enforced by
118+ // handlePopupCreate. GetBehavior still returns the configured value.
119+ cfg := &entity.PopupBehaviorConfig{
120+ OpenInNewPane: false,
121+ BlankTargetBehavior: "split",
122+ }
123+ assert.Equal(t, entity.PopupBehaviorSplit, GetBehavior(PopupTypeTab, cfg))
124+}
125+
126+// ---------------------------------------------------------------------------
127+// trackOAuthPopup / capturePopupOAuthState
128+// ---------------------------------------------------------------------------
129+
130+func TestTrackOAuthPopup_StoresState(t *testing.T) {
131+ t.Parallel()
132+
133+ c := &Coordinator{
134+ webViews: make(map[entity.PaneID]port.WebView),
135+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
136+ }
137+
138+ popupID := port.WebViewID(1)
139+ parentPaneID := entity.PaneID("parent-pane")
140+
141+ c.trackOAuthPopup(popupID, parentPaneID)
142+
143+ c.popupMu.RLock()
144+ state, ok := c.popupOAuth[popupID]
145+ c.popupMu.RUnlock()
146+
147+ require.True(t, ok, "oauth state should be tracked after trackOAuthPopup")
148+ require.NotNil(t, state)
149+ assert.Equal(t, parentPaneID, state.ParentPaneID)
150+ assert.False(t, state.Seen, "Seen should be false before capturing callback URI")
151+}
152+
153+func TestCapturePopupOAuthState_SuccessCallback(t *testing.T) {
154+ t.Parallel()
155+
156+ c := &Coordinator{
157+ webViews: make(map[entity.PaneID]port.WebView),
158+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
159+ }
160+
161+ popupID := port.WebViewID(2)
162+ c.trackOAuthPopup(popupID, entity.PaneID("parent"))
163+ c.capturePopupOAuthState(popupID, "https://app.example.com/callback?code=abc123")
164+
165+ c.popupMu.RLock()
166+ state := c.popupOAuth[popupID]
167+ c.popupMu.RUnlock()
168+
169+ require.NotNil(t, state)
170+ assert.True(t, state.Seen)
171+ assert.True(t, state.Success)
172+ assert.False(t, state.Error)
173+ assert.Equal(t, "https://app.example.com/callback?code=abc123", state.CallbackURI)
174+}
175+
176+func TestCapturePopupOAuthState_ErrorCallback(t *testing.T) {
177+ t.Parallel()
178+
179+ c := &Coordinator{
180+ webViews: make(map[entity.PaneID]port.WebView),
181+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
182+ }
183+
184+ popupID := port.WebViewID(3)
185+ c.trackOAuthPopup(popupID, entity.PaneID("parent"))
186+ c.capturePopupOAuthState(popupID, "https://app.example.com/callback?error=access_denied")
187+
188+ c.popupMu.RLock()
189+ state := c.popupOAuth[popupID]
190+ c.popupMu.RUnlock()
191+
192+ require.NotNil(t, state)
193+ assert.True(t, state.Seen)
194+ assert.False(t, state.Success)
195+ assert.True(t, state.Error)
196+}
197+
198+func TestCapturePopupOAuthState_UnknownPopupIsNoop(t *testing.T) {
199+ t.Parallel()
200+
201+ c := &Coordinator{
202+ webViews: make(map[entity.PaneID]port.WebView),
203+ popupOAuth: make(map[port.WebViewID]*popupOAuthState),
204+ }
205+
206+ // Should not panic when no tracking entry exists.
207+ c.capturePopupOAuthState(port.WebViewID(999), "https://example.com/callback?code=x")
208+
209+ c.popupMu.RLock()
210+ _, ok := c.popupOAuth[port.WebViewID(999)]
211+ c.popupMu.RUnlock()
212+
213+ assert.False(t, ok)
214+}
215+
216+// ---------------------------------------------------------------------------
217+// SetPopupConfig
218+// ---------------------------------------------------------------------------
219+
220+func TestSetPopupConfig_SetsFields(t *testing.T) {
221+ t.Parallel()
222+
223+ c := &Coordinator{}
224+
225+ factory := &stubFactory{}
226+ cfg := &entity.PopupBehaviorConfig{Behavior: entity.PopupBehaviorSplit}
227+ genID := func() string { return "test-id" }
228+
229+ c.SetPopupConfig(factory, cfg, genID)
230+
231+ assert.Equal(t, factory, c.factory)
232+ assert.Equal(t, cfg, c.popupConfig)
233+ assert.NotNil(t, c.generateID)
234+ assert.Equal(t, "test-id", c.generateID())
235+}
236+
237+func TestSetPopupConfig_NilConfigAllowed(t *testing.T) {
238+ t.Parallel()
239+
240+ c := &Coordinator{}
241+ c.SetPopupConfig(nil, nil, nil)
242+
243+ assert.Nil(t, c.factory)
244+ assert.Nil(t, c.popupConfig)
245+ assert.Nil(t, c.generateID)
246+}
247+
248+// stubFactory satisfies port.WebViewFactory without importing the mocks package.
249+type stubFactory struct{}
250+
251+func (s *stubFactory) Create(_ context.Context) (port.WebView, error) {
252+ return nil, nil
253+}
254+
255+func (s *stubFactory) CreateRelated(_ context.Context, _ port.WebViewID) (port.WebView, error) {
256+ return nil, nil
257+}
258+
259+// ---------------------------------------------------------------------------
260+// Concurrent access to pendingPopups
261+// ---------------------------------------------------------------------------
262+
263+func TestPendingPopups_ConcurrentAccess(t *testing.T) {
264+ t.Parallel()
265+
266+ c := &Coordinator{
267+ pendingPopups: make(map[port.WebViewID]*PendingPopup),
268+ }
269+
270+ const workers = 20
271+ var wg sync.WaitGroup
272+ wg.Add(workers * 2)
273+
274+ // Writers
275+ for i := 0; i < workers; i++ {
276+ go func(id int) {
277+ defer wg.Done()
278+ popupID := port.WebViewID(id)
279+ c.popupMu.Lock()
280+ c.pendingPopups[popupID] = &PendingPopup{
281+ FrameName: "_blank",
282+ PopupType: PopupTypeTab,
283+ }
284+ c.popupMu.Unlock()
285+ }(i)
286+ }
287+
288+ // Readers
289+ for i := 0; i < workers; i++ {
290+ go func(id int) {
291+ defer wg.Done()
292+ popupID := port.WebViewID(id)
293+ c.popupMu.RLock()
294+ _ = c.pendingPopups[popupID]
295+ c.popupMu.RUnlock()
296+ }(i)
297+ }
298+
299+ wg.Wait()
300+
301+ c.popupMu.RLock()
302+ count := len(c.pendingPopups)
303+ c.popupMu.RUnlock()
304+
305+ assert.Equal(t, workers, count, "each writer uses a unique key, so all inserts should be present")
306+}
307+
308+func TestPendingPopups_ConcurrentDeleteAndRead(t *testing.T) {
309+ t.Parallel()
310+
311+ c := &Coordinator{
312+ pendingPopups: make(map[port.WebViewID]*PendingPopup),
313+ }
314+
315+ // Pre-populate
316+ for i := 0; i < 10; i++ {
317+ c.pendingPopups[port.WebViewID(i)] = &PendingPopup{PopupType: PopupTypePopup}
318+ }
319+
320+ var wg sync.WaitGroup
321+ wg.Add(20)
322+
323+ // Deleters
324+ for i := 0; i < 10; i++ {
325+ go func(id int) {
326+ defer wg.Done()
327+ c.popupMu.Lock()
328+ delete(c.pendingPopups, port.WebViewID(id))
329+ c.popupMu.Unlock()
330+ }(i)
331+ }
332+
333+ // Readers
334+ for i := 0; i < 10; i++ {
335+ go func(id int) {
336+ defer wg.Done()
337+ c.popupMu.RLock()
338+ _ = c.pendingPopups[port.WebViewID(id)]
339+ c.popupMu.RUnlock()
340+ }(i)
341+ }
342+
343+ wg.Wait()
344+
345+ c.popupMu.RLock()
346+ defer c.popupMu.RUnlock()
347+ assert.Empty(t, c.pendingPopups, "all preloaded popups should have been deleted")
348+}
A internal/ui/coordinator/content/title.go +327 -0
@@ -0,0 +1,327 @@
1+package content
2+
3+import (
4+ "context"
5+ "strings"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/domain/entity"
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+ if c.getActiveWS == nil {
35+ return
36+ }
37+ ws, wsView := c.getActiveWS()
38+ if ws != nil {
39+ paneNode := ws.FindPane(paneID)
40+ if paneNode != nil && paneNode.Pane != nil {
41+ paneNode.Pane.Title = title
42+ }
43+ // Check if this pane is the active one
44+ if ws.ActivePaneID == paneID {
45+ isActivePaneTitle = true
46+ }
47+ }
48+
49+ // Update StackedView title bar if this pane is in a stack
50+ if wsView != nil {
51+ tr := wsView.TreeRenderer()
52+ if tr != nil {
53+ stackedView := tr.GetStackedViewForPane(string(paneID))
54+ if stackedView != nil {
55+ c.updateStackedPaneTitle(ctx, stackedView, paneID, title)
56+ }
57+ }
58+ }
59+
60+ // Notify history persistence (get URL from WebView)
61+ if c.onTitleUpdated != nil {
62+ if wv := c.getWebViewLocked(paneID); wv != nil {
63+ currentURI := wv.URI()
64+ if currentURI != "" && title != "" {
65+ c.onTitleUpdated(ctx, paneID, currentURI, title)
66+ }
67+ }
68+ }
69+
70+ // Notify window title update if this is the active pane
71+ if isActivePaneTitle && c.onWindowTitleChanged != nil {
72+ c.onWindowTitleChanged(title)
73+ }
74+
75+ log.Debug().
76+ Str("pane_id", string(paneID)).
77+ Str("title", title).
78+ Msg("pane title updated")
79+}
80+
81+// updateStackedPaneTitle updates the title of a pane in a StackedView.
82+func (c *Coordinator) updateStackedPaneTitle(
83+ ctx context.Context,
84+ sv *layout.StackedView,
85+ paneID entity.PaneID,
86+ title string,
87+) {
88+ log := logging.FromContext(ctx)
89+
90+ // Find the pane's index directly in the StackedView
91+ index := sv.FindPaneIndex(string(paneID))
92+ if index < 0 {
93+ log.Debug().
94+ Str("pane_id", string(paneID)).
95+ Msg("pane not found in StackedView for title update")
96+ return
97+ }
98+
99+ if err := sv.UpdateTitle(index, title); err != nil {
100+ log.Warn().Err(err).Int("index", index).Msg("failed to update stacked pane title")
101+ }
102+}
103+
104+// syncStackedTitle updates the stacked title bar for a pane if it's in a stack.
105+// Called from onLoadCommitted to keep titles in sync during navigation.
106+func (c *Coordinator) syncStackedTitle(ctx context.Context, paneID entity.PaneID, title string) {
107+ if c.getActiveWS == nil {
108+ return
109+ }
110+ _, wsView := c.getActiveWS()
111+ if wsView == nil {
112+ return
113+ }
114+ tr := wsView.TreeRenderer()
115+ if tr == nil {
116+ return
117+ }
118+ if sv := tr.GetStackedViewForPane(string(paneID)); sv != nil {
119+ c.updateStackedPaneTitle(ctx, sv, paneID, title)
120+ }
121+}
122+
123+// onFaviconChanged updates favicon tracking when a WebView's favicon changes.
124+func (c *Coordinator) onFaviconChanged(ctx context.Context, paneID entity.PaneID, emittingWV port.WebView, favicon *gdk.Texture) {
125+ log := logging.FromContext(ctx)
126+
127+ // Verify this WebView is still bound to the expected pane
128+ currentWV := c.getWebViewLocked(paneID)
129+ if currentWV == nil || currentWV != emittingWV {
130+ log.Debug().
131+ Str("pane_id", string(paneID)).
132+ Msg("ignoring favicon change from unbound webview")
133+ return
134+ }
135+
136+ uri := emittingWV.URI()
137+
138+ // Update favicon cache with domain key (handles cross-domain redirects)
139+ if c.faviconAdapter != nil && favicon != nil && uri != "" {
140+ c.navOriginMu.RLock()
141+ originURL := c.navOrigins[paneID]
142+ c.navOriginMu.RUnlock()
143+ c.faviconAdapter.StoreFromWebKitWithOrigin(ctx, uri, originURL, favicon)
144+ }
145+
146+ // Update StackedView favicon if this pane is in a stack
147+ c.updateStackedFaviconForPane(ctx, paneID, favicon)
148+
149+ log.Debug().
150+ Str("pane_id", string(paneID)).
151+ Str("uri", uri).
152+ Bool("has_favicon", favicon != nil).
153+ Msg("pane favicon updated")
154+}
155+
156+// updateStackedPaneFavicon updates the favicon of a pane in a StackedView.
157+func (c *Coordinator) updateStackedPaneFavicon(
158+ ctx context.Context,
159+ sv *layout.StackedView,
160+ paneID entity.PaneID,
161+ favicon *gdk.Texture,
162+) {
163+ log := logging.FromContext(ctx)
164+
165+ // Find the pane's index directly in the StackedView
166+ index := sv.FindPaneIndex(string(paneID))
167+ if index < 0 {
168+ log.Debug().
169+ Str("pane_id", string(paneID)).
170+ Msg("pane not found in StackedView for favicon update")
171+ return
172+ }
173+
174+ if err := sv.UpdateFaviconTexture(index, favicon); err != nil {
175+ log.Warn().Err(err).Int("index", index).Msg("failed to update stacked pane favicon")
176+ }
177+}
178+
179+// updateStackedFaviconForPane updates the stacked title bar favicon for a pane.
180+func (c *Coordinator) updateStackedFaviconForPane(ctx context.Context, paneID entity.PaneID, texture *gdk.Texture) {
181+ if c.getActiveWS == nil {
182+ return
183+ }
184+ _, wsView := c.getActiveWS()
185+ if wsView == nil {
186+ return
187+ }
188+ tr := wsView.TreeRenderer()
189+ if tr == nil {
190+ return
191+ }
192+ if sv := tr.GetStackedViewForPane(string(paneID)); sv != nil {
193+ c.updateStackedPaneFavicon(ctx, sv, paneID, texture)
194+ }
195+}
196+
197+// resolveCommittedFavicon ensures the stacked title bar has a favicon after navigation commits.
198+// First checks if WebKit already has a favicon for the page (common for subpath URLs).
199+// Falls back to the full GetOrFetch pipeline (cache + WebKit DB + DuckDuckGo API).
200+// This closes the gap where title bars relied solely on the notify::favicon signal.
201+func (c *Coordinator) resolveCommittedFavicon(ctx context.Context, paneID entity.PaneID, wv port.WebView) {
202+ if c.faviconAdapter == nil || wv == nil {
203+ return
204+ }
205+
206+ uri := wv.URI()
207+ if uri == "" || strings.HasPrefix(uri, "about:") || strings.HasPrefix(uri, "dumb://") {
208+ return
209+ }
210+
211+ log := logging.FromContext(ctx)
212+
213+ // Check if we already have a texture cached for this domain
214+ if texture := c.faviconAdapter.GetTextureByURL(uri); texture != nil {
215+ // Already cached - just ensure the stacked view is updated
216+ c.updateStackedFaviconForPane(ctx, paneID, texture)
217+ return
218+ }
219+
220+ // Check if the WebView already has a favicon for this page
221+ if icon := wv.Favicon(); icon != nil {
222+ if gdkIcon, ok := icon.(*gdk.Texture); ok {
223+ log.Debug().Str("pane_id", string(paneID)).Str("uri", uri).Msg("using existing favicon for committed page")
224+ // Store and update through the normal path
225+ c.navOriginMu.RLock()
226+ originURL := c.navOrigins[paneID]
227+ c.navOriginMu.RUnlock()
228+ c.faviconAdapter.StoreFromWebKitWithOrigin(ctx, uri, originURL, gdkIcon)
229+ c.updateStackedFaviconForPane(ctx, paneID, gdkIcon)
230+ return
231+ }
232+ }
233+
234+ // Fall back to GetOrFetch (checks service cache, engine DB, then DuckDuckGo API)
235+ // Capture current generation to guard against stale callbacks
236+ gen := wv.Generation()
237+ capturedURI := uri
238+ c.faviconAdapter.GetOrFetch(ctx, uri, func(texture *gdk.Texture) {
239+ // Skip nil results — a nil means "couldn't resolve", not "no favicon".
240+ // Without this guard, a late nil callback can overwrite a good favicon
241+ // that was already set by an earlier onFaviconChanged signal.
242+ if texture == nil {
243+ return
244+ }
245+ // Verify WebView is still bound to pane and hasn't been reused
246+ if wv.Generation() != gen {
247+ return
248+ }
249+ currentWV := c.getWebViewLocked(paneID)
250+ if currentWV != wv {
251+ return
252+ }
253+ // Verify the WebView hasn't navigated away since we started the fetch
254+ if currentWV.URI() != capturedURI {
255+ return
256+ }
257+ c.updateStackedFaviconForPane(ctx, paneID, texture)
258+ })
259+}
260+
261+// FaviconAdapter returns the favicon adapter for external use (e.g., omnibox).
262+func (c *Coordinator) FaviconAdapter() *adapter.FaviconAdapter {
263+ return c.faviconAdapter
264+}
265+
266+// SetNavigationOrigin records the original URL before navigation starts.
267+// This allows caching favicons under both original and final domains
268+// when cross-domain redirects occur (e.g., google.fr → google.com).
269+func (c *Coordinator) SetNavigationOrigin(paneID entity.PaneID, uri string) {
270+ c.navOriginMu.Lock()
271+ c.navOrigins[paneID] = uri
272+ c.navOriginMu.Unlock()
273+}
274+
275+// PreloadCachedFavicon checks the favicon cache and updates the stacked pane
276+// title bar immediately if a cached favicon exists for the URL.
277+// This provides instant favicon display without waiting for WebKit.
278+func (c *Coordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.PaneID, uri string) {
279+ if c.faviconAdapter == nil || uri == "" {
280+ return
281+ }
282+ select {
283+ case <-ctx.Done():
284+ return
285+ default:
286+ }
287+
288+ // Check memory and disk cache (no external fetch)
289+ texture := c.faviconAdapter.PreloadFromCache(uri)
290+ select {
291+ case <-ctx.Done():
292+ return
293+ default:
294+ }
295+
296+ // Update stacked pane favicon on GTK main loop.
297+ cb := glib.SourceFunc(func(_ uintptr) bool {
298+ select {
299+ case <-ctx.Done():
300+ return false
301+ default:
302+ }
303+ if c.getActiveWS == nil {
304+ return false
305+ }
306+ // Only update if we found a cached texture.
307+ // If nil, let resolveCommittedFavicon handle it after load commits.
308+ if texture == nil {
309+ return false
310+ }
311+ _, wsView := c.getActiveWS()
312+ if wsView == nil {
313+ return false
314+ }
315+ tr := wsView.TreeRenderer()
316+ if tr == nil {
317+ return false
318+ }
319+ stackedView := tr.GetStackedViewForPane(string(paneID))
320+ if stackedView == nil {
321+ return false
322+ }
323+ c.updateStackedPaneFavicon(ctx, stackedView, paneID, texture)
324+ return false
325+ })
326+ glib.IdleAdd(&cb, 0)
327+}
A internal/ui/coordinator/content/title_test.go +440 -0
@@ -0,0 +1,440 @@
1+package content
2+
3+import (
4+ "context"
5+ "sync"
6+ "testing"
7+
8+ "github.com/stretchr/testify/assert"
9+
10+ "github.com/bnema/dumber/internal/domain/entity"
11+)
12+
13+// TestGetTitle tests the GetTitle method for title state retrieval.
14+
15+func TestGetTitle_ReturnsEmptyForUnknownPane(t *testing.T) {
16+ t.Parallel()
17+
18+ c := &Coordinator{
19+ paneTitles: make(map[entity.PaneID]string),
20+ }
21+
22+ title := c.GetTitle("unknown-pane")
23+ assert.Empty(t, title)
24+}
25+
26+func TestGetTitle_ReturnsTrackedTitle(t *testing.T) {
27+ t.Parallel()
28+
29+ paneID := entity.PaneID("pane-1")
30+ c := &Coordinator{
31+ paneTitles: map[entity.PaneID]string{
32+ paneID: "My Page Title",
33+ },
34+ }
35+
36+ title := c.GetTitle(paneID)
37+ assert.Equal(t, "My Page Title", title)
38+}
39+
40+func TestGetTitle_DirectMapUpdate(t *testing.T) {
41+ t.Parallel()
42+
43+ paneID := entity.PaneID("pane-1")
44+ c := &Coordinator{
45+ paneTitles: make(map[entity.PaneID]string),
46+ }
47+
48+ // Simulate the write that onTitleChanged performs
49+ c.titleMu.Lock()
50+ c.paneTitles[paneID] = "Updated Title"
51+ c.titleMu.Unlock()
52+
53+ title := c.GetTitle(paneID)
54+ assert.Equal(t, "Updated Title", title)
55+}
56+
57+func TestGetTitle_EmptyStringAfterZeroValue(t *testing.T) {
58+ t.Parallel()
59+
60+ paneID := entity.PaneID("pane-1")
61+ c := &Coordinator{
62+ paneTitles: map[entity.PaneID]string{
63+ paneID: "",
64+ },
65+ }
66+
67+ title := c.GetTitle(paneID)
68+ assert.Empty(t, title)
69+}
70+
71+// TestPaneTitles_ConcurrentAccess verifies that concurrent reads and writes to
72+// paneTitles do not race.
73+func TestPaneTitles_ConcurrentAccess(t *testing.T) {
74+ t.Parallel()
75+
76+ c := &Coordinator{
77+ paneTitles: make(map[entity.PaneID]string),
78+ }
79+
80+ paneIDs := []entity.PaneID{"pane-1", "pane-2", "pane-3"}
81+
82+ var wg sync.WaitGroup
83+ for i := 0; i < 50; i++ {
84+ for _, paneID := range paneIDs {
85+ wg.Add(2)
86+
87+ go func(id entity.PaneID) {
88+ defer wg.Done()
89+ c.titleMu.Lock()
90+ c.paneTitles[id] = "title for " + string(id)
91+ c.titleMu.Unlock()
92+ }(paneID)
93+
94+ go func(id entity.PaneID) {
95+ defer wg.Done()
96+ _ = c.GetTitle(id)
97+ }(paneID)
98+ }
99+ }
100+
101+ wg.Wait()
102+ // Test passes if no race conditions occur
103+}
104+
105+func TestPaneTitles_MultiplepanesTrackedIndependently(t *testing.T) {
106+ t.Parallel()
107+
108+ pane1 := entity.PaneID("pane-1")
109+ pane2 := entity.PaneID("pane-2")
110+
111+ c := &Coordinator{
112+ paneTitles: map[entity.PaneID]string{
113+ pane1: "Title One",
114+ pane2: "Title Two",
115+ },
116+ }
117+
118+ assert.Equal(t, "Title One", c.GetTitle(pane1))
119+ assert.Equal(t, "Title Two", c.GetTitle(pane2))
120+}
121+
122+// TestSetNavigationOrigin tests navigation origin storage and retrieval.
123+
124+func TestSetNavigationOrigin_StoresOriginURL(t *testing.T) {
125+ t.Parallel()
126+
127+ paneID := entity.PaneID("pane-1")
128+ c := &Coordinator{
129+ navOrigins: make(map[entity.PaneID]string),
130+ }
131+
132+ c.SetNavigationOrigin(paneID, "https://google.fr/search?q=test")
133+
134+ c.navOriginMu.RLock()
135+ origin := c.navOrigins[paneID]
136+ c.navOriginMu.RUnlock()
137+
138+ assert.Equal(t, "https://google.fr/search?q=test", origin)
139+}
140+
141+func TestSetNavigationOrigin_OverwritesPreviousOrigin(t *testing.T) {
142+ t.Parallel()
143+
144+ paneID := entity.PaneID("pane-1")
145+ c := &Coordinator{
146+ navOrigins: make(map[entity.PaneID]string),
147+ }
148+
149+ c.SetNavigationOrigin(paneID, "https://google.fr/")
150+ c.SetNavigationOrigin(paneID, "https://example.com/new")
151+
152+ c.navOriginMu.RLock()
153+ origin := c.navOrigins[paneID]
154+ c.navOriginMu.RUnlock()
155+
156+ assert.Equal(t, "https://example.com/new", origin)
157+}
158+
159+func TestSetNavigationOrigin_IndependentPerPane(t *testing.T) {
160+ t.Parallel()
161+
162+ pane1 := entity.PaneID("pane-1")
163+ pane2 := entity.PaneID("pane-2")
164+ c := &Coordinator{
165+ navOrigins: make(map[entity.PaneID]string),
166+ }
167+
168+ c.SetNavigationOrigin(pane1, "https://pane1.example.com/")
169+ c.SetNavigationOrigin(pane2, "https://pane2.example.com/")
170+
171+ c.navOriginMu.RLock()
172+ origin1 := c.navOrigins[pane1]
173+ origin2 := c.navOrigins[pane2]
174+ c.navOriginMu.RUnlock()
175+
176+ assert.Equal(t, "https://pane1.example.com/", origin1)
177+ assert.Equal(t, "https://pane2.example.com/", origin2)
178+}
179+
180+func TestSetNavigationOrigin_EmptyOrigin(t *testing.T) {
181+ t.Parallel()
182+
183+ paneID := entity.PaneID("pane-1")
184+ c := &Coordinator{
185+ navOrigins: map[entity.PaneID]string{
186+ paneID: "https://old-origin.example.com/",
187+ },
188+ }
189+
190+ c.SetNavigationOrigin(paneID, "")
191+
192+ c.navOriginMu.RLock()
193+ origin := c.navOrigins[paneID]
194+ c.navOriginMu.RUnlock()
195+
196+ assert.Empty(t, origin)
197+}
198+
199+// TestNavOrigins_ConcurrentAccess verifies that concurrent reads and writes to
200+// navOrigins do not race.
201+func TestNavOrigins_ConcurrentAccess(t *testing.T) {
202+ t.Parallel()
203+
204+ c := &Coordinator{
205+ navOrigins: make(map[entity.PaneID]string),
206+ }
207+
208+ paneIDs := []entity.PaneID{"pane-1", "pane-2", "pane-3"}
209+
210+ var wg sync.WaitGroup
211+ for i := 0; i < 50; i++ {
212+ for _, paneID := range paneIDs {
213+ wg.Add(2)
214+
215+ go func(id entity.PaneID) {
216+ defer wg.Done()
217+ c.SetNavigationOrigin(id, "https://origin-for-"+string(id)+".example.com/")
218+ }(paneID)
219+
220+ go func(id entity.PaneID) {
221+ defer wg.Done()
222+ c.navOriginMu.RLock()
223+ _ = c.navOrigins[id]
224+ c.navOriginMu.RUnlock()
225+ }(paneID)
226+ }
227+ }
228+
229+ wg.Wait()
230+ // Test passes if no race conditions occur
231+}
232+
233+// TestQueueThemeApply tests the appearance queue management for theme applies.
234+
235+func TestQueueThemeApply_TakeReturnsTrue(t *testing.T) {
236+ t.Parallel()
237+
238+ paneID := entity.PaneID("pane-1")
239+ c := &Coordinator{}
240+
241+ c.queueThemeApply(paneID, true, "body { color: white; }")
242+
243+ update, ok := c.takePendingThemeApply(paneID)
244+ assert.True(t, ok)
245+ assert.True(t, update.prefersDark)
246+ assert.Equal(t, "body { color: white; }", update.cssText)
247+}
248+
249+func TestQueueThemeApply_SecondTakeReturnsFalse(t *testing.T) {
250+ t.Parallel()
251+
252+ paneID := entity.PaneID("pane-1")
253+ c := &Coordinator{}
254+
255+ c.queueThemeApply(paneID, false, "body { color: black; }")
256+
257+ _, firstOk := c.takePendingThemeApply(paneID)
258+ assert.True(t, firstOk)
259+
260+ _, secondOk := c.takePendingThemeApply(paneID)
261+ assert.False(t, secondOk)
262+}
263+
264+func TestQueueThemeApply_TakeWithoutQueue_ReturnsFalse(t *testing.T) {
265+ t.Parallel()
266+
267+ paneID := entity.PaneID("pane-1")
268+ c := &Coordinator{}
269+
270+ _, ok := c.takePendingThemeApply(paneID)
271+ assert.False(t, ok)
272+}
273+
274+func TestQueueThemeApply_MultiplePanesQueued_Independently(t *testing.T) {
275+ t.Parallel()
276+
277+ pane1 := entity.PaneID("pane-1")
278+ pane2 := entity.PaneID("pane-2")
279+ c := &Coordinator{}
280+
281+ // pendingThemeUpdate is a single shared payload; queueThemeApply always
282+ // overwrites it with the latest call. Both panes are marked as pending,
283+ // but they share the last-written update.
284+ c.queueThemeApply(pane1, true, "shared-css")
285+ c.queueThemeApply(pane2, true, "shared-css")
286+
287+ // Both panes should be independently takeable
288+ update1, ok1 := c.takePendingThemeApply(pane1)
289+ assert.True(t, ok1)
290+ assert.Equal(t, "shared-css", update1.cssText)
291+
292+ // pane2 should still be queued after taking pane1
293+ update2, ok2 := c.takePendingThemeApply(pane2)
294+ assert.True(t, ok2)
295+ assert.Equal(t, "shared-css", update2.cssText)
296+
297+ // Neither should be available after both are taken
298+ _, ok3 := c.takePendingThemeApply(pane1)
299+ assert.False(t, ok3)
300+ _, ok4 := c.takePendingThemeApply(pane2)
301+ assert.False(t, ok4)
302+}
303+
304+func TestQueueThemeApply_LastWriteWins(t *testing.T) {
305+ t.Parallel()
306+
307+ paneID := entity.PaneID("pane-1")
308+ c := &Coordinator{}
309+
310+ // Queue twice; the second call overwrites pendingThemeUpdate
311+ c.queueThemeApply(paneID, true, "first-css")
312+ c.queueThemeApply(paneID, false, "second-css")
313+
314+ update, ok := c.takePendingThemeApply(paneID)
315+ assert.True(t, ok)
316+ assert.False(t, update.prefersDark)
317+ assert.Equal(t, "second-css", update.cssText)
318+}
319+
320+func TestQueueThemeApply_HasPendingFlagClearedWhenAllTaken(t *testing.T) {
321+ t.Parallel()
322+
323+ pane1 := entity.PaneID("pane-1")
324+ pane2 := entity.PaneID("pane-2")
325+ c := &Coordinator{}
326+
327+ c.queueThemeApply(pane1, true, "css")
328+ c.queueThemeApply(pane2, true, "css")
329+
330+ _, _ = c.takePendingThemeApply(pane1)
331+
332+ // hasPendingThemeUpdate should still be true since pane2 is pending
333+ c.appearanceMu.Lock()
334+ assert.True(t, c.hasPendingThemeUpdate)
335+ c.appearanceMu.Unlock()
336+
337+ _, _ = c.takePendingThemeApply(pane2)
338+
339+ // Now hasPendingThemeUpdate should be false
340+ c.appearanceMu.Lock()
341+ assert.False(t, c.hasPendingThemeUpdate)
342+ c.appearanceMu.Unlock()
343+}
344+
345+// TestQueueScriptRefresh tests the appearance queue management for script refreshes.
346+
347+func TestQueueScriptRefresh_TakeReturnsTrue(t *testing.T) {
348+ t.Parallel()
349+
350+ paneID := entity.PaneID("pane-1")
351+ c := &Coordinator{}
352+
353+ c.queueScriptRefresh(paneID)
354+
355+ ok := c.takePendingScriptRefresh(paneID)
356+ assert.True(t, ok)
357+}
358+
359+func TestQueueScriptRefresh_SecondTakeReturnsFalse(t *testing.T) {
360+ t.Parallel()
361+
362+ paneID := entity.PaneID("pane-1")
363+ c := &Coordinator{}
364+
365+ c.queueScriptRefresh(paneID)
366+
367+ firstOk := c.takePendingScriptRefresh(paneID)
368+ assert.True(t, firstOk)
369+
370+ secondOk := c.takePendingScriptRefresh(paneID)
371+ assert.False(t, secondOk)
372+}
373+
374+func TestQueueScriptRefresh_TakeWithoutQueue_ReturnsFalse(t *testing.T) {
375+ t.Parallel()
376+
377+ paneID := entity.PaneID("pane-1")
378+ c := &Coordinator{}
379+
380+ ok := c.takePendingScriptRefresh(paneID)
381+ assert.False(t, ok)
382+}
383+
384+func TestQueueScriptRefresh_MultiplePanesQueuedIndependently(t *testing.T) {
385+ t.Parallel()
386+
387+ pane1 := entity.PaneID("pane-1")
388+ pane2 := entity.PaneID("pane-2")
389+ pane3 := entity.PaneID("pane-3")
390+ c := &Coordinator{}
391+
392+ c.queueScriptRefresh(pane1)
393+ c.queueScriptRefresh(pane2)
394+
395+ // pane3 was never queued
396+ assert.True(t, c.takePendingScriptRefresh(pane1))
397+ assert.True(t, c.takePendingScriptRefresh(pane2))
398+ assert.False(t, c.takePendingScriptRefresh(pane3))
399+}
400+
401+// TestApplyPendingThemeUpdate tests the state management aspect of applyPendingThemeUpdate.
402+
403+func TestApplyPendingThemeUpdate_ReturnsFalseWhenNothingQueued(t *testing.T) {
404+ t.Parallel()
405+
406+ paneID := entity.PaneID("pane-1")
407+ c := &Coordinator{}
408+
409+ // applyPendingThemeUpdate calls takePendingThemeApply; with nothing queued it
410+ // returns false immediately without touching the WebView.
411+ result := c.applyPendingThemeUpdate(context.TODO(), paneID, nil)
412+ assert.False(t, result)
413+}
414+
415+// TestClearPendingAppearance tests that clearPendingAppearance removes both
416+// theme and script queues for the targeted pane only.
417+func TestClearPendingAppearance_ClearsBothQueuesForPane(t *testing.T) {
418+ t.Parallel()
419+
420+ pane1 := entity.PaneID("pane-1")
421+ pane2 := entity.PaneID("pane-2")
422+ c := &Coordinator{}
423+
424+ c.queueThemeApply(pane1, true, "css")
425+ c.queueScriptRefresh(pane1)
426+ c.queueThemeApply(pane2, true, "css")
427+ c.queueScriptRefresh(pane2)
428+
429+ c.clearPendingAppearance(pane1)
430+
431+ // pane1 queues should be gone
432+ assert.False(t, c.takePendingScriptRefresh(pane1))
433+ _, themeOk1 := c.takePendingThemeApply(pane1)
434+ assert.False(t, themeOk1)
435+
436+ // pane2 queues should still be present
437+ assert.True(t, c.takePendingScriptRefresh(pane2))
438+ _, themeOk2 := c.takePendingThemeApply(pane2)
439+ assert.True(t, themeOk2)
440+}
M internal/ui/coordinator/navigation.go +11 -10
@@ -5,10 +5,11 @@ import (
55 "fmt"
66 "time"
77
8+ "github.com/bnema/dumber/internal/application/port"
89 "github.com/bnema/dumber/internal/application/usecase"
910 "github.com/bnema/dumber/internal/domain/entity"
10- "github.com/bnema/dumber/internal/infrastructure/webkit"
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")
@@ -210,9 +211,9 @@ func (c *NavigationCoordinator) OpenDevTools(ctx context.Context) error {
210211
211212 log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("opening devtools")
212213
213- // Type assert to access ShowDevTools (not in port.WebView interface)
214- if webkitWV, ok := interface{}(wv).(*webkit.WebView); ok {
215- return webkitWV.ShowDevTools()
214+ if opener, ok := wv.(port.DevToolsOpener); ok {
215+ opener.OpenDevTools()
216+ return nil
216217 }
217218
218219 return fmt.Errorf("webview does not support devtools")
@@ -230,9 +231,9 @@ func (c *NavigationCoordinator) PrintPage(ctx context.Context) error {
230231
231232 log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("opening print dialog")
232233
233- // Type assert to access Print (not in port.WebView interface)
234- if webkitWV, ok := interface{}(wv).(*webkit.WebView); ok {
235- return webkitWV.Print()
234+ if printer, ok := wv.(port.Printer); ok {
235+ printer.PrintPage()
236+ return nil
236237 }
237238
238239 return fmt.Errorf("webview does not support printing")
@@ -269,7 +270,7 @@ func (c *NavigationCoordinator) ClearPaneHistory(paneID entity.PaneID) {
269270 }
270271
271272 // ActiveWebView returns the WebView for the active pane (for zoom operations).
272-func (c *NavigationCoordinator) ActiveWebView(ctx context.Context) *webkit.WebView {
273+func (c *NavigationCoordinator) ActiveWebView(ctx context.Context) port.WebView {
273274 return c.contentCoord.ActiveWebView(ctx)
274275 }
275276
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/infrastructure/webkit"
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(webkit.LoadEvent),
181- next func(webkit.LoadEvent),
182-) func(webkit.LoadEvent) {
183- if existing == nil {
184- return next
185- }
186- if next == nil {
187- return existing
188- }
189- return func(event webkit.LoadEvent) {
190- existing(event)
191- next(event)
192- }
193-}
D internal/ui/coordinator/popup_types.go +0 -128
@@ -1,128 +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- "github.com/bnema/dumber/internal/infrastructure/webkit"
10-)
11-
12-// PopupType indicates whether the popup was triggered by a link or JavaScript.
13-type PopupType int
14-
15-const (
16- // PopupTypeTab represents a popup from target="_blank" links.
17- // These are typically user-initiated and open like new tabs.
18- PopupTypeTab PopupType = iota
19- // PopupTypePopup represents a popup from window.open() JavaScript calls.
20- // These may have different placement behavior than tab-like popups.
21- PopupTypePopup
22-)
23-
24-// String returns a human-readable name for the popup type.
25-func (t PopupType) String() string {
26- switch t {
27- case PopupTypeTab:
28- return "tab"
29- case PopupTypePopup:
30- return "popup"
31- default:
32- return "unknown"
33- }
34-}
35-
36-// PendingPopup tracks a popup WebView that has been created but not yet
37-// inserted into the workspace. This is used during the three-phase popup
38-// lifecycle: create → ready-to-show → (insert into workspace).
39-type PendingPopup struct {
40- // WebView is the related WebView created in the "create" phase.
41- // It shares cookies/session with the parent for OAuth support.
42- WebView *webkit.WebView
43-
44- // ParentPaneID is the pane that spawned this popup.
45- ParentPaneID entity.PaneID
46-
47- // ParentWebViewID is the WebView ID of the parent (for lookup).
48- ParentWebViewID port.WebViewID
49-
50- // TargetURI is the initial URL the popup will load.
51- TargetURI string
52-
53- // FrameName is the target frame name from the navigation action.
54- // "_blank" indicates a tab-like popup, other values indicate JS popups.
55- FrameName string
56-
57- // IsUserGesture indicates if the popup was triggered by user action
58- // (click) vs script-initiated (e.g., popup after timeout).
59- IsUserGesture bool
60-
61- // PopupType categorizes this as a tab-like or JS popup.
62- PopupType PopupType
63-
64- // CreatedAt is when the popup was created (for timeout handling).
65- CreatedAt time.Time
66-}
67-
68-// DetectPopupType determines if this is a tab-like or JS popup based on frame name.
69-func DetectPopupType(frameName string) PopupType {
70- // _blank is the standard indicator for "open in new tab/window"
71- if frameName == "_blank" {
72- return PopupTypeTab
73- }
74- // Empty or custom frame names indicate JS window.open()
75- return PopupTypePopup
76-}
77-
78-// InsertPopupInput contains the data needed to insert a popup into the workspace.
79-type InsertPopupInput struct {
80- // ParentPaneID is the pane that spawned this popup.
81- ParentPaneID entity.PaneID
82-
83- // PopupPane is the pre-created pane entity for the popup.
84- // It should have IsRelated=true and ParentPaneID set.
85- PopupPane *entity.Pane
86-
87- // WebView is the related WebView for the popup.
88- WebView *webkit.WebView
89-
90- // Behavior determines how the popup is inserted (split/stacked/tabbed).
91- Behavior config.PopupBehavior
92-
93- // Placement specifies direction for split behavior (right/left/top/bottom).
94- Placement string
95-
96- // PopupType indicates if this is a tab-like or JS popup.
97- PopupType PopupType
98-
99- // TargetURI is the URL to load in the popup.
100- TargetURI string
101-}
102-
103-// GetBehavior returns the appropriate behavior based on popup type and config.
104-func GetBehavior(popupType PopupType, cfg *config.PopupBehaviorConfig) config.PopupBehavior {
105- if cfg == nil {
106- return config.PopupBehaviorSplit // Default
107- }
108-
109- switch popupType {
110- case PopupTypeTab:
111- // Tab-like popups (_blank) use blank_target_behavior
112- switch cfg.BlankTargetBehavior {
113- case "split":
114- return config.PopupBehaviorSplit
115- case "stacked":
116- return config.PopupBehaviorStacked
117- case "tabbed":
118- return config.PopupBehaviorTabbed
119- default:
120- return config.PopupBehaviorStacked // Default for _blank
121- }
122- case PopupTypePopup:
123- // JS popups use the main behavior setting
124- return cfg.Behavior
125- default:
126- return cfg.Behavior
127- }
128-}
M internal/ui/coordinator/tab.go +21 -22
@@ -3,10 +3,9 @@ package coordinator
33 import (
44 "context"
55
6+ "github.com/bnema/dumber/internal/application/port"
67 "github.com/bnema/dumber/internal/application/usecase"
78 "github.com/bnema/dumber/internal/domain/entity"
8- "github.com/bnema/dumber/internal/infrastructure/config"
9- "github.com/bnema/dumber/internal/infrastructure/webkit"
109 "github.com/bnema/dumber/internal/logging"
1110 "github.com/bnema/dumber/internal/ui/component"
1211 "github.com/bnema/dumber/internal/ui/window"
@@ -14,25 +13,25 @@ import (
1413
1514 // TabCoordinator manages tab lifecycle operations.
1615 type TabCoordinator struct {
17- tabsUC *usecase.ManageTabsUseCase
18- tabs *entity.TabList
19- mainWindow *window.MainWindow
20- config *config.Config
16+ tabsUC *usecase.ManageTabsUseCase
17+ tabs *entity.TabList
18+ mainWindow *window.MainWindow
19+ hideTabBarWhenSingleTab bool
2120
2221 // Callbacks to avoid circular dependencies
2322 onTabCreated func(ctx context.Context, tab *entity.Tab)
2423 onTabSwitched func(ctx context.Context, tab *entity.Tab)
2524 onQuit func()
26- onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView) // For popup tabs
27- onStateChanged func() // For session snapshots
25+ onAttachPopupToTab func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView) // For popup tabs
26+ onStateChanged func() // For session snapshots
2827 }
2928
3029 // TabCoordinatorConfig holds configuration for TabCoordinator.
3130 type TabCoordinatorConfig struct {
32- TabsUC *usecase.ManageTabsUseCase
33- Tabs *entity.TabList
34- MainWindow *window.MainWindow
35- Config *config.Config
31+ TabsUC *usecase.ManageTabsUseCase
32+ Tabs *entity.TabList
33+ MainWindow *window.MainWindow
34+ HideTabBarWhenSingleTab bool
3635 }
3736
3837 // NewTabCoordinator creates a new TabCoordinator.
@@ -41,10 +40,10 @@ func NewTabCoordinator(ctx context.Context, cfg TabCoordinatorConfig) *TabCoordi
4140 log.Debug().Msg("creating tab coordinator")
4241
4342 return &TabCoordinator{
44- tabsUC: cfg.TabsUC,
45- tabs: cfg.Tabs,
46- mainWindow: cfg.MainWindow,
47- config: cfg.Config,
43+ tabsUC: cfg.TabsUC,
44+ tabs: cfg.Tabs,
45+ mainWindow: cfg.MainWindow,
46+ hideTabBarWhenSingleTab: cfg.HideTabBarWhenSingleTab,
4847 }
4948 }
5049
@@ -304,10 +303,7 @@ func (c *TabCoordinator) UpdateBarVisibility(ctx context.Context) {
304303 log := logging.FromContext(ctx)
305304
306305 // Check if feature is enabled
307- hideEnabled := true
308- if c.config != nil {
309- hideEnabled = c.config.Workspace.HideTabBarWhenSingleTab
310- }
306+ hideEnabled := c.hideTabBarWhenSingleTab
311307
312308 if !hideEnabled {
313309 log.Debug().Msg("tab bar auto-hide disabled by config")
@@ -335,7 +331,7 @@ func (c *TabCoordinator) GetTabBar() *component.TabBar {
335331 }
336332
337333 // SetOnAttachPopupToTab sets the callback for attaching popup WebViews to tabs.
338-func (c *TabCoordinator) SetOnAttachPopupToTab(fn func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv *webkit.WebView)) {
334+func (c *TabCoordinator) SetOnAttachPopupToTab(fn func(ctx context.Context, tabID entity.TabID, pane *entity.Pane, wv port.WebView)) {
339335 c.onAttachPopupToTab = fn
340336 }
341337
@@ -344,7 +340,7 @@ func (c *TabCoordinator) SetOnAttachPopupToTab(fn func(ctx context.Context, tabI
344340 func (c *TabCoordinator) CreateWithPane(
345341 ctx context.Context,
346342 pane *entity.Pane,
347- wv *webkit.WebView,
343+ wv port.WebView,
348344 initialURL string,
349345 ) (*entity.Tab, error) {
350346 log := logging.FromContext(ctx)
@@ -387,6 +383,9 @@ func (c *TabCoordinator) CreateWithPane(
387383 c.onTabSwitched(ctx, output.Tab)
388384 }
389385
386+ // Notify state change for session snapshots
387+ c.notifyStateChanged()
388+
390389 log.Debug().
391390 Str("tab_id", string(output.Tab.ID)).
392391 Str("pane_id", string(pane.ID)).
M internal/ui/coordinator/update.go +25 -32
@@ -9,44 +9,47 @@ import (
99
1010 "github.com/bnema/dumber/internal/application/usecase"
1111 "github.com/bnema/dumber/internal/domain/entity"
12- "github.com/bnema/dumber/internal/infrastructure/config"
1312 "github.com/bnema/dumber/internal/logging"
1413 "github.com/bnema/dumber/internal/ui/component"
1514 )
1615
1716 // UpdateCoordinator handles update checking and notification.
1817 type UpdateCoordinator struct {
19- checkUC *usecase.CheckUpdateUseCase
20- applyUC *usecase.ApplyUpdateUseCase
21- toaster *component.Toaster
22- config *config.Config
23- mu sync.RWMutex // Protects status and lastInfo
24- status entity.UpdateStatus
25- lastInfo *usecase.CheckUpdateOutput
18+ checkUC *usecase.CheckUpdateUseCase
19+ applyUC *usecase.ApplyUpdateUseCase
20+ toaster *component.Toaster
21+ enableOnStartup bool
22+ autoDownload bool
23+ mu sync.RWMutex // Protects status and lastInfo
24+ status entity.UpdateStatus
25+ lastInfo *usecase.CheckUpdateOutput
2626 }
2727
2828 // NewUpdateCoordinator creates a new update coordinator.
29+// enableOnStartup controls whether an update check runs at startup.
30+// autoDownload controls whether updates are downloaded automatically in the background.
2931 func NewUpdateCoordinator(
3032 checkUC *usecase.CheckUpdateUseCase,
3133 applyUC *usecase.ApplyUpdateUseCase,
3234 toaster *component.Toaster,
33- cfg *config.Config,
35+ enableOnStartup bool,
36+ autoDownload bool,
3437 ) *UpdateCoordinator {
3538 return &UpdateCoordinator{
36- checkUC: checkUC,
37- applyUC: applyUC,
38- toaster: toaster,
39- config: cfg,
40- status: entity.UpdateStatusUnknown,
39+ checkUC: checkUC,
40+ applyUC: applyUC,
41+ toaster: toaster,
42+ enableOnStartup: enableOnStartup,
43+ autoDownload: autoDownload,
44+ status: entity.UpdateStatusUnknown,
4145 }
4246 }
4347
44-// CheckOnStartup performs an async update check if enabled in config.
45-// This should be called during app initialization.
48+// CheckOnStartup triggers an update check if enabled via the enableOnStartup constructor flag.
4649 func (c *UpdateCoordinator) CheckOnStartup(ctx context.Context) {
4750 log := logging.FromContext(ctx)
4851
49- if !c.config.Update.EnableOnStartup {
52+ if !c.enableOnStartup {
5053 log.Debug().Msg("update check on startup disabled")
5154 return
5255 }
@@ -54,7 +57,6 @@ func (c *UpdateCoordinator) CheckOnStartup(ctx context.Context) {
5457 go c.checkAsync(ctx)
5558 }
5659
57-// checkAsync performs the update check in a goroutine.
5860 func (c *UpdateCoordinator) checkAsync(ctx context.Context) {
5961 log := logging.FromContext(ctx)
6062
@@ -67,7 +69,6 @@ func (c *UpdateCoordinator) checkAsync(ctx context.Context) {
6769 return
6870 }
6971
70- // Update both lastInfo and status atomically to avoid inconsistent reads.
7172 c.mu.Lock()
7273 c.lastInfo = result
7374 if !result.UpdateAvailable {
@@ -86,27 +87,22 @@ func (c *UpdateCoordinator) checkAsync(ctx context.Context) {
8687 Bool("can_auto_update", result.CanAutoUpdate).
8788 Msg("update available")
8889
89- // Show notification on GTK main thread.
9090 c.showUpdateNotification(ctx, result)
9191
92- // Auto-download if enabled and possible.
93- if c.config.Update.AutoDownload && result.CanAutoUpdate {
94- // Spawn download in separate goroutine to not block check completion.
92+ if c.autoDownload && result.CanAutoUpdate {
9593 go c.downloadAsync(ctx, result.DownloadURL)
9694 }
9795 }
9896
99-// showUpdateNotification displays a toast notification about the update.
10097 func (c *UpdateCoordinator) showUpdateNotification(ctx context.Context, result *usecase.CheckUpdateOutput) {
10198 var msg string
10299 switch {
103- case result.CanAutoUpdate && c.config.Update.AutoDownload:
100+ case result.CanAutoUpdate && c.autoDownload:
104101 msg = fmt.Sprintf("Downloading update %s...", result.LatestVersion)
105102 default:
106103 msg = fmt.Sprintf("Update %s available", result.LatestVersion)
107104 }
108105
109- // Dispatch to GTK main thread.
110106 cb := glib.SourceFunc(func(_ uintptr) bool {
111107 c.toaster.Show(ctx, msg, component.ToastInfo)
112108 return false
@@ -114,7 +110,6 @@ func (c *UpdateCoordinator) showUpdateNotification(ctx context.Context, result *
114110 glib.IdleAdd(&cb, 0)
115111 }
116112
117-// downloadAsync downloads and stages the update in the background.
118113 func (c *UpdateCoordinator) downloadAsync(ctx context.Context, downloadURL string) {
119114 log := logging.FromContext(ctx)
120115
@@ -148,7 +143,6 @@ func (c *UpdateCoordinator) downloadAsync(ctx context.Context, downloadURL strin
148143 }
149144 }
150145
151-// showToast dispatches a toast notification to the GTK main thread.
152146 func (c *UpdateCoordinator) showToast(ctx context.Context, msg string, level component.ToastLevel) {
153147 cb := glib.SourceFunc(func(_ uintptr) bool {
154148 c.toaster.Show(ctx, msg, level)
@@ -157,8 +151,7 @@ func (c *UpdateCoordinator) showToast(ctx context.Context, msg string, level com
157151 glib.IdleAdd(&cb, 0)
158152 }
159153
160-// FinalizeOnExit applies any staged update during shutdown.
161-// This should be called from the app's shutdown handler.
154+// FinalizeOnExit applies a staged update on process exit.
162155 func (c *UpdateCoordinator) FinalizeOnExit(ctx context.Context) error {
163156 if c.applyUC == nil {
164157 return nil
@@ -173,14 +166,14 @@ func (c *UpdateCoordinator) Status() entity.UpdateStatus {
173166 return c.status
174167 }
175168
176-// LastCheckResult returns the result of the last update check.
169+// LastCheckResult returns the most recent update check result.
177170 func (c *UpdateCoordinator) LastCheckResult() *usecase.CheckUpdateOutput {
178171 c.mu.RLock()
179172 defer c.mu.RUnlock()
180173 return c.lastInfo
181174 }
182175
183-// HasPendingUpdate returns true if an update is staged for exit.
176+// HasPendingUpdate returns true if a downloaded update is waiting to be applied.
184177 func (c *UpdateCoordinator) HasPendingUpdate(ctx context.Context) bool {
185178 if c.applyUC == nil {
186179 return false
M internal/ui/coordinator/workspace.go +74 -39
@@ -8,9 +8,9 @@ import (
88 "github.com/bnema/dumber/internal/application/usecase"
99 "github.com/bnema/dumber/internal/domain/entity"
1010 domainurl "github.com/bnema/dumber/internal/domain/url"
11- "github.com/bnema/dumber/internal/infrastructure/config"
1211 "github.com/bnema/dumber/internal/logging"
1312 "github.com/bnema/dumber/internal/ui/component"
13+ "github.com/bnema/dumber/internal/ui/coordinator/content"
1414 "github.com/bnema/dumber/internal/ui/focus"
1515 "github.com/bnema/dumber/internal/ui/layout"
1616 "github.com/rs/zerolog"
@@ -27,26 +27,34 @@ type WorkspaceCoordinator struct {
2727 focusMgr *focus.Manager
2828 stackedPaneMgr *component.StackedPaneManager
2929 widgetFactory layout.WidgetFactory
30- contentCoord *ContentCoordinator
30+ contentCoord *content.Coordinator
31+
32+ // Config-derived values (injected to avoid direct config dependency)
33+ newPaneURL string
34+ resizeStepPercent float64
35+ resizeMinPanePercent float64
3136
3237 // Callbacks to avoid circular dependencies
3338 getActiveWS func() (*entity.Workspace, *component.WorkspaceView)
3439 generateID func() string
3540 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
41+ onCreatePopupTab func(ctx context.Context, input content.InsertPopupInput) error // For tabbed popup behavior
42+ onStateChanged func() // For session snapshots
43+ onPaneClosed func(paneID entity.PaneID) // For pane-specific cleanup hooks
3944 }
4045
4146 // WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator.
4247 type WorkspaceCoordinatorConfig struct {
43- PanesUC *usecase.ManagePanesUseCase
44- FocusMgr *focus.Manager
45- StackedPaneMgr *component.StackedPaneManager
46- WidgetFactory layout.WidgetFactory
47- ContentCoord *ContentCoordinator
48- GetActiveWS func() (*entity.Workspace, *component.WorkspaceView)
49- GenerateID func() string
48+ PanesUC *usecase.ManagePanesUseCase
49+ FocusMgr *focus.Manager
50+ StackedPaneMgr *component.StackedPaneManager
51+ WidgetFactory layout.WidgetFactory
52+ ContentCoord *content.Coordinator
53+ GetActiveWS func() (*entity.Workspace, *component.WorkspaceView)
54+ GenerateID func() string
55+ NewPaneURL string
56+ ResizeStepPercent float64
57+ ResizeMinPanePercent float64
5058 }
5159
5260 type splitContext struct {
@@ -75,19 +83,43 @@ type incrementalCloseContext struct {
7583 precheckReason string
7684 }
7785
86+const (
87+ defaultResizeStepPercent = 0.05
88+ defaultResizeMinPanePercent = 0.1
89+)
90+
91+// clampResizeStep clamps resizeStepPercent to (0, 0.5], returning the default (0.05) if out of range.
92+func clampResizeStep(v float64) float64 {
93+ if v <= 0 || v > 0.5 {
94+ return defaultResizeStepPercent
95+ }
96+ return v
97+}
98+
99+// clampResizeMin clamps resizeMinPanePercent to (0, 0.5), returning the default (0.1) if out of range.
100+func clampResizeMin(v float64) float64 {
101+ if v <= 0 || v >= 0.5 {
102+ return defaultResizeMinPanePercent
103+ }
104+ return v
105+}
106+
78107 // NewWorkspaceCoordinator creates a new WorkspaceCoordinator.
79108 func NewWorkspaceCoordinator(ctx context.Context, cfg WorkspaceCoordinatorConfig) *WorkspaceCoordinator {
80109 log := logging.FromContext(ctx)
81110 log.Debug().Msg("creating workspace coordinator")
82111
83112 return &WorkspaceCoordinator{
84- panesUC: cfg.PanesUC,
85- focusMgr: cfg.FocusMgr,
86- stackedPaneMgr: cfg.StackedPaneMgr,
87- widgetFactory: cfg.WidgetFactory,
88- contentCoord: cfg.ContentCoord,
89- getActiveWS: cfg.GetActiveWS,
90- generateID: cfg.GenerateID,
113+ panesUC: cfg.PanesUC,
114+ focusMgr: cfg.FocusMgr,
115+ stackedPaneMgr: cfg.StackedPaneMgr,
116+ widgetFactory: cfg.WidgetFactory,
117+ contentCoord: cfg.ContentCoord,
118+ getActiveWS: cfg.GetActiveWS,
119+ generateID: cfg.GenerateID,
120+ newPaneURL: cfg.NewPaneURL,
121+ resizeStepPercent: clampResizeStep(cfg.ResizeStepPercent),
122+ resizeMinPanePercent: clampResizeMin(cfg.ResizeMinPanePercent),
91123 }
92124 }
93125
@@ -143,7 +175,7 @@ func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.Spli
143175 Workspace: splitCtx.ws,
144176 TargetPane: splitCtx.activePane,
145177 Direction: direction,
146- InitialURL: domainurl.Normalize(config.Get().Workspace.NewPaneURL),
178+ InitialURL: domainurl.Normalize(c.newPaneURL),
147179 })
148180 if err != nil {
149181 log.Error().Err(err).Str("direction", string(direction)).Msg("failed to split pane")
@@ -475,13 +507,13 @@ func (c *WorkspaceCoordinator) doIncrementalSplit(
475507 // 2. Get the active pane's StackedView widget (what we're actually splitting)
476508 tr := wsView.TreeRenderer()
477509 if tr == nil {
478- return nil
510+ return fmt.Errorf("tree renderer not available")
479511 }
480512
481513 activeStackedView := tr.GetStackedViewForPane(string(oldActivePaneID))
482514 if activeStackedView == nil {
483515 log.Warn().Msg("no stacked view found for active pane")
484- return nil
516+ return fmt.Errorf("no stacked view found for active pane")
485517 }
486518 activePaneWidget := activeStackedView.Widget()
487519
@@ -511,7 +543,7 @@ func (c *WorkspaceCoordinator) doIncrementalSplit(
511543
512544 orientation, existingFirst, ok := splitOrientation(direction)
513545 if !ok {
514- return nil
546+ return fmt.Errorf("unsupported split direction: %s", direction)
515547 }
516548
517549 // 7. Handle root vs non-root split differently
@@ -1180,7 +1212,7 @@ func (c *WorkspaceCoordinator) doIncrementalStackClose(
11801212 // Release the closing pane's webview
11811213 c.contentCoord.ReleaseWebView(ctx, closingPaneID)
11821214
1183- // Sync remaining title bars with current titles from ContentCoordinator.
1215+ // Sync remaining title bars with current titles from content.Coordinator.
11841216 // The domain model (stackNode.Children) has already been updated by the use case,
11851217 // so we iterate the remaining children and update their titles in the UI.
11861218 c.syncStackedPaneTitles(ctx, stackedView, stackNode)
@@ -1349,7 +1381,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error {
13491381 // Create new pane entity
13501382 newPaneID := entity.PaneID(c.generateID())
13511383 newPane := entity.NewPane(newPaneID)
1352- newPane.URI = domainurl.Normalize(config.Get().Workspace.NewPaneURL)
1384+ newPane.URI = domainurl.Normalize(c.newPaneURL)
13531385 newPane.Title = defaultPaneTitle
13541386
13551387 // Determine if we need to create a new stack or add to existing
@@ -1756,13 +1788,13 @@ func (c *WorkspaceCoordinator) SetupStackedPaneCallbacks(ctx context.Context, ws
17561788
17571789 // SetOnCreatePopupTab sets the callback for creating popup tabs.
17581790 // This is used when popup behavior is "tabbed".
1759-func (c *WorkspaceCoordinator) SetOnCreatePopupTab(fn func(ctx context.Context, input InsertPopupInput) error) {
1791+func (c *WorkspaceCoordinator) SetOnCreatePopupTab(fn func(ctx context.Context, input content.InsertPopupInput) error) {
17601792 c.onCreatePopupTab = fn
17611793 }
17621794
17631795 // InsertPopup inserts a popup pane into the workspace based on the specified behavior.
17641796 // Supports split, stacked, and tabbed behaviors.
1765-func (c *WorkspaceCoordinator) InsertPopup(ctx context.Context, input InsertPopupInput) error {
1797+func (c *WorkspaceCoordinator) InsertPopup(ctx context.Context, input content.InsertPopupInput) error {
17661798 log := logging.FromContext(ctx)
17671799
17681800 log.Debug().
@@ -1774,11 +1806,11 @@ func (c *WorkspaceCoordinator) InsertPopup(ctx context.Context, input InsertPopu
17741806 Msg("inserting popup into workspace")
17751807
17761808 switch input.Behavior {
1777- case config.PopupBehaviorSplit:
1809+ case entity.PopupBehaviorSplit:
17781810 return c.insertPopupSplit(ctx, input)
1779- case config.PopupBehaviorStacked:
1811+ case entity.PopupBehaviorStacked:
17801812 return c.insertPopupStacked(ctx, input)
1781- case config.PopupBehaviorTabbed:
1813+ case entity.PopupBehaviorTabbed:
17821814 return c.insertPopupTabbed(ctx, input)
17831815 default:
17841816 // Default to split right
@@ -1787,7 +1819,7 @@ func (c *WorkspaceCoordinator) InsertPopup(ctx context.Context, input InsertPopu
17871819 }
17881820
17891821 // insertPopupSplit inserts a popup as a split pane adjacent to the parent.
1790-func (c *WorkspaceCoordinator) insertPopupSplit(ctx context.Context, input InsertPopupInput) error {
1822+func (c *WorkspaceCoordinator) insertPopupSplit(ctx context.Context, input content.InsertPopupInput) error {
17911823 log := logging.FromContext(ctx)
17921824
17931825 ws, wsView := c.getActiveWS()
@@ -1830,6 +1862,8 @@ func (c *WorkspaceCoordinator) insertPopupSplit(ctx context.Context, input Inser
18301862 c.attachPopupWebView(ctx, wsView, input)
18311863 }
18321864
1865+ c.notifyStateChanged()
1866+
18331867 log.Info().
18341868 Str("popup_pane", string(input.PopupPane.ID)).
18351869 Str("direction", string(direction)).
@@ -1882,7 +1916,7 @@ func (c *WorkspaceCoordinator) resolvePopupSplitWidget(
18821916 func (c *WorkspaceCoordinator) attachPopupWebView(
18831917 ctx context.Context,
18841918 wsView *component.WorkspaceView,
1885- input InsertPopupInput,
1919+ input content.InsertPopupInput,
18861920 ) {
18871921 if wsView == nil || input.WebView == nil {
18881922 return
@@ -1902,7 +1936,7 @@ func (c *WorkspaceCoordinator) attachPopupWebView(
19021936
19031937 // insertPopupStacked inserts a popup as a stacked pane on top of the parent.
19041938 // Uses CreateStack and AddToStack use cases for proper domain model management.
1905-func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input InsertPopupInput) error {
1939+func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input content.InsertPopupInput) error {
19061940 log := logging.FromContext(ctx)
19071941
19081942 ws, wsView := c.getActiveWS()
@@ -1945,6 +1979,8 @@ func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input Ins
19451979 return err
19461980 }
19471981
1982+ c.notifyStateChanged()
1983+
19481984 log.Info().
19491985 Str("popup_pane", string(input.PopupPane.ID)).
19501986 Int("stack_size", len(stackNode.Children)).
@@ -2040,7 +2076,7 @@ func (c *WorkspaceCoordinator) resolveOrCreateStackNode(
20402076
20412077 func (c *WorkspaceCoordinator) attachPopupPaneView(
20422078 ctx context.Context,
2043- input InsertPopupInput,
2079+ input content.InsertPopupInput,
20442080 wsView *component.WorkspaceView,
20452081 stackNode *entity.PaneNode,
20462082 log *zerolog.Logger,
@@ -2053,6 +2089,7 @@ func (c *WorkspaceCoordinator) attachPopupPaneView(
20532089 wsView.RegisterPaneView(input.PopupPane.ID, newPaneView)
20542090
20552091 if err := c.stackedPaneMgr.AddPaneToStack(ctx, wsView, input.ParentPaneID, newPaneView, input.PopupPane.Title); err != nil {
2092+ wsView.UnregisterPaneView(input.PopupPane.ID)
20562093 log.Error().Err(err).Msg("failed to add popup pane to stack")
20572094 return err
20582095 }
@@ -2085,7 +2122,7 @@ func (c *WorkspaceCoordinator) attachPopupPaneView(
20852122 }
20862123
20872124 // insertPopupTabbed creates a new tab for the popup.
2088-func (c *WorkspaceCoordinator) insertPopupTabbed(ctx context.Context, input InsertPopupInput) error {
2125+func (c *WorkspaceCoordinator) insertPopupTabbed(ctx context.Context, input content.InsertPopupInput) error {
20892126 log := logging.FromContext(ctx)
20902127
20912128 if c.onCreatePopupTab == nil {
@@ -2196,8 +2233,7 @@ func (c *WorkspaceCoordinator) Resize(ctx context.Context, dir usecase.ResizeDir
21962233 return nil
21972234 }
21982235
2199- cfg := config.Get()
2200- err := c.panesUC.Resize(ctx, ws, target, dir, cfg.Workspace.ResizeMode.StepPercent, cfg.Workspace.ResizeMode.MinPanePercent)
2236+ err := c.panesUC.Resize(ctx, ws, target, dir, c.resizeStepPercent, c.resizeMinPanePercent)
22012237 if errors.Is(err, usecase.ErrNothingToResize) {
22022238 c.ShowToastOnActivePane(ctx, "Nothing to resize", component.ToastInfo)
22032239 return nil
@@ -2228,12 +2264,11 @@ func (c *WorkspaceCoordinator) SetSplitRatio(ctx context.Context, splitNodeID st
22282264 return nil
22292265 }
22302266
2231- cfg := config.Get()
22322267 err := c.panesUC.SetSplitRatio(ctx, usecase.SetSplitRatioInput{
22332268 Workspace: ws,
22342269 SplitNodeID: splitNodeID,
22352270 Ratio: ratio,
2236- MinPanePercent: cfg.Workspace.ResizeMode.MinPanePercent,
2271+ MinPanePercent: c.resizeMinPanePercent,
22372272 })
22382273 if err != nil {
22392274 return err
M internal/ui/deps.go +41 -21
@@ -8,12 +8,10 @@ import (
88 "github.com/bnema/dumber/internal/application/usecase"
99 "github.com/bnema/dumber/internal/domain/entity"
1010 "github.com/bnema/dumber/internal/domain/repository"
11- "github.com/bnema/dumber/internal/infrastructure/colorscheme"
1211 "github.com/bnema/dumber/internal/infrastructure/config"
13- "github.com/bnema/dumber/internal/infrastructure/favicon"
14- "github.com/bnema/dumber/internal/infrastructure/filtering"
15- "github.com/bnema/dumber/internal/infrastructure/webkit"
12+ "github.com/bnema/dumber/internal/ui/adapter"
1613 "github.com/bnema/dumber/internal/ui/theme"
14+ "github.com/jwijenbergh/puregotk/v4/gtk"
1715 )
1816
1917 // Dependencies holds all injected dependencies for the UI layer.
@@ -21,9 +19,9 @@ import (
2119 type Dependencies struct {
2220 // Core context and configuration
2321 Ctx context.Context
24- Config *config.Config
25- InitialURL string // URL to open on startup (optional)
26- RestoreSessionID string // Session ID to restore on startup (optional)
22+ Config *config.Config // TODO: replace with port interface in a later milestone
23+ InitialURL string // URL to open on startup (optional)
24+ RestoreSessionID string // Session ID to restore on startup (optional)
2725 StartupCrashReports []string
2826 OnFirstWebViewShown func(context.Context)
2927 OnSessionPersisted func() // Called by main after session is persisted to DB
@@ -32,18 +30,13 @@ type Dependencies struct {
3230 // Theme and color scheme management
3331 Theme *theme.Manager
3432 ColorResolver port.ColorSchemeResolver
35- AdwaitaDetector *colorscheme.AdwaitaDetector
33+ AdwaitaDetector port.ToolkitAvailabilityNotifier
3634
3735 // XDG paths
3836 XDG port.XDGPaths
3937
40- // WebKit infrastructure
41- WebContext *webkit.WebKitContext
42- Pool *webkit.WebViewPool
43- Factory port.WebViewFactory
44- Settings *webkit.SettingsManager
45- Injector *webkit.ContentInjector
46- MessageRouter *webkit.MessageRouter
38+ // Engine (replaces individual webkit fields)
39+ Engine port.Engine
4740
4841 // Repositories
4942 HistoryRepo repository.HistoryRepository
@@ -63,23 +56,48 @@ type Dependencies struct {
6356 CopyURLUC *usecase.CopyURLUseCase
6457
6558 // Infrastructure Adapters
66- Clipboard port.Clipboard
67- FaviconService *favicon.Service
68- FilterManager *filtering.Manager
69- IdleInhibitor port.IdleInhibitor
59+ Clipboard port.Clipboard
60+ FaviconService port.FaviconService
61+ FaviconAdapterConfig adapter.FaviconAdapterConfig
62+ FilterManager port.FilterManager
63+ IdleInhibitor port.IdleInhibitor
7064
7165 // Accent picker for dead keys support
72- InsertAccentUC *usecase.InsertAccentUseCase
66+ InsertAccentUC *usecase.InsertAccentUseCase
67+ AccentFocusProvider port.FocusedInputProvider
68+ // NewGTKEntryTarget creates a port.TextInputTarget from a GTK SearchEntry.
69+ // Injected to avoid importing infrastructure/textinput from the UI layer.
70+ NewGTKEntryTarget func(entry *gtk.SearchEntry) port.TextInputTarget
7371
7472 // Session management
7573 SessionStateRepo repository.SessionStateRepository
7674 SessionRepo repository.SessionRepository
7775 CurrentSessionID entity.SessionID
7876 SnapshotUC *usecase.SnapshotSessionUseCase
77+ // SnapshotServiceFactory creates a snapshot service bound to the given tab-list provider.
78+ // Called after the App is initialized so the App can serve as the provider.
79+ SnapshotServiceFactory func(provider port.TabListProvider, intervalMs int) port.SnapshotService
80+ // SessionSpawner spawns a new dumber instance for session restoration.
81+ SessionSpawner port.SessionSpawner
82+ // FileSystem provides file operations (e.g., for download deduplication).
83+ FileSystem port.FileSystem
84+ // OnConfigChange registers a callback for config hot-reload.
85+ // The config pointer in deps.Config is updated in-place before the callback fires.
86+ OnConfigChange func(callback func())
87+ // WatchConfig starts watching the config file for changes.
88+ WatchConfig func() error
7989
8090 // Update management
8191 CheckUpdateUC *usecase.CheckUpdateUseCase
8292 ApplyUpdateUC *usecase.ApplyUpdateUseCase
93+
94+ // Config migration checker (optional; nil disables migration notifications)
95+ ConfigMigrator port.ConfigMigrator
96+
97+ // LaunchExternalURL opens a URI with the system's default handler (e.g. xdg-open).
98+ // Used for external URL schemes (vscode://, spotify://, etc.).
99+ // Optional: if nil, external URLs are silently dropped.
100+ LaunchExternalURL func(uri string)
83101 }
84102
85103 // Validate checks that all required dependencies are set.
@@ -90,7 +108,9 @@ func (d *Dependencies) Validate() error {
90108 if d.Config == nil {
91109 return ErrMissingDependency("Config")
92110 }
93- // WebKit dependencies are optional for testing without WebViews
111+ if d.Engine == nil {
112+ return ErrMissingDependency("Engine")
113+ }
94114 // Use cases are optional - can be nil if not needed
95115 return nil
96116 }
M internal/ui/dialog/permission.go +88 -50
@@ -4,6 +4,7 @@ package dialog
44 import (
55 "context"
66 "fmt"
7+ "strings"
78 "sync"
89
910 "github.com/bnema/dumber/internal/application/port"
@@ -125,78 +126,115 @@ func (d *PermissionDialog) showNextQueuedRequest() {
125126 d.showRequest(next)
126127 }
127128
128-// buildHeading creates the dialog heading based on permission types.
129-func (d *PermissionDialog) buildHeading(permTypes []entity.PermissionType) string {
130- hasMic := false
131- hasCam := false
132- hasDisplay := false
129+// permFlags holds parsed permission type flags.
130+type permFlags struct {
131+ mic, cam, display, dataAccess bool
132+}
133133
134+// parsePermFlags extracts boolean flags from permission types.
135+func parsePermFlags(permTypes []entity.PermissionType) permFlags {
136+ var f permFlags
134137 for _, pt := range permTypes {
135138 switch pt {
136139 case entity.PermissionTypeMicrophone:
137- hasMic = true
140+ f.mic = true
138141 case entity.PermissionTypeCamera:
139- hasCam = true
142+ f.cam = true
140143 case entity.PermissionTypeDisplay:
141- hasDisplay = true
144+ f.display = true
145+ case entity.PermissionTypeWebsiteDataAccess:
146+ f.dataAccess = true
142147 }
143148 }
149+ return f
150+}
151+
152+// joinPermissionLabels joins labels with commas and "and".
153+func joinPermissionLabels(labels []string) string {
154+ switch len(labels) {
155+ case 0:
156+ return ""
157+ case 1:
158+ return labels[0]
159+ case 2:
160+ return labels[0] + " and " + labels[1]
161+ default:
162+ return strings.Join(labels[:len(labels)-1], ", ") +
163+ ", and " + labels[len(labels)-1]
164+ }
165+}
144166
167+// buildHeading creates the dialog heading based on permission types.
168+func (d *PermissionDialog) buildHeading(
169+ permTypes []entity.PermissionType,
170+) string {
171+ f := parsePermFlags(permTypes)
172+ var labels []string
173+ if f.mic {
174+ labels = append(labels, "Microphone")
175+ }
176+ if f.cam {
177+ labels = append(labels, "Camera")
178+ }
179+ if f.display {
180+ labels = append(labels, "Screen Sharing")
181+ }
182+ if f.dataAccess {
183+ labels = append(labels, "Data Access")
184+ }
145185 switch {
146- case hasMic && hasCam && hasDisplay:
147- return "Allow Microphone, Camera, and Screen Sharing?"
148- case hasMic && hasDisplay:
149- return "Allow Microphone and Screen Sharing?"
150- case hasCam && hasDisplay:
151- return "Allow Camera and Screen Sharing?"
152- case hasMic && hasCam:
153- return "Allow Microphone and Camera?"
154- case hasMic:
155- return "Allow Microphone Access?"
156- case hasCam:
157- return "Allow Camera Access?"
158- case hasDisplay:
186+ case len(labels) == 0:
187+ return "Allow Permission?"
188+ case len(labels) == 1 && f.dataAccess:
189+ return "Allow Third-Party Data Access?"
190+ case len(labels) == 1 && f.display:
159191 return "Allow Screen Sharing?"
192+ case len(labels) == 1:
193+ return "Allow " + labels[0] + " Access?"
160194 default:
161- return "Allow Permission?"
195+ return "Allow " + joinPermissionLabels(labels) + "?"
162196 }
163197 }
164198
165199 // buildBody creates the dialog body text.
166-func (d *PermissionDialog) buildBody(origin string, permTypes []entity.PermissionType) string {
167- hasMic := false
168- hasCam := false
169- hasDisplay := false
170-
171- for _, pt := range permTypes {
172- switch pt {
173- case entity.PermissionTypeMicrophone:
174- hasMic = true
175- case entity.PermissionTypeCamera:
176- hasCam = true
177- case entity.PermissionTypeDisplay:
178- hasDisplay = true
200+func (d *PermissionDialog) buildBody(
201+ origin string, permTypes []entity.PermissionType,
202+) string {
203+ f := parsePermFlags(permTypes)
204+ var parts []string
205+ if f.mic && f.cam {
206+ parts = append(parts, "access your microphone and camera")
207+ } else if f.mic {
208+ parts = append(parts, "access your microphone")
209+ } else if f.cam {
210+ parts = append(parts, "access your camera")
211+ }
212+ if f.display {
213+ parts = append(parts, "share your screen")
214+ }
215+ if f.dataAccess {
216+ if len(parts) == 0 {
217+ parts = append(parts,
218+ "access its stored data while you browse this site")
219+ } else {
220+ parts = append(parts, "access its stored data")
179221 }
180222 }
181223
182224 var action string
183225 switch {
184- case hasMic && hasCam && hasDisplay:
185- action = "access your microphone and camera, and share your screen"
186- case hasMic && hasDisplay:
187- action = "access your microphone and share your screen"
188- case hasCam && hasDisplay:
189- action = "access your camera and share your screen"
190- case hasMic && hasCam:
191- action = "access your microphone and camera"
192- case hasMic:
193- action = "access your microphone"
194- case hasCam:
195- action = "access your camera"
196- case hasDisplay:
197- action = "share your screen"
198- default:
226+ case len(parts) == 0:
199227 action = "access your device"
228+ case len(parts) == 1:
229+ action = parts[0]
230+ case len(parts) == 2 && (!f.mic || !f.cam):
231+ // Simple two-part join when neither part contains "and".
232+ action = parts[0] + " and " + parts[1]
233+ default:
234+ // Oxford-comma join for 3+ parts, or when a part already
235+ // contains "and" (e.g. "access your microphone and camera").
236+ action = strings.Join(parts[:len(parts)-1], ", ") +
237+ ", and " + parts[len(parts)-1]
200238 }
201239
202240 return fmt.Sprintf("%s wants to %s.", origin, action)
M internal/ui/dialog/permission_test.go +6 -0
@@ -142,6 +142,12 @@ func TestPermissionDialog_BuildHeadingAndBody_DisplayCombinations(t *testing.T)
142142 expectHeading: "Allow Microphone, Camera, and Screen Sharing?",
143143 expectedAction: "access your microphone and camera, and share your screen",
144144 },
145+ {
146+ name: "website data access only",
147+ permTypes: []entity.PermissionType{entity.PermissionTypeWebsiteDataAccess},
148+ expectHeading: "Allow Third-Party Data Access?",
149+ expectedAction: "access its stored data while you browse this site",
150+ },
145151 }
146152
147153 for _, tt := range tests {
M internal/ui/dispatcher/keyboard.go +13 -8
@@ -7,7 +7,6 @@ import (
77 "github.com/bnema/dumber/internal/application/usecase"
88 "github.com/bnema/dumber/internal/domain/entity"
99 domainurl "github.com/bnema/dumber/internal/domain/url"
10- "github.com/bnema/dumber/internal/infrastructure/config"
1110 "github.com/bnema/dumber/internal/logging"
1211 "github.com/bnema/dumber/internal/ui/component"
1312 "github.com/bnema/dumber/internal/ui/coordinator"
@@ -22,6 +21,7 @@ type KeyboardDispatcher struct {
2221 navCoord *coordinator.NavigationCoordinator
2322 zoomUC *usecase.ManageZoomUseCase
2423 copyURLUC *usecase.CopyURLUseCase
24+ newPaneURL string
2525 actionHandlers map[input.Action]func(ctx context.Context) error
2626 onQuit func()
2727 onFindOpen func(ctx context.Context) error
@@ -43,16 +43,18 @@ func NewKeyboardDispatcher(
4343 navCoord *coordinator.NavigationCoordinator,
4444 zoomUC *usecase.ManageZoomUseCase,
4545 copyURLUC *usecase.CopyURLUseCase,
46+ newPaneURL string,
4647 ) *KeyboardDispatcher {
4748 log := logging.FromContext(ctx)
4849 log.Debug().Msg("creating keyboard dispatcher")
4950
5051 dispatcher := &KeyboardDispatcher{
51- tabCoord: tabCoord,
52- wsCoord: wsCoord,
53- navCoord: navCoord,
54- zoomUC: zoomUC,
55- copyURLUC: copyURLUC,
52+ tabCoord: tabCoord,
53+ wsCoord: wsCoord,
54+ navCoord: navCoord,
55+ zoomUC: zoomUC,
56+ copyURLUC: copyURLUC,
57+ newPaneURL: newPaneURL,
5658 }
5759 dispatcher.initActionHandlers()
5860 return dispatcher
@@ -130,8 +132,11 @@ func (d *KeyboardDispatcher) initActionHandlers() {
130132 d.actionHandlers = map[input.Action]func(ctx context.Context) error{
131133 // Tab actions
132134 input.ActionNewTab: func(ctx context.Context) error {
133- cfg := config.Get()
134- _, err := d.tabCoord.Create(ctx, domainurl.Normalize(cfg.Workspace.NewPaneURL))
135+ if d.newPaneURL == "" {
136+ logging.FromContext(ctx).Warn().Msg("ActionNewTab: newPaneURL is empty, cannot open new tab")
137+ return fmt.Errorf("newPaneURL is not configured")
138+ }
139+ _, err := d.tabCoord.Create(ctx, domainurl.Normalize(d.newPaneURL))
135140 return err
136141 },
137142 input.ActionCloseTab: d.tabCoord.Close,
M internal/ui/input/floating_shortcuts_test.go +35 -21
@@ -4,15 +4,29 @@ import (
44 "context"
55 "testing"
66
7- "github.com/bnema/dumber/internal/infrastructure/config"
7+ "github.com/bnema/dumber/internal/domain/entity"
88 "github.com/jwijenbergh/puregotk/v4/gdk"
99 "github.com/stretchr/testify/assert"
1010 "github.com/stretchr/testify/require"
1111 )
1212
13+// defaultWorkspaceConfig returns a WorkspaceConfig with the default shortcuts,
14+// mirroring the relevant portion of config.DefaultConfig().
15+func defaultWorkspaceConfig() *entity.WorkspaceConfig {
16+ return &entity.WorkspaceConfig{
17+ Shortcuts: entity.GlobalShortcutsConfig{
18+ Actions: map[string]entity.ActionBinding{
19+ "toggle_floating_pane": {Keys: []string{"alt+f"}},
20+ "consume_or_expel_left": {Keys: []string{"alt+["}},
21+ "consume_or_expel_right": {Keys: []string{"alt+]"}},
22+ },
23+ },
24+ }
25+}
26+
1327 func TestShortcutSet_GlobalToggleFloatingPane(t *testing.T) {
14- cfg := config.DefaultConfig()
15- set := NewShortcutSet(context.Background(), cfg)
28+ ws := defaultWorkspaceConfig()
29+ set := NewShortcutSet(context.Background(), ws, nil)
1630
1731 action, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_f), Modifiers: ModAlt}, ModeNormal)
1832 require.True(t, ok)
@@ -20,8 +34,8 @@ func TestShortcutSet_GlobalToggleFloatingPane(t *testing.T) {
2034 }
2135
2236 func TestShortcutSet_FloatingProfiles_RegisterMultiple(t *testing.T) {
23- cfg := config.DefaultConfig()
24- cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{
37+ ws := defaultWorkspaceConfig()
38+ ws.FloatingPane.Profiles = map[string]entity.FloatingPaneProfile{
2539 "google": {
2640 Keys: []string{"alt+g"},
2741 URL: "https://google.com",
@@ -32,7 +46,7 @@ func TestShortcutSet_FloatingProfiles_RegisterMultiple(t *testing.T) {
3246 },
3347 }
3448
35- set := NewShortcutSet(context.Background(), cfg)
49+ set := NewShortcutSet(context.Background(), ws, nil)
3650
3751 googleAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_g), Modifiers: ModAlt}, ModeNormal)
3852 require.True(t, ok)
@@ -50,8 +64,8 @@ func TestShortcutSet_FloatingProfiles_RegisterMultiple(t *testing.T) {
5064 }
5165
5266 func TestCollectFloatingProfileShortcuts_SkipsConflicts(t *testing.T) {
53- cfg := config.DefaultConfig()
54- cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{
67+ ws := defaultWorkspaceConfig()
68+ ws.FloatingPane.Profiles = map[string]entity.FloatingPaneProfile{
5569 "tab-conflict": {
5670 Keys: []string{"alt+1"},
5771 URL: "https://example.com/tab",
@@ -71,7 +85,7 @@ func TestCollectFloatingProfileShortcuts_SkipsConflicts(t *testing.T) {
7185 {Keyval: uint(gdk.KEY_bracketleft), Modifiers: ModAlt}: ActionConsumeOrExpelLeft,
7286 }
7387
74- shortcuts := collectFloatingProfileShortcuts(context.Background(), cfg, occupied)
88+ shortcuts := collectFloatingProfileShortcutsFromWorkspace(context.Background(), ws, occupied)
7589 require.Len(t, shortcuts, 1)
7690 assert.Equal(t, KeyBinding{Keyval: uint(gdk.KEY_g), Modifiers: ModAlt}, shortcuts[0].Binding)
7791 target, ok := ParseFloatingProfileTarget(shortcuts[0].Action)
@@ -81,15 +95,15 @@ func TestCollectFloatingProfileShortcuts_SkipsConflicts(t *testing.T) {
8195 }
8296
8397 func TestShortcutSet_FloatingProfilesPreserveExistingGlobalShortcuts(t *testing.T) {
84- cfg := config.DefaultConfig()
85- cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{
98+ ws := defaultWorkspaceConfig()
99+ ws.FloatingPane.Profiles = map[string]entity.FloatingPaneProfile{
86100 "conflict": {
87101 Keys: []string{"alt+["},
88102 URL: "https://example.com/override-attempt",
89103 },
90104 }
91105
92- set := NewShortcutSet(context.Background(), cfg)
106+ set := NewShortcutSet(context.Background(), ws, nil)
93107
94108 tabAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_bracketleft), Modifiers: ModAlt}, ModeNormal)
95109 require.True(t, ok)
@@ -101,8 +115,8 @@ func TestShortcutSet_FloatingProfilesPreserveExistingGlobalShortcuts(t *testing.
101115 }
102116
103117 func TestShortcutSet_FloatingProfilesSkipGlobalOnlyConflicts(t *testing.T) {
104- cfg := config.DefaultConfig()
105- cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{
118+ ws := defaultWorkspaceConfig()
119+ ws.FloatingPane.Profiles = map[string]entity.FloatingPaneProfile{
106120 "alt-one-conflict": {
107121 Keys: []string{"alt+1"},
108122 URL: "https://example.com/one",
@@ -117,7 +131,7 @@ func TestShortcutSet_FloatingProfilesSkipGlobalOnlyConflicts(t *testing.T) {
117131 },
118132 }
119133
120- set := NewShortcutSet(context.Background(), cfg)
134+ set := NewShortcutSet(context.Background(), ws, nil)
121135
122136 _, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_1), Modifiers: ModAlt}, ModeNormal)
123137 assert.False(t, ok)
@@ -134,8 +148,8 @@ func TestShortcutSet_FloatingProfilesSkipGlobalOnlyConflicts(t *testing.T) {
134148 }
135149
136150 func TestShortcutSet_FloatingProfilesWithSameURLGetDistinctSessionIDs(t *testing.T) {
137- cfg := config.DefaultConfig()
138- cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{
151+ ws := defaultWorkspaceConfig()
152+ ws.FloatingPane.Profiles = map[string]entity.FloatingPaneProfile{
139153 "work-mail": {
140154 Keys: []string{"alt+w"},
141155 URL: "https://mail.google.com",
@@ -146,7 +160,7 @@ func TestShortcutSet_FloatingProfilesWithSameURLGetDistinctSessionIDs(t *testing
146160 },
147161 }
148162
149- set := NewShortcutSet(context.Background(), cfg)
163+ set := NewShortcutSet(context.Background(), ws, nil)
150164
151165 workAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_w), Modifiers: ModAlt}, ModeNormal)
152166 require.True(t, ok)
@@ -164,8 +178,8 @@ func TestShortcutSet_FloatingProfilesWithSameURLGetDistinctSessionIDs(t *testing
164178 }
165179
166180 func TestShortcutSet_FloatingProfiles_SupportModifierCombos(t *testing.T) {
167- cfg := config.DefaultConfig()
168- cfg.Workspace.FloatingPane.Profiles = map[string]config.FloatingPaneProfile{
181+ ws := defaultWorkspaceConfig()
182+ ws.FloatingPane.Profiles = map[string]entity.FloatingPaneProfile{
169183 "mail": {
170184 Keys: []string{"ctrl+alt+m"},
171185 URL: "https://mail.example.com",
@@ -176,7 +190,7 @@ func TestShortcutSet_FloatingProfiles_SupportModifierCombos(t *testing.T) {
176190 },
177191 }
178192
179- set := NewShortcutSet(context.Background(), cfg)
193+ set := NewShortcutSet(context.Background(), ws, nil)
180194
181195 mailAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_m), Modifiers: ModCtrl | ModAlt}, ModeNormal)
182196 require.True(t, ok)
M internal/ui/input/global_shortcuts.go +12 -11
@@ -6,7 +6,7 @@ import (
66 "fmt"
77 "strings"
88
9- "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/bnema/dumber/internal/domain/entity"
1010 "github.com/bnema/dumber/internal/logging"
1111 "github.com/jwijenbergh/puregotk/v4/gdk"
1212 "github.com/jwijenbergh/puregotk/v4/glib"
@@ -33,7 +33,8 @@ type GlobalShortcutHandler struct {
3333 func NewGlobalShortcutHandler(
3434 ctx context.Context,
3535 window *gtk.ApplicationWindow,
36- cfg *config.Config,
36+ workspace *entity.WorkspaceConfig,
37+ session *entity.SessionConfig,
3738 kbHandler *KeyboardHandler,
3839 onAction ActionHandler,
3940 ) *GlobalShortcutHandler {
@@ -102,7 +103,7 @@ func NewGlobalShortcutHandler(
102103 Str("action", string(ActionOpenSessionManager)).
103104 Msg("registered global shortcut")
104105
105- if cfg != nil {
106+ if workspace != nil {
106107 // Map action names to action constants for global shortcuts
107108 actionMap := map[string]Action{
108109 "toggle_floating_pane": ActionToggleFloatingPane,
@@ -113,7 +114,7 @@ func NewGlobalShortcutHandler(
113114 "consume_or_expel_down": ActionConsumeOrExpelDown,
114115 }
115116
116- for actionName, actionBinding := range cfg.Workspace.Shortcuts.Actions {
117+ for actionName, actionBinding := range workspace.Shortcuts.Actions {
117118 action, ok := actionMap[actionName]
118119 if !ok {
119120 continue
@@ -134,7 +135,7 @@ func NewGlobalShortcutHandler(
134135 for binding, action := range h.registered {
135136 occupied[binding] = action
136137 }
137- for _, shortcut := range collectFloatingProfileShortcuts(ctx, cfg, occupied) {
138+ for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) {
138139 if !h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) {
139140 continue
140141 }
@@ -148,7 +149,7 @@ func NewGlobalShortcutHandler(
148149
149150 // Register all app-reserved shortcuts (mode activations, Ctrl+L, Ctrl+F, etc.)
150151 // so they work even when WebView has focus.
151- shortcuts := NewShortcutSet(ctx, cfg)
152+ shortcuts := NewShortcutSet(ctx, workspace, session)
152153 for binding, action := range shortcuts.Global {
153154 if _, exists := h.registered[binding]; exists {
154155 continue
@@ -249,7 +250,7 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
249250 // ReloadShortcuts rebuilds global shortcuts from a new config.
250251 // It replaces the GTK shortcut controller to ensure stale shortcuts
251252 // are removed.
252-func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, cfg *config.Config) {
253+func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
253254 log := logging.FromContext(ctx)
254255
255256 if h.window == nil || h.controller == nil {
@@ -283,7 +284,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, cfg *config
283284 h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager)
284285
285286 // Re-register config-driven shortcuts
286- if cfg != nil {
287+ if workspace != nil {
287288 actionMap := map[string]Action{
288289 "toggle_floating_pane": ActionToggleFloatingPane,
289290 "toggle-floating-pane": ActionToggleFloatingPane,
@@ -293,7 +294,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, cfg *config
293294 "consume_or_expel_down": ActionConsumeOrExpelDown,
294295 }
295296
296- for actionName, actionBinding := range cfg.Workspace.Shortcuts.Actions {
297+ for actionName, actionBinding := range workspace.Shortcuts.Actions {
297298 action, ok := actionMap[actionName]
298299 if !ok {
299300 continue
@@ -311,7 +312,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, cfg *config
311312 for binding, action := range h.registered {
312313 occupied[binding] = action
313314 }
314- for _, shortcut := range collectFloatingProfileShortcuts(ctx, cfg, occupied) {
315+ for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) {
315316 if h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) {
316317 if url, ok := ParseFloatingProfileAction(shortcut.Action); ok {
317318 log.Trace().Str("shortcut", formatBinding(shortcut.Binding)).Str("url", url).Msg("registered floating profile global shortcut")
@@ -319,7 +320,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, cfg *config
319320 }
320321 }
321322
322- shortcuts := NewShortcutSet(ctx, cfg)
323+ shortcuts := NewShortcutSet(ctx, workspace, session)
323324 for binding, action := range shortcuts.Global {
324325 if _, exists := h.registered[binding]; exists {
325326 continue
M internal/ui/input/keyboard.go +52 -27
@@ -23,7 +23,7 @@ import (
2323 "sync"
2424 "time"
2525
26- "github.com/bnema/dumber/internal/infrastructure/config"
26+ "github.com/bnema/dumber/internal/domain/entity"
2727 "github.com/bnema/dumber/internal/logging"
2828 "github.com/jwijenbergh/puregotk/v4/gdk"
2929 "github.com/jwijenbergh/puregotk/v4/glib"
@@ -55,7 +55,8 @@ type AccentHandler interface {
5555 type KeyboardHandler struct {
5656 shortcuts *ShortcutSet
5757 modal *ModalState
58- cfg *config.Config
58+ workspace *entity.WorkspaceConfig
59+ session *entity.SessionConfig
5960
6061 // Action handler callback
6162 onAction ActionHandler
@@ -80,15 +81,16 @@ type KeyboardHandler struct {
8081 }
8182
8283 // NewKeyboardHandler creates a new keyboard handler.
83-func NewKeyboardHandler(ctx context.Context, cfg *config.Config) *KeyboardHandler {
84+func NewKeyboardHandler(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) *KeyboardHandler {
8485 log := logging.FromContext(ctx)
8586
8687 log.Debug().Msg("creating keyboard handler")
8788
8889 h := &KeyboardHandler{
89- shortcuts: NewShortcutSet(ctx, cfg),
90+ shortcuts: NewShortcutSet(ctx, workspace, session),
9091 modal: NewModalState(ctx),
91- cfg: cfg,
92+ workspace: workspace,
93+ session: session,
9294 ctx: ctx,
9395 }
9496
@@ -102,17 +104,18 @@ func (h *KeyboardHandler) SetOnAction(fn ActionHandler) {
102104 h.onAction = fn
103105 }
104106
105-// ReloadShortcuts rebuilds the shortcut set from the new config.
107+// ReloadShortcuts rebuilds the shortcut set from new config values.
106108 // This enables hot-reloading of keybindings without restarting.
107-func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, cfg *config.Config) {
109+func (h *KeyboardHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
108110 h.mu.Lock()
109111 defer h.mu.Unlock()
110112
111113 log := logging.FromContext(ctx)
112114 log.Debug().Msg("reloading shortcuts from config")
113115
114- h.shortcuts = NewShortcutSet(ctx, cfg)
115- h.cfg = cfg
116+ h.shortcuts = NewShortcutSet(ctx, workspace, session)
117+ h.workspace = workspace
118+ h.session = session
116119 }
117120
118121 // SetOnModeChange sets the callback for mode changes (for UI updates).
@@ -433,29 +436,42 @@ func isResizeAction(action Action) bool {
433436
434437 func (h *KeyboardHandler) handleModeAction(action Action) bool {
435438 h.mu.RLock()
436- cfg := h.cfg
439+ workspace := h.workspace
440+ session := h.session
437441 h.mu.RUnlock()
438442
439443 switch action {
440444 case ActionEnterTabMode:
441- timeout := time.Duration(cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
442- h.modal.EnterTabMode(h.ctx, timeout)
445+ var ms int
446+ if workspace != nil {
447+ ms = workspace.TabMode.TimeoutMilliseconds
448+ }
449+ h.modal.EnterTabMode(h.ctx, time.Duration(ms)*time.Millisecond)
443450 return true
444451 case ActionEnterPaneMode:
445- timeout := time.Duration(cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond
446- h.modal.EnterPaneMode(h.ctx, timeout)
452+ var ms int
453+ if workspace != nil {
454+ ms = workspace.PaneMode.TimeoutMilliseconds
455+ }
456+ h.modal.EnterPaneMode(h.ctx, time.Duration(ms)*time.Millisecond)
447457 return true
448458 case ActionEnterSessionMode:
449- timeout := time.Duration(cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond
450- h.modal.EnterSessionMode(h.ctx, timeout)
459+ var ms int
460+ if session != nil {
461+ ms = session.SessionMode.TimeoutMilliseconds
462+ }
463+ h.modal.EnterSessionMode(h.ctx, time.Duration(ms)*time.Millisecond)
451464 return true
452465 case ActionEnterResizeMode:
453466 if h.modal.Mode() == ModeResize {
454467 h.modal.ExitMode(h.ctx)
455468 return true
456469 }
457- timeout := time.Duration(cfg.Workspace.ResizeMode.TimeoutMilliseconds) * time.Millisecond
458- h.modal.EnterResizeMode(h.ctx, timeout)
470+ var ms int
471+ if workspace != nil {
472+ ms = workspace.ResizeMode.TimeoutMilliseconds
473+ }
474+ h.modal.EnterResizeMode(h.ctx, time.Duration(ms)*time.Millisecond)
459475 return true
460476 case ActionExitMode:
461477 h.modal.ExitMode(h.ctx)
@@ -469,30 +485,39 @@ func (h *KeyboardHandler) handleModeAction(action Action) bool {
469485 // Useful for testing or programmatic mode changes.
470486 func (h *KeyboardHandler) EnterTabMode() {
471487 h.mu.RLock()
472- cfg := h.cfg
488+ workspace := h.workspace
473489 h.mu.RUnlock()
474- timeout := time.Duration(cfg.Workspace.TabMode.TimeoutMilliseconds) * time.Millisecond
475- h.modal.EnterTabMode(h.ctx, timeout)
490+ var ms int
491+ if workspace != nil {
492+ ms = workspace.TabMode.TimeoutMilliseconds
493+ }
494+ h.modal.EnterTabMode(h.ctx, time.Duration(ms)*time.Millisecond)
476495 }
477496
478497 // EnterPaneMode programmatically enters pane mode.
479498 // Useful for testing or programmatic mode changes.
480499 func (h *KeyboardHandler) EnterPaneMode() {
481500 h.mu.RLock()
482- cfg := h.cfg
501+ workspace := h.workspace
483502 h.mu.RUnlock()
484- timeout := time.Duration(cfg.Workspace.PaneMode.TimeoutMilliseconds) * time.Millisecond
485- h.modal.EnterPaneMode(h.ctx, timeout)
503+ var ms int
504+ if workspace != nil {
505+ ms = workspace.PaneMode.TimeoutMilliseconds
506+ }
507+ h.modal.EnterPaneMode(h.ctx, time.Duration(ms)*time.Millisecond)
486508 }
487509
488510 // EnterSessionMode programmatically enters session mode.
489511 // Useful for testing or programmatic mode changes.
490512 func (h *KeyboardHandler) EnterSessionMode() {
491513 h.mu.RLock()
492- cfg := h.cfg
514+ session := h.session
493515 h.mu.RUnlock()
494- timeout := time.Duration(cfg.Session.SessionMode.TimeoutMilliseconds) * time.Millisecond
495- h.modal.EnterSessionMode(h.ctx, timeout)
516+ var ms int
517+ if session != nil {
518+ ms = session.SessionMode.TimeoutMilliseconds
519+ }
520+ h.modal.EnterSessionMode(h.ctx, time.Duration(ms)*time.Millisecond)
496521 }
497522
498523 // ExitMode programmatically exits modal mode.
M internal/ui/input/keyboard_test.go +21 -21
@@ -4,20 +4,26 @@ import (
44 "context"
55 "testing"
66
7- "github.com/bnema/dumber/internal/infrastructure/config"
7+ "github.com/bnema/dumber/internal/domain/entity"
88 "github.com/jwijenbergh/puregotk/v4/gdk"
99 "github.com/stretchr/testify/assert"
1010 )
1111
12-// newTestConfig creates a minimal Config with activation shortcuts set.
12+// newTestWorkspace creates a minimal WorkspaceConfig with activation shortcuts set.
1313 // Used by tests that construct a KeyboardHandler.
14-func newTestConfig() *config.Config {
15- cfg := &config.Config{}
16- cfg.Workspace.TabMode.ActivationShortcut = "ctrl+t"
17- cfg.Workspace.PaneMode.ActivationShortcut = "ctrl+p"
18- cfg.Workspace.ResizeMode.ActivationShortcut = "ctrl+alt+r"
19- cfg.Session.SessionMode.ActivationShortcut = "ctrl+s"
20- return cfg
14+func newTestWorkspace() *entity.WorkspaceConfig {
15+ return &entity.WorkspaceConfig{
16+ TabMode: entity.TabModeConfig{ActivationShortcut: "ctrl+t"},
17+ PaneMode: entity.PaneModeConfig{ActivationShortcut: "ctrl+p"},
18+ ResizeMode: entity.ResizeModeConfig{ActivationShortcut: "ctrl+alt+r"},
19+ }
20+}
21+
22+// newTestSession creates a minimal SessionConfig with activation shortcuts set.
23+func newTestSession() *entity.SessionConfig {
24+ return &entity.SessionConfig{
25+ SessionMode: entity.SessionModeConfig{ActivationShortcut: "ctrl+s"},
26+ }
2127 }
2228
2329 // stubAccentHandler is a test stub for the AccentHandler interface.
@@ -108,9 +114,8 @@ func TestIsTextInputKey(t *testing.T) {
108114
109115 func TestHandleKeyPress_RoutePassToWidget(t *testing.T) {
110116 ctx := context.Background()
111- cfg := newTestConfig()
112117
113- h := NewKeyboardHandler(ctx, cfg)
118+ h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
114119
115120 // Route all non-shortcut keys to widget (simulates WebView text input)
116121 h.SetRouteKey(func(kc KeyContext) KeyRoute {
@@ -139,9 +144,8 @@ func TestHandleKeyPress_RoutePassToWidget(t *testing.T) {
139144
140145 func TestHandleKeyPress_RouteAccentDetection_NoAccent(t *testing.T) {
141146 ctx := context.Background()
142- cfg := newTestConfig()
143147
144- h := NewKeyboardHandler(ctx, cfg)
148+ h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
145149
146150 // Set a stub accent handler that always returns false (simulates "no accents for this key")
147151 h.SetAccentHandler(&stubAccentHandler{onKeyPressedResult: false})
@@ -158,9 +162,8 @@ func TestHandleKeyPress_RouteAccentDetection_NoAccent(t *testing.T) {
158162
159163 func TestHandleKeyPress_ShortcutsWorkWithRouting(t *testing.T) {
160164 ctx := context.Background()
161- cfg := newTestConfig()
162165
163- h := NewKeyboardHandler(ctx, cfg)
166+ h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
164167
165168 h.SetOnAction(func(ctx context.Context, action Action) error {
166169 return nil
@@ -181,9 +184,8 @@ func TestHandleKeyPress_ShortcutsWorkWithRouting(t *testing.T) {
181184
182185 func TestHandleKeyPress_ModalModeIgnoresRoute(t *testing.T) {
183186 ctx := context.Background()
184- cfg := newTestConfig()
185187
186- h := NewKeyboardHandler(ctx, cfg)
188+ h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
187189 h.EnterTabMode()
188190
189191 // Even if routeKey says pass to widget, modal mode ignores it
@@ -198,8 +200,7 @@ func TestHandleKeyPress_ModalModeIgnoresRoute(t *testing.T) {
198200
199201 func TestKeyboardHandler_SetRouteKey(t *testing.T) {
200202 ctx := context.Background()
201- cfg := &config.Config{}
202- h := NewKeyboardHandler(ctx, cfg)
203+ h := NewKeyboardHandler(ctx, nil, nil)
203204
204205 // Default: no callback set
205206 h.mu.RLock()
@@ -221,9 +222,8 @@ func TestKeyboardHandler_SetRouteKey(t *testing.T) {
221222
222223 func TestHandleKeyPress_NoRouteCallback_DefaultsToShortcuts(t *testing.T) {
223224 ctx := context.Background()
224- cfg := newTestConfig()
225225
226- h := NewKeyboardHandler(ctx, cfg)
226+ h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession())
227227
228228 // No routeKey set -- should default to shortcut handling
229229 // 'z' is not a registered shortcut in ModeNormal, so returns false
M internal/ui/input/shortcuts.go +83 -46
@@ -6,7 +6,7 @@ import (
66 "sort"
77 "strings"
88
9- "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/bnema/dumber/internal/domain/entity"
1010 "github.com/bnema/dumber/internal/logging"
1111 "github.com/jwijenbergh/puregotk/v4/gdk"
1212 )
@@ -218,8 +218,10 @@ type ShortcutSet struct {
218218 ResizeMode ShortcutTable
219219 }
220220
221-// NewShortcutSet creates a ShortcutSet from the configuration.
222-func NewShortcutSet(ctx context.Context, cfg *config.Config) *ShortcutSet {
221+// NewShortcutSet creates a ShortcutSet from workspace and session configuration.
222+// workspace may be nil (workspace-specific shortcuts will be empty).
223+// session may be nil (session mode shortcuts will be empty).
224+func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) *ShortcutSet {
223225 log := logging.FromContext(ctx)
224226 set := &ShortcutSet{
225227 Global: make(ShortcutTable),
@@ -229,11 +231,15 @@ func NewShortcutSet(ctx context.Context, cfg *config.Config) *ShortcutSet {
229231 ResizeMode: make(ShortcutTable),
230232 }
231233
232- set.buildGlobalShortcuts(ctx, cfg)
233- set.buildTabModeShortcuts(ctx, &cfg.Workspace)
234- set.buildPaneModeShortcuts(ctx, &cfg.Workspace)
235- set.buildResizeModeShortcuts(ctx, &cfg.Workspace)
236- set.buildSessionModeShortcuts(ctx, &cfg.Session)
234+ set.buildGlobalShortcutsFromParts(ctx, workspace, session)
235+ if workspace != nil {
236+ set.buildTabModeShortcuts(ctx, workspace)
237+ set.buildPaneModeShortcuts(ctx, workspace)
238+ set.buildResizeModeShortcuts(ctx, workspace)
239+ }
240+ if session != nil {
241+ set.buildSessionModeShortcuts(ctx, session)
242+ }
237243
238244 log.Debug().
239245 Int("global", len(set.Global)).
@@ -246,81 +252,104 @@ func NewShortcutSet(ctx context.Context, cfg *config.Config) *ShortcutSet {
246252 return set
247253 }
248254
249-// buildGlobalShortcuts populates global shortcuts from config.
250-func (s *ShortcutSet) buildGlobalShortcuts(ctx context.Context, cfg *config.Config) {
251- s.registerActivationShortcuts(ctx, cfg)
252- s.registerConfiguredShortcuts(&cfg.Workspace)
255+// buildGlobalShortcutsFromParts populates global shortcuts from workspace and session configs.
256+func (s *ShortcutSet) buildGlobalShortcutsFromParts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
257+ s.registerActivationShortcutsFromParts(ctx, workspace, session)
258+ s.registerConfiguredShortcuts(workspace)
253259 s.registerStandardShortcuts()
254260 s.registerPaneNavigationShortcuts()
255261 s.registerTabSwitchShortcuts()
256- s.registerFloatingProfileShortcuts(ctx, cfg)
262+ s.registerFloatingProfileShortcutsFromWorkspace(ctx, workspace)
257263 }
258264
259265 // buildTabModeShortcuts populates tab mode shortcuts from config.
260-func (s *ShortcutSet) buildTabModeShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) {
266+func (s *ShortcutSet) buildTabModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {
261267 s.buildModeShortcuts(ctx, cfg.TabMode.GetKeyBindings(), s.TabMode, "tab")
262268 }
263269
264270 // buildPaneModeShortcuts populates pane mode shortcuts from config.
265-func (s *ShortcutSet) buildPaneModeShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) {
271+func (s *ShortcutSet) buildPaneModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {
266272 s.buildModeShortcuts(ctx, cfg.PaneMode.GetKeyBindings(), s.PaneMode, "pane")
267273 }
268274
269275 // buildSessionModeShortcuts populates session mode shortcuts from config.
270-func (s *ShortcutSet) buildSessionModeShortcuts(ctx context.Context, cfg *config.SessionConfig) {
276+func (s *ShortcutSet) buildSessionModeShortcuts(ctx context.Context, cfg *entity.SessionConfig) {
271277 s.buildModeShortcuts(ctx, cfg.SessionMode.GetKeyBindings(), s.SessionMode, "session")
272278 }
273279
274280 // buildResizeModeShortcuts populates resize mode shortcuts from config.
275-func (s *ShortcutSet) buildResizeModeShortcuts(ctx context.Context, cfg *config.WorkspaceConfig) {
281+func (s *ShortcutSet) buildResizeModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {
276282 s.buildModeShortcuts(ctx, cfg.ResizeMode.GetKeyBindings(), s.ResizeMode, "resize")
277283 }
278284
279-func (s *ShortcutSet) registerActivationShortcuts(ctx context.Context, cfg *config.Config) {
285+func (s *ShortcutSet) registerActivationShortcutsFromParts(
286+ ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig,
287+) {
280288 log := logging.FromContext(ctx)
281- if binding, ok := ParseKeyString(cfg.Workspace.TabMode.ActivationShortcut); ok {
289+ if workspace == nil {
290+ // Still register session mode if available.
291+ if session != nil {
292+ if binding, ok := ParseKeyString(session.SessionMode.ActivationShortcut); ok {
293+ s.Global[binding] = ActionEnterSessionMode
294+ log.Trace().
295+ Str("shortcut", session.SessionMode.ActivationShortcut).
296+ Uint("keyval", binding.Keyval).
297+ Uint("mod", uint(binding.Modifiers)).
298+ Msg("session mode activation registered")
299+ } else {
300+ log.Warn().Str("shortcut", session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut")
301+ }
302+ }
303+ return
304+ }
305+ if binding, ok := ParseKeyString(workspace.TabMode.ActivationShortcut); ok {
282306 s.Global[binding] = ActionEnterTabMode
283307 log.Trace().
284- Str("shortcut", cfg.Workspace.TabMode.ActivationShortcut).
308+ Str("shortcut", workspace.TabMode.ActivationShortcut).
285309 Uint("keyval", binding.Keyval).
286310 Uint("mod", uint(binding.Modifiers)).
287311 Msg("tab mode activation registered")
288312 } else {
289- log.Warn().Str("shortcut", cfg.Workspace.TabMode.ActivationShortcut).Msg("failed to parse tab mode activation shortcut")
313+ log.Warn().Str("shortcut", workspace.TabMode.ActivationShortcut).Msg("failed to parse tab mode activation shortcut")
290314 }
291- if binding, ok := ParseKeyString(cfg.Workspace.PaneMode.ActivationShortcut); ok {
315+ if binding, ok := ParseKeyString(workspace.PaneMode.ActivationShortcut); ok {
292316 s.Global[binding] = ActionEnterPaneMode
293317 log.Trace().
294- Str("shortcut", cfg.Workspace.PaneMode.ActivationShortcut).
318+ Str("shortcut", workspace.PaneMode.ActivationShortcut).
295319 Uint("keyval", binding.Keyval).
296320 Uint("mod", uint(binding.Modifiers)).
297321 Msg("pane mode activation registered")
298322 } else {
299- log.Warn().Str("shortcut", cfg.Workspace.PaneMode.ActivationShortcut).Msg("failed to parse pane mode activation shortcut")
300- }
301- if binding, ok := ParseKeyString(cfg.Session.SessionMode.ActivationShortcut); ok {
302- s.Global[binding] = ActionEnterSessionMode
303- log.Trace().
304- Str("shortcut", cfg.Session.SessionMode.ActivationShortcut).
305- Uint("keyval", binding.Keyval).
306- Uint("mod", uint(binding.Modifiers)).
307- Msg("session mode activation registered")
308- } else {
309- log.Warn().Str("shortcut", cfg.Session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut")
323+ log.Warn().Str("shortcut", workspace.PaneMode.ActivationShortcut).Msg("failed to parse pane mode activation shortcut")
324+ }
325+ if session != nil {
326+ if binding, ok := ParseKeyString(session.SessionMode.ActivationShortcut); ok {
327+ s.Global[binding] = ActionEnterSessionMode
328+ log.Trace().
329+ Str("shortcut", session.SessionMode.ActivationShortcut).
330+ Uint("keyval", binding.Keyval).
331+ Uint("mod", uint(binding.Modifiers)).
332+ Msg("session mode activation registered")
333+ } else {
334+ log.Warn().Str("shortcut", session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut")
335+ }
310336 }
311- if binding, ok := ParseKeyString(cfg.Workspace.ResizeMode.ActivationShortcut); ok {
337+ if binding, ok := ParseKeyString(workspace.ResizeMode.ActivationShortcut); ok {
312338 s.Global[binding] = ActionEnterResizeMode
313339 log.Trace().
314- Str("shortcut", cfg.Workspace.ResizeMode.ActivationShortcut).
340+ Str("shortcut", workspace.ResizeMode.ActivationShortcut).
315341 Uint("keyval", binding.Keyval).
316342 Uint("mod", uint(binding.Modifiers)).
317343 Msg("resize mode activation registered")
318344 } else {
319- log.Warn().Str("shortcut", cfg.Workspace.ResizeMode.ActivationShortcut).Msg("failed to parse resize mode activation shortcut")
345+ log.Warn().Str("shortcut", workspace.ResizeMode.ActivationShortcut).Msg("failed to parse resize mode activation shortcut")
320346 }
321347 }
322348
323-func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) {
349+func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) {
350+ if cfg == nil {
351+ return
352+ }
324353 // Note: Ctrl+T is NOT registered globally - it enters tab mode.
325354 // In tab mode, use:
326355 // n = new tab
@@ -336,12 +365,19 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) {
336365 "toggle_floating_pane": ActionToggleFloatingPane,
337366 "toggle-floating-pane": ActionToggleFloatingPane,
338367 "close_pane": ActionClosePane,
368+ "close-pane": ActionClosePane,
339369 "next_tab": ActionNextTab,
370+ "next-tab": ActionNextTab,
340371 "previous_tab": ActionPreviousTab,
372+ "previous-tab": ActionPreviousTab,
341373 "consume_or_expel_left": ActionConsumeOrExpelLeft,
374+ "consume-or-expel-left": ActionConsumeOrExpelLeft,
342375 "consume_or_expel_right": ActionConsumeOrExpelRight,
376+ "consume-or-expel-right": ActionConsumeOrExpelRight,
343377 "consume_or_expel_up": ActionConsumeOrExpelUp,
378+ "consume-or-expel-up": ActionConsumeOrExpelUp,
344379 "consume_or_expel_down": ActionConsumeOrExpelDown,
380+ "consume-or-expel-down": ActionConsumeOrExpelDown,
345381 }
346382
347383 for actionName, actionBinding := range cfg.Shortcuts.Actions {
@@ -357,13 +393,13 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) {
357393 }
358394 }
359395
360-func (s *ShortcutSet) registerFloatingProfileShortcuts(ctx context.Context, cfg *config.Config) {
396+func (s *ShortcutSet) registerFloatingProfileShortcutsFromWorkspace(ctx context.Context, workspace *entity.WorkspaceConfig) {
361397 occupied := make(map[KeyBinding]Action, len(s.Global))
362398 for binding, action := range s.Global {
363399 occupied[binding] = action
364400 }
365401
366- for _, shortcut := range collectFloatingProfileShortcuts(ctx, cfg, occupied) {
402+ for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) {
367403 s.Global[shortcut.Binding] = shortcut.Action
368404 }
369405 }
@@ -373,26 +409,27 @@ type floatingProfileShortcut struct {
373409 Action Action
374410 }
375411
376-func collectFloatingProfileShortcuts(
412+// collectFloatingProfileShortcutsFromWorkspace collects floating profile shortcuts from a workspace config.
413+func collectFloatingProfileShortcutsFromWorkspace(
377414 ctx context.Context,
378- cfg *config.Config,
415+ workspace *entity.WorkspaceConfig,
379416 occupied map[KeyBinding]Action,
380417 ) []floatingProfileShortcut {
381- if cfg == nil {
418+ if workspace == nil {
382419 return nil
383420 }
384421
385422 log := logging.FromContext(ctx)
386423 reserveGlobalOnlyShortcutBindings(occupied)
387424 result := make([]floatingProfileShortcut, 0)
388- profileNames := make([]string, 0, len(cfg.Workspace.FloatingPane.Profiles))
389- for profileName := range cfg.Workspace.FloatingPane.Profiles {
425+ profileNames := make([]string, 0, len(workspace.FloatingPane.Profiles))
426+ for profileName := range workspace.FloatingPane.Profiles {
390427 profileNames = append(profileNames, profileName)
391428 }
392429 sort.Strings(profileNames)
393430
394431 for _, profileName := range profileNames {
395- profile := cfg.Workspace.FloatingPane.Profiles[profileName]
432+ profile := workspace.FloatingPane.Profiles[profileName]
396433 url := strings.TrimSpace(profile.URL)
397434 if url == "" {
398435 log.Warn().Str("profile", profileName).Msg("floating profile missing URL, skipping")
M internal/ui/theme/manager.go +50 -30
@@ -5,7 +5,7 @@ import (
55 "fmt"
66
77 "github.com/bnema/dumber/internal/application/port"
8- "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/bnema/dumber/internal/domain/entity"
99 "github.com/bnema/dumber/internal/logging"
1010 "github.com/jwijenbergh/puregotk/v4/gdk"
1111 "github.com/jwijenbergh/puregotk/v4/gtk"
@@ -26,13 +26,23 @@ type Manager struct {
2626
2727 // NewManager creates a new theme manager from configuration.
2828 // The ColorSchemeResolver is required for proper color scheme detection.
29-func NewManager(ctx context.Context, cfg *config.Config, resolver port.ColorSchemeResolver) *Manager {
29+//
30+// appearance controls fonts, palette overrides and color scheme.
31+// uiScale is the GTK UI scale factor (0 or negative means use default 1.0).
32+// styling controls mode indicator colors; may be nil to use defaults.
33+func NewManager(
34+ ctx context.Context,
35+ appearance *entity.AppearanceConfig,
36+ uiScale float64,
37+ styling *entity.WorkspaceStylingConfig,
38+ resolver port.ColorSchemeResolver,
39+) *Manager {
3040 log := logging.FromContext(ctx)
3141
3242 // Determine color scheme preference
3343 scheme := "system"
34- if cfg != nil && cfg.Appearance.ColorScheme != "" {
35- scheme = cfg.Appearance.ColorScheme
44+ if appearance != nil && appearance.ColorScheme != "" {
45+ scheme = appearance.ColorScheme
3646 }
3747
3848 // Resolve whether we should use dark mode via resolver
@@ -41,28 +51,27 @@ func NewManager(ctx context.Context, cfg *config.Config, resolver port.ColorSche
4151
4252 // Build palettes from config or defaults
4353 var lightPalette, darkPalette Palette
44- if cfg != nil {
45- lightPalette = PaletteFromConfig(&cfg.Appearance.LightPalette, false)
46- darkPalette = PaletteFromConfig(&cfg.Appearance.DarkPalette, true)
54+ if appearance != nil {
55+ lightPalette = PaletteFromConfig(&appearance.LightPalette, false)
56+ darkPalette = PaletteFromConfig(&appearance.DarkPalette, true)
4757 } else {
4858 lightPalette = DefaultLightPalette()
4959 darkPalette = DefaultDarkPalette()
5060 }
5161
5262 // Get UI scale factor (default to 1.0 if not set)
53- uiScale := 1.0
54- if cfg != nil && cfg.DefaultUIScale > 0 {
55- uiScale = cfg.DefaultUIScale
63+ if uiScale <= 0 {
64+ uiScale = 1.0
5665 }
5766
5867 fonts := DefaultFontConfig()
59- if cfg != nil {
60- fonts.SansFont = Coalesce(cfg.Appearance.SansFont, fonts.SansFont)
61- fonts.MonospaceFont = Coalesce(cfg.Appearance.MonospaceFont, fonts.MonospaceFont)
68+ if appearance != nil {
69+ fonts.SansFont = Coalesce(appearance.SansFont, fonts.SansFont)
70+ fonts.MonospaceFont = Coalesce(appearance.MonospaceFont, fonts.MonospaceFont)
6271 }
6372
6473 // Build mode colors from config
65- modeColors := modeColorsFromConfig(cfg)
74+ modeColors := modeColorsFromStyling(styling)
6675
6776 log.Debug().
6877 Str("scheme", scheme).
@@ -84,13 +93,12 @@ func NewManager(ctx context.Context, cfg *config.Config, resolver port.ColorSche
8493 }
8594 }
8695
87-// modeColorsFromConfig extracts mode colors from config, using defaults for missing values.
88-func modeColorsFromConfig(cfg *config.Config) ModeColors {
96+// modeColorsFromStyling extracts mode colors from styling config, using defaults for missing values.
97+func modeColorsFromStyling(styling *entity.WorkspaceStylingConfig) ModeColors {
8998 defaults := DefaultModeColors()
90- if cfg == nil {
99+ if styling == nil {
91100 return defaults
92101 }
93- styling := &cfg.Workspace.Styling
94102 return ModeColors{
95103 PaneMode: Coalesce(styling.PaneModeColor, defaults.PaneMode),
96104 TabMode: Coalesce(styling.TabModeColor, defaults.TabMode),
@@ -198,40 +206,52 @@ func (m *Manager) SetColorScheme(ctx context.Context, scheme string, display *gd
198206 }
199207 }
200208
201-// UpdateFromConfig updates the theme manager state from a new config.
202-func (m *Manager) UpdateFromConfig(ctx context.Context, cfg *config.Config, display *gdk.Display) {
209+// UpdateFromConfig updates the theme manager state from new config values.
210+//
211+// appearance controls fonts, palette overrides and color scheme.
212+// uiScale is the GTK UI scale factor (0 or negative means keep existing scale).
213+// styling controls mode indicator colors; may be nil to keep existing colors.
214+func (m *Manager) UpdateFromConfig(
215+ ctx context.Context,
216+ appearance *entity.AppearanceConfig,
217+ uiScale float64,
218+ styling *entity.WorkspaceStylingConfig,
219+ display *gdk.Display,
220+) {
203221 log := logging.FromContext(ctx)
204222
205- if cfg == nil {
223+ if appearance == nil {
206224 return
207225 }
208226
209227 // Update scheme and resolve prefersDark via resolver
210228 scheme := "system"
211- if cfg.Appearance.ColorScheme != "" {
212- scheme = cfg.Appearance.ColorScheme
229+ if appearance.ColorScheme != "" {
230+ scheme = appearance.ColorScheme
213231 }
214232 m.scheme = scheme
215233 pref := m.colorResolver.Refresh()
216234 m.prefersDark = pref.PrefersDark
217235
218236 // Update palettes
219- m.lightPalette = PaletteFromConfig(&cfg.Appearance.LightPalette, false)
220- m.darkPalette = PaletteFromConfig(&cfg.Appearance.DarkPalette, true)
237+ m.lightPalette = PaletteFromConfig(&appearance.LightPalette, false)
238+ m.darkPalette = PaletteFromConfig(&appearance.DarkPalette, true)
221239
222240 // Update UI scale
223- if cfg.DefaultUIScale > 0 {
224- m.uiScale = cfg.DefaultUIScale
241+ if uiScale > 0 {
242+ m.uiScale = uiScale
225243 }
226244
227245 defaults := DefaultFontConfig()
228246 m.fonts = FontConfig{
229- SansFont: Coalesce(cfg.Appearance.SansFont, defaults.SansFont),
230- MonospaceFont: Coalesce(cfg.Appearance.MonospaceFont, defaults.MonospaceFont),
247+ SansFont: Coalesce(appearance.SansFont, defaults.SansFont),
248+ MonospaceFont: Coalesce(appearance.MonospaceFont, defaults.MonospaceFont),
231249 }
232250
233251 // Update mode colors
234- m.modeColors = modeColorsFromConfig(cfg)
252+ if styling != nil {
253+ m.modeColors = modeColorsFromStyling(styling)
254+ }
235255
236256 log.Info().
237257 Str("scheme", m.scheme).
M internal/ui/theme/manager_test.go +45 -72
@@ -6,17 +6,15 @@ import (
66
77 "github.com/bnema/dumber/internal/application/port"
88 "github.com/bnema/dumber/internal/application/port/mocks"
9- "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/bnema/dumber/internal/domain/entity"
1010 "github.com/stretchr/testify/assert"
1111 "github.com/stretchr/testify/require"
1212 )
1313
1414 func TestNewManager_DarkMode(t *testing.T) {
1515 ctx := context.Background()
16- cfg := &config.Config{
17- Appearance: config.AppearanceConfig{
18- ColorScheme: "default",
19- },
16+ appearance := &entity.AppearanceConfig{
17+ ColorScheme: "default",
2018 }
2119
2220 mockResolver := mocks.NewMockColorSchemeResolver(t)
@@ -25,7 +23,7 @@ func TestNewManager_DarkMode(t *testing.T) {
2523 Source: "test",
2624 })
2725
28- manager := NewManager(ctx, cfg, mockResolver)
26+ manager := NewManager(ctx, appearance, 1.0, nil, mockResolver)
2927
3028 assert.True(t, manager.PrefersDark())
3129 assert.Equal(t, "default", manager.scheme)
@@ -33,10 +31,8 @@ func TestNewManager_DarkMode(t *testing.T) {
3331
3432 func TestNewManager_LightMode(t *testing.T) {
3533 ctx := context.Background()
36- cfg := &config.Config{
37- Appearance: config.AppearanceConfig{
38- ColorScheme: "default",
39- },
34+ appearance := &entity.AppearanceConfig{
35+ ColorScheme: "default",
4036 }
4137
4238 mockResolver := mocks.NewMockColorSchemeResolver(t)
@@ -45,7 +41,7 @@ func TestNewManager_LightMode(t *testing.T) {
4541 Source: "test",
4642 })
4743
48- manager := NewManager(ctx, cfg, mockResolver)
44+ manager := NewManager(ctx, appearance, 1.0, nil, mockResolver)
4945
5046 assert.False(t, manager.PrefersDark())
5147 }
@@ -59,7 +55,7 @@ func TestNewManager_WithNilConfig(t *testing.T) {
5955 Source: "fallback",
6056 })
6157
62- manager := NewManager(ctx, nil, mockResolver)
58+ manager := NewManager(ctx, nil, 0, nil, mockResolver)
6359
6460 require.NotNil(t, manager)
6561 assert.True(t, manager.PrefersDark())
@@ -68,10 +64,8 @@ func TestNewManager_WithNilConfig(t *testing.T) {
6864
6965 func TestNewManager_UsesConfigScheme(t *testing.T) {
7066 ctx := context.Background()
71- cfg := &config.Config{
72- Appearance: config.AppearanceConfig{
73- ColorScheme: "prefer-dark",
74- },
67+ appearance := &entity.AppearanceConfig{
68+ ColorScheme: "prefer-dark",
7569 }
7670
7771 mockResolver := mocks.NewMockColorSchemeResolver(t)
@@ -80,14 +74,13 @@ func TestNewManager_UsesConfigScheme(t *testing.T) {
8074 Source: "config",
8175 })
8276
83- manager := NewManager(ctx, cfg, mockResolver)
77+ manager := NewManager(ctx, appearance, 1.0, nil, mockResolver)
8478
8579 assert.Equal(t, "prefer-dark", manager.scheme)
8680 }
8781
8882 func TestManager_GetCurrentPalette_Dark(t *testing.T) {
8983 ctx := context.Background()
90- cfg := &config.Config{}
9184
9285 mockResolver := mocks.NewMockColorSchemeResolver(t)
9386 mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
@@ -95,7 +88,7 @@ func TestManager_GetCurrentPalette_Dark(t *testing.T) {
9588 Source: "test",
9689 })
9790
98- manager := NewManager(ctx, cfg, mockResolver)
91+ manager := NewManager(ctx, nil, 0, nil, mockResolver)
9992 palette := manager.GetCurrentPalette()
10093
10194 // Dark palette should be returned
@@ -104,7 +97,6 @@ func TestManager_GetCurrentPalette_Dark(t *testing.T) {
10497
10598 func TestManager_GetCurrentPalette_Light(t *testing.T) {
10699 ctx := context.Background()
107- cfg := &config.Config{}
108100
109101 mockResolver := mocks.NewMockColorSchemeResolver(t)
110102 mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
@@ -112,7 +104,7 @@ func TestManager_GetCurrentPalette_Light(t *testing.T) {
112104 Source: "test",
113105 })
114106
115- manager := NewManager(ctx, cfg, mockResolver)
107+ manager := NewManager(ctx, nil, 0, nil, mockResolver)
116108 palette := manager.GetCurrentPalette()
117109
118110 // Light palette should be returned
@@ -121,7 +113,6 @@ func TestManager_GetCurrentPalette_Light(t *testing.T) {
121113
122114 func TestManager_SetColorScheme(t *testing.T) {
123115 ctx := context.Background()
124- cfg := &config.Config{}
125116
126117 mockResolver := mocks.NewMockColorSchemeResolver(t)
127118 // Initial resolve call
@@ -135,7 +126,7 @@ func TestManager_SetColorScheme(t *testing.T) {
135126 Source: "config",
136127 })
137128
138- manager := NewManager(ctx, cfg, mockResolver)
129+ manager := NewManager(ctx, nil, 0, nil, mockResolver)
139130 assert.True(t, manager.PrefersDark())
140131
141132 // Change scheme - this calls Refresh on resolver
@@ -147,10 +138,8 @@ func TestManager_SetColorScheme(t *testing.T) {
147138
148139 func TestManager_UpdateFromConfig(t *testing.T) {
149140 ctx := context.Background()
150- cfg := &config.Config{
151- Appearance: config.AppearanceConfig{
152- ColorScheme: "default",
153- },
141+ appearance := &entity.AppearanceConfig{
142+ ColorScheme: "default",
154143 }
155144
156145 mockResolver := mocks.NewMockColorSchemeResolver(t)
@@ -165,16 +154,14 @@ func TestManager_UpdateFromConfig(t *testing.T) {
165154 Source: "config",
166155 })
167156
168- manager := NewManager(ctx, cfg, mockResolver)
157+ manager := NewManager(ctx, appearance, 1.0, nil, mockResolver)
169158 assert.True(t, manager.PrefersDark())
170159
171160 // Update with new config
172- newCfg := &config.Config{
173- Appearance: config.AppearanceConfig{
174- ColorScheme: "prefer-light",
175- },
161+ newAppearance := &entity.AppearanceConfig{
162+ ColorScheme: "prefer-light",
176163 }
177- manager.UpdateFromConfig(ctx, newCfg, nil)
164+ manager.UpdateFromConfig(ctx, newAppearance, 0, nil, nil)
178165
179166 assert.False(t, manager.PrefersDark())
180167 assert.Equal(t, "prefer-light", manager.scheme)
@@ -182,7 +169,6 @@ func TestManager_UpdateFromConfig(t *testing.T) {
182169
183170 func TestManager_UpdateFromConfig_NilConfig(t *testing.T) {
184171 ctx := context.Background()
185- cfg := &config.Config{}
186172
187173 mockResolver := mocks.NewMockColorSchemeResolver(t)
188174 mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
@@ -190,18 +176,17 @@ func TestManager_UpdateFromConfig_NilConfig(t *testing.T) {
190176 Source: "test",
191177 })
192178
193- manager := NewManager(ctx, cfg, mockResolver)
179+ manager := NewManager(ctx, nil, 0, nil, mockResolver)
194180 initialScheme := manager.scheme
195181
196- // UpdateFromConfig with nil should be a no-op
197- manager.UpdateFromConfig(ctx, nil, nil)
182+ // UpdateFromConfig with nil appearance should be a no-op
183+ manager.UpdateFromConfig(ctx, nil, 0, nil, nil)
198184
199185 assert.Equal(t, initialScheme, manager.scheme)
200186 }
201187
202188 func TestManager_GetWebUIThemeCSS(t *testing.T) {
203189 ctx := context.Background()
204- cfg := &config.Config{}
205190
206191 mockResolver := mocks.NewMockColorSchemeResolver(t)
207192 mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
@@ -209,7 +194,7 @@ func TestManager_GetWebUIThemeCSS(t *testing.T) {
209194 Source: "test",
210195 })
211196
212- manager := NewManager(ctx, cfg, mockResolver)
197+ manager := NewManager(ctx, nil, 0, nil, mockResolver)
213198 css := manager.GetWebUIThemeCSS()
214199
215200 // Should contain both light and dark variables
@@ -219,7 +204,6 @@ func TestManager_GetWebUIThemeCSS(t *testing.T) {
219204
220205 func TestManager_GetBackgroundRGBA(t *testing.T) {
221206 ctx := context.Background()
222- cfg := &config.Config{}
223207
224208 mockResolver := mocks.NewMockColorSchemeResolver(t)
225209 mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
@@ -227,7 +211,7 @@ func TestManager_GetBackgroundRGBA(t *testing.T) {
227211 Source: "test",
228212 })
229213
230- manager := NewManager(ctx, cfg, mockResolver)
214+ manager := NewManager(ctx, nil, 0, nil, mockResolver)
231215 r, g, b, a := manager.GetBackgroundRGBA()
232216
233217 // Should return valid RGBA values (0-1 range)
@@ -243,16 +227,14 @@ func TestManager_GetBackgroundRGBA(t *testing.T) {
243227
244228 func TestManager_CustomPalettes(t *testing.T) {
245229 ctx := context.Background()
246- cfg := &config.Config{
247- Appearance: config.AppearanceConfig{
248- LightPalette: config.ColorPalette{
249- Background: "#ffffff",
250- Text: "#000000",
251- },
252- DarkPalette: config.ColorPalette{
253- Background: "#000000",
254- Text: "#ffffff",
255- },
230+ appearance := &entity.AppearanceConfig{
231+ LightPalette: entity.ColorPalette{
232+ Background: "#ffffff",
233+ Text: "#000000",
234+ },
235+ DarkPalette: entity.ColorPalette{
236+ Background: "#000000",
237+ Text: "#ffffff",
256238 },
257239 }
258240
@@ -262,7 +244,7 @@ func TestManager_CustomPalettes(t *testing.T) {
262244 Source: "test",
263245 })
264246
265- manager := NewManager(ctx, cfg, mockResolver)
247+ manager := NewManager(ctx, appearance, 1.0, nil, mockResolver)
266248
267249 lightPalette := manager.GetLightPalette()
268250 darkPalette := manager.GetDarkPalette()
@@ -275,9 +257,6 @@ func TestManager_CustomPalettes(t *testing.T) {
275257
276258 func TestManager_UIScale(t *testing.T) {
277259 ctx := context.Background()
278- cfg := &config.Config{
279- DefaultUIScale: 1.5,
280- }
281260
282261 mockResolver := mocks.NewMockColorSchemeResolver(t)
283262 mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{
@@ -285,7 +264,7 @@ func TestManager_UIScale(t *testing.T) {
285264 Source: "test",
286265 })
287266
288- manager := NewManager(ctx, cfg, mockResolver)
267+ manager := NewManager(ctx, nil, 1.5, nil, mockResolver)
289268
290269 // UI scale should be stored (we can't directly access it, but we can verify
291270 // the manager was created without error)
@@ -294,11 +273,9 @@ func TestManager_UIScale(t *testing.T) {
294273
295274 func TestManager_Fonts(t *testing.T) {
296275 ctx := context.Background()
297- cfg := &config.Config{
298- Appearance: config.AppearanceConfig{
299- SansFont: "Inter",
300- MonospaceFont: "JetBrains Mono",
301- },
276+ appearance := &entity.AppearanceConfig{
277+ SansFont: "Inter",
278+ MonospaceFont: "JetBrains Mono",
302279 }
303280
304281 mockResolver := mocks.NewMockColorSchemeResolver(t)
@@ -307,7 +284,7 @@ func TestManager_Fonts(t *testing.T) {
307284 Source: "test",
308285 })
309286
310- manager := NewManager(ctx, cfg, mockResolver)
287+ manager := NewManager(ctx, appearance, 1.0, nil, mockResolver)
311288
312289 // Fonts should be stored (manager creation should succeed)
313290 require.NotNil(t, manager)
@@ -315,15 +292,11 @@ func TestManager_Fonts(t *testing.T) {
315292
316293 func TestManager_ModeColors(t *testing.T) {
317294 ctx := context.Background()
318- cfg := &config.Config{
319- Workspace: config.WorkspaceConfig{
320- Styling: config.WorkspaceStylingConfig{
321- PaneModeColor: "#ff0000",
322- TabModeColor: "#00ff00",
323- SessionModeColor: "#0000ff",
324- ResizeModeColor: "#ffff00",
325- },
326- },
295+ styling := &entity.WorkspaceStylingConfig{
296+ PaneModeColor: "#ff0000",
297+ TabModeColor: "#00ff00",
298+ SessionModeColor: "#0000ff",
299+ ResizeModeColor: "#ffff00",
327300 }
328301
329302 mockResolver := mocks.NewMockColorSchemeResolver(t)
@@ -332,7 +305,7 @@ func TestManager_ModeColors(t *testing.T) {
332305 Source: "test",
333306 })
334307
335- manager := NewManager(ctx, cfg, mockResolver)
308+ manager := NewManager(ctx, nil, 1.0, styling, mockResolver)
336309 modeColors := manager.GetModeColors()
337310
338311 assert.Equal(t, "#ff0000", modeColors.PaneMode)
M internal/ui/theme/palette.go +2 -2
@@ -6,7 +6,7 @@ import (
66 "regexp"
77 "strings"
88
9- "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/bnema/dumber/internal/domain/entity"
1010 )
1111
1212 // Palette holds semantic color tokens for theming.
@@ -85,7 +85,7 @@ func DefaultLightPalette() Palette {
8585 }
8686
8787 // PaletteFromConfig creates a Palette from config values, filling missing values with defaults.
88-func PaletteFromConfig(cfg *config.ColorPalette, isDark bool) Palette {
88+func PaletteFromConfig(cfg *entity.ColorPalette, isDark bool) Palette {
8989 var defaults Palette
9090 if isDark {
9191 defaults = DefaultDarkPalette()
M internal/ui/window/main_window.go +21 -39
@@ -4,7 +4,6 @@ package window
44 import (
55 "context"
66
7- "github.com/bnema/dumber/internal/infrastructure/config"
87 "github.com/bnema/dumber/internal/logging"
98 "github.com/bnema/dumber/internal/ui/component"
109 "github.com/jwijenbergh/puregotk/v4/gtk"
@@ -26,31 +25,29 @@ type MainWindow struct {
2625 contentArea *gtk.Box // Container for workspace content
2726 currentContent *gtk.Widget // Track current content for removal on tab switch
2827
29- cfg *config.Config
30- logger zerolog.Logger
28+ tabBarPosition string // "top" or "bottom"
29+ logger zerolog.Logger
3130 }
3231
3332 // New creates a new main browser window.
34-func New(ctx context.Context, app *gtk.Application, cfg *config.Config) (*MainWindow, error) {
33+// tabBarPosition controls whether the tab bar is at the "top" or "bottom" (default: "top").
34+func New(ctx context.Context, app *gtk.Application, tabBarPosition string) (*MainWindow, error) {
3535 log := logging.FromContext(ctx)
3636
3737 mw := &MainWindow{
38- cfg: cfg,
39- logger: log.With().Str("component", "main-window").Logger(),
38+ tabBarPosition: tabBarPosition,
39+ logger: log.With().Str("component", "main-window").Logger(),
4040 }
4141
42- // Create the application window
4342 mw.window = gtk.NewApplicationWindow(app)
4443 if mw.window == nil {
4544 return nil, ErrWindowCreationFailed
4645 }
4746
48- // Configure window
4947 title := windowTitle
5048 mw.window.SetTitle(&title)
5149 mw.window.SetDefaultSize(defaultWidth, defaultHeight)
5250
53- // Create root container (vertical box)
5451 mw.rootBox = gtk.NewBox(gtk.OrientationVerticalValue, 0)
5552 if mw.rootBox == nil {
5653 mw.window.Unref()
@@ -60,7 +57,6 @@ func New(ctx context.Context, app *gtk.Application, cfg *config.Config) (*MainWi
6057 mw.rootBox.SetVexpand(true)
6158 mw.rootBox.SetVisible(true)
6259
63- // Create tab bar
6460 mw.tabBar = component.NewTabBar()
6561 if mw.tabBar == nil {
6662 mw.rootBox.Unref()
@@ -68,7 +64,6 @@ func New(ctx context.Context, app *gtk.Application, cfg *config.Config) (*MainWi
6864 return nil, ErrWidgetCreationFailed("tabBar")
6965 }
7066
71- // Create content overlay (for omnibox and other overlays)
7267 mw.contentOverlay = gtk.NewOverlay()
7368 if mw.contentOverlay == nil {
7469 mw.tabBar.Destroy()
@@ -80,7 +75,6 @@ func New(ctx context.Context, app *gtk.Application, cfg *config.Config) (*MainWi
8075 mw.contentOverlay.SetVexpand(true)
8176 mw.contentOverlay.SetVisible(true)
8277
83- // Create content area
8478 mw.contentArea = gtk.NewBox(gtk.OrientationVerticalValue, 0)
8579 if mw.contentArea == nil {
8680 mw.contentOverlay.Unref()
@@ -94,31 +88,25 @@ func New(ctx context.Context, app *gtk.Application, cfg *config.Config) (*MainWi
9488 mw.contentArea.SetVisible(true)
9589 mw.contentArea.AddCssClass("content-area")
9690
97- // Set content area as the main child of the overlay
9891 mw.contentOverlay.SetChild(&mw.contentArea.Widget)
9992
100- // Assemble layout based on tab bar position
10193 mw.assembleLayout()
10294
103- // Set root box as window content
10495 mw.window.SetChild(&mw.rootBox.Widget)
10596
10697 return mw, nil
10798 }
10899
109-// assembleLayout arranges widgets based on configuration.
110100 func (mw *MainWindow) assembleLayout() {
111101 tabBarPos := "top" // default
112- if mw.cfg != nil {
113- tabBarPos = mw.cfg.Workspace.TabBarPosition
102+ if mw.tabBarPosition != "" {
103+ tabBarPos = mw.tabBarPosition
114104 }
115105
116106 if tabBarPos == "bottom" {
117- // Content first, then tab bar
118107 mw.rootBox.Append(&mw.contentOverlay.Widget)
119108 mw.rootBox.Append(mw.tabBar.Widget())
120109 } else {
121- // Tab bar first, then content (default: top)
122110 mw.rootBox.Append(mw.tabBar.Widget())
123111 mw.rootBox.Append(&mw.contentOverlay.Widget)
124112 }
@@ -128,7 +116,7 @@ func (mw *MainWindow) assembleLayout() {
128116 Msg("window layout assembled")
129117 }
130118
131-// Show makes the window visible.
119+// Show presents the window.
132120 func (mw *MainWindow) Show() {
133121 mw.window.Present()
134122 }
@@ -138,26 +126,23 @@ func (mw *MainWindow) Close() {
138126 mw.window.Close()
139127 }
140128
141-// TabBar returns the tab bar component.
129+// TabBar returns the window's tab bar component.
142130 func (mw *MainWindow) TabBar() *component.TabBar {
143131 return mw.tabBar
144132 }
145133
146-// ContentArea returns the content area box.
134+// ContentArea returns the main content container widget.
147135 func (mw *MainWindow) ContentArea() *gtk.Box {
148136 return mw.contentArea
149137 }
150138
151-// SetContent sets the content of the content area.
152-// This removes any existing content first to properly swap workspace views on tab switch.
139+// SetContent replaces the current content widget with the given widget.
153140 func (mw *MainWindow) SetContent(widget *gtk.Widget) {
154- // Remove existing content first
155141 if mw.currentContent != nil {
156142 mw.contentArea.Remove(mw.currentContent)
157143 mw.currentContent = nil
158144 }
159145
160- // Add new content
161146 if widget != nil {
162147 widget.SetVisible(true)
163148 mw.contentArea.Append(widget)
@@ -165,18 +150,16 @@ func (mw *MainWindow) SetContent(widget *gtk.Widget) {
165150 }
166151 }
167152
168-// Window returns the underlying GTK window.
153+// Window returns the underlying GTK application window.
169154 func (mw *MainWindow) Window() *gtk.ApplicationWindow {
170155 return mw.window
171156 }
172157
173-// SetTitle updates the window title with proper formatting.
174-// The title is capped at 255 characters for display.
158+// SetTitle sets the window title.
175159 func (mw *MainWindow) SetTitle(title string) {
176160 if mw.window == nil {
177161 return
178162 }
179- // Truncate title to 255 characters max (by runes, to avoid breaking UTF-8)
180163 const maxTitleLen = 255
181164 runes := []rune(title)
182165 if len(runes) > maxTitleLen {
@@ -185,21 +168,19 @@ func (mw *MainWindow) SetTitle(title string) {
185168 mw.window.SetTitle(&title)
186169 }
187170
188-// ContentOverlay returns the content overlay widget.
189-// Used by components that need the overlay dimensions for sizing calculations.
171+// ContentOverlay returns the overlay container for the content area.
190172 func (mw *MainWindow) ContentOverlay() *gtk.Overlay {
191173 return mw.contentOverlay
192174 }
193175
194-// AddOverlay adds a widget to the content overlay.
195-// The widget will be displayed on top of the workspace content.
176+// AddOverlay adds a widget as an overlay above the content area.
196177 func (mw *MainWindow) AddOverlay(widget *gtk.Widget) {
197178 if mw.contentOverlay != nil && widget != nil {
198179 mw.contentOverlay.AddOverlay(widget)
199180 }
200181 }
201182
202-// Destroy cleans up window resources.
183+// Destroy releases all GTK resources held by the window.
203184 func (mw *MainWindow) Destroy() {
204185 if mw.tabBar != nil {
205186 mw.tabBar.Destroy()
@@ -219,21 +200,22 @@ func (mw *MainWindow) Destroy() {
219200 }
220201 }
221202
222-// WindowError represents a window-related error.
203+// WindowError is a window-related error.
223204 type WindowError struct {
224205 Message string
225206 }
226207
208+// Error implements the error interface.
227209 func (e WindowError) Error() string {
228210 return e.Message
229211 }
230212
231-// Error constants.
232213 var (
214+ // ErrWindowCreationFailed is returned when the GTK application window cannot be created.
233215 ErrWindowCreationFailed = WindowError{Message: "failed to create application window"}
234216 )
235217
236-// ErrWidgetCreationFailed creates an error for widget creation failure.
218+// ErrWidgetCreationFailed returns an error for a failed widget creation.
237219 func ErrWidgetCreationFailed(name string) error {
238220 return WindowError{Message: "failed to create widget: " + name}
239221 }
M webui/package-lock.json +14 -1 generated file: collapsed

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