Merge pull request #193 from bnema/refactor/engine-abstraction
refactor: engine abstraction layer
121 files · +19000 -6804
M .mockery.yaml +46 -2
| @@ -20,7 +20,6 @@ packages: | |||
| 20 | 20 | WebViewFactory: {} | |
| 21 | 21 | WebViewPool: {} | |
| 22 | 22 | MemoryPressureApplier: {} | |
| 23 | - | WebKitContextProvider: {} | |
| 24 | 23 | HardwareSurveyor: {} | |
| 25 | 24 | ColorSchemeDetector: {} | |
| 26 | 25 | ColorSchemeResolver: {} | |
| @@ -32,6 +31,52 @@ packages: | |||
| 32 | 31 | KeybindingsSaver: {} | |
| 33 | 32 | DownloadResponse: {} | |
| 34 | 33 | 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: {} | |
| 35 | 80 | github.com/bnema/dumber/internal/domain/repository: | |
| 36 | 81 | config: | |
| 37 | 82 | dir: '{{.InterfaceDir}}/mocks' | |
| @@ -46,7 +91,6 @@ packages: | |||
| 46 | 91 | SessionStateRepository: {} | |
| 47 | 92 | TagRepository: {} | |
| 48 | 93 | ZoomRepository: {} | |
| 49 | - | PermissionRepository: {} | |
| 50 | 94 | github.com/bnema/dumber/internal/infrastructure/filtering: | |
| 51 | 95 | config: | |
| 52 | 96 | dir: '{{.InterfaceDir}}/mocks' | |
M cmd/dumber/main.go +74 -32
| @@ -18,18 +18,23 @@ import ( | |||
| 18 | 18 | "github.com/bnema/dumber/internal/domain/repository" | |
| 19 | 19 | "github.com/bnema/dumber/internal/infrastructure/cache" | |
| 20 | 20 | "github.com/bnema/dumber/internal/infrastructure/clipboard" | |
| 21 | - | "github.com/bnema/dumber/internal/infrastructure/colorscheme" | |
| 22 | 21 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 23 | 22 | "github.com/bnema/dumber/internal/infrastructure/deps" | |
| 23 | + | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 24 | 24 | "github.com/bnema/dumber/internal/infrastructure/favicon" | |
| 25 | + | "github.com/bnema/dumber/internal/infrastructure/filesystem" | |
| 25 | 26 | "github.com/bnema/dumber/internal/infrastructure/idle" | |
| 26 | 27 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 28 | + | "github.com/bnema/dumber/internal/infrastructure/snapshot" | |
| 29 | + | "github.com/bnema/dumber/internal/infrastructure/textinput" | |
| 27 | 30 | "github.com/bnema/dumber/internal/infrastructure/updater" | |
| 28 | 31 | "github.com/bnema/dumber/internal/infrastructure/xdg" | |
| 29 | 32 | "github.com/bnema/dumber/internal/logging" | |
| 30 | 33 | "github.com/bnema/dumber/internal/ui" | |
| 34 | + | "github.com/bnema/dumber/internal/ui/adapter" | |
| 31 | 35 | "github.com/bnema/dumber/internal/ui/component" | |
| 32 | 36 | "github.com/bnema/dumber/internal/ui/theme" | |
| 37 | + | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 33 | 38 | "github.com/rs/zerolog" | |
| 34 | 39 | ) | |
| 35 | 40 | ||
| @@ -94,7 +99,7 @@ func runGUI() int { | |||
| 94 | 99 | ||
| 95 | 100 | needsEagerDB := restoreSessionID != "" || cfg.Session.AutoRestore | |
| 96 | 101 | ||
| 97 | - | stack, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, needsEagerDB) | |
| 102 | + | engine, repos, dbCleanup, err := initStackAndRepos(ctx, cfg, initResult, needsEagerDB) | |
| 98 | 103 | if err != nil { | |
| 99 | 104 | bootstrapLog.Fatal().Err(err).Msg("failed to initialize database/webkit") | |
| 100 | 105 | } | |
| @@ -111,9 +116,7 @@ func runGUI() int { | |||
| 111 | 116 | logging.Trace().UpdateLogger(log) | |
| 112 | 117 | logCoreDumpLimits(ctx) | |
| 113 | 118 | ||
| 114 | - | if stack.MessageRouter != nil { | |
| 115 | - | stack.MessageRouter.SetBaseContext(ctx) | |
| 116 | - | } | |
| 119 | + | engine.SetHandlerContext(ctx) | |
| 117 | 120 | ||
| 118 | 121 | useCases := createUseCases(repos, cfg) | |
| 119 | 122 | if needsEagerDB { | |
| @@ -124,7 +127,7 @@ func runGUI() int { | |||
| 124 | 127 | defer closeIdleInhibitor(idleInhibitor) | |
| 125 | 128 | timer.Mark("use_cases") | |
| 126 | 129 | ||
| 127 | - | app, err := buildAndConfigureApp(ctx, cfg, initResult, &stack, repos, useCases, idleInhibitor, browserSession) | |
| 130 | + | app, err := buildAndConfigureApp(ctx, cfg, initResult, engine, repos, useCases, idleInhibitor, browserSession) | |
| 128 | 131 | if err != nil { | |
| 129 | 132 | log.Error().Err(err).Msg("failed to create application") | |
| 130 | 133 | return 1 | |
| @@ -194,7 +197,7 @@ func buildAndConfigureApp( | |||
| 194 | 197 | ctx context.Context, | |
| 195 | 198 | cfg *config.Config, | |
| 196 | 199 | initResult *bootstrap.ParallelInitResult, | |
| 197 | - | stack *bootstrap.WebKitStack, | |
| 200 | + | engine port.Engine, | |
| 198 | 201 | repos *repositories, | |
| 199 | 202 | useCases *useCases, | |
| 200 | 203 | idleInhibitor port.IdleInhibitor, | |
| @@ -203,14 +206,14 @@ func buildAndConfigureApp( | |||
| 203 | 206 | uiDeps := buildUIDependencies( | |
| 204 | 207 | ctx, cfg, initResult.ThemeManager, | |
| 205 | 208 | initResult.ColorResolver, initResult.AdwaitaDetector, | |
| 206 | - | stack, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.UnexpectedCloseReports(), | |
| 209 | + | engine, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.UnexpectedCloseReports(), | |
| 207 | 210 | ) | |
| 208 | 211 | configureDeferredInit(uiDeps, cfg, browserSession) | |
| 209 | 212 | return ui.New(uiDeps) | |
| 210 | 213 | } | |
| 211 | 214 | ||
| 212 | 215 | func initStartupContext(cfg *config.Config) context.Context { | |
| 213 | - | deps.ApplyPrefixEnv(cfg.Runtime.Prefix) | |
| 216 | + | deps.ApplyPrefixEnv(cfg.Engine.WebKit.Prefix) | |
| 214 | 217 | bootstrapLogger := logging.NewFromConfigValuesWithTimeFormat( | |
| 215 | 218 | cfg.Logging.Level, | |
| 216 | 219 | cfg.Logging.Format, | |
| @@ -230,10 +233,10 @@ func initStackAndRepos( | |||
| 230 | 233 | cfg *config.Config, | |
| 231 | 234 | initResult *bootstrap.ParallelInitResult, | |
| 232 | 235 | needsEagerDB bool, | |
| 233 | - | ) (bootstrap.WebKitStack, *repositories, func(), error) { | |
| 236 | + | ) (port.Engine, *repositories, func(), error) { | |
| 234 | 237 | 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{ | |
| 237 | 240 | Ctx: ctx, | |
| 238 | 241 | Config: cfg, | |
| 239 | 242 | DataDir: initResult.DataDir, | |
| @@ -242,13 +245,13 @@ func initStackAndRepos( | |||
| 242 | 245 | ColorResolver: initResult.ColorResolver, | |
| 243 | 246 | }) | |
| 244 | 247 | if err != nil { | |
| 245 | - | return bootstrap.WebKitStack{}, nil, nil, err | |
| 248 | + | return nil, nil, nil, err | |
| 246 | 249 | } | |
| 247 | - | return dbWebKit.Stack, createRepositories(dbWebKit.DB), dbWebKit.DBCleanup, nil | |
| 250 | + | return dbEngine.Engine, createRepositories(dbEngine.DB), dbEngine.DBCleanup, nil | |
| 248 | 251 | } | |
| 249 | 252 | ||
| 250 | 253 | log := logging.FromContext(ctx) | |
| 251 | - | stack := bootstrap.BuildWebKitStack(bootstrap.WebKitStackInput{ | |
| 254 | + | engine, err := bootstrap.BuildEngine(bootstrap.EngineInput{ | |
| 252 | 255 | Ctx: ctx, | |
| 253 | 256 | Config: cfg, | |
| 254 | 257 | DataDir: initResult.DataDir, | |
| @@ -257,13 +260,16 @@ func initStackAndRepos( | |||
| 257 | 260 | ColorResolver: initResult.ColorResolver, | |
| 258 | 261 | Logger: *log, | |
| 259 | 262 | }) | |
| 263 | + | if err != nil { | |
| 264 | + | return nil, nil, nil, err | |
| 265 | + | } | |
| 260 | 266 | ||
| 261 | 267 | lazyDB, err := bootstrap.CreateLazyDatabase() | |
| 262 | 268 | if err != nil { | |
| 263 | - | return stack, nil, nil, err | |
| 269 | + | return nil, nil, nil, err | |
| 264 | 270 | } | |
| 265 | 271 | dbCleanup := func() { _ = lazyDB.Close() } | |
| 266 | - | return stack, createLazyRepositories(lazyDB), dbCleanup, nil | |
| 272 | + | return engine, createLazyRepositories(lazyDB), dbCleanup, nil | |
| 267 | 273 | } | |
| 268 | 274 | ||
| 269 | 275 | func configureDeferredInit( | |
| @@ -462,7 +468,7 @@ func createUseCases(repos *repositories, cfg *config.Config) *useCases { | |||
| 462 | 468 | xdgDirs, _ := config.GetXDGDirs() | |
| 463 | 469 | stateDir, _ := config.GetStateDir() | |
| 464 | 470 | defaultZoom := cfg.DefaultWebpageZoom | |
| 465 | - | zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Performance.ZoomCacheSize) | |
| 471 | + | zoomCache := cache.NewLRU[string, *entity.ZoomLevel](cfg.Engine.ZoomCacheSize) | |
| 466 | 472 | ||
| 467 | 473 | buildInfo := build.Info{ | |
| 468 | 474 | Version: version, | |
| @@ -500,15 +506,22 @@ func buildUIDependencies( | |||
| 500 | 506 | cfg *config.Config, | |
| 501 | 507 | themeManager *theme.Manager, | |
| 502 | 508 | colorResolver port.ColorSchemeResolver, | |
| 503 | - | adwaitaDetector *colorscheme.AdwaitaDetector, | |
| 504 | - | stack *bootstrap.WebKitStack, | |
| 509 | + | adwaitaDetector port.ToolkitAvailabilityNotifier, | |
| 510 | + | engine port.Engine, | |
| 505 | 511 | repos *repositories, | |
| 506 | 512 | uc *useCases, | |
| 507 | 513 | idleInhibitor port.IdleInhibitor, | |
| 508 | 514 | currentSessionID entity.SessionID, | |
| 509 | 515 | startupCrashReports []string, | |
| 510 | 516 | ) *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{ | |
| 512 | 525 | Ctx: ctx, | |
| 513 | 526 | Config: cfg, | |
| 514 | 527 | InitialURL: initialURL, | |
| @@ -518,12 +531,8 @@ func buildUIDependencies( | |||
| 518 | 531 | ColorResolver: colorResolver, | |
| 519 | 532 | AdwaitaDetector: adwaitaDetector, | |
| 520 | 533 | 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, | |
| 527 | 536 | HistoryRepo: repos.history, | |
| 528 | 537 | FavoriteRepo: repos.favorite, | |
| 529 | 538 | ZoomRepo: repos.zoom, | |
| @@ -538,12 +547,45 @@ func buildUIDependencies( | |||
| 538 | 547 | CopyURLUC: uc.copyURL, | |
| 539 | 548 | Clipboard: uc.clipboard, | |
| 540 | 549 | 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 | + | }, | |
| 546 | 564 | CheckUpdateUC: uc.checkUpdate, | |
| 547 | 565 | 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(), | |
| 548 | 588 | } | |
| 589 | + | ||
| 590 | + | return uiDeps | |
| 549 | 591 | } | |
M internal/application/port/color_scheme.go +5 -0
| @@ -33,6 +33,11 @@ type ColorSchemeDetector interface { | |||
| 33 | 33 | Detect() (prefersDark bool, ok bool) | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 36 | + | // ToolkitAvailabilityNotifier signals that a toolkit (e.g., GTK/Adwaita) is available. | |
| 37 | + | type ToolkitAvailabilityNotifier interface { | |
| 38 | + | MarkAvailable() | |
| 39 | + | } | |
| 40 | + | ||
| 36 | 41 | // ColorSchemeResolver resolves the effective color scheme preference. | |
| 37 | 42 | // It manages multiple detectors and respects config overrides. | |
| 38 | 43 | type ColorSchemeResolver interface { | |
M internal/application/port/content_injector.go +8 -0
| @@ -8,4 +8,12 @@ type ContentInjector interface { | |||
| 8 | 8 | // InjectThemeCSS injects CSS variables into the page for theming. | |
| 9 | 9 | // The css parameter should contain CSS custom property declarations. | |
| 10 | 10 | 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 | |
| 11 | 19 | } | |
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) { | |||
| 81 | 81 | } | |
| 82 | 82 | } | |
| 83 | 83 | ||
| 84 | - | func TestWebKitContextOptions_IsWebProcessMemoryConfigured(t *testing.T) { | |
| 84 | + | func TestEngineOptions_IsWebProcessMemoryConfigured(t *testing.T) { | |
| 85 | 85 | tests := []struct { | |
| 86 | 86 | name string | |
| 87 | - | opts port.WebKitContextOptions | |
| 87 | + | opts port.EngineOptions | |
| 88 | 88 | expected bool | |
| 89 | 89 | }{ | |
| 90 | 90 | { | |
| 91 | 91 | name: "nil WebProcessMemory", | |
| 92 | - | opts: port.WebKitContextOptions{}, | |
| 92 | + | opts: port.EngineOptions{}, | |
| 93 | 93 | expected: false, | |
| 94 | 94 | }, | |
| 95 | 95 | { | |
| 96 | 96 | name: "empty WebProcessMemory", | |
| 97 | - | opts: port.WebKitContextOptions{ | |
| 97 | + | opts: port.EngineOptions{ | |
| 98 | 98 | WebProcessMemory: &port.MemoryPressureConfig{}, | |
| 99 | 99 | }, | |
| 100 | 100 | expected: false, | |
| 101 | 101 | }, | |
| 102 | 102 | { | |
| 103 | 103 | name: "configured WebProcessMemory", | |
| 104 | - | opts: port.WebKitContextOptions{ | |
| 104 | + | opts: port.EngineOptions{ | |
| 105 | 105 | WebProcessMemory: &port.MemoryPressureConfig{ | |
| 106 | 106 | MemoryLimitMB: 1024, | |
| 107 | 107 | }, | |
| @@ -112,33 +112,33 @@ func TestWebKitContextOptions_IsWebProcessMemoryConfigured(t *testing.T) { | |||
| 112 | 112 | ||
| 113 | 113 | for _, tt := range tests { | |
| 114 | 114 | t.Run(tt.name, func(t *testing.T) { | |
| 115 | - | result := tt.opts.IsWebProcessMemoryConfigured() | |
| 115 | + | result := tt.opts.WebProcessMemory.IsConfigured() | |
| 116 | 116 | assert.Equal(t, tt.expected, result) | |
| 117 | 117 | }) | |
| 118 | 118 | } | |
| 119 | 119 | } | |
| 120 | 120 | ||
| 121 | - | func TestWebKitContextOptions_IsNetworkProcessMemoryConfigured(t *testing.T) { | |
| 121 | + | func TestEngineOptions_IsNetworkProcessMemoryConfigured(t *testing.T) { | |
| 122 | 122 | tests := []struct { | |
| 123 | 123 | name string | |
| 124 | - | opts port.WebKitContextOptions | |
| 124 | + | opts port.EngineOptions | |
| 125 | 125 | expected bool | |
| 126 | 126 | }{ | |
| 127 | 127 | { | |
| 128 | 128 | name: "nil NetworkProcessMemory", | |
| 129 | - | opts: port.WebKitContextOptions{}, | |
| 129 | + | opts: port.EngineOptions{}, | |
| 130 | 130 | expected: false, | |
| 131 | 131 | }, | |
| 132 | 132 | { | |
| 133 | 133 | name: "empty NetworkProcessMemory", | |
| 134 | - | opts: port.WebKitContextOptions{ | |
| 134 | + | opts: port.EngineOptions{ | |
| 135 | 135 | NetworkProcessMemory: &port.MemoryPressureConfig{}, | |
| 136 | 136 | }, | |
| 137 | 137 | expected: false, | |
| 138 | 138 | }, | |
| 139 | 139 | { | |
| 140 | 140 | name: "configured NetworkProcessMemory", | |
| 141 | - | opts: port.WebKitContextOptions{ | |
| 141 | + | opts: port.EngineOptions{ | |
| 142 | 142 | NetworkProcessMemory: &port.MemoryPressureConfig{ | |
| 143 | 143 | PollIntervalSec: 20.0, | |
| 144 | 144 | }, | |
| @@ -149,7 +149,7 @@ func TestWebKitContextOptions_IsNetworkProcessMemoryConfigured(t *testing.T) { | |||
| 149 | 149 | ||
| 150 | 150 | for _, tt := range tests { | |
| 151 | 151 | t.Run(tt.name, func(t *testing.T) { | |
| 152 | - | result := tt.opts.IsNetworkProcessMemoryConfigured() | |
| 152 | + | result := tt.opts.NetworkProcessMemory.IsConfigured() | |
| 153 | 153 | assert.Equal(t, tt.expected, result) | |
| 154 | 154 | }) | |
| 155 | 155 | } | |
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 { | |||
| 64 | 64 | ResetKeybinding(ctx context.Context, req ResetKeybindingRequest) error | |
| 65 | 65 | ResetAllKeybindings(ctx context.Context) error | |
| 66 | 66 | } | |
| 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 ( | |||
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | 9 | // 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. | |
| 11 | 12 | type PermissionRepository interface { | |
| 12 | 13 | // Get retrieves the permission record for a specific origin and permission type. | |
| 13 | 14 | // Returns nil if no record exists (treat as "prompt" state). | |
M internal/application/port/rendering_env.go +4 -41
| @@ -17,55 +17,18 @@ const ( | |||
| 17 | 17 | GPUVendorUnknown GPUVendor = "unknown" | |
| 18 | 18 | ) | |
| 19 | 19 | ||
| 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 | - | ||
| 57 | 20 | // RenderingEnvManager configures rendering environment variables | |
| 58 | 21 | // for GStreamer, WebKit, and GTK/GSK. | |
| 59 | 22 | // 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. | |
| 60 | 27 | type RenderingEnvManager interface { | |
| 61 | 28 | // DetectGPUVendor identifies the primary GPU vendor from system info. | |
| 62 | 29 | // Uses /sys/class/drm/card*/device/vendor as primary detection method. | |
| 63 | 30 | DetectGPUVendor(ctx context.Context) GPUVendor | |
| 64 | 31 | ||
| 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 | - | ||
| 69 | 32 | // GetAppliedVars returns a map of environment variables that were set. | |
| 70 | 33 | // Useful for logging and debugging. | |
| 71 | 34 | GetAppliedVars() map[string]string | |
M internal/application/port/session_state.go +20 -1
| @@ -1,6 +1,10 @@ | |||
| 1 | 1 | package port | |
| 2 | 2 | ||
| 3 | - | import "github.com/bnema/dumber/internal/domain/entity" | |
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 7 | + | ) | |
| 4 | 8 | ||
| 5 | 9 | // TabListProvider provides access to the current tab list state. | |
| 6 | 10 | // Implemented by the UI layer to allow the snapshot service to read state. | |
| @@ -16,3 +20,18 @@ type SessionSpawner interface { | |||
| 16 | 20 | // SpawnWithSession starts a new dumber instance to restore a session. | |
| 17 | 21 | SpawnWithSession(sessionID entity.SessionID) error | |
| 18 | 22 | } | |
| 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 { | |||
| 18 | 18 | Focus(ctx context.Context) | |
| 19 | 19 | } | |
| 20 | 20 | ||
| 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 | + | ||
| 21 | 28 | // FocusedInputProvider tracks which text input target currently has focus. | |
| 22 | 29 | // This allows the accent picker to insert text into the correct input field. | |
| 23 | 30 | 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 { | |||
| 70 | 70 | ParentViewID WebViewID | |
| 71 | 71 | } | |
| 72 | 72 | ||
| 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. | |
| 76 | 76 | type Texture interface { | |
| 77 | - | // GoPointer returns the underlying C pointer for GTK interop. | |
| 78 | 77 | GoPointer() uintptr | |
| 79 | 78 | } | |
| 80 | 79 | ||
| @@ -109,6 +108,21 @@ type WebViewCallbacks struct { | |||
| 109 | 108 | // Return true to indicate the request was handled. Call allow()/deny() to respond. | |
| 110 | 109 | // The permission types are passed as strings ("microphone", "camera", "display", etc.). | |
| 111 | 110 | 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) | |
| 112 | 126 | } | |
| 113 | 127 | ||
| 114 | 128 | // FindOptions configures search behavior. | |
| @@ -210,6 +224,26 @@ type WebView interface { | |||
| 210 | 224 | // Pass nil to clear all callbacks. | |
| 211 | 225 | SetCallbacks(callbacks *WebViewCallbacks) | |
| 212 | 226 | ||
| 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 | + | ||
| 213 | 247 | // --- Media/Fullscreen State --- | |
| 214 | 248 | ||
| 215 | 249 | // IsFullscreen returns true if the WebView is currently in fullscreen mode. | |
| @@ -240,10 +274,15 @@ type WebViewPool interface { | |||
| 240 | 274 | // If the pool is full, the WebView will be destroyed. | |
| 241 | 275 | Release(wv WebView) | |
| 242 | 276 | ||
| 243 | - | // Prewarm creates WebViews in the background to populate the pool. | |
| 277 | + | // Prewarm creates WebViews synchronously to populate the pool. | |
| 244 | 278 | // Pass count <= 0 to use the default prewarm count. | |
| 245 | 279 | Prewarm(count int) | |
| 246 | 280 | ||
| 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 | + | ||
| 247 | 286 | // Size returns the current number of available WebViews in the pool. | |
| 248 | 287 | Size() int | |
| 249 | 288 | ||
| @@ -262,3 +301,44 @@ type WebViewFactory interface { | |||
| 262 | 301 | // Popup WebViews bypass the pool since they must be related to a specific parent. | |
| 263 | 302 | CreateRelated(ctx context.Context, parentID WebViewID) (WebView, error) | |
| 264 | 303 | } | |
| 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 ( | |||
| 9 | 9 | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |
| 10 | 10 | "github.com/bnema/dumber/internal/application/usecase" | |
| 11 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | - | repomocks "github.com/bnema/dumber/internal/domain/repository/mocks" | |
| 13 | 12 | "github.com/rs/zerolog" | |
| 14 | 13 | "github.com/stretchr/testify/assert" | |
| 15 | 14 | "github.com/stretchr/testify/mock" | |
| @@ -22,7 +21,7 @@ func permissionLoggerFromContext(ctx context.Context) *zerolog.Logger { | |||
| 22 | 21 | ||
| 23 | 22 | func TestHandlePermissionUseCase_AutoAllowDisplayCapture(t *testing.T) { | |
| 24 | 23 | ctx := testContext() | |
| 25 | - | permRepo := repomocks.NewMockPermissionRepository(t) | |
| 24 | + | permRepo := portmocks.NewMockPermissionRepository(t) | |
| 26 | 25 | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |
| 27 | 26 | ||
| 28 | 27 | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |
| @@ -49,7 +48,7 @@ func TestHandlePermissionUseCase_AutoAllowDisplayCapture(t *testing.T) { | |||
| 49 | 48 | ||
| 50 | 49 | func TestHandlePermissionUseCase_AutoAllowDeviceInfo(t *testing.T) { | |
| 51 | 50 | ctx := testContext() | |
| 52 | - | permRepo := repomocks.NewMockPermissionRepository(t) | |
| 51 | + | permRepo := portmocks.NewMockPermissionRepository(t) | |
| 53 | 52 | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |
| 54 | 53 | ||
| 55 | 54 | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |
| @@ -73,7 +72,7 @@ func TestHandlePermissionUseCase_AutoAllowDeviceInfo(t *testing.T) { | |||
| 73 | 72 | ||
| 74 | 73 | func TestHandlePermissionUseCase_StoredPermissionGranted(t *testing.T) { | |
| 75 | 74 | ctx := testContext() | |
| 76 | - | permRepo := repomocks.NewMockPermissionRepository(t) | |
| 75 | + | permRepo := portmocks.NewMockPermissionRepository(t) | |
| 77 | 76 | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |
| 78 | 77 | ||
| 79 | 78 | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |
| @@ -103,7 +102,7 @@ func TestHandlePermissionUseCase_StoredPermissionGranted(t *testing.T) { | |||
| 103 | 102 | ||
| 104 | 103 | func TestHandlePermissionUseCase_StoredPermissionDenied(t *testing.T) { | |
| 105 | 104 | ctx := testContext() | |
| 106 | - | permRepo := repomocks.NewMockPermissionRepository(t) | |
| 105 | + | permRepo := portmocks.NewMockPermissionRepository(t) | |
| 107 | 106 | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |
| 108 | 107 | ||
| 109 | 108 | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |
| @@ -133,7 +132,7 @@ func TestHandlePermissionUseCase_StoredPermissionDenied(t *testing.T) { | |||
| 133 | 132 | ||
| 134 | 133 | func TestHandlePermissionUseCase_ShowDialogForPrompt(t *testing.T) { | |
| 135 | 134 | ctx := testContext() | |
| 136 | - | permRepo := repomocks.NewMockPermissionRepository(t) | |
| 135 | + | permRepo := portmocks.NewMockPermissionRepository(t) | |
| 137 | 136 | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |
| 138 | 137 | ||
| 139 | 138 | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |
| @@ -166,7 +165,7 @@ func TestHandlePermissionUseCase_ShowDialogForPrompt(t *testing.T) { | |||
| 166 | 165 | ||
| 167 | 166 | func TestHandlePermissionUseCase_PersistAllowed(t *testing.T) { | |
| 168 | 167 | ctx := testContext() | |
| 169 | - | permRepo := repomocks.NewMockPermissionRepository(t) | |
| 168 | + | permRepo := portmocks.NewMockPermissionRepository(t) | |
| 170 | 169 | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |
| 171 | 170 | ||
| 172 | 171 | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |
| @@ -205,7 +204,7 @@ func TestHandlePermissionUseCase_PersistAllowed(t *testing.T) { | |||
| 205 | 204 | ||
| 206 | 205 | func TestHandlePermissionUseCase_PersistDenied(t *testing.T) { | |
| 207 | 206 | ctx := testContext() | |
| 208 | - | permRepo := repomocks.NewMockPermissionRepository(t) | |
| 207 | + | permRepo := portmocks.NewMockPermissionRepository(t) | |
| 209 | 208 | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |
| 210 | 209 | ||
| 211 | 210 | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |
| @@ -243,7 +242,7 @@ func TestHandlePermissionUseCase_PersistDenied(t *testing.T) { | |||
| 243 | 242 | ||
| 244 | 243 | func TestHandlePermissionUseCase_EmptyOrigin(t *testing.T) { | |
| 245 | 244 | ctx := testContext() | |
| 246 | - | permRepo := repomocks.NewMockPermissionRepository(t) | |
| 245 | + | permRepo := portmocks.NewMockPermissionRepository(t) | |
| 247 | 246 | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |
| 248 | 247 | ||
| 249 | 248 | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |
| @@ -264,7 +263,7 @@ func TestHandlePermissionUseCase_EmptyOrigin(t *testing.T) { | |||
| 264 | 263 | ||
| 265 | 264 | func TestHandlePermissionUseCase_CombinedPermissions(t *testing.T) { | |
| 266 | 265 | ctx := testContext() | |
| 267 | - | permRepo := repomocks.NewMockPermissionRepository(t) | |
| 266 | + | permRepo := portmocks.NewMockPermissionRepository(t) | |
| 268 | 267 | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |
| 269 | 268 | ||
| 270 | 269 | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |
| @@ -300,7 +299,7 @@ func TestHandlePermissionUseCase_CombinedPermissions(t *testing.T) { | |||
| 300 | 299 | ||
| 301 | 300 | func TestHandlePermissionUseCase_QueryPermissionState_AutoAllow(t *testing.T) { | |
| 302 | 301 | ctx := testContext() | |
| 303 | - | permRepo := repomocks.NewMockPermissionRepository(t) | |
| 302 | + | permRepo := portmocks.NewMockPermissionRepository(t) | |
| 304 | 303 | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |
| 305 | 304 | ||
| 306 | 305 | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |
| @@ -314,7 +313,7 @@ func TestHandlePermissionUseCase_QueryPermissionState_AutoAllow(t *testing.T) { | |||
| 314 | 313 | ||
| 315 | 314 | func TestHandlePermissionUseCase_QueryPermissionState_AutoAllowManualOverride(t *testing.T) { | |
| 316 | 315 | ctx := testContext() | |
| 317 | - | permRepo := repomocks.NewMockPermissionRepository(t) | |
| 316 | + | permRepo := portmocks.NewMockPermissionRepository(t) | |
| 318 | 317 | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |
| 319 | 318 | ||
| 320 | 319 | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |
| @@ -333,7 +332,7 @@ func TestHandlePermissionUseCase_QueryPermissionState_AutoAllowManualOverride(t | |||
| 333 | 332 | ||
| 334 | 333 | func TestHandlePermissionUseCase_QueryPermissionState_Stored(t *testing.T) { | |
| 335 | 334 | ctx := testContext() | |
| 336 | - | permRepo := repomocks.NewMockPermissionRepository(t) | |
| 335 | + | permRepo := portmocks.NewMockPermissionRepository(t) | |
| 337 | 336 | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |
| 338 | 337 | ||
| 339 | 338 | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |
| @@ -352,7 +351,7 @@ func TestHandlePermissionUseCase_QueryPermissionState_Stored(t *testing.T) { | |||
| 352 | 351 | ||
| 353 | 352 | func TestHandlePermissionUseCase_QueryPermissionState_NoRecord(t *testing.T) { | |
| 354 | 353 | ctx := testContext() | |
| 355 | - | permRepo := repomocks.NewMockPermissionRepository(t) | |
| 354 | + | permRepo := portmocks.NewMockPermissionRepository(t) | |
| 356 | 355 | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |
| 357 | 356 | ||
| 358 | 357 | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |
| @@ -367,7 +366,7 @@ func TestHandlePermissionUseCase_QueryPermissionState_NoRecord(t *testing.T) { | |||
| 367 | 366 | ||
| 368 | 367 | func TestHandlePermissionUseCase_QueryPermissionState_EmptyOriginReturnsPrompt(t *testing.T) { | |
| 369 | 368 | ctx := testContext() | |
| 370 | - | permRepo := repomocks.NewMockPermissionRepository(t) | |
| 369 | + | permRepo := portmocks.NewMockPermissionRepository(t) | |
| 371 | 370 | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |
| 372 | 371 | ||
| 373 | 372 | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |
| @@ -380,7 +379,7 @@ func TestHandlePermissionUseCase_QueryPermissionState_EmptyOriginReturnsPrompt(t | |||
| 380 | 379 | ||
| 381 | 380 | func TestHandlePermissionUseCase_NonPersistableNotSaved(t *testing.T) { | |
| 382 | 381 | ctx := testContext() | |
| 383 | - | permRepo := repomocks.NewMockPermissionRepository(t) | |
| 382 | + | permRepo := portmocks.NewMockPermissionRepository(t) | |
| 384 | 383 | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |
| 385 | 384 | ||
| 386 | 385 | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |
| @@ -407,7 +406,7 @@ func TestHandlePermissionUseCase_NonPersistableNotSaved(t *testing.T) { | |||
| 407 | 406 | ||
| 408 | 407 | func TestHandlePermissionUseCase_NoDialogPresenter(t *testing.T) { | |
| 409 | 408 | ctx := testContext() | |
| 410 | - | permRepo := repomocks.NewMockPermissionRepository(t) | |
| 409 | + | permRepo := portmocks.NewMockPermissionRepository(t) | |
| 411 | 410 | ||
| 412 | 411 | // Create use case with nil dialog presenter | |
| 413 | 412 | uc := usecase.NewHandlePermissionUseCase(permRepo, nil, permissionLoggerFromContext) | |
| @@ -431,7 +430,7 @@ func TestHandlePermissionUseCase_NoDialogPresenter(t *testing.T) { | |||
| 431 | 430 | ||
| 432 | 431 | func TestHandlePermissionUseCase_DisplayOverrideDenied(t *testing.T) { | |
| 433 | 432 | ctx := testContext() | |
| 434 | - | permRepo := repomocks.NewMockPermissionRepository(t) | |
| 433 | + | permRepo := portmocks.NewMockPermissionRepository(t) | |
| 435 | 434 | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |
| 436 | 435 | ||
| 437 | 436 | uc := usecase.NewHandlePermissionUseCase(permRepo, dialog, permissionLoggerFromContext) | |
| @@ -459,7 +458,7 @@ func TestHandlePermissionUseCase_DisplayOverrideDenied(t *testing.T) { | |||
| 459 | 458 | ||
| 460 | 459 | func TestHandlePermissionUseCase_GetSetResetManualDecision(t *testing.T) { | |
| 461 | 460 | ctx := testContext() | |
| 462 | - | permRepo := repomocks.NewMockPermissionRepository(t) | |
| 461 | + | permRepo := portmocks.NewMockPermissionRepository(t) | |
| 463 | 462 | uc := usecase.NewHandlePermissionUseCase(permRepo, nil, permissionLoggerFromContext) | |
| 464 | 463 | ||
| 465 | 464 | permRepo.EXPECT().Set(mock.Anything, mock.AnythingOfType("*entity.PermissionRecord")). | |
| @@ -499,7 +498,7 @@ func TestHandlePermissionUseCase_GetSetResetManualDecision(t *testing.T) { | |||
| 499 | 498 | ||
| 500 | 499 | func TestHandlePermissionUseCase_SetManualPermissionDecision_NonPersistable(t *testing.T) { | |
| 501 | 500 | ctx := testContext() | |
| 502 | - | permRepo := repomocks.NewMockPermissionRepository(t) | |
| 501 | + | permRepo := portmocks.NewMockPermissionRepository(t) | |
| 503 | 502 | uc := usecase.NewHandlePermissionUseCase(permRepo, nil, permissionLoggerFromContext) | |
| 504 | 503 | ||
| 505 | 504 | err := uc.SetManualPermissionDecision( | |
| @@ -517,7 +516,7 @@ func TestHandlePermissionUseCase_SetManualPermissionDecision_NonPersistable(t *t | |||
| 517 | 516 | ||
| 518 | 517 | func TestHandlePermissionUseCase_UsesInjectedLoggerFactory(t *testing.T) { | |
| 519 | 518 | ctx := testContext() | |
| 520 | - | permRepo := repomocks.NewMockPermissionRepository(t) | |
| 519 | + | permRepo := portmocks.NewMockPermissionRepository(t) | |
| 521 | 520 | dialog := portmocks.NewMockPermissionDialogPresenter(t) | |
| 522 | 521 | ||
| 523 | 522 | called := false | |
| @@ -534,3 +533,74 @@ func TestHandlePermissionUseCase_UsesInjectedLoggerFactory(t *testing.T) { | |||
| 534 | 533 | ||
| 535 | 534 | assert.True(t, called) | |
| 536 | 535 | } | |
| 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 ( | |||
| 11 | 11 | "github.com/bnema/dumber/internal/logging" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | + | // Compile-time check: InsertAccentUseCase must satisfy port.AccentKeyHandler. | |
| 15 | + | var _ port.AccentKeyHandler = (*InsertAccentUseCase)(nil) | |
| 16 | + | ||
| 14 | 17 | // LongPressDelay is the duration a key must be held to trigger the accent picker. | |
| 15 | 18 | const LongPressDelay = 400 * time.Millisecond | |
| 16 | 19 | ||
M internal/application/usecase/keybindings.go +6 -0
| @@ -7,6 +7,12 @@ import ( | |||
| 7 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 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 | + | ||
| 10 | 16 | // validModes defines the valid keybinding modes. | |
| 11 | 17 | var validModes = map[string]bool{ | |
| 12 | 18 | "global": true, | |
M internal/application/usecase/manage_favorites.go +4 -0
| @@ -8,12 +8,16 @@ import ( | |||
| 8 | 8 | "sync" | |
| 9 | 9 | "time" | |
| 10 | 10 | ||
| 11 | + | "github.com/bnema/dumber/internal/application/port" | |
| 11 | 12 | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | 13 | "github.com/bnema/dumber/internal/domain/repository" | |
| 13 | 14 | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 14 | 15 | "github.com/bnema/dumber/internal/logging" | |
| 15 | 16 | ) | |
| 16 | 17 | ||
| 18 | + | // Compile-time check: ManageFavoritesUseCase must satisfy port.HomepageFavorites. | |
| 19 | + | var _ port.HomepageFavorites = (*ManageFavoritesUseCase)(nil) | |
| 20 | + | ||
| 17 | 21 | // ManageFavoritesUseCase handles favorite, folder, and tag operations. | |
| 18 | 22 | type ManageFavoritesUseCase struct { | |
| 19 | 23 | favoriteRepo repository.FavoriteRepository | |
M internal/application/usecase/prepare_download.go +7 -17
| @@ -9,24 +9,14 @@ import ( | |||
| 9 | 9 | "github.com/bnema/dumber/internal/logging" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 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 | |
| 22 | 14 | ||
| 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) | |
| 30 | 20 | ||
| 31 | 21 | // PrepareDownloadUseCase orchestrates filename resolution for downloads. | |
| 32 | 22 | // It combines multiple sources (suggested name, response headers, URI) to | |
M internal/application/usecase/search_history.go +8 -9
| @@ -5,11 +5,15 @@ import ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "time" | |
| 7 | 7 | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 9 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | 10 | "github.com/bnema/dumber/internal/domain/repository" | |
| 10 | 11 | "github.com/bnema/dumber/internal/logging" | |
| 11 | 12 | ) | |
| 12 | 13 | ||
| 14 | + | // Compile-time check: SearchHistoryUseCase must satisfy port.HomepageHistory. | |
| 15 | + | var _ port.HomepageHistory = (*SearchHistoryUseCase)(nil) | |
| 16 | + | ||
| 13 | 17 | // SearchHistoryUseCase handles history search and retrieval operations. | |
| 14 | 18 | type SearchHistoryUseCase struct { | |
| 15 | 19 | historyRepo repository.HistoryRepository | |
| @@ -22,16 +26,11 @@ func NewSearchHistoryUseCase(historyRepo repository.HistoryRepository) *SearchHi | |||
| 22 | 26 | } | |
| 23 | 27 | } | |
| 24 | 28 | ||
| 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 | |
| 30 | 31 | ||
| 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 | |
| 35 | 34 | ||
| 36 | 35 | // Search performs a full-text search on history entries using SQLite FTS5. | |
| 37 | 36 | // 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 { | |||
| 14 | 14 | } | |
| 15 | 15 | var limit unix.Rlimit | |
| 16 | 16 | 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) | |
| 19 | 19 | return d | |
| 20 | 20 | } | |
| 21 | 21 | 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) { | |||
| 125 | 125 | var themeManager *theme.Manager | |
| 126 | 126 | go func() { | |
| 127 | 127 | 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 | + | ) | |
| 129 | 132 | }() | |
| 130 | 133 | ||
| 131 | 134 | wg.Wait() | |
| @@ -199,7 +202,7 @@ func CheckRuntimeRequirements(ctx context.Context, cfg *config.Config) error { | |||
| 199 | 202 | probe := deps.NewPkgConfigProbe() | |
| 200 | 203 | checkRuntimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe) | |
| 201 | 204 | runtimeOut, err := checkRuntimeUC.Execute(ctx, usecase.CheckRuntimeDependenciesInput{ | |
| 202 | - | Prefix: cfg.Runtime.Prefix, | |
| 205 | + | Prefix: cfg.Engine.WebKit.Prefix, | |
| 203 | 206 | }) | |
| 204 | 207 | if err != nil { | |
| 205 | 208 | return fmt.Errorf("runtime check execution failed: %w", err) | |
| @@ -263,7 +266,7 @@ func OpenDatabase(ctx context.Context) (*DatabaseResult, error) { | |||
| 263 | 266 | // CreateLazyDatabase creates a lazy database provider that defers initialization. | |
| 264 | 267 | // The database is initialized on first access, allowing the UI to render faster. | |
| 265 | 268 | // | |
| 266 | - | // Currently unused: the application uses OpenDatabase with RunParallelDBWebKit for | |
| 269 | + | // Currently unused: the application uses OpenDatabase with RunParallelDBEngine for | |
| 267 | 270 | // eager initialization. This function is kept for potential future use when lazy | |
| 268 | 271 | // initialization past first paint becomes beneficial. | |
| 269 | 272 | func CreateLazyDatabase() (*sqlite.LazyDB, error) { | |
| @@ -274,19 +277,19 @@ func CreateLazyDatabase() (*sqlite.LazyDB, error) { | |||
| 274 | 277 | return sqlite.NewLazyDB(dbPath), nil | |
| 275 | 278 | } | |
| 276 | 279 | ||
| 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 { | |
| 279 | 282 | DB *sql.DB | |
| 280 | 283 | DBCleanup func() | |
| 281 | - | Stack WebKitStack | |
| 284 | + | Engine port.Engine | |
| 282 | 285 | } | |
| 283 | 286 | ||
| 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 { | |
| 286 | 289 | Ctx context.Context | |
| 287 | 290 | 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 | |
| 290 | 293 | ThemeManager *theme.Manager | |
| 291 | 294 | ColorResolver port.ColorSchemeResolver | |
| 292 | 295 | } | |
| @@ -300,10 +303,10 @@ type dbInitResult struct { | |||
| 300 | 303 | err error | |
| 301 | 304 | } | |
| 302 | 305 | ||
| 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) { | |
| 307 | 310 | log := logging.FromContext(input.Ctx) | |
| 308 | 311 | ||
| 309 | 312 | dbCh := make(chan dbInitResult, 1) | |
| @@ -321,8 +324,8 @@ func RunParallelDBWebKit(input ParallelDBWebKitInput) (*ParallelDBWebKitResult, | |||
| 321 | 324 | } | |
| 322 | 325 | }() | |
| 323 | 326 | ||
| 324 | - | // WebKit stack on main thread (GTK requirement) | |
| 325 | - | stack := BuildWebKitStack(WebKitStackInput{ | |
| 327 | + | // Engine on main thread (GTK requirement) | |
| 328 | + | engine, err := BuildEngine(EngineInput{ | |
| 326 | 329 | Ctx: input.Ctx, | |
| 327 | 330 | Config: input.Config, | |
| 328 | 331 | DataDir: input.DataDir, | |
| @@ -331,6 +334,13 @@ func RunParallelDBWebKit(input ParallelDBWebKitInput) (*ParallelDBWebKitResult, | |||
| 331 | 334 | ColorResolver: input.ColorResolver, | |
| 332 | 335 | Logger: *log, | |
| 333 | 336 | }) | |
| 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 | + | } | |
| 334 | 344 | ||
| 335 | 345 | // Wait for database | |
| 336 | 346 | dbRes := <-dbCh | |
| @@ -339,9 +349,9 @@ func RunParallelDBWebKit(input ParallelDBWebKitInput) (*ParallelDBWebKitResult, | |||
| 339 | 349 | return nil, fmt.Errorf("database initialization: %w", dbRes.err) | |
| 340 | 350 | } | |
| 341 | 351 | ||
| 342 | - | return &ParallelDBWebKitResult{ | |
| 352 | + | return &ParallelDBEngineResult{ | |
| 343 | 353 | DB: dbRes.db, | |
| 344 | 354 | DBCleanup: dbRes.cleanup, | |
| 345 | - | Stack: stack, | |
| 355 | + | Engine: engine, | |
| 346 | 356 | }, nil | |
| 347 | 357 | } | |
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 { | |||
| 247 | 247 | return nil | |
| 248 | 248 | } | |
| 249 | 249 | ||
| 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 | + | ||
| 250 | 261 | // Detect changes (includes renames, additions, and removals) | |
| 251 | 262 | ctx := context.Background() | |
| 252 | 263 | 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 { | |||
| 59 | 59 | probe := deps.NewPkgConfigProbe() | |
| 60 | 60 | runtimeUC := usecase.NewCheckRuntimeDependenciesUseCase(probe) | |
| 61 | 61 | runtimeOut, err := runtimeUC.Execute(app.Ctx(), usecase.CheckRuntimeDependenciesInput{ | |
| 62 | - | Prefix: app.Config.Runtime.Prefix, | |
| 62 | + | Prefix: app.Config.Engine.WebKit.Prefix, | |
| 63 | 63 | }) | |
| 64 | 64 | if err != nil { | |
| 65 | 65 | return err | |
M internal/cli/styles/config.go +11 -0
| @@ -247,6 +247,17 @@ func (r *ConfigRenderer) RenderNoConfigFile(path string) string { | |||
| 247 | 247 | ) | |
| 248 | 248 | } | |
| 249 | 249 | ||
| 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 | + | ||
| 250 | 261 | // RenderOpening renders a brief "opening config" message for `dumber config open`. | |
| 251 | 262 | func (r *ConfigRenderer) RenderOpening(path, editor string) string { | |
| 252 | 263 | 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 | |||
| 2979 | 2979 | return _c | |
| 2980 | 2980 | } | |
| 2981 | 2981 | ||
| 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 | - | ||
| 3271 | 2982 | // 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. | |
| 3272 | 2983 | // The first argument is typically a *testing.T value. | |
| 3273 | 2984 | func NewMockSessionRepository(t interface { | |
M internal/infrastructure/config/defaults.go +16 -42
| @@ -142,22 +142,19 @@ func DefaultConfig() *Config { | |||
| 142 | 142 | Debug: DebugConfig{ | |
| 143 | 143 | EnableDevTools: true, | |
| 144 | 144 | }, | |
| 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 | + | }, | |
| 161 | 158 | }, | |
| 162 | 159 | DefaultWebpageZoom: 1.2, // 120% default zoom for better readability | |
| 163 | 160 | DefaultUIScale: defaultUIScale, // 1.0 = 100%, 2.0 = 200% | |
| @@ -293,32 +290,9 @@ func DefaultConfig() *Config { | |||
| 293 | 290 | HardwareDecodingMode: HardwareDecodingAuto, // auto allows sw fallback | |
| 294 | 291 | PreferAV1: false, // Don't force codec preference, let site choose | |
| 295 | 292 | 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. | |
| 322 | 296 | }, | |
| 323 | 297 | Update: UpdateConfig{ | |
| 324 | 298 | EnableOnStartup: true, // Check for updates on startup by default | |
M internal/infrastructure/config/defaults_test.go +10 -4
| @@ -6,12 +6,18 @@ import ( | |||
| 6 | 6 | "github.com/stretchr/testify/assert" | |
| 7 | 7 | ) | |
| 8 | 8 | ||
| 9 | - | func TestDefaultConfig_RuntimeLoggingProfile(t *testing.T) { | |
| 9 | + | func TestDefaultConfig_CoreDefaults(t *testing.T) { | |
| 10 | 10 | cfg := DefaultConfig() | |
| 11 | 11 | assert.Equal(t, "info", cfg.Logging.Level) | |
| 12 | 12 | 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) | |
| 16 | 13 | 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). | |
| 17 | 23 | } | |
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 { | |||
| 87 | 87 | return err | |
| 88 | 88 | } | |
| 89 | 89 | ||
| 90 | + | if err := m.checkLegacyFormat(); err != nil { | |
| 91 | + | return err | |
| 92 | + | } | |
| 93 | + | ||
| 90 | 94 | config, err := m.unmarshalConfig() | |
| 91 | 95 | if err != nil { | |
| 92 | 96 | return err | |
| @@ -151,6 +155,29 @@ func (m *Manager) transformLegacyActionBindings() { | |||
| 151 | 155 | } | |
| 152 | 156 | } | |
| 153 | 157 | ||
| 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 | + | ||
| 154 | 181 | func (m *Manager) unmarshalConfig() (*Config, error) { | |
| 155 | 182 | config := &Config{} | |
| 156 | 183 | if err := m.viper.Unmarshal(config); err != nil { | |
| @@ -177,52 +204,54 @@ func ensureDatabasePath(config *Config) error { | |||
| 177 | 204 | } | |
| 178 | 205 | ||
| 179 | 206 | func normalizeConfig(config *Config) { | |
| 180 | - | normalizeRendering(config) | |
| 181 | - | normalizePrivacy(config) | |
| 182 | 207 | normalizeAppearance(config) | |
| 183 | 208 | normalizeMedia(config) | |
| 184 | - | ||
| 185 | - | config.Runtime.Prefix = strings.TrimSpace(config.Runtime.Prefix) | |
| 186 | - | normalizePerformanceProfile(config) | |
| 209 | + | normalizeEngineConfig(config) | |
| 187 | 210 | } | |
| 188 | 211 | ||
| 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)) { | |
| 202 | 215 | case "", string(GSKRendererAuto): | |
| 203 | - | config.Rendering.GSKRenderer = GSKRendererAuto | |
| 216 | + | config.Engine.WebKit.GSKRenderer = GSKRendererAuto | |
| 204 | 217 | case string(GSKRendererOpenGL): | |
| 205 | - | config.Rendering.GSKRenderer = GSKRendererOpenGL | |
| 218 | + | config.Engine.WebKit.GSKRenderer = GSKRendererOpenGL | |
| 206 | 219 | case string(GSKRendererVulkan): | |
| 207 | - | config.Rendering.GSKRenderer = GSKRendererVulkan | |
| 220 | + | config.Engine.WebKit.GSKRenderer = GSKRendererVulkan | |
| 208 | 221 | case string(GSKRendererCairo): | |
| 209 | - | config.Rendering.GSKRenderer = GSKRendererCairo | |
| 222 | + | config.Engine.WebKit.GSKRenderer = GSKRendererCairo | |
| 210 | 223 | default: | |
| 211 | - | config.Rendering.GSKRenderer = GSKRendererAuto | |
| 224 | + | config.Engine.WebKit.GSKRenderer = GSKRendererAuto | |
| 212 | 225 | } | |
| 213 | - | } | |
| 214 | 226 | ||
| 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)) { | |
| 217 | 229 | case "", string(CookiePolicyNoThirdParty): | |
| 218 | - | config.Privacy.CookiePolicy = CookiePolicyNoThirdParty | |
| 230 | + | config.Engine.CookiePolicy = CookiePolicyNoThirdParty | |
| 219 | 231 | case string(CookiePolicyAlways): | |
| 220 | - | config.Privacy.CookiePolicy = CookiePolicyAlways | |
| 232 | + | config.Engine.CookiePolicy = CookiePolicyAlways | |
| 221 | 233 | 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 | |
| 223 | 249 | default: | |
| 224 | - | config.Privacy.CookiePolicy = CookiePolicyNoThirdParty | |
| 250 | + | config.Engine.Profile = ProfileDefault | |
| 225 | 251 | } | |
| 252 | + | ||
| 253 | + | // Trim runtime prefix (engine.webkit) | |
| 254 | + | config.Engine.WebKit.Prefix = strings.TrimSpace(config.Engine.WebKit.Prefix) | |
| 226 | 255 | } | |
| 227 | 256 | ||
| 228 | 257 | func normalizeAppearance(config *Config) { | |
| @@ -249,34 +278,6 @@ func normalizeMedia(config *Config) { | |||
| 249 | 278 | default: | |
| 250 | 279 | config.Media.HardwareDecodingMode = HardwareDecodingAuto | |
| 251 | 280 | } | |
| 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 | - | } | |
| 280 | 281 | } | |
| 281 | 282 | ||
| 282 | 283 | // Get returns the current configuration (thread-safe). | |
| @@ -401,17 +402,15 @@ func (m *Manager) setDefaults() { | |||
| 401 | 402 | m.setLoggingDefaults(defaults) | |
| 402 | 403 | m.setDebugDefaults(defaults) | |
| 403 | 404 | m.setAppearanceDefaults(defaults) | |
| 404 | - | m.setRenderingDefaults(defaults) | |
| 405 | - | m.setPrivacyDefaults(defaults) | |
| 405 | + | m.setEngineDefaults(defaults) | |
| 406 | + | m.setZoomAndScaleDefaults(defaults) | |
| 406 | 407 | m.setWorkspaceDefaults(defaults) | |
| 407 | 408 | m.setContentFilteringDefaults(defaults) | |
| 408 | 409 | m.setClipboardDefaults(defaults) | |
| 409 | 410 | m.setOmniboxDefaults(defaults) | |
| 410 | 411 | m.setMediaDefaults(defaults) | |
| 411 | - | m.setRuntimeDefaults(defaults) | |
| 412 | 412 | m.setSessionDefaults(defaults) | |
| 413 | 413 | m.setUpdateDefaults(defaults) | |
| 414 | - | m.setPerformanceDefaults(defaults) | |
| 415 | 414 | m.setDownloadsDefaults(defaults) | |
| 416 | 415 | } | |
| 417 | 416 | ||
| @@ -461,27 +460,11 @@ func (m *Manager) setAppearanceDefaults(defaults *Config) { | |||
| 461 | 460 | m.viper.SetDefault("appearance.color_scheme", defaults.Appearance.ColorScheme) | |
| 462 | 461 | } | |
| 463 | 462 | ||
| 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) { | |
| 476 | 464 | m.viper.SetDefault("default_webpage_zoom", defaults.DefaultWebpageZoom) | |
| 477 | 465 | m.viper.SetDefault("default_ui_scale", defaults.DefaultUIScale) | |
| 478 | 466 | } | |
| 479 | 467 | ||
| 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 | - | ||
| 485 | 468 | func (m *Manager) setWorkspaceDefaults(defaults *Config) { | |
| 486 | 469 | m.viper.SetDefault("workspace.new_pane_url", defaults.Workspace.NewPaneURL) | |
| 487 | 470 | m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut) | |
| @@ -535,17 +518,14 @@ func (m *Manager) setOmniboxDefaults(defaults *Config) { | |||
| 535 | 518 | } | |
| 536 | 519 | ||
| 537 | 520 | 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. | |
| 538 | 523 | m.viper.SetDefault("media.hardware_decoding", string(defaults.Media.HardwareDecodingMode)) | |
| 539 | 524 | m.viper.SetDefault("media.prefer_av1", defaults.Media.PreferAV1) | |
| 540 | 525 | 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) | |
| 544 | 526 | } | |
| 545 | 527 | ||
| 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]. | |
| 549 | 529 | ||
| 550 | 530 | func (m *Manager) setSessionDefaults(defaults *Config) { | |
| 551 | 531 | m.viper.SetDefault("session.auto_restore", defaults.Session.AutoRestore) | |
| @@ -563,31 +543,49 @@ func (m *Manager) setUpdateDefaults(defaults *Config) { | |||
| 563 | 543 | m.viper.SetDefault("update.notify_on_new_settings", defaults.Update.NotifyOnNewSettings) | |
| 564 | 544 | } | |
| 565 | 545 | ||
| 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]. | |
| 586 | 547 | ||
| 587 | 548 | func (m *Manager) setDownloadsDefaults(defaults *Config) { | |
| 588 | 549 | m.viper.SetDefault("downloads.path", defaults.Downloads.Path) | |
| 589 | 550 | } | |
| 590 | 551 | ||
| 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 | + | ||
| 591 | 589 | // New returns a new default configuration instance. | |
| 592 | 590 | // This is a convenience function for getting default config without the full manager. | |
| 593 | 591 | 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 ( | |||
| 7 | 7 | "github.com/stretchr/testify/assert" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | - | func TestSetPrivacyDefaults(t *testing.T) { | |
| 10 | + | func TestSetEngineDefaults(t *testing.T) { | |
| 11 | 11 | mgr := &Manager{viper: viper.New()} | |
| 12 | 12 | mgr.setDefaults() | |
| 13 | 13 | ||
| 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")) | |
| 16 | 32 | } | |
| 17 | 33 | ||
| 18 | - | func TestNormalizeConfig_PrivacyCookiePolicy(t *testing.T) { | |
| 34 | + | func TestNormalizeConfig_EngineCookiePolicy(t *testing.T) { | |
| 19 | 35 | cfg := DefaultConfig() | |
| 20 | - | cfg.Privacy.CookiePolicy = CookiePolicy("INVALID") | |
| 36 | + | cfg.Engine.CookiePolicy = CookiePolicy("INVALID") | |
| 21 | 37 | ||
| 22 | 38 | normalizeConfig(cfg) | |
| 23 | 39 | ||
| 24 | - | assert.Equal(t, CookiePolicyNoThirdParty, cfg.Privacy.CookiePolicy) | |
| 40 | + | assert.Equal(t, CookiePolicyNoThirdParty, cfg.Engine.CookiePolicy) | |
| 25 | 41 | } | |
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 @@ | |||
| 1 | 1 | package config | |
| 2 | 2 | ||
| 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 | + | ||
| 3 | 68 | // Config represents the complete configuration for dumber. | |
| 4 | 69 | type Config struct { | |
| 5 | 70 | Database DatabaseConfig `mapstructure:"database" yaml:"database" toml:"database"` | |
| @@ -25,31 +90,16 @@ type Config struct { | |||
| 25 | 90 | Clipboard ClipboardConfig `mapstructure:"clipboard" yaml:"clipboard" toml:"clipboard"` | |
| 26 | 91 | // Omnibox controls the omnibox behavior (initial history display) | |
| 27 | 92 | 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"` | |
| 32 | 93 | // Media controls video playback and hardware acceleration | |
| 33 | 94 | 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"` | |
| 38 | 95 | // Update controls automatic update checking and downloading. | |
| 39 | 96 | Update UpdateConfig `mapstructure:"update" yaml:"update" toml:"update"` | |
| 40 | 97 | // Downloads configures file download behavior. | |
| 41 | 98 | 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"` | |
| 42 | 101 | } | |
| 43 | 102 | ||
| 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 | - | ||
| 53 | 103 | // CookiePolicy controls cookie acceptance behavior. | |
| 54 | 104 | type CookiePolicy string | |
| 55 | 105 | ||
| @@ -72,60 +122,6 @@ const ( | |||
| 72 | 122 | GSKRendererCairo GSKRendererMode = "cairo" | |
| 73 | 123 | ) | |
| 74 | 124 | ||
| 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 | - | ||
| 129 | 125 | // HardwareDecodingMode controls video hardware acceleration. | |
| 130 | 126 | type HardwareDecodingMode string | |
| 131 | 127 | ||
| @@ -185,20 +181,19 @@ type MediaConfig struct { | |||
| 185 | 181 | PreferAV1 bool `mapstructure:"prefer_av1" yaml:"prefer_av1" toml:"prefer_av1"` | |
| 186 | 182 | // ShowDiagnosticsOnStartup shows media capability warnings at startup | |
| 187 | 183 | 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. | |
| 191 | 189 | // 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:"-"` | |
| 202 | 197 | } | |
| 203 | 198 | ||
| 204 | 199 | // DatabaseConfig holds database-related configuration. | |
| @@ -326,31 +321,6 @@ type LoggingConfig struct { | |||
| 326 | 321 | CaptureGTKLogs bool `mapstructure:"capture_gtk_logs" yaml:"capture_gtk_logs" toml:"capture_gtk_logs" json:"capture_gtk_logs"` | |
| 327 | 322 | } | |
| 328 | 323 | ||
| 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 | - | ||
| 354 | 324 | // ContentFilteringConfig holds content filtering and ad blocking preferences | |
| 355 | 325 | type ContentFilteringConfig struct { | |
| 356 | 326 | // Enabled controls whether ad blocking is active (default: true) | |
| @@ -386,256 +356,9 @@ type DebugConfig struct { | |||
| 386 | 356 | EnableDevTools bool `mapstructure:"enable_devtools" yaml:"enable_devtools" toml:"enable_devtools"` | |
| 387 | 357 | } | |
| 388 | 358 | ||
| 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 | - | ||
| 403 | 359 | // DownloadsConfig holds file download preferences. | |
| 404 | 360 | type DownloadsConfig struct { | |
| 405 | 361 | // Path is the directory where downloads are saved. | |
| 406 | 362 | // Empty string means use XDG_DOWNLOAD_DIR or ~/Downloads. | |
| 407 | 363 | Path string `mapstructure:"path" yaml:"path" toml:"path"` | |
| 408 | 364 | } | |
| 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 | |||
| 712 | 712 | func (*SchemaProvider) getRenderingKeys(defaults *Config) []entity.ConfigKeyInfo { | |
| 713 | 713 | return []entity.ConfigKeyInfo{ | |
| 714 | 714 | { | |
| 715 | - | Key: "rendering.mode", | |
| 715 | + | Key: "engine.webkit.gsk_renderer", | |
| 716 | 716 | 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), | |
| 726 | 718 | Description: "GTK Scene Kit renderer selection", | |
| 727 | 719 | Values: []string{"auto", "opengl", "vulkan", "cairo"}, | |
| 728 | 720 | Section: SectionRendering, | |
| 729 | 721 | }, | |
| 730 | 722 | { | |
| 731 | - | Key: "rendering.disable_dmabuf_renderer", | |
| 723 | + | Key: "engine.webkit.disable_dmabuf_renderer", | |
| 732 | 724 | Type: "bool", | |
| 733 | - | Default: fmt.Sprintf("%t", defaults.Rendering.DisableDMABufRenderer), | |
| 725 | + | Default: fmt.Sprintf("%t", defaults.Engine.WebKit.DisableDMABufRenderer), | |
| 734 | 726 | Description: "Disable DMA-BUF accelerated renderer", | |
| 735 | 727 | Section: SectionRendering, | |
| 736 | 728 | }, | |
| 737 | 729 | { | |
| 738 | - | Key: "rendering.force_compositing_mode", | |
| 730 | + | Key: "engine.webkit.force_compositing_mode", | |
| 739 | 731 | Type: "bool", | |
| 740 | - | Default: fmt.Sprintf("%t", defaults.Rendering.ForceCompositingMode), | |
| 732 | + | Default: fmt.Sprintf("%t", defaults.Engine.WebKit.ForceCompositingMode), | |
| 741 | 733 | Description: "Force accelerated compositing", | |
| 742 | 734 | Section: SectionRendering, | |
| 743 | 735 | }, | |
| 744 | 736 | { | |
| 745 | - | Key: "rendering.disable_compositing_mode", | |
| 737 | + | Key: "engine.webkit.disable_compositing_mode", | |
| 746 | 738 | Type: "bool", | |
| 747 | - | Default: fmt.Sprintf("%t", defaults.Rendering.DisableCompositingMode), | |
| 739 | + | Default: fmt.Sprintf("%t", defaults.Engine.WebKit.DisableCompositingMode), | |
| 748 | 740 | Description: "Disable accelerated compositing", | |
| 749 | 741 | Section: SectionRendering, | |
| 750 | 742 | }, | |
| 751 | 743 | { | |
| 752 | - | Key: "rendering.disable_mipmaps", | |
| 744 | + | Key: "engine.webkit.disable_mipmaps", | |
| 753 | 745 | Type: "bool", | |
| 754 | - | Default: fmt.Sprintf("%t", defaults.Rendering.DisableMipmaps), | |
| 746 | + | Default: fmt.Sprintf("%t", defaults.Engine.WebKit.DisableMipmaps), | |
| 755 | 747 | Description: "Disable mipmap generation", | |
| 756 | 748 | Section: SectionRendering, | |
| 757 | 749 | }, | |
| 758 | 750 | { | |
| 759 | - | Key: "rendering.prefer_gl", | |
| 751 | + | Key: "engine.webkit.prefer_gl", | |
| 760 | 752 | Type: "bool", | |
| 761 | - | Default: fmt.Sprintf("%t", defaults.Rendering.PreferGL), | |
| 753 | + | Default: fmt.Sprintf("%t", defaults.Engine.WebKit.PreferGL), | |
| 762 | 754 | Description: "Force OpenGL over GLES", | |
| 763 | 755 | Section: SectionRendering, | |
| 764 | 756 | }, | |
| 765 | 757 | { | |
| 766 | - | Key: "rendering.draw_compositing_indicators", | |
| 758 | + | Key: "engine.webkit.draw_compositing_indicators", | |
| 767 | 759 | Type: "bool", | |
| 768 | - | Default: fmt.Sprintf("%t", defaults.Rendering.DrawCompositingIndicators), | |
| 760 | + | Default: fmt.Sprintf("%t", defaults.Engine.WebKit.DrawCompositingIndicators), | |
| 769 | 761 | Description: "Show compositing layer borders", | |
| 770 | 762 | Section: SectionRendering, | |
| 771 | 763 | }, | |
| 772 | 764 | { | |
| 773 | - | Key: "rendering.show_fps", | |
| 765 | + | Key: "engine.webkit.show_fps", | |
| 774 | 766 | Type: "bool", | |
| 775 | - | Default: fmt.Sprintf("%t", defaults.Rendering.ShowFPS), | |
| 767 | + | Default: fmt.Sprintf("%t", defaults.Engine.WebKit.ShowFPS), | |
| 776 | 768 | Description: "Display WebKit FPS counter", | |
| 777 | 769 | Section: SectionRendering, | |
| 778 | 770 | }, | |
| 779 | 771 | { | |
| 780 | - | Key: "rendering.sample_memory", | |
| 772 | + | Key: "engine.webkit.sample_memory", | |
| 781 | 773 | Type: "bool", | |
| 782 | - | Default: fmt.Sprintf("%t", defaults.Rendering.SampleMemory), | |
| 774 | + | Default: fmt.Sprintf("%t", defaults.Engine.WebKit.SampleMemory), | |
| 783 | 775 | Description: "Enable memory sampling", | |
| 784 | 776 | Section: SectionRendering, | |
| 785 | 777 | }, | |
| 786 | 778 | { | |
| 787 | - | Key: "rendering.debug_frames", | |
| 779 | + | Key: "engine.webkit.debug_frames", | |
| 788 | 780 | Type: "bool", | |
| 789 | - | Default: fmt.Sprintf("%t", defaults.Rendering.DebugFrames), | |
| 781 | + | Default: fmt.Sprintf("%t", defaults.Engine.WebKit.DebugFrames), | |
| 790 | 782 | Description: "Enable frame timing debug output", | |
| 791 | 783 | Section: SectionRendering, | |
| 792 | 784 | }, | |
| @@ -803,14 +795,6 @@ func (*SchemaProvider) getMediaKeys(defaults *Config) []entity.ConfigKeyInfo { | |||
| 803 | 795 | Values: []string{"auto", "force", "disable"}, | |
| 804 | 796 | Section: SectionMedia, | |
| 805 | 797 | }, | |
| 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 | - | }, | |
| 814 | 798 | { | |
| 815 | 799 | Key: "media.prefer_av1", | |
| 816 | 800 | Type: "bool", | |
| @@ -825,21 +809,6 @@ func (*SchemaProvider) getMediaKeys(defaults *Config) []entity.ConfigKeyInfo { | |||
| 825 | 809 | Description: "Show media capability warnings at startup", | |
| 826 | 810 | Section: SectionMedia, | |
| 827 | 811 | }, | |
| 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 | - | }, | |
| 843 | 812 | } | |
| 844 | 813 | } | |
| 845 | 814 | ||
| @@ -884,112 +853,112 @@ func (*SchemaProvider) getDebugKeys(defaults *Config) []entity.ConfigKeyInfo { | |||
| 884 | 853 | func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyInfo { | |
| 885 | 854 | return []entity.ConfigKeyInfo{ | |
| 886 | 855 | { | |
| 887 | - | Key: "performance.profile", | |
| 856 | + | Key: "engine.profile", | |
| 888 | 857 | Type: "string", | |
| 889 | - | Default: string(defaults.Performance.Profile), | |
| 858 | + | Default: string(defaults.Engine.Profile), | |
| 890 | 859 | Description: "Performance profile preset (custom enables manual tuning)", | |
| 891 | 860 | Values: []string{"default", "lite", "max", "custom"}, | |
| 892 | 861 | Section: SectionPerformance, | |
| 893 | 862 | }, | |
| 894 | 863 | { | |
| 895 | - | Key: "performance.zoom_cache_size", | |
| 864 | + | Key: "engine.zoom_cache_size", | |
| 896 | 865 | Type: "int", | |
| 897 | - | Default: fmt.Sprintf("%d", defaults.Performance.ZoomCacheSize), | |
| 866 | + | Default: fmt.Sprintf("%d", defaults.Engine.ZoomCacheSize), | |
| 898 | 867 | Description: "Number of domain zoom levels to cache", | |
| 899 | 868 | Range: ">=0", | |
| 900 | 869 | Section: SectionPerformance, | |
| 901 | 870 | }, | |
| 902 | 871 | { | |
| 903 | - | Key: "performance.webview_pool_prewarm_count", | |
| 872 | + | Key: "engine.pool_prewarm_count", | |
| 904 | 873 | Type: "int", | |
| 905 | - | Default: fmt.Sprintf("%d", defaults.Performance.WebViewPoolPrewarmCount), | |
| 874 | + | Default: fmt.Sprintf("%d", defaults.Engine.PoolPrewarmCount), | |
| 906 | 875 | Description: "WebViews to pre-create at startup", | |
| 907 | 876 | Range: ">=0", | |
| 908 | 877 | Section: SectionPerformance, | |
| 909 | 878 | }, | |
| 910 | 879 | { | |
| 911 | - | Key: "performance.skia_cpu_painting_threads", | |
| 880 | + | Key: "engine.webkit.skia_cpu_painting_threads", | |
| 912 | 881 | Type: "int", | |
| 913 | - | Default: fmt.Sprintf("%d", defaults.Performance.SkiaCPUPaintingThreads), | |
| 882 | + | Default: fmt.Sprintf("%d", defaults.Engine.WebKit.SkiaCPUPaintingThreads), | |
| 914 | 883 | Description: "Skia CPU rendering threads (0=unset, custom profile only)", | |
| 915 | 884 | Range: ">=0", | |
| 916 | 885 | Section: SectionPerformance, | |
| 917 | 886 | }, | |
| 918 | 887 | { | |
| 919 | - | Key: "performance.skia_gpu_painting_threads", | |
| 888 | + | Key: "engine.webkit.skia_gpu_painting_threads", | |
| 920 | 889 | Type: "int", | |
| 921 | - | Default: fmt.Sprintf("%d", defaults.Performance.SkiaGPUPaintingThreads), | |
| 890 | + | Default: fmt.Sprintf("%d", defaults.Engine.WebKit.SkiaGPUPaintingThreads), | |
| 922 | 891 | Description: "Skia GPU rendering threads (-1=unset, 0=disable, custom profile only)", | |
| 923 | 892 | Range: ">=-1", | |
| 924 | 893 | Section: SectionPerformance, | |
| 925 | 894 | }, | |
| 926 | 895 | { | |
| 927 | - | Key: "performance.skia_enable_cpu_rendering", | |
| 896 | + | Key: "engine.webkit.skia_enable_cpu_rendering", | |
| 928 | 897 | Type: "bool", | |
| 929 | - | Default: fmt.Sprintf("%t", defaults.Performance.SkiaEnableCPURendering), | |
| 898 | + | Default: fmt.Sprintf("%t", defaults.Engine.WebKit.SkiaEnableCPURendering), | |
| 930 | 899 | Description: "Force CPU rendering (custom profile only)", | |
| 931 | 900 | Section: SectionPerformance, | |
| 932 | 901 | }, | |
| 933 | 902 | { | |
| 934 | - | Key: "performance.web_process_memory_limit_mb", | |
| 903 | + | Key: "engine.webkit.web_process_memory_limit_mb", | |
| 935 | 904 | Type: "int", | |
| 936 | - | Default: fmt.Sprintf("%d", defaults.Performance.WebProcessMemoryLimitMB), | |
| 905 | + | Default: fmt.Sprintf("%d", defaults.Engine.WebKit.WebProcessMemoryLimitMB), | |
| 937 | 906 | Description: "Web process memory limit in MB (0=unset, custom profile only)", | |
| 938 | 907 | Range: ">=0", | |
| 939 | 908 | Section: SectionPerformance, | |
| 940 | 909 | }, | |
| 941 | 910 | { | |
| 942 | - | Key: "performance.web_process_memory_poll_interval_sec", | |
| 911 | + | Key: "engine.webkit.web_process_memory_poll_interval_sec", | |
| 943 | 912 | Type: "float64", | |
| 944 | - | Default: fmt.Sprintf("%.1f", defaults.Performance.WebProcessMemoryPollIntervalSec), | |
| 913 | + | Default: fmt.Sprintf("%.1f", defaults.Engine.WebKit.WebProcessMemoryPollIntervalSec), | |
| 945 | 914 | Description: "Memory check interval in seconds (0=default 30s, custom profile only)", | |
| 946 | 915 | Range: ">=0", | |
| 947 | 916 | Section: SectionPerformance, | |
| 948 | 917 | }, | |
| 949 | 918 | { | |
| 950 | - | Key: "performance.web_process_memory_conservative_threshold", | |
| 919 | + | Key: "engine.webkit.web_process_memory_conservative_threshold", | |
| 951 | 920 | Type: "float64", | |
| 952 | - | Default: fmt.Sprintf("%.2f", defaults.Performance.WebProcessMemoryConservativeThreshold), | |
| 921 | + | Default: fmt.Sprintf("%.2f", defaults.Engine.WebKit.WebProcessMemoryConservativeThreshold), | |
| 953 | 922 | Description: "Conservative memory cleanup threshold (0-1, custom profile only)", | |
| 954 | 923 | Range: "0-1", | |
| 955 | 924 | Section: SectionPerformance, | |
| 956 | 925 | }, | |
| 957 | 926 | { | |
| 958 | - | Key: "performance.web_process_memory_strict_threshold", | |
| 927 | + | Key: "engine.webkit.web_process_memory_strict_threshold", | |
| 959 | 928 | Type: "float64", | |
| 960 | - | Default: fmt.Sprintf("%.2f", defaults.Performance.WebProcessMemoryStrictThreshold), | |
| 929 | + | Default: fmt.Sprintf("%.2f", defaults.Engine.WebKit.WebProcessMemoryStrictThreshold), | |
| 961 | 930 | Description: "Strict memory cleanup threshold (0-1, custom profile only)", | |
| 962 | 931 | Range: "0-1", | |
| 963 | 932 | Section: SectionPerformance, | |
| 964 | 933 | }, | |
| 965 | 934 | { | |
| 966 | - | Key: "performance.network_process_memory_limit_mb", | |
| 935 | + | Key: "engine.webkit.network_process_memory_limit_mb", | |
| 967 | 936 | Type: "int", | |
| 968 | - | Default: fmt.Sprintf("%d", defaults.Performance.NetworkProcessMemoryLimitMB), | |
| 937 | + | Default: fmt.Sprintf("%d", defaults.Engine.WebKit.NetworkProcessMemoryLimitMB), | |
| 969 | 938 | Description: "Network process memory limit in MB (0=unset, custom profile only)", | |
| 970 | 939 | Range: ">=0", | |
| 971 | 940 | Section: SectionPerformance, | |
| 972 | 941 | }, | |
| 973 | 942 | { | |
| 974 | - | Key: "performance.network_process_memory_poll_interval_sec", | |
| 943 | + | Key: "engine.webkit.network_process_memory_poll_interval_sec", | |
| 975 | 944 | Type: "float64", | |
| 976 | - | Default: fmt.Sprintf("%.1f", defaults.Performance.NetworkProcessMemoryPollIntervalSec), | |
| 945 | + | Default: fmt.Sprintf("%.1f", defaults.Engine.WebKit.NetworkProcessMemoryPollIntervalSec), | |
| 977 | 946 | Description: "Network memory check interval in seconds (custom profile only)", | |
| 978 | 947 | Range: ">=0", | |
| 979 | 948 | Section: SectionPerformance, | |
| 980 | 949 | }, | |
| 981 | 950 | { | |
| 982 | - | Key: "performance.network_process_memory_conservative_threshold", | |
| 951 | + | Key: "engine.webkit.network_process_memory_conservative_threshold", | |
| 983 | 952 | Type: "float64", | |
| 984 | - | Default: fmt.Sprintf("%.2f", defaults.Performance.NetworkProcessMemoryConservativeThreshold), | |
| 953 | + | Default: fmt.Sprintf("%.2f", defaults.Engine.WebKit.NetworkProcessMemoryConservativeThreshold), | |
| 985 | 954 | Description: "Network conservative memory threshold (0-1, custom profile only)", | |
| 986 | 955 | Range: "0-1", | |
| 987 | 956 | Section: SectionPerformance, | |
| 988 | 957 | }, | |
| 989 | 958 | { | |
| 990 | - | Key: "performance.network_process_memory_strict_threshold", | |
| 959 | + | Key: "engine.webkit.network_process_memory_strict_threshold", | |
| 991 | 960 | Type: "float64", | |
| 992 | - | Default: fmt.Sprintf("%.2f", defaults.Performance.NetworkProcessMemoryStrictThreshold), | |
| 961 | + | Default: fmt.Sprintf("%.2f", defaults.Engine.WebKit.NetworkProcessMemoryStrictThreshold), | |
| 993 | 962 | Description: "Network strict memory threshold (0-1, custom profile only)", | |
| 994 | 963 | Range: "0-1", | |
| 995 | 964 | Section: SectionPerformance, | |
| @@ -997,12 +966,12 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn | |||
| 997 | 966 | } | |
| 998 | 967 | } | |
| 999 | 968 | ||
| 1000 | - | func (*SchemaProvider) getRuntimeKeys(_ *Config) []entity.ConfigKeyInfo { | |
| 969 | + | func (*SchemaProvider) getRuntimeKeys(defaults *Config) []entity.ConfigKeyInfo { | |
| 1001 | 970 | return []entity.ConfigKeyInfo{ | |
| 1002 | 971 | { | |
| 1003 | - | Key: "runtime.prefix", | |
| 972 | + | Key: "engine.webkit.prefix", | |
| 1004 | 973 | Type: "string", | |
| 1005 | - | Default: "(empty)", | |
| 974 | + | Default: defaults.Engine.WebKit.Prefix, | |
| 1006 | 975 | Description: "Custom runtime prefix for WebKitGTK/GTK", | |
| 1007 | 976 | Section: SectionRuntime, | |
| 1008 | 977 | }, | |
M internal/infrastructure/config/validation.go +21 -29
| @@ -379,30 +379,21 @@ func validateOmnibox(config *Config) []string { | |||
| 379 | 379 | func validateRendering(config *Config) []string { | |
| 380 | 380 | var validationErrors []string | |
| 381 | 381 | ||
| 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 { | |
| 392 | 383 | case GSKRendererAuto, GSKRendererOpenGL, GSKRendererVulkan, GSKRendererCairo, "": | |
| 393 | 384 | default: | |
| 394 | 385 | validationErrors = append( | |
| 395 | 386 | validationErrors, | |
| 396 | 387 | 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, | |
| 399 | 390 | ), | |
| 400 | 391 | ) | |
| 401 | 392 | } | |
| 402 | 393 | ||
| 403 | - | if config.Rendering.ForceCompositingMode && config.Rendering.DisableCompositingMode { | |
| 394 | + | if config.Engine.WebKit.ForceCompositingMode && config.Engine.WebKit.DisableCompositingMode { | |
| 404 | 395 | 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", | |
| 406 | 397 | ) | |
| 407 | 398 | } | |
| 408 | 399 | ||
| @@ -410,13 +401,13 @@ func validateRendering(config *Config) []string { | |||
| 410 | 401 | } | |
| 411 | 402 | ||
| 412 | 403 | func validatePrivacy(config *Config) []string { | |
| 413 | - | switch config.Privacy.CookiePolicy { | |
| 404 | + | switch config.Engine.CookiePolicy { | |
| 414 | 405 | case CookiePolicyAlways, CookiePolicyNoThirdParty, CookiePolicyNever, "": | |
| 415 | 406 | return nil | |
| 416 | 407 | default: | |
| 417 | 408 | 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, | |
| 420 | 411 | )} | |
| 421 | 412 | } | |
| 422 | 413 | } | |
| @@ -451,39 +442,40 @@ func validatePerformanceProfile(config *Config) []string { | |||
| 451 | 442 | var validationErrors []string | |
| 452 | 443 | ||
| 453 | 444 | // Validate profile name | |
| 454 | - | if !IsValidPerformanceProfile(config.Performance.Profile) { | |
| 445 | + | if !IsValidPerformanceProfile(config.Engine.Profile) { | |
| 455 | 446 | 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, | |
| 458 | 449 | )) | |
| 459 | 450 | } | |
| 460 | 451 | ||
| 461 | 452 | // When profile is not "custom", warn if individual fields are set | |
| 462 | 453 | // (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) { | |
| 465 | 457 | 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'; "+ | |
| 467 | 459 | "set profile = \"custom\" to use individual field values", | |
| 468 | 460 | ) | |
| 469 | 461 | } | |
| 470 | 462 | } | |
| 471 | 463 | ||
| 472 | 464 | // 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 | |
| 475 | 467 | if webCons > 0 && webStrict > 0 && webCons >= webStrict { | |
| 476 | 468 | 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 "+ | |
| 478 | 470 | "web_process_memory_strict_threshold when both are set", | |
| 479 | 471 | ) | |
| 480 | 472 | } | |
| 481 | 473 | ||
| 482 | - | netCons := config.Performance.NetworkProcessMemoryConservativeThreshold | |
| 483 | - | netStrict := config.Performance.NetworkProcessMemoryStrictThreshold | |
| 474 | + | netCons := config.Engine.WebKit.NetworkProcessMemoryConservativeThreshold | |
| 475 | + | netStrict := config.Engine.WebKit.NetworkProcessMemoryStrictThreshold | |
| 484 | 476 | if netCons > 0 && netStrict > 0 && netCons >= netStrict { | |
| 485 | 477 | 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 "+ | |
| 487 | 479 | "network_process_memory_strict_threshold when both are set", | |
| 488 | 480 | ) | |
| 489 | 481 | } | |
M internal/infrastructure/config/validation_test.go +3 -3
| @@ -7,7 +7,7 @@ import ( | |||
| 7 | 7 | "github.com/stretchr/testify/require" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | - | func TestValidateConfig_PrivacyCookiePolicy(t *testing.T) { | |
| 10 | + | func TestValidateConfig_EngineCookiePolicy(t *testing.T) { | |
| 11 | 11 | tests := []struct { | |
| 12 | 12 | name string | |
| 13 | 13 | cookiePolicy CookiePolicy | |
| @@ -22,12 +22,12 @@ func TestValidateConfig_PrivacyCookiePolicy(t *testing.T) { | |||
| 22 | 22 | for _, tt := range tests { | |
| 23 | 23 | t.Run(tt.name, func(t *testing.T) { | |
| 24 | 24 | cfg := DefaultConfig() | |
| 25 | - | cfg.Privacy.CookiePolicy = tt.cookiePolicy | |
| 25 | + | cfg.Engine.CookiePolicy = tt.cookiePolicy | |
| 26 | 26 | ||
| 27 | 27 | err := validateConfig(cfg) | |
| 28 | 28 | if tt.wantErr { | |
| 29 | 29 | require.Error(t, err) | |
| 30 | - | assert.Contains(t, err.Error(), "privacy.cookie_policy") | |
| 30 | + | assert.Contains(t, err.Error(), "engine.cookie_policy") | |
| 31 | 31 | return | |
| 32 | 32 | } | |
| 33 | 33 | 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 | |||
| 80 | 80 | ||
| 81 | 81 | // Performance profile (requires restart to take effect) | |
| 82 | 82 | if cfg.Performance.Profile != "" { | |
| 83 | - | current.Performance.Profile = PerformanceProfile(cfg.Performance.Profile) | |
| 83 | + | current.Engine.Profile = PerformanceProfile(cfg.Performance.Profile) | |
| 84 | 84 | } | |
| 85 | 85 | ||
| 86 | 86 | // Custom performance fields (only used when profile is "custom") | |
| 87 | 87 | 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) | |
| 93 | 93 | } | |
| 94 | 94 | ||
| 95 | 95 | 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 { | |||
| 78 | 78 | ||
| 79 | 79 | // ApplyEnvironment sets all rendering environment variables. | |
| 80 | 80 | // 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 { | |
| 82 | 82 | log := logging.FromContext(ctx) | |
| 83 | 83 | m.appliedVars = make(map[string]string) | |
| 84 | 84 | ||
| @@ -97,7 +97,7 @@ func (m *Manager) ApplyEnvironment(ctx context.Context, settings port.RenderingE | |||
| 97 | 97 | return nil | |
| 98 | 98 | } | |
| 99 | 99 | ||
| 100 | - | func (m *Manager) applyGStreamerEnv(settings port.RenderingEnvSettings) { | |
| 100 | + | func (m *Manager) applyGStreamerEnv(settings RenderingSettings) { | |
| 101 | 101 | // VSync | |
| 102 | 102 | if settings.ForceVSync { | |
| 103 | 103 | m.setEnv("__GL_SYNC_TO_VBLANK", "1") | |
| @@ -149,7 +149,7 @@ func (m *Manager) applyGPUSpecificEnv() { | |||
| 149 | 149 | } | |
| 150 | 150 | } | |
| 151 | 151 | ||
| 152 | - | func (m *Manager) applyWebKitEnv(settings port.RenderingEnvSettings) { | |
| 152 | + | func (m *Manager) applyWebKitEnv(settings RenderingSettings) { | |
| 153 | 153 | if settings.DisableDMABufRenderer { | |
| 154 | 154 | m.setEnv("WEBKIT_DISABLE_DMABUF_RENDERER", "1") | |
| 155 | 155 | } | |
| @@ -161,7 +161,7 @@ func (m *Manager) applyWebKitEnv(settings port.RenderingEnvSettings) { | |||
| 161 | 161 | } | |
| 162 | 162 | } | |
| 163 | 163 | ||
| 164 | - | func (m *Manager) applyGTKEnv(settings port.RenderingEnvSettings) { | |
| 164 | + | func (m *Manager) applyGTKEnv(settings RenderingSettings) { | |
| 165 | 165 | renderer := strings.ToLower(strings.TrimSpace(settings.GSKRenderer)) | |
| 166 | 166 | if renderer != "" && renderer != "auto" { | |
| 167 | 167 | m.setEnv("GSK_RENDERER", renderer) | |
| @@ -187,7 +187,7 @@ func (m *Manager) applyGTKEnv(settings port.RenderingEnvSettings) { | |||
| 187 | 187 | } | |
| 188 | 188 | } | |
| 189 | 189 | ||
| 190 | - | func (m *Manager) applyDebugEnv(settings port.RenderingEnvSettings) { | |
| 190 | + | func (m *Manager) applyDebugEnv(settings RenderingSettings) { | |
| 191 | 191 | if settings.ShowFPS { | |
| 192 | 192 | m.setEnv("WEBKIT_SHOW_FPS", "1") | |
| 193 | 193 | } | |
| @@ -196,7 +196,7 @@ func (m *Manager) applyDebugEnv(settings port.RenderingEnvSettings) { | |||
| 196 | 196 | } | |
| 197 | 197 | } | |
| 198 | 198 | ||
| 199 | - | func (m *Manager) applySkiaEnv(settings port.RenderingEnvSettings) { | |
| 199 | + | func (m *Manager) applySkiaEnv(settings RenderingSettings) { | |
| 200 | 200 | // CPU painting threads: only set if > 0 and env var not already set | |
| 201 | 201 | if settings.SkiaCPUPaintingThreads > 0 && os.Getenv("WEBKIT_SKIA_CPU_PAINTING_THREADS") == "" { | |
| 202 | 202 | m.setEnv("WEBKIT_SKIA_CPU_PAINTING_THREADS", fmt.Sprintf("%d", settings.SkiaCPUPaintingThreads)) | |
| @@ -249,4 +249,6 @@ func (*Manager) detectFromDRM() port.GPUVendor { | |||
| 249 | 249 | return port.GPUVendorUnknown | |
| 250 | 250 | } | |
| 251 | 251 | ||
| 252 | + | // Ensure Manager implements port.RenderingEnvManager (DetectGPUVendor, GetAppliedVars). | |
| 253 | + | // ApplyEnvironment takes the infra-local RenderingSettings type instead of a port type. | |
| 252 | 254 | var _ port.RenderingEnvManager = (*Manager)(nil) | |
M internal/infrastructure/env/manager_test.go +10 -11
| @@ -6,7 +6,6 @@ import ( | |||
| 6 | 6 | "os/exec" | |
| 7 | 7 | "testing" | |
| 8 | 8 | ||
| 9 | - | "github.com/bnema/dumber/internal/application/port" | |
| 10 | 9 | "github.com/bnema/dumber/internal/infrastructure/env" | |
| 11 | 10 | "github.com/stretchr/testify/assert" | |
| 12 | 11 | "github.com/stretchr/testify/require" | |
| @@ -25,14 +24,14 @@ func TestManager_ApplyEnvironment_SkiaEnv(t *testing.T) { | |||
| 25 | 24 | ||
| 26 | 25 | tests := []struct { | |
| 27 | 26 | name string | |
| 28 | - | settings port.RenderingEnvSettings | |
| 27 | + | settings env.RenderingSettings | |
| 29 | 28 | presetEnv map[string]string | |
| 30 | 29 | expectedVars map[string]string | |
| 31 | 30 | unexpectedVars []string | |
| 32 | 31 | }{ | |
| 33 | 32 | { | |
| 34 | 33 | name: "CPU threads set when > 0", | |
| 35 | - | settings: port.RenderingEnvSettings{ | |
| 34 | + | settings: env.RenderingSettings{ | |
| 36 | 35 | SkiaCPUPaintingThreads: 4, | |
| 37 | 36 | SkiaGPUPaintingThreads: -1, // unset | |
| 38 | 37 | }, | |
| @@ -43,7 +42,7 @@ func TestManager_ApplyEnvironment_SkiaEnv(t *testing.T) { | |||
| 43 | 42 | }, | |
| 44 | 43 | { | |
| 45 | 44 | name: "GPU threads set when >= 0", | |
| 46 | - | settings: port.RenderingEnvSettings{ | |
| 45 | + | settings: env.RenderingSettings{ | |
| 47 | 46 | SkiaCPUPaintingThreads: 0, // unset | |
| 48 | 47 | SkiaGPUPaintingThreads: 2, | |
| 49 | 48 | }, | |
| @@ -54,7 +53,7 @@ func TestManager_ApplyEnvironment_SkiaEnv(t *testing.T) { | |||
| 54 | 53 | }, | |
| 55 | 54 | { | |
| 56 | 55 | name: "GPU threads 0 disables GPU tile painting", | |
| 57 | - | settings: port.RenderingEnvSettings{ | |
| 56 | + | settings: env.RenderingSettings{ | |
| 58 | 57 | SkiaGPUPaintingThreads: 0, | |
| 59 | 58 | }, | |
| 60 | 59 | expectedVars: map[string]string{ | |
| @@ -63,7 +62,7 @@ func TestManager_ApplyEnvironment_SkiaEnv(t *testing.T) { | |||
| 63 | 62 | }, | |
| 64 | 63 | { | |
| 65 | 64 | name: "CPU rendering enabled", | |
| 66 | - | settings: port.RenderingEnvSettings{ | |
| 65 | + | settings: env.RenderingSettings{ | |
| 67 | 66 | SkiaEnableCPURendering: true, | |
| 68 | 67 | }, | |
| 69 | 68 | expectedVars: map[string]string{ | |
| @@ -72,14 +71,14 @@ func TestManager_ApplyEnvironment_SkiaEnv(t *testing.T) { | |||
| 72 | 71 | }, | |
| 73 | 72 | { | |
| 74 | 73 | name: "CPU rendering disabled (default)", | |
| 75 | - | settings: port.RenderingEnvSettings{ | |
| 74 | + | settings: env.RenderingSettings{ | |
| 76 | 75 | SkiaEnableCPURendering: false, | |
| 77 | 76 | }, | |
| 78 | 77 | unexpectedVars: []string{"WEBKIT_SKIA_ENABLE_CPU_RENDERING"}, | |
| 79 | 78 | }, | |
| 80 | 79 | { | |
| 81 | 80 | name: "does not override existing CPU threads env var", | |
| 82 | - | settings: port.RenderingEnvSettings{ | |
| 81 | + | settings: env.RenderingSettings{ | |
| 83 | 82 | SkiaCPUPaintingThreads: 8, | |
| 84 | 83 | }, | |
| 85 | 84 | presetEnv: map[string]string{ | |
| @@ -91,7 +90,7 @@ func TestManager_ApplyEnvironment_SkiaEnv(t *testing.T) { | |||
| 91 | 90 | }, | |
| 92 | 91 | { | |
| 93 | 92 | name: "does not override existing GPU threads env var", | |
| 94 | - | settings: port.RenderingEnvSettings{ | |
| 93 | + | settings: env.RenderingSettings{ | |
| 95 | 94 | SkiaGPUPaintingThreads: 4, | |
| 96 | 95 | }, | |
| 97 | 96 | presetEnv: map[string]string{ | |
| @@ -103,7 +102,7 @@ func TestManager_ApplyEnvironment_SkiaEnv(t *testing.T) { | |||
| 103 | 102 | }, | |
| 104 | 103 | { | |
| 105 | 104 | name: "does not override existing CPU rendering env var", | |
| 106 | - | settings: port.RenderingEnvSettings{ | |
| 105 | + | settings: env.RenderingSettings{ | |
| 107 | 106 | SkiaEnableCPURendering: true, | |
| 108 | 107 | }, | |
| 109 | 108 | presetEnv: map[string]string{ | |
| @@ -115,7 +114,7 @@ func TestManager_ApplyEnvironment_SkiaEnv(t *testing.T) { | |||
| 115 | 114 | }, | |
| 116 | 115 | { | |
| 117 | 116 | name: "all unset by default", | |
| 118 | - | settings: port.RenderingEnvSettings{ | |
| 117 | + | settings: env.RenderingSettings{ | |
| 119 | 118 | SkiaCPUPaintingThreads: 0, // unset | |
| 120 | 119 | SkiaGPUPaintingThreads: -1, // unset | |
| 121 | 120 | 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 ( | |||
| 12 | 12 | "sync/atomic" | |
| 13 | 13 | "time" | |
| 14 | 14 | ||
| 15 | + | "github.com/bnema/dumber/internal/application/port" | |
| 15 | 16 | "github.com/bnema/dumber/internal/logging" | |
| 16 | 17 | "github.com/bnema/puregotk-webkit/webkit" | |
| 17 | 18 | ) | |
| 18 | 19 | ||
| 20 | + | // Compile-time check that Manager satisfies port.FilterManager. | |
| 21 | + | var _ port.FilterManager = (*Manager)(nil) | |
| 22 | + | ||
| 19 | 23 | const ( | |
| 20 | 24 | storeDirPerm = 0o755 | |
| 21 | 25 | jsonDirPerm = 0o755 | |
M internal/infrastructure/filtering/types.go +15 -16
| @@ -1,31 +1,30 @@ | |||
| 1 | 1 | package filtering | |
| 2 | 2 | ||
| 3 | - | import "time" | |
| 3 | + | import ( | |
| 4 | + | "time" | |
| 4 | 5 | ||
| 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 | |
| 7 | 14 | ||
| 8 | 15 | const ( | |
| 9 | 16 | // StateUninitialized means the filter manager has not been initialized yet. | |
| 10 | - | StateUninitialized FilterState = "uninitialized" | |
| 17 | + | StateUninitialized FilterState = port.FilterStateUninitialized | |
| 11 | 18 | // StateLoading means filters are being downloaded or compiled. | |
| 12 | - | StateLoading FilterState = "loading" | |
| 19 | + | StateLoading FilterState = port.FilterStateLoading | |
| 13 | 20 | // StateActive means filters are loaded and active. | |
| 14 | - | StateActive FilterState = "active" | |
| 21 | + | StateActive FilterState = port.FilterStateActive | |
| 15 | 22 | // StateDisabled means filtering is disabled by configuration. | |
| 16 | - | StateDisabled FilterState = "disabled" | |
| 23 | + | StateDisabled FilterState = port.FilterStateDisabled | |
| 17 | 24 | // StateError means an error occurred during filter loading. | |
| 18 | - | StateError FilterState = "error" | |
| 25 | + | StateError FilterState = port.FilterStateError | |
| 19 | 26 | ) | |
| 20 | 27 | ||
| 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 | 28 | // Manifest represents the manifest.json from ublock-webkit-filters releases. | |
| 30 | 29 | // It contains metadata about the filter lists and their rule counts. | |
| 31 | 30 | type Manifest struct { | |
M internal/infrastructure/snapshot/service.go +3 -0
| @@ -19,6 +19,9 @@ const ( | |||
| 19 | 19 | fkRetryDelay = 100 * time.Millisecond | |
| 20 | 20 | ) | |
| 21 | 21 | ||
| 22 | + | // Compile-time interface check. | |
| 23 | + | var _ port.SnapshotService = (*Service)(nil) | |
| 24 | + | ||
| 22 | 25 | // Service handles debounced session state snapshots. | |
| 23 | 26 | type Service struct { | |
| 24 | 27 | snapshotUC *usecase.SnapshotSessionUseCase | |
M internal/infrastructure/textinput/gtk_entry_target.go +5 -1
| @@ -14,8 +14,12 @@ type GTKEntryTarget struct { | |||
| 14 | 14 | entry *gtk.SearchEntry | |
| 15 | 15 | } | |
| 16 | 16 | ||
| 17 | - | // Compile-time interface check. | |
| 17 | + | // Compile-time interface checks. | |
| 18 | 18 | var _ port.TextInputTarget = (*GTKEntryTarget)(nil) | |
| 19 | + | var _ port.EntryInputTarget = (*GTKEntryTarget)(nil) | |
| 20 | + | ||
| 21 | + | // IsGTKEntryTarget implements port.EntryInputTarget. | |
| 22 | + | func (*GTKEntryTarget) IsGTKEntryTarget() bool { return true } | |
| 19 | 23 | ||
| 20 | 24 | // NewGTKEntryTarget creates a new GTK entry target for a SearchEntry. | |
| 21 | 25 | func NewGTKEntryTarget(entry *gtk.SearchEntry) *GTKEntryTarget { | |
M internal/infrastructure/webkit/context.go +14 -13
| @@ -6,7 +6,6 @@ import ( | |||
| 6 | 6 | "path/filepath" | |
| 7 | 7 | "sync" | |
| 8 | 8 | ||
| 9 | - | "github.com/bnema/dumber/internal/application/port" | |
| 10 | 9 | "github.com/bnema/dumber/internal/logging" | |
| 11 | 10 | "github.com/bnema/puregotk-webkit/webkit" | |
| 12 | 11 | "github.com/rs/zerolog" | |
| @@ -36,17 +35,17 @@ type WebKitContext struct { | |||
| 36 | 35 | // The dataDir and cacheDir are used for cookie storage, cache, and other persistent data. | |
| 37 | 36 | // This MUST be called before creating any WebViews to ensure they use persistent storage. | |
| 38 | 37 | func NewWebKitContext(ctx context.Context, dataDir, cacheDir string) (*WebKitContext, error) { | |
| 39 | - | return NewWebKitContextWithOptions(ctx, port.WebKitContextOptions{ | |
| 38 | + | return NewWebKitContextWithOptions(ctx, webKitContextOptions{ | |
| 40 | 39 | DataDir: dataDir, | |
| 41 | 40 | CacheDir: cacheDir, | |
| 42 | - | CookiePolicy: port.WebKitCookiePolicyNoThirdParty, | |
| 41 | + | CookiePolicy: cookiePolicyNoThirdParty, | |
| 43 | 42 | ITPEnabled: true, | |
| 44 | 43 | }) | |
| 45 | 44 | } | |
| 46 | 45 | ||
| 47 | 46 | // NewWebKitContextWithOptions creates and initializes a WebKitContext with the given options. | |
| 48 | 47 | // 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) { | |
| 50 | 49 | log := logging.FromContext(ctx).With().Str("component", "webkit-context").Logger() | |
| 51 | 50 | ||
| 52 | 51 | if opts.DataDir == "" { | |
| @@ -114,7 +113,7 @@ func NewWebKitContextWithOptions(ctx context.Context, opts port.WebKitContextOpt | |||
| 114 | 113 | } | |
| 115 | 114 | ||
| 116 | 115 | // 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 { | |
| 118 | 117 | // Create persistent network session | |
| 119 | 118 | session := webkit.NewNetworkSession(&c.dataDir, &c.cacheDir) | |
| 120 | 119 | if session == nil { | |
| @@ -188,16 +187,18 @@ func (c *WebKitContext) initNetworkSession(opts port.WebKitContextOptions) error | |||
| 188 | 187 | return nil | |
| 189 | 188 | } | |
| 190 | 189 | ||
| 191 | - | func mapCookiePolicy(policy port.WebKitCookiePolicy) (webkit.CookieAcceptPolicy, string) { | |
| 190 | + | func mapCookiePolicy(policy cookiePolicy) (webkit.CookieAcceptPolicy, string) { | |
| 192 | 191 | 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, "" | |
| 199 | 200 | default: | |
| 200 | - | return webkit.CookiePolicyAcceptNoThirdPartyValue, string(port.WebKitCookiePolicyNoThirdParty) | |
| 201 | + | return webkit.CookiePolicyAcceptNoThirdPartyValue, string(cookiePolicyNoThirdParty) | |
| 201 | 202 | } | |
| 202 | 203 | } | |
| 203 | 204 | ||
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 ( | |||
| 7 | 7 | "sync" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| 10 | - | "github.com/bnema/dumber/internal/application/usecase" | |
| 11 | 10 | downloadutil "github.com/bnema/dumber/internal/domain/download" | |
| 12 | 11 | "github.com/bnema/dumber/internal/logging" | |
| 13 | 12 | "github.com/bnema/puregotk-webkit/webkit" | |
| @@ -20,26 +19,26 @@ const ( | |||
| 20 | 19 | ||
| 21 | 20 | // DownloadHandler manages WebKit downloads and notifies the UI layer. | |
| 22 | 21 | 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 | |
| 27 | 26 | } | |
| 28 | 27 | ||
| 29 | 28 | // 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). | |
| 31 | 30 | func NewDownloadHandler( | |
| 32 | 31 | downloadPath string, | |
| 33 | 32 | handler port.DownloadEventHandler, | |
| 34 | - | prepareDownloadUC *usecase.PrepareDownloadUseCase, | |
| 33 | + | preparer port.DownloadPreparer, | |
| 35 | 34 | ) *DownloadHandler { | |
| 36 | - | if prepareDownloadUC == nil { | |
| 37 | - | panic("prepareDownloadUC is required") | |
| 35 | + | if preparer == nil { | |
| 36 | + | panic("preparer is required") | |
| 38 | 37 | } | |
| 39 | 38 | return &DownloadHandler{ | |
| 40 | - | downloadPath: downloadPath, | |
| 41 | - | eventHandler: handler, | |
| 42 | - | prepareDownloadUC: prepareDownloadUC, | |
| 39 | + | downloadPath: downloadPath, | |
| 40 | + | eventHandler: handler, | |
| 41 | + | preparer: preparer, | |
| 43 | 42 | } | |
| 44 | 43 | } | |
| 45 | 44 | ||
| @@ -57,7 +56,7 @@ func (h *DownloadHandler) HandleDownload(ctx context.Context, download *webkit.D | |||
| 57 | 56 | h.mu.RLock() | |
| 58 | 57 | downloadPath := h.downloadPath | |
| 59 | 58 | eventHandler := h.eventHandler | |
| 60 | - | prepareDownloadUC := h.prepareDownloadUC | |
| 59 | + | preparer := h.preparer | |
| 61 | 60 | h.mu.RUnlock() | |
| 62 | 61 | ||
| 63 | 62 | // 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 | |||
| 80 | 79 | response = &uriResponseAdapter{resp: resp} | |
| 81 | 80 | } | |
| 82 | 81 | ||
| 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{ | |
| 85 | 84 | SuggestedFilename: suggestedFilename, | |
| 86 | 85 | Response: response, | |
| 87 | 86 | DownloadDir: downloadPath, | |
M internal/infrastructure/webkit/download_handler_test.go +1 -1
| @@ -16,7 +16,7 @@ func TestNewDownloadHandler(t *testing.T) { | |||
| 16 | 16 | assert.NotNil(t, handler) | |
| 17 | 17 | assert.Equal(t, "/tmp/downloads", handler.downloadPath) | |
| 18 | 18 | assert.Nil(t, handler.eventHandler) | |
| 19 | - | assert.NotNil(t, handler.prepareDownloadUC) | |
| 19 | + | assert.NotNil(t, handler.preparer) | |
| 20 | 20 | }) | |
| 21 | 21 | ||
| 22 | 22 | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| @@ -10,75 +10,124 @@ import ( | |||
| 10 | 10 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 11 | 11 | "github.com/bnema/dumber/internal/infrastructure/env" | |
| 12 | 12 | "github.com/bnema/dumber/internal/infrastructure/filtering" | |
| 13 | - | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 14 | 13 | "github.com/bnema/dumber/internal/logging" | |
| 15 | 14 | "github.com/bnema/dumber/internal/ui/theme" | |
| 16 | 15 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 17 | 16 | "github.com/rs/zerolog" | |
| 18 | 17 | ) | |
| 19 | 18 | ||
| 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) | |
| 48 | 37 | logging.Trace().Mark("render_env") | |
| 49 | 38 | ||
| 50 | - | wkOpts := buildWebKitContextOptions(cfg, input.DataDir, input.CacheDir, &perfSettings) | |
| 39 | + | // --- Build webKitContextOptions from opts + wkCfg + perfSettings --- | |
| 40 | + | wkOpts := engineBuildContextOptions(opts, wkCfg, &perfSettings) | |
| 51 | 41 | logger.Info(). | |
| 52 | 42 | Str("cookie_policy", string(wkOpts.CookiePolicy)). | |
| 53 | 43 | Bool("itp_enabled", wkOpts.ITPEnabled). | |
| 54 | 44 | Msg("webkit privacy configuration") | |
| 55 | - | wkCtx, err := webkit.NewWebKitContextWithOptions(ctx, wkOpts) | |
| 45 | + | ||
| 46 | + | wkCtx, err := NewWebKitContextWithOptions(ctx, wkOpts) | |
| 56 | 47 | if err != nil { | |
| 57 | - | logger.Fatal().Err(err).Msg("failed to initialize WebKit context") | |
| 48 | + | return nil, err | |
| 58 | 49 | } | |
| 59 | 50 | logging.Trace().Mark("webkit_context") | |
| 60 | 51 | ||
| 61 | - | filterManager := initFilterManager(ctx, cfg, input.DataDir, logger) | |
| 52 | + | // --- Filter manager --- | |
| 53 | + | filterManager := engineInitFilterManager(ctx, cfg, opts.DataDir, logger) | |
| 62 | 54 | ||
| 63 | - | schemeHandler := webkit.NewDumbSchemeHandler(ctx) | |
| 55 | + | // --- Scheme handler --- | |
| 56 | + | schemeHandler := NewDumbSchemeHandler(ctx) | |
| 64 | 57 | schemeHandler.SetAssets(assets.WebUIAssets) | |
| 65 | 58 | schemeHandler.RegisterWithContext(wkCtx) | |
| 66 | 59 | ||
| 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) | |
| 68 | 63 | ||
| 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 | + | }) | |
| 70 | 67 | ||
| 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") | |
| 78 | 97 | } | |
| 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 | |
| 79 | 125 | } | |
| 80 | 126 | ||
| 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 { | |
| 82 | 131 | hwSurveyor := env.NewHardwareSurveyor() | |
| 83 | 132 | hwInfo := hwSurveyor.Survey(ctx) | |
| 84 | 133 | logging.Trace().Mark("hardware_survey") | |
| @@ -90,10 +139,11 @@ func surveyHardwareAndResolveProfile(ctx context.Context, cfg *config.Config, lo | |||
| 90 | 139 | Uint64("vram_mb", hwInfo.VRAM/(1024*1024)). | |
| 91 | 140 | Msg("hardware survey completed") | |
| 92 | 141 | ||
| 93 | - | perfSettings := config.ResolvePerformanceProfile(&cfg.Performance, &hwInfo) | |
| 142 | + | perfCfg := config.PerformanceConfigFromEngine(&cfg.Engine) | |
| 143 | + | perfSettings := config.ResolvePerformanceProfile(&perfCfg, &hwInfo) | |
| 94 | 144 | logging.Trace().Mark("performance_profile") | |
| 95 | 145 | logger.Info(). | |
| 96 | - | Str("profile", string(cfg.Performance.Profile)). | |
| 146 | + | Str("profile", string(cfg.Engine.Profile)). | |
| 97 | 147 | Int("skia_cpu_threads", perfSettings.SkiaCPUPaintingThreads). | |
| 98 | 148 | Int("skia_gpu_threads", perfSettings.SkiaGPUPaintingThreads). | |
| 99 | 149 | Int("webview_pool_prewarm", perfSettings.WebViewPoolPrewarmCount). | |
| @@ -102,26 +152,72 @@ func surveyHardwareAndResolveProfile(ctx context.Context, cfg *config.Config, lo | |||
| 102 | 152 | return perfSettings | |
| 103 | 153 | } | |
| 104 | 154 | ||
| 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, | |
| 106 | 159 | cfg *config.Config, | |
| 107 | - | dataDir, cacheDir string, | |
| 160 | + | wkCfg WebKitEngineConfig, | |
| 108 | 161 | 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, | |
| 115 | 193 | } | |
| 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 | + | } | |
| 116 | 202 | ||
| 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, | |
| 122 | 216 | } | |
| 123 | 217 | ||
| 124 | - | if hasWebProcessMemoryConfig(perfSettings) { | |
| 218 | + | if opts.WebProcessMemory != nil { | |
| 219 | + | wkOpts.WebProcessMemory = opts.WebProcessMemory | |
| 220 | + | } else if engineHasWebProcessMemoryConfig(perfSettings) { | |
| 125 | 221 | wkOpts.WebProcessMemory = &port.MemoryPressureConfig{ | |
| 126 | 222 | MemoryLimitMB: perfSettings.WebProcessMemoryLimitMB, | |
| 127 | 223 | PollIntervalSec: perfSettings.WebProcessMemoryPollIntervalSec, | |
| @@ -130,7 +226,9 @@ func buildWebKitContextOptions( | |||
| 130 | 226 | } | |
| 131 | 227 | } | |
| 132 | 228 | ||
| 133 | - | if hasNetworkProcessMemoryConfig(perfSettings) { | |
| 229 | + | if opts.NetworkProcessMemory != nil { | |
| 230 | + | wkOpts.NetworkProcessMemory = opts.NetworkProcessMemory | |
| 231 | + | } else if engineHasNetworkProcessMemoryConfig(perfSettings) { | |
| 134 | 232 | wkOpts.NetworkProcessMemory = &port.MemoryPressureConfig{ | |
| 135 | 233 | MemoryLimitMB: perfSettings.NetworkProcessMemoryLimitMB, | |
| 136 | 234 | PollIntervalSec: perfSettings.NetworkProcessMemoryPollIntervalSec, | |
| @@ -142,7 +240,8 @@ func buildWebKitContextOptions( | |||
| 142 | 240 | return wkOpts | |
| 143 | 241 | } | |
| 144 | 242 | ||
| 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 { | |
| 146 | 245 | filterStoreDir := filepath.Join(dataDir, "filters", "store") | |
| 147 | 246 | filterJSONDir := filepath.Join(dataDir, "filters", "json") | |
| 148 | 247 | filterManager, err := filtering.NewManager(filtering.ManagerConfig{ | |
| @@ -162,135 +261,18 @@ func initFilterManager(ctx context.Context, cfg *config.Config, dataDir string, | |||
| 162 | 261 | return filterManager | |
| 163 | 262 | } | |
| 164 | 263 | ||
| 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 { | |
| 233 | 266 | return p.WebProcessMemoryLimitMB > 0 || | |
| 234 | 267 | p.WebProcessMemoryPollIntervalSec > 0 || | |
| 235 | 268 | p.WebProcessMemoryConservativeThreshold > 0 || | |
| 236 | 269 | p.WebProcessMemoryStrictThreshold > 0 | |
| 237 | 270 | } | |
| 238 | 271 | ||
| 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 { | |
| 241 | 274 | return p.NetworkProcessMemoryLimitMB > 0 || | |
| 242 | 275 | p.NetworkProcessMemoryPollIntervalSec > 0 || | |
| 243 | 276 | p.NetworkProcessMemoryConservativeThreshold > 0 || | |
| 244 | 277 | p.NetworkProcessMemoryStrictThreshold > 0 | |
| 245 | 278 | } | |
| 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 | |||
| 103 | 103 | ||
| 104 | 104 | // Set background color to match theme (eliminates white flash) | |
| 105 | 105 | 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)) | |
| 107 | 107 | } | |
| 108 | 108 | ||
| 109 | 109 | // Keep hidden until content is painted | |
M internal/infrastructure/webkit/handlers/clipboard.go +10 -12
| @@ -5,24 +5,23 @@ import ( | |||
| 5 | 5 | "encoding/json" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 9 | 8 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 10 | 9 | "github.com/bnema/dumber/internal/logging" | |
| 11 | 10 | ) | |
| 12 | 11 | ||
| 13 | 12 | // ClipboardHandler handles clipboard-related messages from webviews. | |
| 14 | 13 | 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) | |
| 18 | 17 | } | |
| 19 | 18 | ||
| 20 | 19 | // 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 { | |
| 22 | 21 | return &ClipboardHandler{ | |
| 23 | - | clipboard: clipboard, | |
| 24 | - | configGetter: configGetter, | |
| 25 | - | onCopied: onCopied, | |
| 22 | + | clipboard: clipboard, | |
| 23 | + | autoCopyConfig: autoCopyConfig, | |
| 24 | + | onCopied: onCopied, | |
| 26 | 25 | } | |
| 27 | 26 | } | |
| 28 | 27 | ||
| @@ -38,8 +37,7 @@ func (h *ClipboardHandler) HandleAutoCopySelection() webkit.MessageHandler { | |||
| 38 | 37 | log := logging.FromContext(ctx) | |
| 39 | 38 | ||
| 40 | 39 | // Check if feature is enabled | |
| 41 | - | cfg := h.configGetter() | |
| 42 | - | if cfg == nil || !cfg.Clipboard.AutoCopyOnSelection { | |
| 40 | + | if h.autoCopyConfig == nil || !h.autoCopyConfig.IsAutoCopyEnabled() { | |
| 43 | 41 | return nil, nil | |
| 44 | 42 | } | |
| 45 | 43 | ||
| @@ -75,10 +73,10 @@ func RegisterClipboardHandlers( | |||
| 75 | 73 | ctx context.Context, | |
| 76 | 74 | router *webkit.MessageRouter, | |
| 77 | 75 | clipboard port.Clipboard, | |
| 78 | - | configGetter func() *config.Config, | |
| 76 | + | autoCopyConfig port.AutoCopyConfig, | |
| 79 | 77 | onCopied func(textLen int), | |
| 80 | 78 | ) error { | |
| 81 | - | handler := NewClipboardHandler(clipboard, configGetter, onCopied) | |
| 79 | + | handler := NewClipboardHandler(clipboard, autoCopyConfig, onCopied) | |
| 82 | 80 | ||
| 83 | 81 | // Register auto_copy_selection handler | |
| 84 | 82 | 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 ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | - | "github.com/bnema/dumber/internal/application/usecase" | |
| 10 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 11 | 9 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 12 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 13 | 11 | ) | |
| 14 | 12 | ||
| 15 | 13 | // ConfigHandler handles configuration-related messages. | |
| 16 | - | type ConfigHandler struct{} | |
| 14 | + | type ConfigHandler struct { | |
| 15 | + | saveConfig func(context.Context, port.WebUIConfig) error | |
| 16 | + | } | |
| 17 | 17 | ||
| 18 | 18 | // 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} | |
| 21 | 25 | } | |
| 22 | 26 | ||
| 23 | 27 | // Handle processes the save_config message. | |
| @@ -25,6 +29,9 @@ func (h *ConfigHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload | |||
| 25 | 29 | if h == nil { | |
| 26 | 30 | return nil, fmt.Errorf("config handler is nil") | |
| 27 | 31 | } | |
| 32 | + | if h.saveConfig == nil { | |
| 33 | + | return nil, fmt.Errorf("config handler: saveConfig not initialized") | |
| 34 | + | } | |
| 28 | 35 | log := logging.FromContext(ctx).With().Str("handler", "config").Logger() | |
| 29 | 36 | ||
| 30 | 37 | var payloadCfg port.WebUIConfig | |
| @@ -34,13 +41,8 @@ func (h *ConfigHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload | |||
| 34 | 41 | } | |
| 35 | 42 | ||
| 36 | 43 | 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 | - | } | |
| 41 | 44 | ||
| 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 { | |
| 44 | 46 | log.Error().Err(err).Msg("failed to save config") | |
| 45 | 47 | return nil, fmt.Errorf("failed to save config: %w", err) | |
| 46 | 48 | } | |
| @@ -49,17 +51,14 @@ func (h *ConfigHandler) Handle(ctx context.Context, _ webkit.WebViewID, payload | |||
| 49 | 51 | } | |
| 50 | 52 | ||
| 51 | 53 | // 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) | |
| 59 | 59 | ||
| 60 | 60 | log := logging.FromContext(ctx).With().Str("component", "handlers").Logger() | |
| 61 | 61 | ||
| 62 | - | // Message type: save_config | |
| 63 | 62 | if err := router.RegisterHandlerWithCallbacks( | |
| 64 | 63 | "save_config", | |
| 65 | 64 | "__dumber_config_saved", | |
M internal/infrastructure/webkit/handlers/homepage/favorites.go +3 -3
| @@ -4,7 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/dumber/internal/application/usecase" | |
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | 9 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 10 | 10 | "github.com/bnema/dumber/internal/logging" | |
| @@ -12,11 +12,11 @@ import ( | |||
| 12 | 12 | ||
| 13 | 13 | // FavoritesHandlers handles favorite-related messages from the homepage. | |
| 14 | 14 | type FavoritesHandlers struct { | |
| 15 | - | favoritesUC *usecase.ManageFavoritesUseCase | |
| 15 | + | favoritesUC port.HomepageFavorites | |
| 16 | 16 | } | |
| 17 | 17 | ||
| 18 | 18 | // NewFavoritesHandlers creates a new FavoritesHandlers instance. | |
| 19 | - | func NewFavoritesHandlers(favoritesUC *usecase.ManageFavoritesUseCase) *FavoritesHandlers { | |
| 19 | + | func NewFavoritesHandlers(favoritesUC port.HomepageFavorites) *FavoritesHandlers { | |
| 20 | 20 | return &FavoritesHandlers{favoritesUC: favoritesUC} | |
| 21 | 21 | } | |
| 22 | 22 | ||
M internal/infrastructure/webkit/handlers/homepage/folders.go +8 -5
| @@ -4,7 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/dumber/internal/application/usecase" | |
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | 9 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 10 | 10 | "github.com/bnema/dumber/internal/logging" | |
| @@ -12,11 +12,11 @@ import ( | |||
| 12 | 12 | ||
| 13 | 13 | // FolderHandlers handles folder-related messages from the homepage. | |
| 14 | 14 | type FolderHandlers struct { | |
| 15 | - | favoritesUC *usecase.ManageFavoritesUseCase | |
| 15 | + | favoritesUC port.HomepageFavorites | |
| 16 | 16 | } | |
| 17 | 17 | ||
| 18 | 18 | // NewFolderHandlers creates a new FolderHandlers instance. | |
| 19 | - | func NewFolderHandlers(favoritesUC *usecase.ManageFavoritesUseCase) *FolderHandlers { | |
| 19 | + | func NewFolderHandlers(favoritesUC port.HomepageFavorites) *FolderHandlers { | |
| 20 | 20 | return &FolderHandlers{favoritesUC: favoritesUC} | |
| 21 | 21 | } | |
| 22 | 22 | ||
| @@ -67,9 +67,12 @@ func (h *FolderHandlers) HandleCreate() webkit.MessageHandler { | |||
| 67 | 67 | return NewErrorResponse(req.RequestID, err), nil | |
| 68 | 68 | } | |
| 69 | 69 | ||
| 70 | - | // Set icon if provided | |
| 70 | + | // Set icon if provided and persist it | |
| 71 | 71 | if req.Icon != nil && *req.Icon != "" { | |
| 72 | 72 | 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 | + | } | |
| 73 | 76 | } | |
| 74 | 77 | ||
| 75 | 78 | return NewSuccessResponse(req.RequestID, folder), nil | |
| @@ -114,7 +117,7 @@ type updateFolderRequest struct { | |||
| 114 | 117 | } | |
| 115 | 118 | ||
| 116 | 119 | // 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. | |
| 118 | 121 | func (h *FolderHandlers) HandleUpdate() webkit.MessageHandler { | |
| 119 | 122 | return webkit.MessageHandlerFunc(func(ctx context.Context, _ webkit.WebViewID, payload json.RawMessage) (any, error) { | |
| 120 | 123 | log := logging.FromContext(ctx) | |
M internal/infrastructure/webkit/handlers/homepage/history.go +4 -4
| @@ -5,7 +5,7 @@ import ( | |||
| 5 | 5 | "encoding/json" | |
| 6 | 6 | "time" | |
| 7 | 7 | ||
| 8 | - | "github.com/bnema/dumber/internal/application/usecase" | |
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | 9 | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | 10 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 11 | 11 | "github.com/bnema/dumber/internal/logging" | |
| @@ -13,11 +13,11 @@ import ( | |||
| 13 | 13 | ||
| 14 | 14 | // HistoryHandlers handles history-related messages from the homepage. | |
| 15 | 15 | type HistoryHandlers struct { | |
| 16 | - | historyUC *usecase.SearchHistoryUseCase | |
| 16 | + | historyUC port.HomepageHistory | |
| 17 | 17 | } | |
| 18 | 18 | ||
| 19 | 19 | // NewHistoryHandlers creates a new HistoryHandlers instance. | |
| 20 | - | func NewHistoryHandlers(historyUC *usecase.SearchHistoryUseCase) *HistoryHandlers { | |
| 20 | + | func NewHistoryHandlers(historyUC port.HomepageHistory) *HistoryHandlers { | |
| 21 | 21 | return &HistoryHandlers{historyUC: historyUC} | |
| 22 | 22 | } | |
| 23 | 23 | ||
| @@ -75,7 +75,7 @@ func (h *HistoryHandlers) HandleSearchFTS() webkit.MessageHandler { | |||
| 75 | 75 | Str("query", req.Query). | |
| 76 | 76 | Msg("handling history_search_fts") | |
| 77 | 77 | ||
| 78 | - | output, err := h.historyUC.Search(ctx, usecase.SearchInput{ | |
| 78 | + | output, err := h.historyUC.Search(ctx, port.HistorySearchInput{ | |
| 79 | 79 | Query: req.Query, | |
| 80 | 80 | Limit: req.Limit, | |
| 81 | 81 | }) | |
M internal/infrastructure/webkit/handlers/homepage/registry.go +3 -3
| @@ -4,15 +4,15 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/dumber/internal/application/usecase" | |
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 8 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 9 | 9 | "github.com/bnema/dumber/internal/logging" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | // Config holds dependencies for homepage handlers. | |
| 13 | 13 | type Config struct { | |
| 14 | - | HistoryUC *usecase.SearchHistoryUseCase | |
| 15 | - | FavoritesUC *usecase.ManageFavoritesUseCase | |
| 14 | + | HistoryUC port.HomepageHistory | |
| 15 | + | FavoritesUC port.HomepageFavorites | |
| 16 | 16 | } | |
| 17 | 17 | ||
| 18 | 18 | // RegisterHandlers registers all homepage message handlers with the router. | |
M internal/infrastructure/webkit/handlers/homepage/tags.go +3 -3
| @@ -4,7 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/dumber/internal/application/usecase" | |
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | 9 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 10 | 10 | "github.com/bnema/dumber/internal/logging" | |
| @@ -12,11 +12,11 @@ import ( | |||
| 12 | 12 | ||
| 13 | 13 | // TagHandlers handles tag-related messages from the homepage. | |
| 14 | 14 | type TagHandlers struct { | |
| 15 | - | favoritesUC *usecase.ManageFavoritesUseCase | |
| 15 | + | favoritesUC port.HomepageFavorites | |
| 16 | 16 | } | |
| 17 | 17 | ||
| 18 | 18 | // NewTagHandlers creates a new TagHandlers instance. | |
| 19 | - | func NewTagHandlers(favoritesUC *usecase.ManageFavoritesUseCase) *TagHandlers { | |
| 19 | + | func NewTagHandlers(favoritesUC port.HomepageFavorites) *TagHandlers { | |
| 20 | 20 | return &TagHandlers{favoritesUC: favoritesUC} | |
| 21 | 21 | } | |
| 22 | 22 | ||
M internal/infrastructure/webkit/handlers/keybindings.go +8 -9
| @@ -6,25 +6,24 @@ import ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | ||
| 8 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | - | "github.com/bnema/dumber/internal/application/usecase" | |
| 10 | 9 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 11 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 12 | 11 | ) | |
| 13 | 12 | ||
| 14 | 13 | // KeybindingsHandler handles keybinding-related messages. | |
| 15 | 14 | 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 | |
| 20 | 19 | } | |
| 21 | 20 | ||
| 22 | 21 | // NewKeybindingsHandler creates a new KeybindingsHandler. | |
| 23 | 22 | 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, | |
| 28 | 27 | ) *KeybindingsHandler { | |
| 29 | 28 | return &KeybindingsHandler{ | |
| 30 | 29 | getUC: getUC, | |
M internal/infrastructure/webkit/handlers/register.go +22 -42
| @@ -7,26 +7,27 @@ import ( | |||
| 7 | 7 | "unicode/utf8" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| 10 | - | "github.com/bnema/dumber/internal/application/usecase" | |
| 11 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 12 | 10 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 13 | 11 | "github.com/bnema/dumber/internal/infrastructure/webkit/handlers/homepage" | |
| 14 | 12 | ) | |
| 15 | 13 | ||
| 16 | 14 | // AccentKeyHandler is implemented by the InsertAccentUseCase to receive | |
| 17 | 15 | // 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 | |
| 22 | 19 | ||
| 23 | 20 | // Config holds all dependencies for message handlers. | |
| 24 | 21 | 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) | |
| 30 | 31 | } | |
| 31 | 32 | ||
| 32 | 33 | // RegisterAll registers all message handlers with the router. | |
| @@ -41,32 +42,28 @@ func RegisterAll(ctx context.Context, router *webkit.MessageRouter, cfg Config) | |||
| 41 | 42 | } | |
| 42 | 43 | } | |
| 43 | 44 | ||
| 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 | + | } | |
| 47 | 50 | } | |
| 48 | 51 | ||
| 49 | 52 | // 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") | |
| 53 | 55 | } | |
| 54 | - | if err := RegisterKeybindingsHandlers(ctx, router, keybindingsHandler); err != nil { | |
| 56 | + | if err := RegisterKeybindingsHandlers(ctx, router, cfg.KeybindingsHandler); err != nil { | |
| 55 | 57 | return err | |
| 56 | 58 | } | |
| 57 | 59 | ||
| 58 | 60 | // 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 { | |
| 61 | 63 | return err | |
| 62 | 64 | } | |
| 63 | 65 | } | |
| 64 | 66 | ||
| 65 | - | // Future handler groups go here: | |
| 66 | - | // - settings handlers | |
| 67 | - | // - downloads handlers | |
| 68 | - | // - etc. | |
| 69 | - | ||
| 70 | 67 | return nil | |
| 71 | 68 | } | |
| 72 | 69 | ||
| @@ -117,20 +114,3 @@ func RegisterAccentHandlers(_ context.Context, router *webkit.MessageRouter, han | |||
| 117 | 114 | ||
| 118 | 115 | return nil | |
| 119 | 116 | } | |
| 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 | |||
| 413 | 413 | ||
| 414 | 414 | log.Debug().Bool("prefers_dark", prefersDark).Bool("auto_copy", autoCopyEnabled).Msg("scripts injected") | |
| 415 | 415 | } | |
| 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) { | |||
| 276 | 276 | return | |
| 277 | 277 | } | |
| 278 | 278 | script := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(wv.ID())) | |
| 279 | - | wv.RunJavaScript(r.baseCtx, script, "") | |
| 279 | + | wv.RunJavaScriptInWorld(r.baseCtx, script, "") | |
| 280 | 280 | } | |
| 281 | 281 | ||
| 282 | 282 | func (r *MessageRouter) markWebViewIDSynced(id WebViewID) bool { | |
| @@ -329,6 +329,6 @@ func (r *MessageRouter) dispatchResponse(ctx context.Context, webviewID WebViewI | |||
| 329 | 329 | string(data), | |
| 330 | 330 | ) | |
| 331 | 331 | ||
| 332 | - | wv.RunJavaScript(ctx, script, world) | |
| 332 | + | wv.RunJavaScriptInWorld(ctx, script, world) | |
| 333 | 333 | return nil | |
| 334 | 334 | } | |
M internal/infrastructure/webkit/pool.go +1 -1
| @@ -159,7 +159,7 @@ func (p *WebViewPool) Acquire(ctx context.Context) (*WebView, error) { | |||
| 159 | 159 | case wv := <-p.pool: | |
| 160 | 160 | if wv != nil && !wv.IsDestroyed() { | |
| 161 | 161 | 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)) | |
| 163 | 163 | } | |
| 164 | 164 | wv.inner.AddCssClass("webview-themed") | |
| 165 | 165 | // 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 | |||
| 347 | 347 | } | |
| 348 | 348 | ||
| 349 | 349 | // 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) | |
| 351 | 352 | ||
| 352 | 353 | resp := configPayload{ | |
| 353 | 354 | DefaultUIScale: cfg.DefaultUIScale, | |
| @@ -363,13 +364,13 @@ func (h *DumbSchemeHandler) buildConfigResponse(cfg *config.Config) *SchemeRespo | |||
| 363 | 364 | DarkPalette: cfg.Appearance.DarkPalette, | |
| 364 | 365 | }, | |
| 365 | 366 | Performance: configPerformancePayload{ | |
| 366 | - | Profile: string(cfg.Performance.Profile), | |
| 367 | + | Profile: string(cfg.Engine.Profile), | |
| 367 | 368 | 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, | |
| 373 | 374 | }, | |
| 374 | 375 | Resolved: configResolvedPerformance{ | |
| 375 | 376 | 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 | |||
| 51 | 51 | log := logging.FromContext(ctx) | |
| 52 | 52 | applyJavaScriptSettings(settings) | |
| 53 | 53 | applyFontSettings(settings, cfg) | |
| 54 | - | applyRenderingSettings(settings, cfg) | |
| 55 | 54 | applyDebugSettings(settings, cfg) | |
| 56 | 55 | applyBrowsingSettings(settings) | |
| 57 | 56 | applyMediaSettings(settings, cfg, log) | |
| @@ -65,7 +64,6 @@ func (sm *SettingsManager) applySettings(ctx context.Context, settings *webkit.S | |||
| 65 | 64 | ||
| 66 | 65 | log.Debug(). | |
| 67 | 66 | Str("sans_font", cfg.Appearance.SansFont). | |
| 68 | - | Str("rendering_mode", string(cfg.Rendering.Mode)). | |
| 69 | 67 | Bool("developer_extras", cfg.Debug.EnableDevTools). | |
| 70 | 68 | Bool("webrtc_enabled", webrtcEnabled). | |
| 71 | 69 | Bool("media_stream_enabled", mediaStreamEnabled). | |
| @@ -93,21 +91,10 @@ func applyFontSettings(settings *webkit.Settings, cfg *config.Config) { | |||
| 93 | 91 | } | |
| 94 | 92 | } | |
| 95 | 93 | ||
| 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 | - | ||
| 107 | 94 | func applyDebugSettings(settings *webkit.Settings, cfg *config.Config) { | |
| 108 | 95 | settings.SetEnableDeveloperExtras(cfg.Debug.EnableDevTools) | |
| 109 | 96 | settings.SetEnableWriteConsoleMessagesToStdout(cfg.Logging.CaptureConsole) | |
| 110 | - | settings.SetDrawCompositingIndicators(cfg.Rendering.DrawCompositingIndicators) | |
| 97 | + | settings.SetDrawCompositingIndicators(cfg.Engine.WebKit.DrawCompositingIndicators) | |
| 111 | 98 | } | |
| 112 | 99 | ||
| 113 | 100 | func applyBrowsingSettings(settings *webkit.Settings) { | |
| @@ -178,18 +165,14 @@ func (sm *SettingsManager) UpdateFromConfig(ctx context.Context, cfg *config.Con | |||
| 178 | 165 | // ApplyToWebView applies current settings to an existing WebView. | |
| 179 | 166 | // This can be used to update a WebView's settings after config hot-reload. | |
| 180 | 167 | func (sm *SettingsManager) ApplyToWebView(ctx context.Context, wv *webkit.WebView) { | |
| 181 | - | if wv == nil { | |
| 168 | + | if sm == nil { | |
| 182 | 169 | return | |
| 183 | 170 | } | |
| 184 | - | ||
| 185 | - | settings := sm.CreateSettings(ctx) | |
| 186 | - | if settings == nil { | |
| 171 | + | if wv == nil { | |
| 187 | 172 | return | |
| 188 | 173 | } | |
| 189 | 174 | ||
| 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 | |
| 193 | 176 | existingSettings := wv.GetSettings() | |
| 194 | 177 | if existingSettings != nil { | |
| 195 | 178 | sm.mu.RLock() | |
M internal/infrastructure/webkit/webview.go +204 -17
| @@ -24,8 +24,11 @@ import ( | |||
| 24 | 24 | "github.com/rs/zerolog" | |
| 25 | 25 | ) | |
| 26 | 26 | ||
| 27 | - | // Compile-time interface check: WebView must implement port.WebView. | |
| 27 | + | // Compile-time interface checks. | |
| 28 | 28 | var _ port.WebView = (*WebView)(nil) | |
| 29 | + | var _ port.DevToolsOpener = (*WebView)(nil) | |
| 30 | + | var _ port.Printer = (*WebView)(nil) | |
| 31 | + | var _ port.OAuthCallbackCapable = (*WebView)(nil) | |
| 29 | 32 | ||
| 30 | 33 | // WebViewID is an alias to port.WebViewID for clean architecture compliance. | |
| 31 | 34 | // Infrastructure layer uses the type defined in the application port. | |
| @@ -995,11 +998,14 @@ func (wv *WebView) connectPermissionRequestSignal() { | |||
| 995 | 998 | } | |
| 996 | 999 | ||
| 997 | 1000 | // Determine permission types from the request | |
| 998 | - | permTypes := wv.determinePermissionTypes(ctx, requestPtr) | |
| 1001 | + | permTypes, originOverride := wv.determinePermissionTypes(ctx, requestPtr) | |
| 999 | 1002 | if len(permTypes) == 0 { | |
| 1000 | 1003 | wv.logger.Warn().Msg("permission request with unknown type, denying") | |
| 1001 | 1004 | return false | |
| 1002 | 1005 | } | |
| 1006 | + | if originOverride != "" { | |
| 1007 | + | origin = originOverride | |
| 1008 | + | } | |
| 1003 | 1009 | ||
| 1004 | 1010 | // Ref the request object to prevent use-after-free | |
| 1005 | 1011 | // The request may outlive the signal handler if we show a dialog | |
| @@ -1045,13 +1051,13 @@ func (wv *WebView) connectPermissionRequestSignal() { | |||
| 1045 | 1051 | // We use GObject property accessors for audio/video request flags. Display detection | |
| 1046 | 1052 | // uses the dedicated WebKit API because this WebKit build does not expose an | |
| 1047 | 1053 | // "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) { | |
| 1049 | 1055 | requestKind := detectPermissionRequestKind(ctx, requestPtr) | |
| 1050 | 1056 | switch requestKind { | |
| 1051 | 1057 | case permissionRequestKindUserMedia: | |
| 1052 | 1058 | userMediaReq := webkit.UserMediaPermissionRequestNewFromInternalPtr(requestPtr) | |
| 1053 | 1059 | if userMediaReq == nil { | |
| 1054 | - | return nil | |
| 1060 | + | return nil, "" | |
| 1055 | 1061 | } | |
| 1056 | 1062 | ||
| 1057 | 1063 | // Use GObject property accessors — more reliable than the C function wrappers | |
| @@ -1066,9 +1072,34 @@ func (wv *WebView) determinePermissionTypes(ctx context.Context, requestPtr uint | |||
| 1066 | 1072 | Bool("is_display", isDisplay). | |
| 1067 | 1073 | Msg("permission request type detection") | |
| 1068 | 1074 | ||
| 1069 | - | return classifyPermissionRequestTypes(ctx, requestKind, isAudio, isVideo, isDisplay) | |
| 1075 | + | return classifyPermissionRequestTypes(ctx, requestKind, isAudio, isVideo, isDisplay), "" | |
| 1070 | 1076 | 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 | |
| 1072 | 1103 | default: | |
| 1073 | 1104 | if requestPtr != 0 { | |
| 1074 | 1105 | typeName := permissionRequestTypeName(ctx, requestPtr) | |
| @@ -1080,7 +1111,7 @@ func (wv *WebView) determinePermissionTypes(ctx context.Context, requestPtr uint | |||
| 1080 | 1111 | } | |
| 1081 | 1112 | // Unknown permission type - could be clipboard, notifications, geolocation, etc. | |
| 1082 | 1113 | // For now, return empty to trigger denial. Future phases will add these types. | |
| 1083 | - | return nil | |
| 1114 | + | return nil, "" | |
| 1084 | 1115 | } | |
| 1085 | 1116 | } | |
| 1086 | 1117 | ||
| @@ -1090,6 +1121,7 @@ const ( | |||
| 1090 | 1121 | permissionRequestKindUnknown permissionRequestKind = iota | |
| 1091 | 1122 | permissionRequestKindUserMedia | |
| 1092 | 1123 | permissionRequestKindDeviceInfo | |
| 1124 | + | permissionRequestKindWebsiteDataAccess | |
| 1093 | 1125 | ) | |
| 1094 | 1126 | ||
| 1095 | 1127 | func detectPermissionRequestKind(ctx context.Context, requestPtr uintptr) permissionRequestKind { | |
| @@ -1103,6 +1135,11 @@ func detectPermissionRequestKind(ctx context.Context, requestPtr uintptr) permis | |||
| 1103 | 1135 | return permissionRequestKindDeviceInfo | |
| 1104 | 1136 | } | |
| 1105 | 1137 | } | |
| 1138 | + | if gtype, ok := safeGLibType(ctx, webkit.WebsiteDataAccessPermissionRequestGLibType); ok { | |
| 1139 | + | if isPermissionRequestType(ctx, requestPtr, gtype) { | |
| 1140 | + | return permissionRequestKindWebsiteDataAccess | |
| 1141 | + | } | |
| 1142 | + | } | |
| 1106 | 1143 | return permissionRequestKindUnknown | |
| 1107 | 1144 | } | |
| 1108 | 1145 | ||
| @@ -1137,6 +1174,8 @@ func classifyPermissionRequestTypes( | |||
| 1137 | 1174 | return classifyUserMediaPermissionTypes(ctx, isAudio, isVideo, isDisplay) | |
| 1138 | 1175 | case permissionRequestKindDeviceInfo: | |
| 1139 | 1176 | return []string{"device_info"} | |
| 1177 | + | case permissionRequestKindWebsiteDataAccess: | |
| 1178 | + | return []string{"website_data_access"} | |
| 1140 | 1179 | default: | |
| 1141 | 1180 | return nil | |
| 1142 | 1181 | } | |
| @@ -1187,6 +1226,14 @@ func (wv *WebView) allowPermissionRequest(requestPtr uintptr) { | |||
| 1187 | 1226 | return | |
| 1188 | 1227 | } | |
| 1189 | 1228 | ||
| 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 | + | ||
| 1190 | 1237 | wv.logger.Warn().Uint64("request_ptr", uint64(requestPtr)).Msg("permission request: unknown type, cannot allow") | |
| 1191 | 1238 | } | |
| 1192 | 1239 | ||
| @@ -1208,6 +1255,14 @@ func (wv *WebView) denyPermissionRequest(requestPtr uintptr) { | |||
| 1208 | 1255 | return | |
| 1209 | 1256 | } | |
| 1210 | 1257 | ||
| 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 | + | ||
| 1211 | 1266 | wv.logger.Warn().Uint64("request_ptr", uint64(requestPtr)).Msg("permission request: unknown type, cannot deny") | |
| 1212 | 1267 | } | |
| 1213 | 1268 | ||
| @@ -1397,9 +1452,22 @@ func (wv *WebView) Title() string { | |||
| 1397 | 1452 | return wv.title | |
| 1398 | 1453 | } | |
| 1399 | 1454 | ||
| 1400 | - | // Favicon returns the current page favicon as a GdkTexture. | |
| 1455 | + | // Favicon returns the current page favicon as a Texture. | |
| 1401 | 1456 | // 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 { | |
| 1403 | 1471 | if wv.destroyed.Load() { | |
| 1404 | 1472 | return nil | |
| 1405 | 1473 | } | |
| @@ -1444,18 +1512,18 @@ func (wv *WebView) GetZoomLevel() float64 { | |||
| 1444 | 1512 | return wv.inner.GetZoomLevel() | |
| 1445 | 1513 | } | |
| 1446 | 1514 | ||
| 1447 | - | // SetBackgroundColor sets the WebView background color. | |
| 1515 | + | // SetBackgroundColor sets the WebView background color (port.WebView interface). | |
| 1448 | 1516 | // This color is shown before content is painted, eliminating white flash. | |
| 1449 | 1517 | // 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) { | |
| 1451 | 1519 | if wv.destroyed.Load() { | |
| 1452 | 1520 | return | |
| 1453 | 1521 | } | |
| 1454 | 1522 | 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), | |
| 1459 | 1527 | } | |
| 1460 | 1528 | wv.inner.SetBackgroundColor(rgba) | |
| 1461 | 1529 | } | |
| @@ -1466,6 +1534,19 @@ func (wv *WebView) ResetBackgroundToDefault() { | |||
| 1466 | 1534 | wv.SetBackgroundColor(1.0, 1.0, 1.0, 1.0) | |
| 1467 | 1535 | } | |
| 1468 | 1536 | ||
| 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 | + | ||
| 1469 | 1550 | // Show makes the WebView widget visible. | |
| 1470 | 1551 | // This should be called after the WebView is ready to be displayed. | |
| 1471 | 1552 | func (wv *WebView) Show() { | |
| @@ -1475,6 +1556,30 @@ func (wv *WebView) Show() { | |||
| 1475 | 1556 | wv.inner.SetVisible(true) | |
| 1476 | 1557 | } | |
| 1477 | 1558 | ||
| 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 | + | ||
| 1478 | 1583 | // State returns the current WebView state as a snapshot. | |
| 1479 | 1584 | func (wv *WebView) State() port.WebViewState { | |
| 1480 | 1585 | return port.WebViewState{ | |
| @@ -1502,6 +1607,10 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) { | |||
| 1502 | 1607 | wv.OnLinkHover = nil | |
| 1503 | 1608 | wv.OnWebProcessTerminated = nil | |
| 1504 | 1609 | wv.OnPermissionRequest = nil | |
| 1610 | + | wv.OnLinkMiddleClick = nil | |
| 1611 | + | wv.OnEnterFullscreen = nil | |
| 1612 | + | wv.OnLeaveFullscreen = nil | |
| 1613 | + | wv.OnAudioStateChanged = nil | |
| 1505 | 1614 | return | |
| 1506 | 1615 | } | |
| 1507 | 1616 | ||
| @@ -1549,6 +1658,10 @@ func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) { | |||
| 1549 | 1658 | wv.OnWebProcessTerminated = nil | |
| 1550 | 1659 | } | |
| 1551 | 1660 | wv.OnPermissionRequest = callbacks.OnPermissionRequest | |
| 1661 | + | wv.OnLinkMiddleClick = callbacks.OnLinkMiddleClick | |
| 1662 | + | wv.OnEnterFullscreen = callbacks.OnEnterFullscreen | |
| 1663 | + | wv.OnLeaveFullscreen = callbacks.OnLeaveFullscreen | |
| 1664 | + | wv.OnAudioStateChanged = callbacks.OnAudioStateChanged | |
| 1552 | 1665 | } | |
| 1553 | 1666 | ||
| 1554 | 1667 | // ShowDevTools opens the WebKit inspector/developer tools. | |
| @@ -1580,6 +1693,20 @@ func (wv *WebView) Print() error { | |||
| 1580 | 1693 | return nil | |
| 1581 | 1694 | } | |
| 1582 | 1695 | ||
| 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 | + | ||
| 1583 | 1710 | // IsDestroyed returns true if the WebView has been destroyed. | |
| 1584 | 1711 | func (wv *WebView) IsDestroyed() bool { | |
| 1585 | 1712 | return wv.destroyed.Load() | |
| @@ -1606,6 +1733,60 @@ func (wv *WebView) Close() { | |||
| 1606 | 1733 | } | |
| 1607 | 1734 | } | |
| 1608 | 1735 | ||
| 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 | + | ||
| 1609 | 1790 | // DisconnectSignals disconnects all GLib signal handlers from the WebView. | |
| 1610 | 1791 | // This must be called before releasing the WebView to the pool or destroying it | |
| 1611 | 1792 | // to prevent callbacks from firing on freed/pooled WebViews. | |
| @@ -1880,10 +2061,16 @@ func (wv *WebView) shouldLogRunJSError(domain, signature string, nonFatal bool, | |||
| 1880 | 2061 | return shouldLog, count | |
| 1881 | 2062 | } | |
| 1882 | 2063 | ||
| 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). | |
| 1884 | 2071 | // This is fire-and-forget: it does not block and errors are logged asynchronously. | |
| 1885 | 2072 | // 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) { | |
| 1887 | 2074 | if wv.destroyed.Load() { | |
| 1888 | 2075 | return | |
| 1889 | 2076 | } | |
M internal/infrastructure/webkit/webview_permission_types_test.go +5 -0
| @@ -87,6 +87,11 @@ func TestClassifyPermissionRequestTypes(t *testing.T) { | |||
| 87 | 87 | kind: permissionRequestKindUnknown, | |
| 88 | 88 | expected: nil, | |
| 89 | 89 | }, | |
| 90 | + | { | |
| 91 | + | name: "website data access request", | |
| 92 | + | kind: permissionRequestKindWebsiteDataAccess, | |
| 93 | + | expected: []string{"website_data_access"}, | |
| 94 | + | }, | |
| 90 | 95 | } | |
| 91 | 96 | ||
| 92 | 97 | for _, tt := range tests { | |
M internal/ui/adapter/favicon.go +69 -50
| @@ -5,12 +5,10 @@ import ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "sync" | |
| 7 | 7 | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 9 | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 9 | - | "github.com/bnema/dumber/internal/infrastructure/favicon" | |
| 10 | - | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 11 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 12 | 11 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 13 | - | "github.com/jwijenbergh/puregotk/v4/gio" | |
| 14 | 12 | "github.com/jwijenbergh/puregotk/v4/glib" | |
| 15 | 13 | "github.com/rs/zerolog" | |
| 16 | 14 | ) | |
| @@ -18,24 +16,47 @@ import ( | |||
| 18 | 16 | // FaviconAdapter bridges the domain FaviconService to GTK by providing | |
| 19 | 17 | // gdk.Texture conversion and WebKit FaviconDatabase integration. | |
| 20 | 18 | type FaviconAdapter struct { | |
| 21 | - | service *favicon.Service | |
| 22 | - | faviconDB *webkit.FaviconDatabase | |
| 19 | + | service port.FaviconService | |
| 20 | + | faviconDB port.FaviconDatabase | |
| 23 | 21 | textureCache map[string]*gdk.Texture | |
| 24 | 22 | mu sync.RWMutex | |
| 25 | 23 | warnMu sync.Mutex | |
| 26 | 24 | 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 | |
| 27 | 31 | } | |
| 28 | 32 | ||
| 29 | 33 | type warningLogFunc func(log *zerolog.Logger, err error) | |
| 30 | 34 | ||
| 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 | + | ||
| 31 | 48 | // 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 { | |
| 34 | 51 | 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, | |
| 39 | 60 | } | |
| 40 | 61 | } | |
| 41 | 62 | ||
| @@ -55,8 +76,8 @@ func (a *FaviconAdapter) GetTexture(domain string) *gdk.Texture { | |||
| 55 | 76 | // For internal dumb:// URLs, returns the app logo texture. | |
| 56 | 77 | func (a *FaviconAdapter) GetTextureByURL(pageURL string) *gdk.Texture { | |
| 57 | 78 | // 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) | |
| 60 | 81 | } | |
| 61 | 82 | domain := domainurl.ExtractDomain(pageURL) | |
| 62 | 83 | return a.GetTexture(domain) | |
| @@ -73,7 +94,7 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||
| 73 | 94 | log := logging.FromContext(ctx) | |
| 74 | 95 | ||
| 75 | 96 | // Handle internal dumb:// scheme URLs - use app logo | |
| 76 | - | if favicon.IsInternalURL(pageURL) { | |
| 97 | + | if a.isInternalURL != nil && a.isInternalURL(pageURL) { | |
| 77 | 98 | texture := a.getOrCreateLogoTexture() | |
| 78 | 99 | callback(texture) | |
| 79 | 100 | return | |
| @@ -127,32 +148,24 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||
| 127 | 148 | }() | |
| 128 | 149 | } | |
| 129 | 150 | ||
| 130 | - | // Try WebKit FaviconDatabase if available | |
| 151 | + | // Try engine FaviconDatabase if available | |
| 131 | 152 | 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") | |
| 135 | 156 | fetchViaService() | |
| 136 | 157 | return | |
| 137 | 158 | } | |
| 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 { | |
| 143 | 162 | fetchViaService() | |
| 144 | 163 | return | |
| 145 | 164 | } | |
| 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) | |
| 153 | 168 | }) | |
| 154 | - | ||
| 155 | - | a.faviconDB.GetFavicon(pageURL, nil, &asyncCb, 0) | |
| 156 | 169 | return | |
| 157 | 170 | } | |
| 158 | 171 | ||
| @@ -233,17 +246,19 @@ func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, t | |||
| 233 | 246 | a.clearWarningDedup(savePNGWarnKey) | |
| 234 | 247 | log.Debug().Str("domain", domain).Str("path", pngPath).Msg("saved favicon PNG") | |
| 235 | 248 | ||
| 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 | + | } | |
| 247 | 262 | } | |
| 248 | 263 | return false // don't repeat | |
| 249 | 264 | }) | |
| @@ -281,7 +296,7 @@ func (a *FaviconAdapter) PreloadFromCache(pageURL string) *gdk.Texture { | |||
| 281 | 296 | ||
| 282 | 297 | // Service returns the underlying favicon service. | |
| 283 | 298 | // Used by CLI components that need disk paths. | |
| 284 | - | func (a *FaviconAdapter) Service() *favicon.Service { | |
| 299 | + | func (a *FaviconAdapter) Service() port.FaviconService { | |
| 285 | 300 | return a.service | |
| 286 | 301 | } | |
| 287 | 302 | ||
| @@ -325,7 +340,7 @@ func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) { | |||
| 325 | 340 | log := logging.FromContext(ctx) | |
| 326 | 341 | ||
| 327 | 342 | hasPNG := a.service.HasPNGOnDisk(domain) | |
| 328 | - | hasSized := a.service.HasPNGSizedOnDisk(domain, favicon.NormalizedIconSize) | |
| 343 | + | hasSized := a.service.HasPNGSizedOnDisk(domain, a.normalizedIconSize) | |
| 329 | 344 | ||
| 330 | 345 | log.Debug(). | |
| 331 | 346 | Str("domain", domain). | |
| @@ -336,7 +351,7 @@ func (a *FaviconAdapter) ensureSizedPNG(ctx context.Context, domain string) { | |||
| 336 | 351 | // Only create if original PNG exists but sized version doesn't | |
| 337 | 352 | if hasPNG && !hasSized { | |
| 338 | 353 | 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 { | |
| 340 | 355 | a.logWarningDedup(ctx, "sized-png:"+domain, err, func(log *zerolog.Logger, warnErr error) { | |
| 341 | 356 | log.Warn().Err(warnErr).Str("domain", domain).Msg("failed to create sized PNG") | |
| 342 | 357 | }) | |
| @@ -386,18 +401,22 @@ func (a *FaviconAdapter) logWarningDedup( | |||
| 386 | 401 | } | |
| 387 | 402 | ||
| 388 | 403 | // 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. | |
| 390 | 405 | func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture { | |
| 406 | + | if a.internalDomain == "" || a.getLogoBytes == nil { | |
| 407 | + | return nil | |
| 408 | + | } | |
| 409 | + | ||
| 391 | 410 | // Check cache first | |
| 392 | - | if texture := a.GetTexture(favicon.InternalDomain); texture != nil { | |
| 411 | + | if texture := a.GetTexture(a.internalDomain); texture != nil { | |
| 393 | 412 | return texture | |
| 394 | 413 | } | |
| 395 | 414 | ||
| 396 | 415 | // Create texture from logo bytes | |
| 397 | - | logoBytes := favicon.GetLogoBytes() | |
| 416 | + | logoBytes := a.getLogoBytes() | |
| 398 | 417 | texture := bytesToTexture(logoBytes) | |
| 399 | 418 | if texture != nil { | |
| 400 | - | a.setTexture(favicon.InternalDomain, texture) | |
| 419 | + | a.setTexture(a.internalDomain, texture) | |
| 401 | 420 | } | |
| 402 | 421 | return texture | |
| 403 | 422 | } | |
M internal/ui/adapter/favicon_test.go +3 -3
| @@ -8,7 +8,7 @@ import ( | |||
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | func TestFaviconWarningDedup_FirstAndRepeated(t *testing.T) { | |
| 11 | - | adapter := NewFaviconAdapter(nil, nil) | |
| 11 | + | adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{}) | |
| 12 | 12 | ||
| 13 | 13 | first, suppressed := adapter.shouldLogWarningDedup("save-png:example.com") | |
| 14 | 14 | if !first { | |
| @@ -36,7 +36,7 @@ func TestFaviconWarningDedup_FirstAndRepeated(t *testing.T) { | |||
| 36 | 36 | } | |
| 37 | 37 | ||
| 38 | 38 | func TestFaviconWarningDedup_ClearResetsState(t *testing.T) { | |
| 39 | - | adapter := NewFaviconAdapter(nil, nil) | |
| 39 | + | adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{}) | |
| 40 | 40 | key := "sized-png:example.com" | |
| 41 | 41 | ||
| 42 | 42 | first, _ := adapter.shouldLogWarningDedup(key) | |
| @@ -56,7 +56,7 @@ func TestFaviconWarningDedup_ClearResetsState(t *testing.T) { | |||
| 56 | 56 | } | |
| 57 | 57 | ||
| 58 | 58 | func TestFaviconWarningDedup_LogWarningDedupInvokesCallbackOnce(t *testing.T) { | |
| 59 | - | adapter := NewFaviconAdapter(nil, nil) | |
| 59 | + | adapter := NewFaviconAdapter(nil, nil, FaviconAdapterConfig{}) | |
| 60 | 60 | key := "save-png:example.com" | |
| 61 | 61 | var calls atomic.Int32 | |
| 62 | 62 | ||
M internal/ui/app.go +236 -190
| @@ -14,19 +14,12 @@ import ( | |||
| 14 | 14 | "github.com/bnema/dumber/internal/application/usecase" | |
| 15 | 15 | "github.com/bnema/dumber/internal/domain/entity" | |
| 16 | 16 | 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 | + | ||
| 26 | 18 | "github.com/bnema/dumber/internal/logging" | |
| 27 | 19 | "github.com/bnema/dumber/internal/ui/adapter" | |
| 28 | 20 | "github.com/bnema/dumber/internal/ui/component" | |
| 29 | 21 | "github.com/bnema/dumber/internal/ui/coordinator" | |
| 22 | + | "github.com/bnema/dumber/internal/ui/coordinator/content" | |
| 30 | 23 | "github.com/bnema/dumber/internal/ui/dialog" | |
| 31 | 24 | "github.com/bnema/dumber/internal/ui/dispatcher" | |
| 32 | 25 | "github.com/bnema/dumber/internal/ui/focus" | |
| @@ -68,12 +61,11 @@ type App struct { | |||
| 68 | 61 | tabsUC *usecase.ManageTabsUseCase | |
| 69 | 62 | ||
| 70 | 63 | // 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 | |
| 77 | 69 | ||
| 78 | 70 | // Pane management (used by coordinators) | |
| 79 | 71 | panesUC *usecase.ManagePanesUseCase | |
| @@ -99,12 +91,10 @@ type App struct { | |||
| 99 | 91 | // Floating pane sessions keyed by tab and profile session ID. | |
| 100 | 92 | floatingSessions map[floatingSessionKey]*floatingWorkspaceSession | |
| 101 | 93 | ||
| 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) | |
| 108 | 98 | faviconAdapter *adapter.FaviconAdapter | |
| 109 | 99 | ||
| 110 | 100 | // App-level toaster for system notifications (filter status, etc.) | |
| @@ -119,7 +109,7 @@ type App struct { | |||
| 119 | 109 | tabPicker *component.TabPicker | |
| 120 | 110 | tabPickerWidget layout.Widget | |
| 121 | 111 | tabPickerPaneID entity.PaneID | |
| 122 | - | snapshotService *snapshot.Service | |
| 112 | + | snapshotService port.SnapshotService | |
| 123 | 113 | ||
| 124 | 114 | // Update management | |
| 125 | 115 | updateCoord *coordinator.UpdateCoordinator | |
| @@ -134,7 +124,7 @@ type App struct { | |||
| 134 | 124 | // Accent picker for dead keys support | |
| 135 | 125 | accentPicker *component.AccentPicker | |
| 136 | 126 | insertAccentUC *usecase.InsertAccentUseCase | |
| 137 | - | accentFocusProvider *textinput.FocusProvider | |
| 127 | + | accentFocusProvider port.FocusedInputProvider | |
| 138 | 128 | ||
| 139 | 129 | // Deferred initialization - runs after first load_started to avoid blocking initial navigation | |
| 140 | 130 | deferredInitOnce sync.Once | |
| @@ -148,7 +138,7 @@ type floatingWorkspaceSession struct { | |||
| 148 | 138 | paneID entity.PaneID | |
| 149 | 139 | pane *component.FloatingPane | |
| 150 | 140 | paneView *component.PaneView | |
| 151 | - | webView *webkit.WebView | |
| 141 | + | webView port.WebView | |
| 152 | 142 | overlay layout.OverlayWidget | |
| 153 | 143 | widget layout.Widget | |
| 154 | 144 | focusWidget layout.Widget | |
| @@ -179,42 +169,34 @@ func New(deps *Dependencies) (*App, error) { | |||
| 179 | 169 | panesUC: deps.PanesUC, | |
| 180 | 170 | workspaceViews: make(map[entity.TabID]*component.WorkspaceView), | |
| 181 | 171 | 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, | |
| 187 | 173 | cancel: cancel, | |
| 188 | 174 | } | |
| 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 | |
| 218 | 200 | } | |
| 219 | 201 | ||
| 220 | 202 | return app, nil | |
| @@ -239,8 +221,8 @@ func (a *App) Run(ctx context.Context, args []string) int { | |||
| 239 | 221 | ||
| 240 | 222 | // Refresh scripts in pooled WebViews that were prewarmed before adw.Init(). | |
| 241 | 223 | // 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) | |
| 244 | 226 | } | |
| 245 | 227 | } | |
| 246 | 228 | ||
| @@ -362,33 +344,34 @@ func (a *App) applyGTKColorSchemePreference(ctx context.Context) { | |||
| 362 | 344 | ||
| 363 | 345 | func (a *App) setupPoolBackgroundColor(ctx context.Context) { | |
| 364 | 346 | 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") | |
| 370 | 349 | return | |
| 371 | 350 | } | |
| 372 | 351 | if a.deps == nil || a.deps.Theme == nil { | |
| 373 | 352 | log.Debug().Msg("theme not available; skipping webview pool background color setup") | |
| 374 | 353 | return | |
| 375 | 354 | } | |
| 376 | - | ||
| 377 | 355 | 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 | + | } | |
| 379 | 359 | log.Debug().Msg("configured webview pool background color") | |
| 380 | 360 | } | |
| 381 | 361 | ||
| 382 | 362 | 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 { | |
| 385 | 368 | return | |
| 386 | 369 | } | |
| 387 | - | a.pool.PrewarmAsync(ctx, 0) | |
| 370 | + | pool.PrewarmAsync(ctx, 0) | |
| 388 | 371 | } | |
| 389 | 372 | ||
| 390 | 373 | 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) | |
| 392 | 375 | if err != nil { | |
| 393 | 376 | return err | |
| 394 | 377 | } | |
| @@ -524,8 +507,8 @@ func (a *App) initAccentPicker(ctx context.Context) { | |||
| 524 | 507 | } | |
| 525 | 508 | } | |
| 526 | 509 | ||
| 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 | |
| 529 | 512 | ||
| 530 | 513 | // Create the use case with glib.IdleAdd for thread-safe GTK calls | |
| 531 | 514 | a.insertAccentUC = usecase.NewInsertAccentUseCase( | |
| @@ -548,12 +531,16 @@ func (a *App) initAccentPicker(ctx context.Context) { | |||
| 548 | 531 | func (a *App) registerAccentHandlers(ctx context.Context) { | |
| 549 | 532 | log := logging.FromContext(ctx) | |
| 550 | 533 | ||
| 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") | |
| 553 | 540 | return | |
| 554 | 541 | } | |
| 555 | 542 | ||
| 556 | - | if err := handlers.RegisterAccentHandlers(ctx, a.router, a.insertAccentUC); err != nil { | |
| 543 | + | if err := a.engine.RegisterAccentHandlers(ctx, a.insertAccentUC); err != nil { | |
| 557 | 544 | log.Error().Err(err).Msg("failed to register accent handlers") | |
| 558 | 545 | } else { | |
| 559 | 546 | log.Info().Msg("registered accent key handlers") | |
| @@ -563,7 +550,7 @@ func (a *App) registerAccentHandlers(ctx context.Context) { | |||
| 563 | 550 | func (a *App) initDownloadHandler(ctx context.Context) { | |
| 564 | 551 | log := logging.FromContext(ctx) | |
| 565 | 552 | ||
| 566 | - | if a.deps == nil || a.deps.WebContext == nil { | |
| 553 | + | if a.deps == nil || a.engine == nil { | |
| 567 | 554 | log.Debug().Msg("WebContext not available, skipping download handler") | |
| 568 | 555 | return | |
| 569 | 556 | } | |
| @@ -595,11 +582,12 @@ func (a *App) initDownloadHandler(ctx context.Context) { | |||
| 595 | 582 | eventAdapter := &downloadEventAdapter{app: a} | |
| 596 | 583 | ||
| 597 | 584 | // Create use case for preparing download destinations with file deduplication. | |
| 598 | - | prepareDownloadUC := usecase.NewPrepareDownloadUseCase(filesystem.New()) | |
| 585 | + | prepareDownloadUC := usecase.NewPrepareDownloadUseCase(a.deps.FileSystem) | |
| 599 | 586 | ||
| 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 | + | } | |
| 603 | 591 | ||
| 604 | 592 | log.Info().Str("path", downloadPath).Msg("download handler initialized") | |
| 605 | 593 | } | |
| @@ -629,6 +617,18 @@ func (d *downloadEventAdapter) OnDownloadEvent(ctx context.Context, event port.D | |||
| 629 | 617 | glib.IdleAdd(&cb, 0) | |
| 630 | 618 | } | |
| 631 | 619 | ||
| 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 | + | ||
| 632 | 632 | func (a *App) initKeyboardHandler(ctx context.Context) { | |
| 633 | 633 | log := logging.FromContext(ctx) | |
| 634 | 634 | ||
| @@ -637,7 +637,7 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||
| 637 | 637 | } | |
| 638 | 638 | ||
| 639 | 639 | // 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) | |
| 641 | 641 | a.keyboardHandler.SetOnAction(func(ctx context.Context, action input.Action) error { | |
| 642 | 642 | if action == input.ActionClosePane { | |
| 643 | 643 | if a.closeAndReleaseActiveFloatingPane(ctx) { | |
| @@ -677,7 +677,7 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||
| 677 | 677 | // visible after focus returns to the WebView, so visibility alone | |
| 678 | 678 | // would misroute keys. | |
| 679 | 679 | if a.accentFocusProvider != nil { | |
| 680 | - | if _, ok := a.accentFocusProvider.GetFocusedInput().(*textinput.GTKEntryTarget); ok { | |
| 680 | + | if _, ok := a.accentFocusProvider.GetFocusedInput().(port.EntryInputTarget); ok { | |
| 681 | 681 | // GTK Entry focused (omnibox or find bar): Alt-modified keys go to | |
| 682 | 682 | // shortcuts (pane navigation), other keys pass through to the widget's | |
| 683 | 683 | // own capture-phase key controller (which handles accent detection) | |
| @@ -712,7 +712,8 @@ func (a *App) initKeyboardHandler(ctx context.Context) { | |||
| 712 | 712 | a.globalShortcutHandler = input.NewGlobalShortcutHandler( | |
| 713 | 713 | ctx, | |
| 714 | 714 | a.mainWindow.Window(), | |
| 715 | - | a.deps.Config, | |
| 715 | + | &a.deps.Config.Workspace, | |
| 716 | + | &a.deps.Config.Session, | |
| 716 | 717 | a.keyboardHandler, | |
| 717 | 718 | func(ctx context.Context, action input.Action) error { | |
| 718 | 719 | return a.kbDispatcher.Dispatch(ctx, action) | |
| @@ -785,7 +786,9 @@ func (a *App) initOmniboxConfig(ctx context.Context) { | |||
| 785 | 786 | OnFocusIn: func(entry *gtk.SearchEntry) { | |
| 786 | 787 | // Set omnibox entry as the focused input for accent picker | |
| 787 | 788 | 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 | + | } | |
| 789 | 792 | } | |
| 790 | 793 | }, | |
| 791 | 794 | OnFocusOut: func() { | |
| @@ -821,7 +824,9 @@ func (a *App) initFindBarConfig(ctx context.Context) { | |||
| 821 | 824 | OnFocusIn: func(entry *gtk.SearchEntry) { | |
| 822 | 825 | // Set find bar entry as the focused input for accent picker | |
| 823 | 826 | 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 | + | } | |
| 825 | 830 | } | |
| 826 | 831 | }, | |
| 827 | 832 | OnFocusOut: func() { | |
| @@ -863,8 +868,8 @@ func (a *App) initSessionManager(ctx context.Context) { | |||
| 863 | 868 | ) | |
| 864 | 869 | } | |
| 865 | 870 | ||
| 866 | - | // Create session spawner for restoration | |
| 867 | - | spawner := desktop.NewSessionSpawner(ctx) | |
| 871 | + | // Use injected session spawner for restoration | |
| 872 | + | spawner := a.deps.SessionSpawner | |
| 868 | 873 | ||
| 869 | 874 | // Create session manager component | |
| 870 | 875 | a.sessionManager = component.NewSessionManager(ctx, component.SessionManagerConfig{ | |
| @@ -877,6 +882,10 @@ func (a *App) initSessionManager(ctx context.Context) { | |||
| 877 | 882 | }, | |
| 878 | 883 | OnOpen: func(sessionID entity.SessionID) { | |
| 879 | 884 | 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 | + | } | |
| 880 | 889 | if err := spawner.SpawnWithSession(sessionID); err != nil { | |
| 881 | 890 | log.Error().Err(err).Str("session_id", string(sessionID)).Msg("failed to spawn session") | |
| 882 | 891 | } | |
| @@ -1166,7 +1175,7 @@ func (a *App) switchToTargetTabIfConfigured(ctx context.Context, out *usecase.Mo | |||
| 1166 | 1175 | return | |
| 1167 | 1176 | } | |
| 1168 | 1177 | ||
| 1169 | - | switchToTarget := config.Get().Workspace.SwitchToTabOnMove | |
| 1178 | + | switchToTarget := a.deps.Config.Workspace.SwitchToTabOnMove | |
| 1170 | 1179 | if out.SourceTabClosed { | |
| 1171 | 1180 | switchToTarget = true | |
| 1172 | 1181 | } | |
| @@ -1226,8 +1235,8 @@ func (a *App) initTabPicker(ctx context.Context) { | |||
| 1226 | 1235 | func (a *App) initSnapshotService(ctx context.Context) { | |
| 1227 | 1236 | log := logging.FromContext(ctx) | |
| 1228 | 1237 | ||
| 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") | |
| 1231 | 1240 | return | |
| 1232 | 1241 | } | |
| 1233 | 1242 | ||
| @@ -1236,7 +1245,11 @@ func (a *App) initSnapshotService(ctx context.Context) { | |||
| 1236 | 1245 | intervalMs = a.deps.Config.Session.SnapshotIntervalMs | |
| 1237 | 1246 | } | |
| 1238 | 1247 | ||
| 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 | + | } | |
| 1240 | 1253 | a.snapshotService.Start(ctx) | |
| 1241 | 1254 | ||
| 1242 | 1255 | // Set up callback for main.go to notify when session is persisted | |
| @@ -1259,7 +1272,8 @@ func (a *App) initUpdateCoordinator(ctx context.Context) { | |||
| 1259 | 1272 | a.deps.CheckUpdateUC, | |
| 1260 | 1273 | a.deps.ApplyUpdateUC, | |
| 1261 | 1274 | a.appToaster, | |
| 1262 | - | a.deps.Config, | |
| 1275 | + | a.deps.Config.Update.EnableOnStartup, | |
| 1276 | + | a.deps.Config.Update.AutoDownload, | |
| 1263 | 1277 | ) | |
| 1264 | 1278 | ||
| 1265 | 1279 | // Start async update check | |
| @@ -1425,7 +1439,7 @@ func (a *App) runAfterFirstLoadStarted(fn func()) { | |||
| 1425 | 1439 | a.deferredInitFn = fn | |
| 1426 | 1440 | } | |
| 1427 | 1441 | ||
| 1428 | - | // triggerDeferredInit is called from ContentCoordinator on first load_started. | |
| 1442 | + | // triggerDeferredInit is called from content.Coordinator on first load_started. | |
| 1429 | 1443 | // It runs deferred initialization at LOW priority so navigation continues unblocked. | |
| 1430 | 1444 | func (a *App) triggerDeferredInit(ctx context.Context) { | |
| 1431 | 1445 | a.deferredInitOnce.Do(func() { | |
| @@ -1482,8 +1496,10 @@ func (a *App) onShutdown(ctx context.Context) { | |||
| 1482 | 1496 | for _, tabID := range tabIDs { | |
| 1483 | 1497 | a.releaseFloatingSessionsForTab(ctx, tabID) | |
| 1484 | 1498 | } | |
| 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 | + | } | |
| 1487 | 1503 | } | |
| 1488 | 1504 | // Close idle inhibitor to release D-Bus connection | |
| 1489 | 1505 | if a.deps.IdleInhibitor != nil { | |
| @@ -1495,27 +1511,15 @@ func (a *App) onShutdown(ctx context.Context) { | |||
| 1495 | 1511 | log.Info().Msg("application shutdown complete") | |
| 1496 | 1512 | } | |
| 1497 | 1513 | ||
| 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( | |
| 1517 | 1520 | ctx, | |
| 1518 | - | a.pool, | |
| 1521 | + | a.engine.Pool(), | |
| 1522 | + | a.engine.ContentInjector(), | |
| 1519 | 1523 | a.widgetFactory, | |
| 1520 | 1524 | a.faviconAdapter, | |
| 1521 | 1525 | getActiveWS, | |
| @@ -1528,17 +1532,50 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 1528 | 1532 | a.contentCoord.SetIdleInhibitor(a.deps.IdleInhibitor) | |
| 1529 | 1533 | } | |
| 1530 | 1534 | ||
| 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 | + | ||
| 1531 | 1548 | // Wire deferred init trigger - runs after first navigation starts | |
| 1532 | 1549 | a.contentCoord.SetOnFirstLoadStarted(func() { | |
| 1533 | 1550 | a.triggerDeferredInit(ctx) | |
| 1534 | 1551 | }) | |
| 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) | |
| 1535 | 1572 | ||
| 1536 | 1573 | // 2. Tab Coordinator | |
| 1537 | 1574 | 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, | |
| 1542 | 1579 | }) | |
| 1543 | 1580 | a.tabCoord.SetOnTabCreated(func(ctx context.Context, tab *entity.Tab) { | |
| 1544 | 1581 | a.createWorkspaceView(ctx, tab) | |
| @@ -1549,7 +1586,7 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 1549 | 1586 | a.tabCoord.SetOnQuit(a.Quit) | |
| 1550 | 1587 | a.tabCoord.SetOnStateChanged(a.MarkDirty) | |
| 1551 | 1588 | // 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) { | |
| 1553 | 1590 | a.attachPopupToTab(ctx, tabID, pane, wv) | |
| 1554 | 1591 | }) | |
| 1555 | 1592 | ||
| @@ -1578,13 +1615,16 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 1578 | 1615 | ||
| 1579 | 1616 | // 3. Workspace Coordinator | |
| 1580 | 1617 | 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, | |
| 1588 | 1628 | }) | |
| 1589 | 1629 | a.wsCoord.SetOnCloseLastPane(func(ctx context.Context) error { | |
| 1590 | 1630 | return a.tabCoord.Close(ctx) | |
| @@ -1592,31 +1632,24 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 1592 | 1632 | a.wsCoord.SetOnStateChanged(a.MarkDirty) | |
| 1593 | 1633 | ||
| 1594 | 1634 | // 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 { | |
| 1604 | 1637 | 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)) | |
| 1606 | 1639 | } | |
| 1607 | 1640 | a.contentCoord.SetPopupConfig( | |
| 1608 | - | a.webViewFactory, | |
| 1641 | + | a.engine.Factory(), | |
| 1609 | 1642 | &a.deps.Config.Workspace.Popups, | |
| 1610 | 1643 | a.generateID, | |
| 1611 | 1644 | ) | |
| 1612 | - | a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input coordinator.InsertPopupInput) error { | |
| 1645 | + | a.contentCoord.SetOnInsertPopup(func(ctx context.Context, input content.InsertPopupInput) error { | |
| 1613 | 1646 | return a.wsCoord.InsertPopup(ctx, input) | |
| 1614 | 1647 | }) | |
| 1615 | 1648 | a.contentCoord.SetOnClosePane(func(ctx context.Context, paneID entity.PaneID) error { | |
| 1616 | 1649 | return a.wsCoord.ClosePaneByID(ctx, paneID) | |
| 1617 | 1650 | }) | |
| 1618 | 1651 | // 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 { | |
| 1620 | 1653 | // Create a new tab with the popup pane | |
| 1621 | 1654 | tab, err := a.tabCoord.CreateWithPane(ctx, input.PopupPane, input.WebView, input.TargetURI) | |
| 1622 | 1655 | if err != nil { | |
| @@ -1695,6 +1728,7 @@ func (a *App) initCoordinators(ctx context.Context) { | |||
| 1695 | 1728 | a.navCoord, | |
| 1696 | 1729 | a.deps.ZoomUC, | |
| 1697 | 1730 | a.deps.CopyURLUC, | |
| 1731 | + | a.deps.Config.Workspace.NewPaneURL, | |
| 1698 | 1732 | ) | |
| 1699 | 1733 | a.wireKeyboardActions() | |
| 1700 | 1734 | ||
| @@ -1748,15 +1782,15 @@ func (a *App) wireWebRTCPermissionIndicator() { | |||
| 1748 | 1782 | } | |
| 1749 | 1783 | log := logging.FromContext(ctx) | |
| 1750 | 1784 | ||
| 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) { | |
| 1752 | 1786 | a.webrtcIndicator.SetOrigin(origin) | |
| 1753 | 1787 | ||
| 1754 | 1788 | switch state { | |
| 1755 | - | case coordinator.PermissionActivityRequesting: | |
| 1789 | + | case content.PermissionActivityRequesting: | |
| 1756 | 1790 | a.webrtcIndicator.MarkRequesting(permTypes) | |
| 1757 | - | case coordinator.PermissionActivityAllowed: | |
| 1791 | + | case content.PermissionActivityAllowed: | |
| 1758 | 1792 | a.webrtcIndicator.MarkAllowed(permTypes) | |
| 1759 | - | case coordinator.PermissionActivityBlocked: | |
| 1793 | + | case content.PermissionActivityBlocked: | |
| 1760 | 1794 | a.webrtcIndicator.MarkBlocked(permTypes) | |
| 1761 | 1795 | } | |
| 1762 | 1796 | ||
| @@ -1781,7 +1815,7 @@ func (a *App) wireWebRTCPermissionIndicator() { | |||
| 1781 | 1815 | // Allowed/requesting → deny future requests. | |
| 1782 | 1816 | // Blocked → allow future requests. | |
| 1783 | 1817 | decision := entity.PermissionDenied | |
| 1784 | - | if state == string(coordinator.PermissionActivityBlocked) { | |
| 1818 | + | if state == string(content.PermissionActivityBlocked) { | |
| 1785 | 1819 | decision = entity.PermissionGranted | |
| 1786 | 1820 | } | |
| 1787 | 1821 | 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. | |||
| 2054 | 2088 | // Set find bar config for this workspace view | |
| 2055 | 2089 | wsView.SetFindBarConfig(a.findBarCfg) | |
| 2056 | 2090 | // 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 | + | } | |
| 2058 | 2094 | ||
| 2059 | 2095 | wsView.SetOnPaneFocused(func(paneID entity.PaneID) { | |
| 2060 | 2096 | if a.keyboardHandler != nil && a.keyboardHandler.Mode() == input.ModeResize { | |
| @@ -2125,13 +2161,15 @@ func (a *App) getActiveWebViewTarget() port.TextInputTarget { | |||
| 2125 | 2161 | return nil | |
| 2126 | 2162 | } | |
| 2127 | 2163 | ||
| 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 | |
| 2130 | 2168 | } | |
| 2131 | 2169 | ||
| 2132 | 2170 | // attachPopupToTab attaches a popup WebView to a newly created tab. | |
| 2133 | 2171 | // 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) { | |
| 2135 | 2173 | log := logging.FromContext(ctx) | |
| 2136 | 2174 | ||
| 2137 | 2175 | wsView := a.workspaceViews[tabID] | |
| @@ -2385,11 +2423,20 @@ func (a *App) installFloatingOverlayPositioning(tabID entity.TabID, workspaceOve | |||
| 2385 | 2423 | gtkOverlay.ConnectGetChildPosition(&cb) | |
| 2386 | 2424 | } | |
| 2387 | 2425 | ||
| 2388 | - | func (a *App) currentFloatingConfig() config.FloatingPaneConfig { | |
| 2426 | + | // currentFloatingWidthPct returns the configured floating pane width percentage. | |
| 2427 | + | func (a *App) currentFloatingWidthPct() float64 { | |
| 2389 | 2428 | if a.deps != nil && a.deps.Config != nil { | |
| 2390 | - | return a.deps.Config.Workspace.FloatingPane | |
| 2429 | + | return a.deps.Config.Workspace.FloatingPane.WidthPct | |
| 2391 | 2430 | } | |
| 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 | |
| 2393 | 2440 | } | |
| 2394 | 2441 | ||
| 2395 | 2442 | func (a *App) ensureFloatingSession( | |
| @@ -2462,10 +2509,9 @@ func (a *App) ensureFloatingSession( | |||
| 2462 | 2509 | wsView.AddWorkspaceOverlayWidget(pvOverlay) | |
| 2463 | 2510 | configureFloatingOverlayMeasurement(wsView.WorkspaceOverlayWidget(), pvOverlay) | |
| 2464 | 2511 | ||
| 2465 | - | floatingCfg := a.currentFloatingConfig() | |
| 2466 | 2512 | floatingPane := component.NewFloatingPane(wsView.WorkspaceOverlayWidget(), component.FloatingPaneOptions{ | |
| 2467 | - | WidthPct: floatingCfg.WidthPct, | |
| 2468 | - | HeightPct: floatingCfg.HeightPct, | |
| 2513 | + | WidthPct: a.currentFloatingWidthPct(), | |
| 2514 | + | HeightPct: a.currentFloatingHeightPct(), | |
| 2469 | 2515 | FallbackWidth: floatingPaneFallbackWidth, | |
| 2470 | 2516 | FallbackHeight: floatingPaneFallbackHeight, | |
| 2471 | 2517 | OnNavigate: func(navCtx context.Context, url string) error { | |
| @@ -3033,29 +3079,29 @@ func (a *App) SetOmniboxOnNavigate(fn func(url string)) { | |||
| 3033 | 3079 | func (a *App) initConfigWatcher(ctx context.Context) { | |
| 3034 | 3080 | log := logging.FromContext(ctx) | |
| 3035 | 3081 | ||
| 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") | |
| 3038 | 3084 | return | |
| 3039 | 3085 | } | |
| 3040 | 3086 | ||
| 3041 | 3087 | // Start viper watcher | |
| 3042 | - | if err := a.configManager.Watch(); err != nil { | |
| 3088 | + | if err := a.deps.WatchConfig(); err != nil { | |
| 3043 | 3089 | log.Warn().Err(err).Msg("failed to start config watcher") | |
| 3044 | 3090 | return | |
| 3045 | 3091 | } | |
| 3046 | 3092 | ||
| 3047 | 3093 | // 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() { | |
| 3050 | 3096 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 3051 | - | a.applyAppearanceConfig(ctx, cfgCopy) | |
| 3097 | + | a.applyAppearanceConfig(ctx) | |
| 3052 | 3098 | // Reload keyboard shortcuts | |
| 3053 | 3099 | if a.keyboardHandler != nil { | |
| 3054 | - | a.keyboardHandler.ReloadShortcuts(ctx, cfgCopy) | |
| 3100 | + | a.keyboardHandler.ReloadShortcuts(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session) | |
| 3055 | 3101 | } | |
| 3056 | 3102 | // Reload global shortcuts (removes stale, re-registers from config) | |
| 3057 | 3103 | if a.globalShortcutHandler != nil { | |
| 3058 | - | a.globalShortcutHandler.ReloadShortcuts(ctx, cfgCopy) | |
| 3104 | + | a.globalShortcutHandler.ReloadShortcuts(ctx, &a.deps.Config.Workspace, &a.deps.Config.Session) | |
| 3059 | 3105 | } | |
| 3060 | 3106 | return false | |
| 3061 | 3107 | }) | |
| @@ -3065,52 +3111,52 @@ func (a *App) initConfigWatcher(ctx context.Context) { | |||
| 3065 | 3111 | log.Debug().Msg("config watcher initialized") | |
| 3066 | 3112 | } | |
| 3067 | 3113 | ||
| 3068 | - | func (a *App) applyAppearanceConfig(ctx context.Context, cfg *config.Config) { | |
| 3114 | + | func (a *App) applyAppearanceConfig(ctx context.Context) { | |
| 3069 | 3115 | log := logging.FromContext(ctx) | |
| 3070 | - | if cfg == nil { | |
| 3116 | + | if a.deps == nil || a.deps.Config == nil { | |
| 3071 | 3117 | return | |
| 3072 | 3118 | } | |
| 3119 | + | cfg := a.deps.Config | |
| 3073 | 3120 | ||
| 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 | + | } | |
| 3082 | 3125 | } | |
| 3083 | 3126 | ||
| 3084 | 3127 | // Apply settings to existing webviews via coordinator | |
| 3085 | 3128 | if a.contentCoord != nil { | |
| 3086 | - | a.contentCoord.ApplySettingsToAll(ctx, a.settings) | |
| 3129 | + | a.contentCoord.ApplySettingsToAll(ctx) | |
| 3087 | 3130 | } | |
| 3088 | 3131 | ||
| 3089 | 3132 | // Update GTK theme and injected WebUI theme vars | |
| 3090 | - | if a.deps != nil && a.deps.Theme != nil { | |
| 3133 | + | if a.deps.Theme != nil { | |
| 3091 | 3134 | var display *gdk.Display | |
| 3092 | 3135 | if a.mainWindow != nil && a.mainWindow.Window() != nil { | |
| 3093 | 3136 | display = a.mainWindow.Window().GetDisplay() | |
| 3094 | 3137 | } | |
| 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 | + | } | |
| 3096 | 3144 | ||
| 3097 | - | // Update find highlight CSS for future navigations | |
| 3098 | - | if a.injector != nil { | |
| 3145 | + | if inj != nil { | |
| 3099 | 3146 | 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 { | |
| 3101 | 3148 | log.Warn().Err(err).Msg("failed to update find highlight CSS") | |
| 3102 | 3149 | } | |
| 3103 | 3150 | } | |
| 3104 | 3151 | ||
| 3105 | - | prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(a.injector) | |
| 3152 | + | prepareThemeUC := usecase.NewPrepareWebUIThemeUseCase(inj) | |
| 3106 | 3153 | cssText := a.deps.Theme.GetWebUIThemeCSS() | |
| 3107 | 3154 | if err := prepareThemeUC.Execute(ctx, usecase.PrepareWebUIThemeInput{CSSVars: cssText}); err != nil { | |
| 3108 | 3155 | log.Warn().Err(err).Msg("failed to update WebUI theme CSS") | |
| 3109 | 3156 | } | |
| 3110 | 3157 | ||
| 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) | |
| 3114 | 3160 | } | |
| 3115 | 3161 | ||
| 3116 | 3162 | // Apply WebUI theme to already-loaded dumb:// pages | |
| @@ -3127,7 +3173,7 @@ func (a *App) initFilteringAsync(ctx context.Context) { | |||
| 3127 | 3173 | return | |
| 3128 | 3174 | } | |
| 3129 | 3175 | ||
| 3130 | - | a.deps.FilterManager.SetStatusCallback(func(status filtering.FilterStatus) { | |
| 3176 | + | a.deps.FilterManager.SetStatusCallback(func(status port.FilterStatus) { | |
| 3131 | 3177 | statusCopy := status // Capture for closure | |
| 3132 | 3178 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 3133 | 3179 | a.showFilterStatus(ctx, statusCopy) | |
| @@ -3139,24 +3185,24 @@ func (a *App) initFilteringAsync(ctx context.Context) { | |||
| 3139 | 3185 | } | |
| 3140 | 3186 | ||
| 3141 | 3187 | // 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) { | |
| 3143 | 3189 | log := logging.FromContext(ctx) | |
| 3144 | 3190 | ||
| 3145 | 3191 | switch status.State { | |
| 3146 | - | case filtering.StateLoading: | |
| 3192 | + | case port.FilterStateLoading: | |
| 3147 | 3193 | if a.appToaster != nil { | |
| 3148 | 3194 | a.appToaster.Show(ctx, status.Message, component.ToastInfo) | |
| 3149 | 3195 | } | |
| 3150 | - | case filtering.StateActive: | |
| 3196 | + | case port.FilterStateActive: | |
| 3151 | 3197 | // Apply filters to existing webviews that were created before filters loaded | |
| 3152 | 3198 | if a.contentCoord != nil && a.deps.FilterManager != nil { | |
| 3153 | - | a.contentCoord.ApplyFiltersToAll(ctx, a.deps.FilterManager) | |
| 3199 | + | a.contentCoord.ApplyFiltersToAll(ctx) | |
| 3154 | 3200 | log.Debug().Msg("applied filters to all existing webviews") | |
| 3155 | 3201 | } | |
| 3156 | 3202 | if a.appToaster != nil { | |
| 3157 | 3203 | a.appToaster.Show(ctx, fmt.Sprintf("Ad blocker ready (%s)", status.Version), component.ToastInfo) | |
| 3158 | 3204 | } | |
| 3159 | - | case filtering.StateError: | |
| 3205 | + | case port.FilterStateError: | |
| 3160 | 3206 | if a.appToaster != nil { | |
| 3161 | 3207 | a.appToaster.Show(ctx, "Filter load failed: "+status.Message, component.ToastError) | |
| 3162 | 3208 | } | |
M internal/ui/config_migration.go +6 -5
| @@ -4,7 +4,6 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "fmt" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 8 | 7 | "github.com/bnema/dumber/internal/logging" | |
| 9 | 8 | "github.com/bnema/dumber/internal/ui/component" | |
| 10 | 9 | ) | |
| @@ -12,10 +11,12 @@ import ( | |||
| 12 | 11 | // checkConfigMigration checks if user config needs migration and shows a toast. | |
| 13 | 12 | func (a *App) checkConfigMigration(ctx context.Context) { | |
| 14 | 13 | log := logging.FromContext(ctx) | |
| 15 | - | cfg := config.Get() | |
| 16 | 14 | ||
| 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() | |
| 19 | 20 | if err != nil { | |
| 20 | 21 | log.Debug().Err(err).Msg("config migration check failed") | |
| 21 | 22 | return | |
| @@ -31,7 +32,7 @@ func (a *App) checkConfigMigration(ctx context.Context) { | |||
| 31 | 32 | Msg("config migration available") | |
| 32 | 33 | ||
| 33 | 34 | // 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 { | |
| 35 | 36 | msg := fmt.Sprintf("Config has %d new settings. Run 'dumber config migrate'", len(result.MissingKeys)) | |
| 36 | 37 | a.appToaster.Show(ctx, msg, component.ToastInfo) | |
| 37 | 38 | } | |
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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| @@ -6,19 +6,20 @@ import ( | |||
| 6 | 6 | ||
| 7 | 7 | "github.com/stretchr/testify/assert" | |
| 8 | 8 | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | "github.com/bnema/dumber/internal/application/port/mocks" | |
| 9 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | - | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 11 | 12 | "github.com/bnema/dumber/internal/ui/component" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| 14 | - | func TestContentCoordinator_ActiveWebView_UsesOverride(t *testing.T) { | |
| 15 | + | func TestCoordinator_ActiveWebView_UsesOverride(t *testing.T) { | |
| 15 | 16 | t.Parallel() | |
| 16 | 17 | ||
| 17 | - | mainWV := &webkit.WebView{} | |
| 18 | - | floatingWV := &webkit.WebView{} | |
| 18 | + | mainWV := mocks.NewMockWebView(t) | |
| 19 | + | floatingWV := mocks.NewMockWebView(t) | |
| 19 | 20 | ||
| 20 | - | c := &ContentCoordinator{ | |
| 21 | - | webViews: map[entity.PaneID]*webkit.WebView{ | |
| 21 | + | c := &Coordinator{ | |
| 22 | + | webViews: map[entity.PaneID]port.WebView{ | |
| 22 | 23 | "main-pane": mainWV, | |
| 23 | 24 | "floating-pane": floatingWV, | |
| 24 | 25 | }, | |
| @@ -30,13 +31,13 @@ func TestContentCoordinator_ActiveWebView_UsesOverride(t *testing.T) { | |||
| 30 | 31 | assert.Equal(t, entity.PaneID("floating-pane"), c.ActivePaneID(context.Background())) | |
| 31 | 32 | } | |
| 32 | 33 | ||
| 33 | - | func TestContentCoordinator_ActiveWebView_ClearOverrideFallsBack(t *testing.T) { | |
| 34 | + | func TestCoordinator_ActiveWebView_ClearOverrideFallsBack(t *testing.T) { | |
| 34 | 35 | t.Parallel() | |
| 35 | 36 | ||
| 36 | - | mainWV := &webkit.WebView{} | |
| 37 | + | mainWV := mocks.NewMockWebView(t) | |
| 37 | 38 | ||
| 38 | - | c := &ContentCoordinator{ | |
| 39 | - | webViews: map[entity.PaneID]*webkit.WebView{ | |
| 39 | + | c := &Coordinator{ | |
| 40 | + | webViews: map[entity.PaneID]port.WebView{ | |
| 40 | 41 | "main-pane": mainWV, | |
| 41 | 42 | }, | |
| 42 | 43 | } | |
| @@ -48,16 +49,16 @@ func TestContentCoordinator_ActiveWebView_ClearOverrideFallsBack(t *testing.T) { | |||
| 48 | 49 | assert.Equal(t, entity.PaneID(""), c.ActivePaneID(context.Background())) | |
| 49 | 50 | } | |
| 50 | 51 | ||
| 51 | - | func TestContentCoordinator_ActiveWebView_ClearOverrideFallsBackToWorkspace(t *testing.T) { | |
| 52 | + | func TestCoordinator_ActiveWebView_ClearOverrideFallsBackToWorkspace(t *testing.T) { | |
| 52 | 53 | t.Parallel() | |
| 53 | 54 | ||
| 54 | 55 | ctx := context.Background() | |
| 55 | - | mainWV := &webkit.WebView{} | |
| 56 | + | mainWV := mocks.NewMockWebView(t) | |
| 56 | 57 | mainPane := entity.NewPane(entity.PaneID("main-pane")) | |
| 57 | 58 | ws := entity.NewWorkspace(entity.WorkspaceID("ws-1"), mainPane) | |
| 58 | 59 | ||
| 59 | - | c := &ContentCoordinator{ | |
| 60 | - | webViews: map[entity.PaneID]*webkit.WebView{ | |
| 60 | + | c := &Coordinator{ | |
| 61 | + | webViews: map[entity.PaneID]port.WebView{ | |
| 61 | 62 | mainPane.ID: mainWV, | |
| 62 | 63 | }, | |
| 63 | 64 | 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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| @@ -7,8 +7,8 @@ import ( | |||
| 7 | 7 | ||
| 8 | 8 | "github.com/stretchr/testify/assert" | |
| 9 | 9 | ||
| 10 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | - | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | // TestPendingReveal tests the pending reveal state management. | |
| @@ -16,7 +16,7 @@ import ( | |||
| 16 | 16 | // without needing to mock WebKit dependencies. | |
| 17 | 17 | ||
| 18 | 18 | func TestMarkPendingReveal_SetsState(t *testing.T) { | |
| 19 | - | c := &ContentCoordinator{ | |
| 19 | + | c := &Coordinator{ | |
| 20 | 20 | pendingReveal: make(map[entity.PaneID]bool), | |
| 21 | 21 | } | |
| 22 | 22 | paneID := entity.PaneID("pane-1") | |
| @@ -29,7 +29,7 @@ func TestMarkPendingReveal_SetsState(t *testing.T) { | |||
| 29 | 29 | } | |
| 30 | 30 | ||
| 31 | 31 | func TestClearPendingReveal_RemovesState(t *testing.T) { | |
| 32 | - | c := &ContentCoordinator{ | |
| 32 | + | c := &Coordinator{ | |
| 33 | 33 | pendingReveal: make(map[entity.PaneID]bool), | |
| 34 | 34 | } | |
| 35 | 35 | paneID := entity.PaneID("pane-1") | |
| @@ -47,9 +47,9 @@ func TestClearPendingReveal_RemovesState(t *testing.T) { | |||
| 47 | 47 | ||
| 48 | 48 | func TestRevealIfPending_NotPending_DoesNothing(t *testing.T) { | |
| 49 | 49 | callbackCalled := false | |
| 50 | - | c := &ContentCoordinator{ | |
| 50 | + | c := &Coordinator{ | |
| 51 | 51 | pendingReveal: make(map[entity.PaneID]bool), | |
| 52 | - | webViews: make(map[entity.PaneID]*webkit.WebView), | |
| 52 | + | webViews: make(map[entity.PaneID]port.WebView), | |
| 53 | 53 | onWebViewShown: func(paneID entity.PaneID) { | |
| 54 | 54 | callbackCalled = true | |
| 55 | 55 | }, | |
| @@ -63,9 +63,9 @@ func TestRevealIfPending_NotPending_DoesNothing(t *testing.T) { | |||
| 63 | 63 | } | |
| 64 | 64 | ||
| 65 | 65 | func TestRevealIfPending_ClearsStateOnReveal(t *testing.T) { | |
| 66 | - | c := &ContentCoordinator{ | |
| 66 | + | c := &Coordinator{ | |
| 67 | 67 | pendingReveal: make(map[entity.PaneID]bool), | |
| 68 | - | webViews: make(map[entity.PaneID]*webkit.WebView), | |
| 68 | + | webViews: make(map[entity.PaneID]port.WebView), | |
| 69 | 69 | } | |
| 70 | 70 | paneID := entity.PaneID("pane-1") | |
| 71 | 71 | ||
| @@ -82,9 +82,9 @@ func TestRevealIfPending_ClearsStateOnReveal(t *testing.T) { | |||
| 82 | 82 | ||
| 83 | 83 | func TestRevealIfPending_OnlyRevealsOnce(t *testing.T) { | |
| 84 | 84 | revealCount := 0 | |
| 85 | - | c := &ContentCoordinator{ | |
| 85 | + | c := &Coordinator{ | |
| 86 | 86 | pendingReveal: make(map[entity.PaneID]bool), | |
| 87 | - | webViews: make(map[entity.PaneID]*webkit.WebView), | |
| 87 | + | webViews: make(map[entity.PaneID]port.WebView), | |
| 88 | 88 | onWebViewShown: func(paneID entity.PaneID) { | |
| 89 | 89 | revealCount++ | |
| 90 | 90 | }, | |
| @@ -109,9 +109,9 @@ func TestRevealIfPending_OnlyRevealsOnce(t *testing.T) { | |||
| 109 | 109 | } | |
| 110 | 110 | ||
| 111 | 111 | func TestPendingReveal_ConcurrentAccess(t *testing.T) { | |
| 112 | - | c := &ContentCoordinator{ | |
| 112 | + | c := &Coordinator{ | |
| 113 | 113 | pendingReveal: make(map[entity.PaneID]bool), | |
| 114 | - | webViews: make(map[entity.PaneID]*webkit.WebView), | |
| 114 | + | webViews: make(map[entity.PaneID]port.WebView), | |
| 115 | 115 | } | |
| 116 | 116 | ||
| 117 | 117 | var wg sync.WaitGroup | |
| @@ -144,7 +144,7 @@ func TestPendingReveal_ConcurrentAccess(t *testing.T) { | |||
| 144 | 144 | } | |
| 145 | 145 | ||
| 146 | 146 | func TestMarkPendingReveal_MultiplePanes(t *testing.T) { | |
| 147 | - | c := &ContentCoordinator{ | |
| 147 | + | c := &Coordinator{ | |
| 148 | 148 | pendingReveal: make(map[entity.PaneID]bool), | |
| 149 | 149 | } | |
| 150 | 150 | ||
| @@ -161,7 +161,7 @@ func TestMarkPendingReveal_MultiplePanes(t *testing.T) { | |||
| 161 | 161 | } | |
| 162 | 162 | ||
| 163 | 163 | func TestClearPendingReveal_OnlyAffectsTargetPane(t *testing.T) { | |
| 164 | - | c := &ContentCoordinator{ | |
| 164 | + | c := &Coordinator{ | |
| 165 | 165 | pendingReveal: make(map[entity.PaneID]bool), | |
| 166 | 166 | } | |
| 167 | 167 | ||
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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "testing" | |
| 5 | 5 | ||
| 6 | - | webkitlib "github.com/bnema/puregotk-webkit/webkit" | |
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |
| 7 | 7 | "github.com/stretchr/testify/assert" | |
| 8 | 8 | ) | |
| 9 | 9 | ||
| 10 | 10 | 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))) | |
| 15 | 15 | } | |
| 16 | 16 | ||
| 17 | 17 | 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 := >k.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 := >k.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 | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| @@ -9,7 +9,6 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/dumber/internal/application/port" | |
| 11 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | - | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 13 | 12 | ) | |
| 14 | 13 | ||
| 15 | 14 | func TestComposeOnClose_Order(t *testing.T) { | |
| @@ -40,10 +39,10 @@ func TestComposeOnLoadChanged_Order(t *testing.T) { | |||
| 40 | 39 | var calls []string | |
| 41 | 40 | ||
| 42 | 41 | 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") }, | |
| 45 | 44 | ) | |
| 46 | - | composed(webkit.LoadCommitted) | |
| 45 | + | composed(port.LoadCommitted) | |
| 47 | 46 | ||
| 48 | 47 | assert.Equal(t, []string{"existing", "next"}, calls) | |
| 49 | 48 | } | |
| @@ -52,8 +51,8 @@ func TestHandlePopupOAuthClose_SuccessSchedulesParentRefresh(t *testing.T) { | |||
| 52 | 51 | parentPaneID := entity.PaneID("parent-pane") | |
| 53 | 52 | popupID := port.WebViewID(101) | |
| 54 | 53 | ||
| 55 | - | c := &ContentCoordinator{ | |
| 56 | - | webViews: make(map[entity.PaneID]*webkit.WebView), | |
| 54 | + | c := &Coordinator{ | |
| 55 | + | webViews: make(map[entity.PaneID]port.WebView), | |
| 57 | 56 | popupOAuth: make(map[port.WebViewID]*popupOAuthState), | |
| 58 | 57 | popupRefresh: make(map[entity.PaneID]*time.Timer), | |
| 59 | 58 | } | |
| @@ -81,8 +80,8 @@ func TestHandlePopupOAuthClose_ErrorDoesNotScheduleParentRefresh(t *testing.T) { | |||
| 81 | 80 | parentPaneID := entity.PaneID("parent-pane") | |
| 82 | 81 | popupID := port.WebViewID(102) | |
| 83 | 82 | ||
| 84 | - | c := &ContentCoordinator{ | |
| 85 | - | webViews: make(map[entity.PaneID]*webkit.WebView), | |
| 83 | + | c := &Coordinator{ | |
| 84 | + | webViews: make(map[entity.PaneID]port.WebView), | |
| 86 | 85 | popupOAuth: make(map[port.WebViewID]*popupOAuthState), | |
| 87 | 86 | popupRefresh: make(map[entity.PaneID]*time.Timer), | |
| 88 | 87 | } | |
| @@ -109,15 +108,7 @@ func TestIsOAuthURL_DetectsOAuthAuthorizeRequest(t *testing.T) { | |||
| 109 | 108 | } | |
| 110 | 109 | ||
| 111 | 110 | 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()) | |
| 121 | 112 | } | |
| 122 | 113 | ||
| 123 | 114 | 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 ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | "time" | |
| 7 | 7 | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 9 | "github.com/bnema/dumber/internal/application/usecase" | |
| 9 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | - | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 11 | 11 | "github.com/bnema/dumber/internal/logging" | |
| 12 | + | "github.com/bnema/dumber/internal/ui/coordinator/content" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| 14 | 15 | // OmniboxProvider provides access to omnibox operations. | |
| @@ -22,7 +23,7 @@ type OmniboxProvider interface { | |||
| 22 | 23 | type NavigationCoordinator struct { | |
| 23 | 24 | contextProvider func() context.Context | |
| 24 | 25 | navigateUC *usecase.NavigateUseCase | |
| 25 | - | contentCoord *ContentCoordinator | |
| 26 | + | contentCoord *content.Coordinator | |
| 26 | 27 | omniboxProvider OmniboxProvider | |
| 27 | 28 | } | |
| 28 | 29 | ||
| @@ -32,7 +33,7 @@ const faviconPreloadTimeout = 300 * time.Millisecond | |||
| 32 | 33 | func NewNavigationCoordinator( | |
| 33 | 34 | ctx context.Context, | |
| 34 | 35 | navigateUC *usecase.NavigateUseCase, | |
| 35 | - | contentCoord *ContentCoordinator, | |
| 36 | + | contentCoord *content.Coordinator, | |
| 36 | 37 | ) *NavigationCoordinator { | |
| 37 | 38 | log := logging.FromContext(ctx) | |
| 38 | 39 | log.Debug().Msg("creating navigation coordinator") | |
| @@ -210,9 +211,9 @@ func (c *NavigationCoordinator) OpenDevTools(ctx context.Context) error { | |||
| 210 | 211 | ||
| 211 | 212 | log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("opening devtools") | |
| 212 | 213 | ||
| 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 | |
| 216 | 217 | } | |
| 217 | 218 | ||
| 218 | 219 | return fmt.Errorf("webview does not support devtools") | |
| @@ -230,9 +231,9 @@ func (c *NavigationCoordinator) PrintPage(ctx context.Context) error { | |||
| 230 | 231 | ||
| 231 | 232 | log.Debug().Uint64("webview_id", uint64(wv.ID())).Msg("opening print dialog") | |
| 232 | 233 | ||
| 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 | |
| 236 | 237 | } | |
| 237 | 238 | ||
| 238 | 239 | return fmt.Errorf("webview does not support printing") | |
| @@ -269,7 +270,7 @@ func (c *NavigationCoordinator) ClearPaneHistory(paneID entity.PaneID) { | |||
| 269 | 270 | } | |
| 270 | 271 | ||
| 271 | 272 | // 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 { | |
| 273 | 274 | return c.contentCoord.ActiveWebView(ctx) | |
| 274 | 275 | } | |
| 275 | 276 | ||
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 | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | ||
| 6 | + | "github.com/bnema/dumber/internal/application/port" | |
| 6 | 7 | "github.com/bnema/dumber/internal/application/usecase" | |
| 7 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 9 | - | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 10 | 9 | "github.com/bnema/dumber/internal/logging" | |
| 11 | 10 | "github.com/bnema/dumber/internal/ui/component" | |
| 12 | 11 | "github.com/bnema/dumber/internal/ui/window" | |
| @@ -14,25 +13,25 @@ import ( | |||
| 14 | 13 | ||
| 15 | 14 | // TabCoordinator manages tab lifecycle operations. | |
| 16 | 15 | 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 | |
| 21 | 20 | ||
| 22 | 21 | // Callbacks to avoid circular dependencies | |
| 23 | 22 | onTabCreated func(ctx context.Context, tab *entity.Tab) | |
| 24 | 23 | onTabSwitched func(ctx context.Context, tab *entity.Tab) | |
| 25 | 24 | 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 | |
| 28 | 27 | } | |
| 29 | 28 | ||
| 30 | 29 | // TabCoordinatorConfig holds configuration for TabCoordinator. | |
| 31 | 30 | 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 | |
| 36 | 35 | } | |
| 37 | 36 | ||
| 38 | 37 | // NewTabCoordinator creates a new TabCoordinator. | |
| @@ -41,10 +40,10 @@ func NewTabCoordinator(ctx context.Context, cfg TabCoordinatorConfig) *TabCoordi | |||
| 41 | 40 | log.Debug().Msg("creating tab coordinator") | |
| 42 | 41 | ||
| 43 | 42 | 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, | |
| 48 | 47 | } | |
| 49 | 48 | } | |
| 50 | 49 | ||
| @@ -304,10 +303,7 @@ func (c *TabCoordinator) UpdateBarVisibility(ctx context.Context) { | |||
| 304 | 303 | log := logging.FromContext(ctx) | |
| 305 | 304 | ||
| 306 | 305 | // Check if feature is enabled | |
| 307 | - | hideEnabled := true | |
| 308 | - | if c.config != nil { | |
| 309 | - | hideEnabled = c.config.Workspace.HideTabBarWhenSingleTab | |
| 310 | - | } | |
| 306 | + | hideEnabled := c.hideTabBarWhenSingleTab | |
| 311 | 307 | ||
| 312 | 308 | if !hideEnabled { | |
| 313 | 309 | log.Debug().Msg("tab bar auto-hide disabled by config") | |
| @@ -335,7 +331,7 @@ func (c *TabCoordinator) GetTabBar() *component.TabBar { | |||
| 335 | 331 | } | |
| 336 | 332 | ||
| 337 | 333 | // 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)) { | |
| 339 | 335 | c.onAttachPopupToTab = fn | |
| 340 | 336 | } | |
| 341 | 337 | ||
| @@ -344,7 +340,7 @@ func (c *TabCoordinator) SetOnAttachPopupToTab(fn func(ctx context.Context, tabI | |||
| 344 | 340 | func (c *TabCoordinator) CreateWithPane( | |
| 345 | 341 | ctx context.Context, | |
| 346 | 342 | pane *entity.Pane, | |
| 347 | - | wv *webkit.WebView, | |
| 343 | + | wv port.WebView, | |
| 348 | 344 | initialURL string, | |
| 349 | 345 | ) (*entity.Tab, error) { | |
| 350 | 346 | log := logging.FromContext(ctx) | |
| @@ -387,6 +383,9 @@ func (c *TabCoordinator) CreateWithPane( | |||
| 387 | 383 | c.onTabSwitched(ctx, output.Tab) | |
| 388 | 384 | } | |
| 389 | 385 | ||
| 386 | + | // Notify state change for session snapshots | |
| 387 | + | c.notifyStateChanged() | |
| 388 | + | ||
| 390 | 389 | log.Debug(). | |
| 391 | 390 | Str("tab_id", string(output.Tab.ID)). | |
| 392 | 391 | Str("pane_id", string(pane.ID)). | |
M internal/ui/coordinator/update.go +25 -32
| @@ -9,44 +9,47 @@ import ( | |||
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/dumber/internal/application/usecase" | |
| 11 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 13 | 12 | "github.com/bnema/dumber/internal/logging" | |
| 14 | 13 | "github.com/bnema/dumber/internal/ui/component" | |
| 15 | 14 | ) | |
| 16 | 15 | ||
| 17 | 16 | // UpdateCoordinator handles update checking and notification. | |
| 18 | 17 | 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 | |
| 26 | 26 | } | |
| 27 | 27 | ||
| 28 | 28 | // 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. | |
| 29 | 31 | func NewUpdateCoordinator( | |
| 30 | 32 | checkUC *usecase.CheckUpdateUseCase, | |
| 31 | 33 | applyUC *usecase.ApplyUpdateUseCase, | |
| 32 | 34 | toaster *component.Toaster, | |
| 33 | - | cfg *config.Config, | |
| 35 | + | enableOnStartup bool, | |
| 36 | + | autoDownload bool, | |
| 34 | 37 | ) *UpdateCoordinator { | |
| 35 | 38 | 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, | |
| 41 | 45 | } | |
| 42 | 46 | } | |
| 43 | 47 | ||
| 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. | |
| 46 | 49 | func (c *UpdateCoordinator) CheckOnStartup(ctx context.Context) { | |
| 47 | 50 | log := logging.FromContext(ctx) | |
| 48 | 51 | ||
| 49 | - | if !c.config.Update.EnableOnStartup { | |
| 52 | + | if !c.enableOnStartup { | |
| 50 | 53 | log.Debug().Msg("update check on startup disabled") | |
| 51 | 54 | return | |
| 52 | 55 | } | |
| @@ -54,7 +57,6 @@ func (c *UpdateCoordinator) CheckOnStartup(ctx context.Context) { | |||
| 54 | 57 | go c.checkAsync(ctx) | |
| 55 | 58 | } | |
| 56 | 59 | ||
| 57 | - | // checkAsync performs the update check in a goroutine. | |
| 58 | 60 | func (c *UpdateCoordinator) checkAsync(ctx context.Context) { | |
| 59 | 61 | log := logging.FromContext(ctx) | |
| 60 | 62 | ||
| @@ -67,7 +69,6 @@ func (c *UpdateCoordinator) checkAsync(ctx context.Context) { | |||
| 67 | 69 | return | |
| 68 | 70 | } | |
| 69 | 71 | ||
| 70 | - | // Update both lastInfo and status atomically to avoid inconsistent reads. | |
| 71 | 72 | c.mu.Lock() | |
| 72 | 73 | c.lastInfo = result | |
| 73 | 74 | if !result.UpdateAvailable { | |
| @@ -86,27 +87,22 @@ func (c *UpdateCoordinator) checkAsync(ctx context.Context) { | |||
| 86 | 87 | Bool("can_auto_update", result.CanAutoUpdate). | |
| 87 | 88 | Msg("update available") | |
| 88 | 89 | ||
| 89 | - | // Show notification on GTK main thread. | |
| 90 | 90 | c.showUpdateNotification(ctx, result) | |
| 91 | 91 | ||
| 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 { | |
| 95 | 93 | go c.downloadAsync(ctx, result.DownloadURL) | |
| 96 | 94 | } | |
| 97 | 95 | } | |
| 98 | 96 | ||
| 99 | - | // showUpdateNotification displays a toast notification about the update. | |
| 100 | 97 | func (c *UpdateCoordinator) showUpdateNotification(ctx context.Context, result *usecase.CheckUpdateOutput) { | |
| 101 | 98 | var msg string | |
| 102 | 99 | switch { | |
| 103 | - | case result.CanAutoUpdate && c.config.Update.AutoDownload: | |
| 100 | + | case result.CanAutoUpdate && c.autoDownload: | |
| 104 | 101 | msg = fmt.Sprintf("Downloading update %s...", result.LatestVersion) | |
| 105 | 102 | default: | |
| 106 | 103 | msg = fmt.Sprintf("Update %s available", result.LatestVersion) | |
| 107 | 104 | } | |
| 108 | 105 | ||
| 109 | - | // Dispatch to GTK main thread. | |
| 110 | 106 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 111 | 107 | c.toaster.Show(ctx, msg, component.ToastInfo) | |
| 112 | 108 | return false | |
| @@ -114,7 +110,6 @@ func (c *UpdateCoordinator) showUpdateNotification(ctx context.Context, result * | |||
| 114 | 110 | glib.IdleAdd(&cb, 0) | |
| 115 | 111 | } | |
| 116 | 112 | ||
| 117 | - | // downloadAsync downloads and stages the update in the background. | |
| 118 | 113 | func (c *UpdateCoordinator) downloadAsync(ctx context.Context, downloadURL string) { | |
| 119 | 114 | log := logging.FromContext(ctx) | |
| 120 | 115 | ||
| @@ -148,7 +143,6 @@ func (c *UpdateCoordinator) downloadAsync(ctx context.Context, downloadURL strin | |||
| 148 | 143 | } | |
| 149 | 144 | } | |
| 150 | 145 | ||
| 151 | - | // showToast dispatches a toast notification to the GTK main thread. | |
| 152 | 146 | func (c *UpdateCoordinator) showToast(ctx context.Context, msg string, level component.ToastLevel) { | |
| 153 | 147 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 154 | 148 | c.toaster.Show(ctx, msg, level) | |
| @@ -157,8 +151,7 @@ func (c *UpdateCoordinator) showToast(ctx context.Context, msg string, level com | |||
| 157 | 151 | glib.IdleAdd(&cb, 0) | |
| 158 | 152 | } | |
| 159 | 153 | ||
| 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. | |
| 162 | 155 | func (c *UpdateCoordinator) FinalizeOnExit(ctx context.Context) error { | |
| 163 | 156 | if c.applyUC == nil { | |
| 164 | 157 | return nil | |
| @@ -173,14 +166,14 @@ func (c *UpdateCoordinator) Status() entity.UpdateStatus { | |||
| 173 | 166 | return c.status | |
| 174 | 167 | } | |
| 175 | 168 | ||
| 176 | - | // LastCheckResult returns the result of the last update check. | |
| 169 | + | // LastCheckResult returns the most recent update check result. | |
| 177 | 170 | func (c *UpdateCoordinator) LastCheckResult() *usecase.CheckUpdateOutput { | |
| 178 | 171 | c.mu.RLock() | |
| 179 | 172 | defer c.mu.RUnlock() | |
| 180 | 173 | return c.lastInfo | |
| 181 | 174 | } | |
| 182 | 175 | ||
| 183 | - | // HasPendingUpdate returns true if an update is staged for exit. | |
| 176 | + | // HasPendingUpdate returns true if a downloaded update is waiting to be applied. | |
| 184 | 177 | func (c *UpdateCoordinator) HasPendingUpdate(ctx context.Context) bool { | |
| 185 | 178 | if c.applyUC == nil { | |
| 186 | 179 | return false | |
M internal/ui/coordinator/workspace.go +74 -39
| @@ -8,9 +8,9 @@ import ( | |||
| 8 | 8 | "github.com/bnema/dumber/internal/application/usecase" | |
| 9 | 9 | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | 10 | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 11 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 12 | 11 | "github.com/bnema/dumber/internal/logging" | |
| 13 | 12 | "github.com/bnema/dumber/internal/ui/component" | |
| 13 | + | "github.com/bnema/dumber/internal/ui/coordinator/content" | |
| 14 | 14 | "github.com/bnema/dumber/internal/ui/focus" | |
| 15 | 15 | "github.com/bnema/dumber/internal/ui/layout" | |
| 16 | 16 | "github.com/rs/zerolog" | |
| @@ -27,26 +27,34 @@ type WorkspaceCoordinator struct { | |||
| 27 | 27 | focusMgr *focus.Manager | |
| 28 | 28 | stackedPaneMgr *component.StackedPaneManager | |
| 29 | 29 | 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 | |
| 31 | 36 | ||
| 32 | 37 | // Callbacks to avoid circular dependencies | |
| 33 | 38 | getActiveWS func() (*entity.Workspace, *component.WorkspaceView) | |
| 34 | 39 | generateID func() string | |
| 35 | 40 | 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 | |
| 39 | 44 | } | |
| 40 | 45 | ||
| 41 | 46 | // WorkspaceCoordinatorConfig holds configuration for WorkspaceCoordinator. | |
| 42 | 47 | 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 | |
| 50 | 58 | } | |
| 51 | 59 | ||
| 52 | 60 | type splitContext struct { | |
| @@ -75,19 +83,43 @@ type incrementalCloseContext struct { | |||
| 75 | 83 | precheckReason string | |
| 76 | 84 | } | |
| 77 | 85 | ||
| 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 | + | ||
| 78 | 107 | // NewWorkspaceCoordinator creates a new WorkspaceCoordinator. | |
| 79 | 108 | func NewWorkspaceCoordinator(ctx context.Context, cfg WorkspaceCoordinatorConfig) *WorkspaceCoordinator { | |
| 80 | 109 | log := logging.FromContext(ctx) | |
| 81 | 110 | log.Debug().Msg("creating workspace coordinator") | |
| 82 | 111 | ||
| 83 | 112 | 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), | |
| 91 | 123 | } | |
| 92 | 124 | } | |
| 93 | 125 | ||
| @@ -143,7 +175,7 @@ func (c *WorkspaceCoordinator) Split(ctx context.Context, direction usecase.Spli | |||
| 143 | 175 | Workspace: splitCtx.ws, | |
| 144 | 176 | TargetPane: splitCtx.activePane, | |
| 145 | 177 | Direction: direction, | |
| 146 | - | InitialURL: domainurl.Normalize(config.Get().Workspace.NewPaneURL), | |
| 178 | + | InitialURL: domainurl.Normalize(c.newPaneURL), | |
| 147 | 179 | }) | |
| 148 | 180 | if err != nil { | |
| 149 | 181 | log.Error().Err(err).Str("direction", string(direction)).Msg("failed to split pane") | |
| @@ -475,13 +507,13 @@ func (c *WorkspaceCoordinator) doIncrementalSplit( | |||
| 475 | 507 | // 2. Get the active pane's StackedView widget (what we're actually splitting) | |
| 476 | 508 | tr := wsView.TreeRenderer() | |
| 477 | 509 | if tr == nil { | |
| 478 | - | return nil | |
| 510 | + | return fmt.Errorf("tree renderer not available") | |
| 479 | 511 | } | |
| 480 | 512 | ||
| 481 | 513 | activeStackedView := tr.GetStackedViewForPane(string(oldActivePaneID)) | |
| 482 | 514 | if activeStackedView == nil { | |
| 483 | 515 | log.Warn().Msg("no stacked view found for active pane") | |
| 484 | - | return nil | |
| 516 | + | return fmt.Errorf("no stacked view found for active pane") | |
| 485 | 517 | } | |
| 486 | 518 | activePaneWidget := activeStackedView.Widget() | |
| 487 | 519 | ||
| @@ -511,7 +543,7 @@ func (c *WorkspaceCoordinator) doIncrementalSplit( | |||
| 511 | 543 | ||
| 512 | 544 | orientation, existingFirst, ok := splitOrientation(direction) | |
| 513 | 545 | if !ok { | |
| 514 | - | return nil | |
| 546 | + | return fmt.Errorf("unsupported split direction: %s", direction) | |
| 515 | 547 | } | |
| 516 | 548 | ||
| 517 | 549 | // 7. Handle root vs non-root split differently | |
| @@ -1180,7 +1212,7 @@ func (c *WorkspaceCoordinator) doIncrementalStackClose( | |||
| 1180 | 1212 | // Release the closing pane's webview | |
| 1181 | 1213 | c.contentCoord.ReleaseWebView(ctx, closingPaneID) | |
| 1182 | 1214 | ||
| 1183 | - | // Sync remaining title bars with current titles from ContentCoordinator. | |
| 1215 | + | // Sync remaining title bars with current titles from content.Coordinator. | |
| 1184 | 1216 | // The domain model (stackNode.Children) has already been updated by the use case, | |
| 1185 | 1217 | // so we iterate the remaining children and update their titles in the UI. | |
| 1186 | 1218 | c.syncStackedPaneTitles(ctx, stackedView, stackNode) | |
| @@ -1349,7 +1381,7 @@ func (c *WorkspaceCoordinator) StackPane(ctx context.Context) error { | |||
| 1349 | 1381 | // Create new pane entity | |
| 1350 | 1382 | newPaneID := entity.PaneID(c.generateID()) | |
| 1351 | 1383 | newPane := entity.NewPane(newPaneID) | |
| 1352 | - | newPane.URI = domainurl.Normalize(config.Get().Workspace.NewPaneURL) | |
| 1384 | + | newPane.URI = domainurl.Normalize(c.newPaneURL) | |
| 1353 | 1385 | newPane.Title = defaultPaneTitle | |
| 1354 | 1386 | ||
| 1355 | 1387 | // 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 | |||
| 1756 | 1788 | ||
| 1757 | 1789 | // SetOnCreatePopupTab sets the callback for creating popup tabs. | |
| 1758 | 1790 | // 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) { | |
| 1760 | 1792 | c.onCreatePopupTab = fn | |
| 1761 | 1793 | } | |
| 1762 | 1794 | ||
| 1763 | 1795 | // InsertPopup inserts a popup pane into the workspace based on the specified behavior. | |
| 1764 | 1796 | // 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 { | |
| 1766 | 1798 | log := logging.FromContext(ctx) | |
| 1767 | 1799 | ||
| 1768 | 1800 | log.Debug(). | |
| @@ -1774,11 +1806,11 @@ func (c *WorkspaceCoordinator) InsertPopup(ctx context.Context, input InsertPopu | |||
| 1774 | 1806 | Msg("inserting popup into workspace") | |
| 1775 | 1807 | ||
| 1776 | 1808 | switch input.Behavior { | |
| 1777 | - | case config.PopupBehaviorSplit: | |
| 1809 | + | case entity.PopupBehaviorSplit: | |
| 1778 | 1810 | return c.insertPopupSplit(ctx, input) | |
| 1779 | - | case config.PopupBehaviorStacked: | |
| 1811 | + | case entity.PopupBehaviorStacked: | |
| 1780 | 1812 | return c.insertPopupStacked(ctx, input) | |
| 1781 | - | case config.PopupBehaviorTabbed: | |
| 1813 | + | case entity.PopupBehaviorTabbed: | |
| 1782 | 1814 | return c.insertPopupTabbed(ctx, input) | |
| 1783 | 1815 | default: | |
| 1784 | 1816 | // Default to split right | |
| @@ -1787,7 +1819,7 @@ func (c *WorkspaceCoordinator) InsertPopup(ctx context.Context, input InsertPopu | |||
| 1787 | 1819 | } | |
| 1788 | 1820 | ||
| 1789 | 1821 | // 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 { | |
| 1791 | 1823 | log := logging.FromContext(ctx) | |
| 1792 | 1824 | ||
| 1793 | 1825 | ws, wsView := c.getActiveWS() | |
| @@ -1830,6 +1862,8 @@ func (c *WorkspaceCoordinator) insertPopupSplit(ctx context.Context, input Inser | |||
| 1830 | 1862 | c.attachPopupWebView(ctx, wsView, input) | |
| 1831 | 1863 | } | |
| 1832 | 1864 | ||
| 1865 | + | c.notifyStateChanged() | |
| 1866 | + | ||
| 1833 | 1867 | log.Info(). | |
| 1834 | 1868 | Str("popup_pane", string(input.PopupPane.ID)). | |
| 1835 | 1869 | Str("direction", string(direction)). | |
| @@ -1882,7 +1916,7 @@ func (c *WorkspaceCoordinator) resolvePopupSplitWidget( | |||
| 1882 | 1916 | func (c *WorkspaceCoordinator) attachPopupWebView( | |
| 1883 | 1917 | ctx context.Context, | |
| 1884 | 1918 | wsView *component.WorkspaceView, | |
| 1885 | - | input InsertPopupInput, | |
| 1919 | + | input content.InsertPopupInput, | |
| 1886 | 1920 | ) { | |
| 1887 | 1921 | if wsView == nil || input.WebView == nil { | |
| 1888 | 1922 | return | |
| @@ -1902,7 +1936,7 @@ func (c *WorkspaceCoordinator) attachPopupWebView( | |||
| 1902 | 1936 | ||
| 1903 | 1937 | // insertPopupStacked inserts a popup as a stacked pane on top of the parent. | |
| 1904 | 1938 | // 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 { | |
| 1906 | 1940 | log := logging.FromContext(ctx) | |
| 1907 | 1941 | ||
| 1908 | 1942 | ws, wsView := c.getActiveWS() | |
| @@ -1945,6 +1979,8 @@ func (c *WorkspaceCoordinator) insertPopupStacked(ctx context.Context, input Ins | |||
| 1945 | 1979 | return err | |
| 1946 | 1980 | } | |
| 1947 | 1981 | ||
| 1982 | + | c.notifyStateChanged() | |
| 1983 | + | ||
| 1948 | 1984 | log.Info(). | |
| 1949 | 1985 | Str("popup_pane", string(input.PopupPane.ID)). | |
| 1950 | 1986 | Int("stack_size", len(stackNode.Children)). | |
| @@ -2040,7 +2076,7 @@ func (c *WorkspaceCoordinator) resolveOrCreateStackNode( | |||
| 2040 | 2076 | ||
| 2041 | 2077 | func (c *WorkspaceCoordinator) attachPopupPaneView( | |
| 2042 | 2078 | ctx context.Context, | |
| 2043 | - | input InsertPopupInput, | |
| 2079 | + | input content.InsertPopupInput, | |
| 2044 | 2080 | wsView *component.WorkspaceView, | |
| 2045 | 2081 | stackNode *entity.PaneNode, | |
| 2046 | 2082 | log *zerolog.Logger, | |
| @@ -2053,6 +2089,7 @@ func (c *WorkspaceCoordinator) attachPopupPaneView( | |||
| 2053 | 2089 | wsView.RegisterPaneView(input.PopupPane.ID, newPaneView) | |
| 2054 | 2090 | ||
| 2055 | 2091 | if err := c.stackedPaneMgr.AddPaneToStack(ctx, wsView, input.ParentPaneID, newPaneView, input.PopupPane.Title); err != nil { | |
| 2092 | + | wsView.UnregisterPaneView(input.PopupPane.ID) | |
| 2056 | 2093 | log.Error().Err(err).Msg("failed to add popup pane to stack") | |
| 2057 | 2094 | return err | |
| 2058 | 2095 | } | |
| @@ -2085,7 +2122,7 @@ func (c *WorkspaceCoordinator) attachPopupPaneView( | |||
| 2085 | 2122 | } | |
| 2086 | 2123 | ||
| 2087 | 2124 | // 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 { | |
| 2089 | 2126 | log := logging.FromContext(ctx) | |
| 2090 | 2127 | ||
| 2091 | 2128 | if c.onCreatePopupTab == nil { | |
| @@ -2196,8 +2233,7 @@ func (c *WorkspaceCoordinator) Resize(ctx context.Context, dir usecase.ResizeDir | |||
| 2196 | 2233 | return nil | |
| 2197 | 2234 | } | |
| 2198 | 2235 | ||
| 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) | |
| 2201 | 2237 | if errors.Is(err, usecase.ErrNothingToResize) { | |
| 2202 | 2238 | c.ShowToastOnActivePane(ctx, "Nothing to resize", component.ToastInfo) | |
| 2203 | 2239 | return nil | |
| @@ -2228,12 +2264,11 @@ func (c *WorkspaceCoordinator) SetSplitRatio(ctx context.Context, splitNodeID st | |||
| 2228 | 2264 | return nil | |
| 2229 | 2265 | } | |
| 2230 | 2266 | ||
| 2231 | - | cfg := config.Get() | |
| 2232 | 2267 | err := c.panesUC.SetSplitRatio(ctx, usecase.SetSplitRatioInput{ | |
| 2233 | 2268 | Workspace: ws, | |
| 2234 | 2269 | SplitNodeID: splitNodeID, | |
| 2235 | 2270 | Ratio: ratio, | |
| 2236 | - | MinPanePercent: cfg.Workspace.ResizeMode.MinPanePercent, | |
| 2271 | + | MinPanePercent: c.resizeMinPanePercent, | |
| 2237 | 2272 | }) | |
| 2238 | 2273 | if err != nil { | |
| 2239 | 2274 | return err | |
M internal/ui/deps.go +41 -21
| @@ -8,12 +8,10 @@ import ( | |||
| 8 | 8 | "github.com/bnema/dumber/internal/application/usecase" | |
| 9 | 9 | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | 10 | "github.com/bnema/dumber/internal/domain/repository" | |
| 11 | - | "github.com/bnema/dumber/internal/infrastructure/colorscheme" | |
| 12 | 11 | "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" | |
| 16 | 13 | "github.com/bnema/dumber/internal/ui/theme" | |
| 14 | + | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| 17 | 15 | ) | |
| 18 | 16 | ||
| 19 | 17 | // Dependencies holds all injected dependencies for the UI layer. | |
| @@ -21,9 +19,9 @@ import ( | |||
| 21 | 19 | type Dependencies struct { | |
| 22 | 20 | // Core context and configuration | |
| 23 | 21 | 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) | |
| 27 | 25 | StartupCrashReports []string | |
| 28 | 26 | OnFirstWebViewShown func(context.Context) | |
| 29 | 27 | OnSessionPersisted func() // Called by main after session is persisted to DB | |
| @@ -32,18 +30,13 @@ type Dependencies struct { | |||
| 32 | 30 | // Theme and color scheme management | |
| 33 | 31 | Theme *theme.Manager | |
| 34 | 32 | ColorResolver port.ColorSchemeResolver | |
| 35 | - | AdwaitaDetector *colorscheme.AdwaitaDetector | |
| 33 | + | AdwaitaDetector port.ToolkitAvailabilityNotifier | |
| 36 | 34 | ||
| 37 | 35 | // XDG paths | |
| 38 | 36 | XDG port.XDGPaths | |
| 39 | 37 | ||
| 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 | |
| 47 | 40 | ||
| 48 | 41 | // Repositories | |
| 49 | 42 | HistoryRepo repository.HistoryRepository | |
| @@ -63,23 +56,48 @@ type Dependencies struct { | |||
| 63 | 56 | CopyURLUC *usecase.CopyURLUseCase | |
| 64 | 57 | ||
| 65 | 58 | // 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 | |
| 70 | 64 | ||
| 71 | 65 | // 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 | |
| 73 | 71 | ||
| 74 | 72 | // Session management | |
| 75 | 73 | SessionStateRepo repository.SessionStateRepository | |
| 76 | 74 | SessionRepo repository.SessionRepository | |
| 77 | 75 | CurrentSessionID entity.SessionID | |
| 78 | 76 | 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 | |
| 79 | 89 | ||
| 80 | 90 | // Update management | |
| 81 | 91 | CheckUpdateUC *usecase.CheckUpdateUseCase | |
| 82 | 92 | 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) | |
| 83 | 101 | } | |
| 84 | 102 | ||
| 85 | 103 | // Validate checks that all required dependencies are set. | |
| @@ -90,7 +108,9 @@ func (d *Dependencies) Validate() error { | |||
| 90 | 108 | if d.Config == nil { | |
| 91 | 109 | return ErrMissingDependency("Config") | |
| 92 | 110 | } | |
| 93 | - | // WebKit dependencies are optional for testing without WebViews | |
| 111 | + | if d.Engine == nil { | |
| 112 | + | return ErrMissingDependency("Engine") | |
| 113 | + | } | |
| 94 | 114 | // Use cases are optional - can be nil if not needed | |
| 95 | 115 | return nil | |
| 96 | 116 | } | |
M internal/ui/dialog/permission.go +88 -50
| @@ -4,6 +4,7 @@ package dialog | |||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | 6 | "fmt" | |
| 7 | + | "strings" | |
| 7 | 8 | "sync" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/dumber/internal/application/port" | |
| @@ -125,78 +126,115 @@ func (d *PermissionDialog) showNextQueuedRequest() { | |||
| 125 | 126 | d.showRequest(next) | |
| 126 | 127 | } | |
| 127 | 128 | ||
| 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 | + | } | |
| 133 | 133 | ||
| 134 | + | // parsePermFlags extracts boolean flags from permission types. | |
| 135 | + | func parsePermFlags(permTypes []entity.PermissionType) permFlags { | |
| 136 | + | var f permFlags | |
| 134 | 137 | for _, pt := range permTypes { | |
| 135 | 138 | switch pt { | |
| 136 | 139 | case entity.PermissionTypeMicrophone: | |
| 137 | - | hasMic = true | |
| 140 | + | f.mic = true | |
| 138 | 141 | case entity.PermissionTypeCamera: | |
| 139 | - | hasCam = true | |
| 142 | + | f.cam = true | |
| 140 | 143 | case entity.PermissionTypeDisplay: | |
| 141 | - | hasDisplay = true | |
| 144 | + | f.display = true | |
| 145 | + | case entity.PermissionTypeWebsiteDataAccess: | |
| 146 | + | f.dataAccess = true | |
| 142 | 147 | } | |
| 143 | 148 | } | |
| 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 | + | } | |
| 144 | 166 | ||
| 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 | + | } | |
| 145 | 185 | 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: | |
| 159 | 191 | return "Allow Screen Sharing?" | |
| 192 | + | case len(labels) == 1: | |
| 193 | + | return "Allow " + labels[0] + " Access?" | |
| 160 | 194 | default: | |
| 161 | - | return "Allow Permission?" | |
| 195 | + | return "Allow " + joinPermissionLabels(labels) + "?" | |
| 162 | 196 | } | |
| 163 | 197 | } | |
| 164 | 198 | ||
| 165 | 199 | // 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") | |
| 179 | 221 | } | |
| 180 | 222 | } | |
| 181 | 223 | ||
| 182 | 224 | var action string | |
| 183 | 225 | 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: | |
| 199 | 227 | 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] | |
| 200 | 238 | } | |
| 201 | 239 | ||
| 202 | 240 | 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) | |||
| 142 | 142 | expectHeading: "Allow Microphone, Camera, and Screen Sharing?", | |
| 143 | 143 | expectedAction: "access your microphone and camera, and share your screen", | |
| 144 | 144 | }, | |
| 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 | + | }, | |
| 145 | 151 | } | |
| 146 | 152 | ||
| 147 | 153 | for _, tt := range tests { | |
M internal/ui/dispatcher/keyboard.go +13 -8
| @@ -7,7 +7,6 @@ import ( | |||
| 7 | 7 | "github.com/bnema/dumber/internal/application/usecase" | |
| 8 | 8 | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | 9 | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 10 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 11 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 12 | 11 | "github.com/bnema/dumber/internal/ui/component" | |
| 13 | 12 | "github.com/bnema/dumber/internal/ui/coordinator" | |
| @@ -22,6 +21,7 @@ type KeyboardDispatcher struct { | |||
| 22 | 21 | navCoord *coordinator.NavigationCoordinator | |
| 23 | 22 | zoomUC *usecase.ManageZoomUseCase | |
| 24 | 23 | copyURLUC *usecase.CopyURLUseCase | |
| 24 | + | newPaneURL string | |
| 25 | 25 | actionHandlers map[input.Action]func(ctx context.Context) error | |
| 26 | 26 | onQuit func() | |
| 27 | 27 | onFindOpen func(ctx context.Context) error | |
| @@ -43,16 +43,18 @@ func NewKeyboardDispatcher( | |||
| 43 | 43 | navCoord *coordinator.NavigationCoordinator, | |
| 44 | 44 | zoomUC *usecase.ManageZoomUseCase, | |
| 45 | 45 | copyURLUC *usecase.CopyURLUseCase, | |
| 46 | + | newPaneURL string, | |
| 46 | 47 | ) *KeyboardDispatcher { | |
| 47 | 48 | log := logging.FromContext(ctx) | |
| 48 | 49 | log.Debug().Msg("creating keyboard dispatcher") | |
| 49 | 50 | ||
| 50 | 51 | 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, | |
| 56 | 58 | } | |
| 57 | 59 | dispatcher.initActionHandlers() | |
| 58 | 60 | return dispatcher | |
| @@ -130,8 +132,11 @@ func (d *KeyboardDispatcher) initActionHandlers() { | |||
| 130 | 132 | d.actionHandlers = map[input.Action]func(ctx context.Context) error{ | |
| 131 | 133 | // Tab actions | |
| 132 | 134 | 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)) | |
| 135 | 140 | return err | |
| 136 | 141 | }, | |
| 137 | 142 | input.ActionCloseTab: d.tabCoord.Close, | |
M internal/ui/input/floating_shortcuts_test.go +35 -21
| @@ -4,15 +4,29 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | 8 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 9 | 9 | "github.com/stretchr/testify/assert" | |
| 10 | 10 | "github.com/stretchr/testify/require" | |
| 11 | 11 | ) | |
| 12 | 12 | ||
| 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 | + | ||
| 13 | 27 | 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) | |
| 16 | 30 | ||
| 17 | 31 | action, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_f), Modifiers: ModAlt}, ModeNormal) | |
| 18 | 32 | require.True(t, ok) | |
| @@ -20,8 +34,8 @@ func TestShortcutSet_GlobalToggleFloatingPane(t *testing.T) { | |||
| 20 | 34 | } | |
| 21 | 35 | ||
| 22 | 36 | 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{ | |
| 25 | 39 | "google": { | |
| 26 | 40 | Keys: []string{"alt+g"}, | |
| 27 | 41 | URL: "https://google.com", | |
| @@ -32,7 +46,7 @@ func TestShortcutSet_FloatingProfiles_RegisterMultiple(t *testing.T) { | |||
| 32 | 46 | }, | |
| 33 | 47 | } | |
| 34 | 48 | ||
| 35 | - | set := NewShortcutSet(context.Background(), cfg) | |
| 49 | + | set := NewShortcutSet(context.Background(), ws, nil) | |
| 36 | 50 | ||
| 37 | 51 | googleAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_g), Modifiers: ModAlt}, ModeNormal) | |
| 38 | 52 | require.True(t, ok) | |
| @@ -50,8 +64,8 @@ func TestShortcutSet_FloatingProfiles_RegisterMultiple(t *testing.T) { | |||
| 50 | 64 | } | |
| 51 | 65 | ||
| 52 | 66 | 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{ | |
| 55 | 69 | "tab-conflict": { | |
| 56 | 70 | Keys: []string{"alt+1"}, | |
| 57 | 71 | URL: "https://example.com/tab", | |
| @@ -71,7 +85,7 @@ func TestCollectFloatingProfileShortcuts_SkipsConflicts(t *testing.T) { | |||
| 71 | 85 | {Keyval: uint(gdk.KEY_bracketleft), Modifiers: ModAlt}: ActionConsumeOrExpelLeft, | |
| 72 | 86 | } | |
| 73 | 87 | ||
| 74 | - | shortcuts := collectFloatingProfileShortcuts(context.Background(), cfg, occupied) | |
| 88 | + | shortcuts := collectFloatingProfileShortcutsFromWorkspace(context.Background(), ws, occupied) | |
| 75 | 89 | require.Len(t, shortcuts, 1) | |
| 76 | 90 | assert.Equal(t, KeyBinding{Keyval: uint(gdk.KEY_g), Modifiers: ModAlt}, shortcuts[0].Binding) | |
| 77 | 91 | target, ok := ParseFloatingProfileTarget(shortcuts[0].Action) | |
| @@ -81,15 +95,15 @@ func TestCollectFloatingProfileShortcuts_SkipsConflicts(t *testing.T) { | |||
| 81 | 95 | } | |
| 82 | 96 | ||
| 83 | 97 | 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{ | |
| 86 | 100 | "conflict": { | |
| 87 | 101 | Keys: []string{"alt+["}, | |
| 88 | 102 | URL: "https://example.com/override-attempt", | |
| 89 | 103 | }, | |
| 90 | 104 | } | |
| 91 | 105 | ||
| 92 | - | set := NewShortcutSet(context.Background(), cfg) | |
| 106 | + | set := NewShortcutSet(context.Background(), ws, nil) | |
| 93 | 107 | ||
| 94 | 108 | tabAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_bracketleft), Modifiers: ModAlt}, ModeNormal) | |
| 95 | 109 | require.True(t, ok) | |
| @@ -101,8 +115,8 @@ func TestShortcutSet_FloatingProfilesPreserveExistingGlobalShortcuts(t *testing. | |||
| 101 | 115 | } | |
| 102 | 116 | ||
| 103 | 117 | 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{ | |
| 106 | 120 | "alt-one-conflict": { | |
| 107 | 121 | Keys: []string{"alt+1"}, | |
| 108 | 122 | URL: "https://example.com/one", | |
| @@ -117,7 +131,7 @@ func TestShortcutSet_FloatingProfilesSkipGlobalOnlyConflicts(t *testing.T) { | |||
| 117 | 131 | }, | |
| 118 | 132 | } | |
| 119 | 133 | ||
| 120 | - | set := NewShortcutSet(context.Background(), cfg) | |
| 134 | + | set := NewShortcutSet(context.Background(), ws, nil) | |
| 121 | 135 | ||
| 122 | 136 | _, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_1), Modifiers: ModAlt}, ModeNormal) | |
| 123 | 137 | assert.False(t, ok) | |
| @@ -134,8 +148,8 @@ func TestShortcutSet_FloatingProfilesSkipGlobalOnlyConflicts(t *testing.T) { | |||
| 134 | 148 | } | |
| 135 | 149 | ||
| 136 | 150 | 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{ | |
| 139 | 153 | "work-mail": { | |
| 140 | 154 | Keys: []string{"alt+w"}, | |
| 141 | 155 | URL: "https://mail.google.com", | |
| @@ -146,7 +160,7 @@ func TestShortcutSet_FloatingProfilesWithSameURLGetDistinctSessionIDs(t *testing | |||
| 146 | 160 | }, | |
| 147 | 161 | } | |
| 148 | 162 | ||
| 149 | - | set := NewShortcutSet(context.Background(), cfg) | |
| 163 | + | set := NewShortcutSet(context.Background(), ws, nil) | |
| 150 | 164 | ||
| 151 | 165 | workAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_w), Modifiers: ModAlt}, ModeNormal) | |
| 152 | 166 | require.True(t, ok) | |
| @@ -164,8 +178,8 @@ func TestShortcutSet_FloatingProfilesWithSameURLGetDistinctSessionIDs(t *testing | |||
| 164 | 178 | } | |
| 165 | 179 | ||
| 166 | 180 | 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{ | |
| 169 | 183 | "mail": { | |
| 170 | 184 | Keys: []string{"ctrl+alt+m"}, | |
| 171 | 185 | URL: "https://mail.example.com", | |
| @@ -176,7 +190,7 @@ func TestShortcutSet_FloatingProfiles_SupportModifierCombos(t *testing.T) { | |||
| 176 | 190 | }, | |
| 177 | 191 | } | |
| 178 | 192 | ||
| 179 | - | set := NewShortcutSet(context.Background(), cfg) | |
| 193 | + | set := NewShortcutSet(context.Background(), ws, nil) | |
| 180 | 194 | ||
| 181 | 195 | mailAction, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_m), Modifiers: ModCtrl | ModAlt}, ModeNormal) | |
| 182 | 196 | require.True(t, ok) | |
M internal/ui/input/global_shortcuts.go +12 -11
| @@ -6,7 +6,7 @@ import ( | |||
| 6 | 6 | "fmt" | |
| 7 | 7 | "strings" | |
| 8 | 8 | ||
| 9 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 11 | 11 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 12 | 12 | "github.com/jwijenbergh/puregotk/v4/glib" | |
| @@ -33,7 +33,8 @@ type GlobalShortcutHandler struct { | |||
| 33 | 33 | func NewGlobalShortcutHandler( | |
| 34 | 34 | ctx context.Context, | |
| 35 | 35 | window *gtk.ApplicationWindow, | |
| 36 | - | cfg *config.Config, | |
| 36 | + | workspace *entity.WorkspaceConfig, | |
| 37 | + | session *entity.SessionConfig, | |
| 37 | 38 | kbHandler *KeyboardHandler, | |
| 38 | 39 | onAction ActionHandler, | |
| 39 | 40 | ) *GlobalShortcutHandler { | |
| @@ -102,7 +103,7 @@ func NewGlobalShortcutHandler( | |||
| 102 | 103 | Str("action", string(ActionOpenSessionManager)). | |
| 103 | 104 | Msg("registered global shortcut") | |
| 104 | 105 | ||
| 105 | - | if cfg != nil { | |
| 106 | + | if workspace != nil { | |
| 106 | 107 | // Map action names to action constants for global shortcuts | |
| 107 | 108 | actionMap := map[string]Action{ | |
| 108 | 109 | "toggle_floating_pane": ActionToggleFloatingPane, | |
| @@ -113,7 +114,7 @@ func NewGlobalShortcutHandler( | |||
| 113 | 114 | "consume_or_expel_down": ActionConsumeOrExpelDown, | |
| 114 | 115 | } | |
| 115 | 116 | ||
| 116 | - | for actionName, actionBinding := range cfg.Workspace.Shortcuts.Actions { | |
| 117 | + | for actionName, actionBinding := range workspace.Shortcuts.Actions { | |
| 117 | 118 | action, ok := actionMap[actionName] | |
| 118 | 119 | if !ok { | |
| 119 | 120 | continue | |
| @@ -134,7 +135,7 @@ func NewGlobalShortcutHandler( | |||
| 134 | 135 | for binding, action := range h.registered { | |
| 135 | 136 | occupied[binding] = action | |
| 136 | 137 | } | |
| 137 | - | for _, shortcut := range collectFloatingProfileShortcuts(ctx, cfg, occupied) { | |
| 138 | + | for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) { | |
| 138 | 139 | if !h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) { | |
| 139 | 140 | continue | |
| 140 | 141 | } | |
| @@ -148,7 +149,7 @@ func NewGlobalShortcutHandler( | |||
| 148 | 149 | ||
| 149 | 150 | // Register all app-reserved shortcuts (mode activations, Ctrl+L, Ctrl+F, etc.) | |
| 150 | 151 | // so they work even when WebView has focus. | |
| 151 | - | shortcuts := NewShortcutSet(ctx, cfg) | |
| 152 | + | shortcuts := NewShortcutSet(ctx, workspace, session) | |
| 152 | 153 | for binding, action := range shortcuts.Global { | |
| 153 | 154 | if _, exists := h.registered[binding]; exists { | |
| 154 | 155 | continue | |
| @@ -249,7 +250,7 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi | |||
| 249 | 250 | // ReloadShortcuts rebuilds global shortcuts from a new config. | |
| 250 | 251 | // It replaces the GTK shortcut controller to ensure stale shortcuts | |
| 251 | 252 | // 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) { | |
| 253 | 254 | log := logging.FromContext(ctx) | |
| 254 | 255 | ||
| 255 | 256 | if h.window == nil || h.controller == nil { | |
| @@ -283,7 +284,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, cfg *config | |||
| 283 | 284 | h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager) | |
| 284 | 285 | ||
| 285 | 286 | // Re-register config-driven shortcuts | |
| 286 | - | if cfg != nil { | |
| 287 | + | if workspace != nil { | |
| 287 | 288 | actionMap := map[string]Action{ | |
| 288 | 289 | "toggle_floating_pane": ActionToggleFloatingPane, | |
| 289 | 290 | "toggle-floating-pane": ActionToggleFloatingPane, | |
| @@ -293,7 +294,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, cfg *config | |||
| 293 | 294 | "consume_or_expel_down": ActionConsumeOrExpelDown, | |
| 294 | 295 | } | |
| 295 | 296 | ||
| 296 | - | for actionName, actionBinding := range cfg.Workspace.Shortcuts.Actions { | |
| 297 | + | for actionName, actionBinding := range workspace.Shortcuts.Actions { | |
| 297 | 298 | action, ok := actionMap[actionName] | |
| 298 | 299 | if !ok { | |
| 299 | 300 | continue | |
| @@ -311,7 +312,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, cfg *config | |||
| 311 | 312 | for binding, action := range h.registered { | |
| 312 | 313 | occupied[binding] = action | |
| 313 | 314 | } | |
| 314 | - | for _, shortcut := range collectFloatingProfileShortcuts(ctx, cfg, occupied) { | |
| 315 | + | for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) { | |
| 315 | 316 | if h.registerShortcut(shortcut.Binding.Keyval, gdk.ModifierType(shortcut.Binding.Modifiers), shortcut.Action) { | |
| 316 | 317 | if url, ok := ParseFloatingProfileAction(shortcut.Action); ok { | |
| 317 | 318 | 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 | |||
| 319 | 320 | } | |
| 320 | 321 | } | |
| 321 | 322 | ||
| 322 | - | shortcuts := NewShortcutSet(ctx, cfg) | |
| 323 | + | shortcuts := NewShortcutSet(ctx, workspace, session) | |
| 323 | 324 | for binding, action := range shortcuts.Global { | |
| 324 | 325 | if _, exists := h.registered[binding]; exists { | |
| 325 | 326 | continue | |
M internal/ui/input/keyboard.go +52 -27
| @@ -23,7 +23,7 @@ import ( | |||
| 23 | 23 | "sync" | |
| 24 | 24 | "time" | |
| 25 | 25 | ||
| 26 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 26 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 27 | 27 | "github.com/bnema/dumber/internal/logging" | |
| 28 | 28 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 29 | 29 | "github.com/jwijenbergh/puregotk/v4/glib" | |
| @@ -55,7 +55,8 @@ type AccentHandler interface { | |||
| 55 | 55 | type KeyboardHandler struct { | |
| 56 | 56 | shortcuts *ShortcutSet | |
| 57 | 57 | modal *ModalState | |
| 58 | - | cfg *config.Config | |
| 58 | + | workspace *entity.WorkspaceConfig | |
| 59 | + | session *entity.SessionConfig | |
| 59 | 60 | ||
| 60 | 61 | // Action handler callback | |
| 61 | 62 | onAction ActionHandler | |
| @@ -80,15 +81,16 @@ type KeyboardHandler struct { | |||
| 80 | 81 | } | |
| 81 | 82 | ||
| 82 | 83 | // 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 { | |
| 84 | 85 | log := logging.FromContext(ctx) | |
| 85 | 86 | ||
| 86 | 87 | log.Debug().Msg("creating keyboard handler") | |
| 87 | 88 | ||
| 88 | 89 | h := &KeyboardHandler{ | |
| 89 | - | shortcuts: NewShortcutSet(ctx, cfg), | |
| 90 | + | shortcuts: NewShortcutSet(ctx, workspace, session), | |
| 90 | 91 | modal: NewModalState(ctx), | |
| 91 | - | cfg: cfg, | |
| 92 | + | workspace: workspace, | |
| 93 | + | session: session, | |
| 92 | 94 | ctx: ctx, | |
| 93 | 95 | } | |
| 94 | 96 | ||
| @@ -102,17 +104,18 @@ func (h *KeyboardHandler) SetOnAction(fn ActionHandler) { | |||
| 102 | 104 | h.onAction = fn | |
| 103 | 105 | } | |
| 104 | 106 | ||
| 105 | - | // ReloadShortcuts rebuilds the shortcut set from the new config. | |
| 107 | + | // ReloadShortcuts rebuilds the shortcut set from new config values. | |
| 106 | 108 | // 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) { | |
| 108 | 110 | h.mu.Lock() | |
| 109 | 111 | defer h.mu.Unlock() | |
| 110 | 112 | ||
| 111 | 113 | log := logging.FromContext(ctx) | |
| 112 | 114 | log.Debug().Msg("reloading shortcuts from config") | |
| 113 | 115 | ||
| 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 | |
| 116 | 119 | } | |
| 117 | 120 | ||
| 118 | 121 | // SetOnModeChange sets the callback for mode changes (for UI updates). | |
| @@ -433,29 +436,42 @@ func isResizeAction(action Action) bool { | |||
| 433 | 436 | ||
| 434 | 437 | func (h *KeyboardHandler) handleModeAction(action Action) bool { | |
| 435 | 438 | h.mu.RLock() | |
| 436 | - | cfg := h.cfg | |
| 439 | + | workspace := h.workspace | |
| 440 | + | session := h.session | |
| 437 | 441 | h.mu.RUnlock() | |
| 438 | 442 | ||
| 439 | 443 | switch action { | |
| 440 | 444 | 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) | |
| 443 | 450 | return true | |
| 444 | 451 | 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) | |
| 447 | 457 | return true | |
| 448 | 458 | 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) | |
| 451 | 464 | return true | |
| 452 | 465 | case ActionEnterResizeMode: | |
| 453 | 466 | if h.modal.Mode() == ModeResize { | |
| 454 | 467 | h.modal.ExitMode(h.ctx) | |
| 455 | 468 | return true | |
| 456 | 469 | } | |
| 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) | |
| 459 | 475 | return true | |
| 460 | 476 | case ActionExitMode: | |
| 461 | 477 | h.modal.ExitMode(h.ctx) | |
| @@ -469,30 +485,39 @@ func (h *KeyboardHandler) handleModeAction(action Action) bool { | |||
| 469 | 485 | // Useful for testing or programmatic mode changes. | |
| 470 | 486 | func (h *KeyboardHandler) EnterTabMode() { | |
| 471 | 487 | h.mu.RLock() | |
| 472 | - | cfg := h.cfg | |
| 488 | + | workspace := h.workspace | |
| 473 | 489 | 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) | |
| 476 | 495 | } | |
| 477 | 496 | ||
| 478 | 497 | // EnterPaneMode programmatically enters pane mode. | |
| 479 | 498 | // Useful for testing or programmatic mode changes. | |
| 480 | 499 | func (h *KeyboardHandler) EnterPaneMode() { | |
| 481 | 500 | h.mu.RLock() | |
| 482 | - | cfg := h.cfg | |
| 501 | + | workspace := h.workspace | |
| 483 | 502 | 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) | |
| 486 | 508 | } | |
| 487 | 509 | ||
| 488 | 510 | // EnterSessionMode programmatically enters session mode. | |
| 489 | 511 | // Useful for testing or programmatic mode changes. | |
| 490 | 512 | func (h *KeyboardHandler) EnterSessionMode() { | |
| 491 | 513 | h.mu.RLock() | |
| 492 | - | cfg := h.cfg | |
| 514 | + | session := h.session | |
| 493 | 515 | 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) | |
| 496 | 521 | } | |
| 497 | 522 | ||
| 498 | 523 | // ExitMode programmatically exits modal mode. | |
M internal/ui/input/keyboard_test.go +21 -21
| @@ -4,20 +4,26 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "testing" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | 8 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 9 | 9 | "github.com/stretchr/testify/assert" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | - | // newTestConfig creates a minimal Config with activation shortcuts set. | |
| 12 | + | // newTestWorkspace creates a minimal WorkspaceConfig with activation shortcuts set. | |
| 13 | 13 | // 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 | + | } | |
| 21 | 27 | } | |
| 22 | 28 | ||
| 23 | 29 | // stubAccentHandler is a test stub for the AccentHandler interface. | |
| @@ -108,9 +114,8 @@ func TestIsTextInputKey(t *testing.T) { | |||
| 108 | 114 | ||
| 109 | 115 | func TestHandleKeyPress_RoutePassToWidget(t *testing.T) { | |
| 110 | 116 | ctx := context.Background() | |
| 111 | - | cfg := newTestConfig() | |
| 112 | 117 | ||
| 113 | - | h := NewKeyboardHandler(ctx, cfg) | |
| 118 | + | h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession()) | |
| 114 | 119 | ||
| 115 | 120 | // Route all non-shortcut keys to widget (simulates WebView text input) | |
| 116 | 121 | h.SetRouteKey(func(kc KeyContext) KeyRoute { | |
| @@ -139,9 +144,8 @@ func TestHandleKeyPress_RoutePassToWidget(t *testing.T) { | |||
| 139 | 144 | ||
| 140 | 145 | func TestHandleKeyPress_RouteAccentDetection_NoAccent(t *testing.T) { | |
| 141 | 146 | ctx := context.Background() | |
| 142 | - | cfg := newTestConfig() | |
| 143 | 147 | ||
| 144 | - | h := NewKeyboardHandler(ctx, cfg) | |
| 148 | + | h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession()) | |
| 145 | 149 | ||
| 146 | 150 | // Set a stub accent handler that always returns false (simulates "no accents for this key") | |
| 147 | 151 | h.SetAccentHandler(&stubAccentHandler{onKeyPressedResult: false}) | |
| @@ -158,9 +162,8 @@ func TestHandleKeyPress_RouteAccentDetection_NoAccent(t *testing.T) { | |||
| 158 | 162 | ||
| 159 | 163 | func TestHandleKeyPress_ShortcutsWorkWithRouting(t *testing.T) { | |
| 160 | 164 | ctx := context.Background() | |
| 161 | - | cfg := newTestConfig() | |
| 162 | 165 | ||
| 163 | - | h := NewKeyboardHandler(ctx, cfg) | |
| 166 | + | h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession()) | |
| 164 | 167 | ||
| 165 | 168 | h.SetOnAction(func(ctx context.Context, action Action) error { | |
| 166 | 169 | return nil | |
| @@ -181,9 +184,8 @@ func TestHandleKeyPress_ShortcutsWorkWithRouting(t *testing.T) { | |||
| 181 | 184 | ||
| 182 | 185 | func TestHandleKeyPress_ModalModeIgnoresRoute(t *testing.T) { | |
| 183 | 186 | ctx := context.Background() | |
| 184 | - | cfg := newTestConfig() | |
| 185 | 187 | ||
| 186 | - | h := NewKeyboardHandler(ctx, cfg) | |
| 188 | + | h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession()) | |
| 187 | 189 | h.EnterTabMode() | |
| 188 | 190 | ||
| 189 | 191 | // Even if routeKey says pass to widget, modal mode ignores it | |
| @@ -198,8 +200,7 @@ func TestHandleKeyPress_ModalModeIgnoresRoute(t *testing.T) { | |||
| 198 | 200 | ||
| 199 | 201 | func TestKeyboardHandler_SetRouteKey(t *testing.T) { | |
| 200 | 202 | ctx := context.Background() | |
| 201 | - | cfg := &config.Config{} | |
| 202 | - | h := NewKeyboardHandler(ctx, cfg) | |
| 203 | + | h := NewKeyboardHandler(ctx, nil, nil) | |
| 203 | 204 | ||
| 204 | 205 | // Default: no callback set | |
| 205 | 206 | h.mu.RLock() | |
| @@ -221,9 +222,8 @@ func TestKeyboardHandler_SetRouteKey(t *testing.T) { | |||
| 221 | 222 | ||
| 222 | 223 | func TestHandleKeyPress_NoRouteCallback_DefaultsToShortcuts(t *testing.T) { | |
| 223 | 224 | ctx := context.Background() | |
| 224 | - | cfg := newTestConfig() | |
| 225 | 225 | ||
| 226 | - | h := NewKeyboardHandler(ctx, cfg) | |
| 226 | + | h := NewKeyboardHandler(ctx, newTestWorkspace(), newTestSession()) | |
| 227 | 227 | ||
| 228 | 228 | // No routeKey set -- should default to shortcut handling | |
| 229 | 229 | // 'z' is not a registered shortcut in ModeNormal, so returns false | |
M internal/ui/input/shortcuts.go +83 -46
| @@ -6,7 +6,7 @@ import ( | |||
| 6 | 6 | "sort" | |
| 7 | 7 | "strings" | |
| 8 | 8 | ||
| 9 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 11 | 11 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 12 | 12 | ) | |
| @@ -218,8 +218,10 @@ type ShortcutSet struct { | |||
| 218 | 218 | ResizeMode ShortcutTable | |
| 219 | 219 | } | |
| 220 | 220 | ||
| 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 { | |
| 223 | 225 | log := logging.FromContext(ctx) | |
| 224 | 226 | set := &ShortcutSet{ | |
| 225 | 227 | Global: make(ShortcutTable), | |
| @@ -229,11 +231,15 @@ func NewShortcutSet(ctx context.Context, cfg *config.Config) *ShortcutSet { | |||
| 229 | 231 | ResizeMode: make(ShortcutTable), | |
| 230 | 232 | } | |
| 231 | 233 | ||
| 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 | + | } | |
| 237 | 243 | ||
| 238 | 244 | log.Debug(). | |
| 239 | 245 | Int("global", len(set.Global)). | |
| @@ -246,81 +252,104 @@ func NewShortcutSet(ctx context.Context, cfg *config.Config) *ShortcutSet { | |||
| 246 | 252 | return set | |
| 247 | 253 | } | |
| 248 | 254 | ||
| 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) | |
| 253 | 259 | s.registerStandardShortcuts() | |
| 254 | 260 | s.registerPaneNavigationShortcuts() | |
| 255 | 261 | s.registerTabSwitchShortcuts() | |
| 256 | - | s.registerFloatingProfileShortcuts(ctx, cfg) | |
| 262 | + | s.registerFloatingProfileShortcutsFromWorkspace(ctx, workspace) | |
| 257 | 263 | } | |
| 258 | 264 | ||
| 259 | 265 | // 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) { | |
| 261 | 267 | s.buildModeShortcuts(ctx, cfg.TabMode.GetKeyBindings(), s.TabMode, "tab") | |
| 262 | 268 | } | |
| 263 | 269 | ||
| 264 | 270 | // 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) { | |
| 266 | 272 | s.buildModeShortcuts(ctx, cfg.PaneMode.GetKeyBindings(), s.PaneMode, "pane") | |
| 267 | 273 | } | |
| 268 | 274 | ||
| 269 | 275 | // 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) { | |
| 271 | 277 | s.buildModeShortcuts(ctx, cfg.SessionMode.GetKeyBindings(), s.SessionMode, "session") | |
| 272 | 278 | } | |
| 273 | 279 | ||
| 274 | 280 | // 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) { | |
| 276 | 282 | s.buildModeShortcuts(ctx, cfg.ResizeMode.GetKeyBindings(), s.ResizeMode, "resize") | |
| 277 | 283 | } | |
| 278 | 284 | ||
| 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 | + | ) { | |
| 280 | 288 | 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 { | |
| 282 | 306 | s.Global[binding] = ActionEnterTabMode | |
| 283 | 307 | log.Trace(). | |
| 284 | - | Str("shortcut", cfg.Workspace.TabMode.ActivationShortcut). | |
| 308 | + | Str("shortcut", workspace.TabMode.ActivationShortcut). | |
| 285 | 309 | Uint("keyval", binding.Keyval). | |
| 286 | 310 | Uint("mod", uint(binding.Modifiers)). | |
| 287 | 311 | Msg("tab mode activation registered") | |
| 288 | 312 | } 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") | |
| 290 | 314 | } | |
| 291 | - | if binding, ok := ParseKeyString(cfg.Workspace.PaneMode.ActivationShortcut); ok { | |
| 315 | + | if binding, ok := ParseKeyString(workspace.PaneMode.ActivationShortcut); ok { | |
| 292 | 316 | s.Global[binding] = ActionEnterPaneMode | |
| 293 | 317 | log.Trace(). | |
| 294 | - | Str("shortcut", cfg.Workspace.PaneMode.ActivationShortcut). | |
| 318 | + | Str("shortcut", workspace.PaneMode.ActivationShortcut). | |
| 295 | 319 | Uint("keyval", binding.Keyval). | |
| 296 | 320 | Uint("mod", uint(binding.Modifiers)). | |
| 297 | 321 | Msg("pane mode activation registered") | |
| 298 | 322 | } 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 | + | } | |
| 310 | 336 | } | |
| 311 | - | if binding, ok := ParseKeyString(cfg.Workspace.ResizeMode.ActivationShortcut); ok { | |
| 337 | + | if binding, ok := ParseKeyString(workspace.ResizeMode.ActivationShortcut); ok { | |
| 312 | 338 | s.Global[binding] = ActionEnterResizeMode | |
| 313 | 339 | log.Trace(). | |
| 314 | - | Str("shortcut", cfg.Workspace.ResizeMode.ActivationShortcut). | |
| 340 | + | Str("shortcut", workspace.ResizeMode.ActivationShortcut). | |
| 315 | 341 | Uint("keyval", binding.Keyval). | |
| 316 | 342 | Uint("mod", uint(binding.Modifiers)). | |
| 317 | 343 | Msg("resize mode activation registered") | |
| 318 | 344 | } 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") | |
| 320 | 346 | } | |
| 321 | 347 | } | |
| 322 | 348 | ||
| 323 | - | func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) { | |
| 349 | + | func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) { | |
| 350 | + | if cfg == nil { | |
| 351 | + | return | |
| 352 | + | } | |
| 324 | 353 | // Note: Ctrl+T is NOT registered globally - it enters tab mode. | |
| 325 | 354 | // In tab mode, use: | |
| 326 | 355 | // n = new tab | |
| @@ -336,12 +365,19 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) { | |||
| 336 | 365 | "toggle_floating_pane": ActionToggleFloatingPane, | |
| 337 | 366 | "toggle-floating-pane": ActionToggleFloatingPane, | |
| 338 | 367 | "close_pane": ActionClosePane, | |
| 368 | + | "close-pane": ActionClosePane, | |
| 339 | 369 | "next_tab": ActionNextTab, | |
| 370 | + | "next-tab": ActionNextTab, | |
| 340 | 371 | "previous_tab": ActionPreviousTab, | |
| 372 | + | "previous-tab": ActionPreviousTab, | |
| 341 | 373 | "consume_or_expel_left": ActionConsumeOrExpelLeft, | |
| 374 | + | "consume-or-expel-left": ActionConsumeOrExpelLeft, | |
| 342 | 375 | "consume_or_expel_right": ActionConsumeOrExpelRight, | |
| 376 | + | "consume-or-expel-right": ActionConsumeOrExpelRight, | |
| 343 | 377 | "consume_or_expel_up": ActionConsumeOrExpelUp, | |
| 378 | + | "consume-or-expel-up": ActionConsumeOrExpelUp, | |
| 344 | 379 | "consume_or_expel_down": ActionConsumeOrExpelDown, | |
| 380 | + | "consume-or-expel-down": ActionConsumeOrExpelDown, | |
| 345 | 381 | } | |
| 346 | 382 | ||
| 347 | 383 | for actionName, actionBinding := range cfg.Shortcuts.Actions { | |
| @@ -357,13 +393,13 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *config.WorkspaceConfig) { | |||
| 357 | 393 | } | |
| 358 | 394 | } | |
| 359 | 395 | ||
| 360 | - | func (s *ShortcutSet) registerFloatingProfileShortcuts(ctx context.Context, cfg *config.Config) { | |
| 396 | + | func (s *ShortcutSet) registerFloatingProfileShortcutsFromWorkspace(ctx context.Context, workspace *entity.WorkspaceConfig) { | |
| 361 | 397 | occupied := make(map[KeyBinding]Action, len(s.Global)) | |
| 362 | 398 | for binding, action := range s.Global { | |
| 363 | 399 | occupied[binding] = action | |
| 364 | 400 | } | |
| 365 | 401 | ||
| 366 | - | for _, shortcut := range collectFloatingProfileShortcuts(ctx, cfg, occupied) { | |
| 402 | + | for _, shortcut := range collectFloatingProfileShortcutsFromWorkspace(ctx, workspace, occupied) { | |
| 367 | 403 | s.Global[shortcut.Binding] = shortcut.Action | |
| 368 | 404 | } | |
| 369 | 405 | } | |
| @@ -373,26 +409,27 @@ type floatingProfileShortcut struct { | |||
| 373 | 409 | Action Action | |
| 374 | 410 | } | |
| 375 | 411 | ||
| 376 | - | func collectFloatingProfileShortcuts( | |
| 412 | + | // collectFloatingProfileShortcutsFromWorkspace collects floating profile shortcuts from a workspace config. | |
| 413 | + | func collectFloatingProfileShortcutsFromWorkspace( | |
| 377 | 414 | ctx context.Context, | |
| 378 | - | cfg *config.Config, | |
| 415 | + | workspace *entity.WorkspaceConfig, | |
| 379 | 416 | occupied map[KeyBinding]Action, | |
| 380 | 417 | ) []floatingProfileShortcut { | |
| 381 | - | if cfg == nil { | |
| 418 | + | if workspace == nil { | |
| 382 | 419 | return nil | |
| 383 | 420 | } | |
| 384 | 421 | ||
| 385 | 422 | log := logging.FromContext(ctx) | |
| 386 | 423 | reserveGlobalOnlyShortcutBindings(occupied) | |
| 387 | 424 | 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 { | |
| 390 | 427 | profileNames = append(profileNames, profileName) | |
| 391 | 428 | } | |
| 392 | 429 | sort.Strings(profileNames) | |
| 393 | 430 | ||
| 394 | 431 | for _, profileName := range profileNames { | |
| 395 | - | profile := cfg.Workspace.FloatingPane.Profiles[profileName] | |
| 432 | + | profile := workspace.FloatingPane.Profiles[profileName] | |
| 396 | 433 | url := strings.TrimSpace(profile.URL) | |
| 397 | 434 | if url == "" { | |
| 398 | 435 | log.Warn().Str("profile", profileName).Msg("floating profile missing URL, skipping") | |
M internal/ui/theme/manager.go +50 -30
| @@ -5,7 +5,7 @@ import ( | |||
| 5 | 5 | "fmt" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 8 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 9 | 9 | "github.com/bnema/dumber/internal/logging" | |
| 10 | 10 | "github.com/jwijenbergh/puregotk/v4/gdk" | |
| 11 | 11 | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| @@ -26,13 +26,23 @@ type Manager struct { | |||
| 26 | 26 | ||
| 27 | 27 | // NewManager creates a new theme manager from configuration. | |
| 28 | 28 | // 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 { | |
| 30 | 40 | log := logging.FromContext(ctx) | |
| 31 | 41 | ||
| 32 | 42 | // Determine color scheme preference | |
| 33 | 43 | scheme := "system" | |
| 34 | - | if cfg != nil && cfg.Appearance.ColorScheme != "" { | |
| 35 | - | scheme = cfg.Appearance.ColorScheme | |
| 44 | + | if appearance != nil && appearance.ColorScheme != "" { | |
| 45 | + | scheme = appearance.ColorScheme | |
| 36 | 46 | } | |
| 37 | 47 | ||
| 38 | 48 | // Resolve whether we should use dark mode via resolver | |
| @@ -41,28 +51,27 @@ func NewManager(ctx context.Context, cfg *config.Config, resolver port.ColorSche | |||
| 41 | 51 | ||
| 42 | 52 | // Build palettes from config or defaults | |
| 43 | 53 | 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) | |
| 47 | 57 | } else { | |
| 48 | 58 | lightPalette = DefaultLightPalette() | |
| 49 | 59 | darkPalette = DefaultDarkPalette() | |
| 50 | 60 | } | |
| 51 | 61 | ||
| 52 | 62 | // 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 | |
| 56 | 65 | } | |
| 57 | 66 | ||
| 58 | 67 | 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) | |
| 62 | 71 | } | |
| 63 | 72 | ||
| 64 | 73 | // Build mode colors from config | |
| 65 | - | modeColors := modeColorsFromConfig(cfg) | |
| 74 | + | modeColors := modeColorsFromStyling(styling) | |
| 66 | 75 | ||
| 67 | 76 | log.Debug(). | |
| 68 | 77 | Str("scheme", scheme). | |
| @@ -84,13 +93,12 @@ func NewManager(ctx context.Context, cfg *config.Config, resolver port.ColorSche | |||
| 84 | 93 | } | |
| 85 | 94 | } | |
| 86 | 95 | ||
| 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 { | |
| 89 | 98 | defaults := DefaultModeColors() | |
| 90 | - | if cfg == nil { | |
| 99 | + | if styling == nil { | |
| 91 | 100 | return defaults | |
| 92 | 101 | } | |
| 93 | - | styling := &cfg.Workspace.Styling | |
| 94 | 102 | return ModeColors{ | |
| 95 | 103 | PaneMode: Coalesce(styling.PaneModeColor, defaults.PaneMode), | |
| 96 | 104 | TabMode: Coalesce(styling.TabModeColor, defaults.TabMode), | |
| @@ -198,40 +206,52 @@ func (m *Manager) SetColorScheme(ctx context.Context, scheme string, display *gd | |||
| 198 | 206 | } | |
| 199 | 207 | } | |
| 200 | 208 | ||
| 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 | + | ) { | |
| 203 | 221 | log := logging.FromContext(ctx) | |
| 204 | 222 | ||
| 205 | - | if cfg == nil { | |
| 223 | + | if appearance == nil { | |
| 206 | 224 | return | |
| 207 | 225 | } | |
| 208 | 226 | ||
| 209 | 227 | // Update scheme and resolve prefersDark via resolver | |
| 210 | 228 | scheme := "system" | |
| 211 | - | if cfg.Appearance.ColorScheme != "" { | |
| 212 | - | scheme = cfg.Appearance.ColorScheme | |
| 229 | + | if appearance.ColorScheme != "" { | |
| 230 | + | scheme = appearance.ColorScheme | |
| 213 | 231 | } | |
| 214 | 232 | m.scheme = scheme | |
| 215 | 233 | pref := m.colorResolver.Refresh() | |
| 216 | 234 | m.prefersDark = pref.PrefersDark | |
| 217 | 235 | ||
| 218 | 236 | // 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) | |
| 221 | 239 | ||
| 222 | 240 | // Update UI scale | |
| 223 | - | if cfg.DefaultUIScale > 0 { | |
| 224 | - | m.uiScale = cfg.DefaultUIScale | |
| 241 | + | if uiScale > 0 { | |
| 242 | + | m.uiScale = uiScale | |
| 225 | 243 | } | |
| 226 | 244 | ||
| 227 | 245 | defaults := DefaultFontConfig() | |
| 228 | 246 | 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), | |
| 231 | 249 | } | |
| 232 | 250 | ||
| 233 | 251 | // Update mode colors | |
| 234 | - | m.modeColors = modeColorsFromConfig(cfg) | |
| 252 | + | if styling != nil { | |
| 253 | + | m.modeColors = modeColorsFromStyling(styling) | |
| 254 | + | } | |
| 235 | 255 | ||
| 236 | 256 | log.Info(). | |
| 237 | 257 | Str("scheme", m.scheme). | |
M internal/ui/theme/manager_test.go +45 -72
| @@ -6,17 +6,15 @@ import ( | |||
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 8 | "github.com/bnema/dumber/internal/application/port/mocks" | |
| 9 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | 10 | "github.com/stretchr/testify/assert" | |
| 11 | 11 | "github.com/stretchr/testify/require" | |
| 12 | 12 | ) | |
| 13 | 13 | ||
| 14 | 14 | func TestNewManager_DarkMode(t *testing.T) { | |
| 15 | 15 | ctx := context.Background() | |
| 16 | - | cfg := &config.Config{ | |
| 17 | - | Appearance: config.AppearanceConfig{ | |
| 18 | - | ColorScheme: "default", | |
| 19 | - | }, | |
| 16 | + | appearance := &entity.AppearanceConfig{ | |
| 17 | + | ColorScheme: "default", | |
| 20 | 18 | } | |
| 21 | 19 | ||
| 22 | 20 | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| @@ -25,7 +23,7 @@ func TestNewManager_DarkMode(t *testing.T) { | |||
| 25 | 23 | Source: "test", | |
| 26 | 24 | }) | |
| 27 | 25 | ||
| 28 | - | manager := NewManager(ctx, cfg, mockResolver) | |
| 26 | + | manager := NewManager(ctx, appearance, 1.0, nil, mockResolver) | |
| 29 | 27 | ||
| 30 | 28 | assert.True(t, manager.PrefersDark()) | |
| 31 | 29 | assert.Equal(t, "default", manager.scheme) | |
| @@ -33,10 +31,8 @@ func TestNewManager_DarkMode(t *testing.T) { | |||
| 33 | 31 | ||
| 34 | 32 | func TestNewManager_LightMode(t *testing.T) { | |
| 35 | 33 | ctx := context.Background() | |
| 36 | - | cfg := &config.Config{ | |
| 37 | - | Appearance: config.AppearanceConfig{ | |
| 38 | - | ColorScheme: "default", | |
| 39 | - | }, | |
| 34 | + | appearance := &entity.AppearanceConfig{ | |
| 35 | + | ColorScheme: "default", | |
| 40 | 36 | } | |
| 41 | 37 | ||
| 42 | 38 | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| @@ -45,7 +41,7 @@ func TestNewManager_LightMode(t *testing.T) { | |||
| 45 | 41 | Source: "test", | |
| 46 | 42 | }) | |
| 47 | 43 | ||
| 48 | - | manager := NewManager(ctx, cfg, mockResolver) | |
| 44 | + | manager := NewManager(ctx, appearance, 1.0, nil, mockResolver) | |
| 49 | 45 | ||
| 50 | 46 | assert.False(t, manager.PrefersDark()) | |
| 51 | 47 | } | |
| @@ -59,7 +55,7 @@ func TestNewManager_WithNilConfig(t *testing.T) { | |||
| 59 | 55 | Source: "fallback", | |
| 60 | 56 | }) | |
| 61 | 57 | ||
| 62 | - | manager := NewManager(ctx, nil, mockResolver) | |
| 58 | + | manager := NewManager(ctx, nil, 0, nil, mockResolver) | |
| 63 | 59 | ||
| 64 | 60 | require.NotNil(t, manager) | |
| 65 | 61 | assert.True(t, manager.PrefersDark()) | |
| @@ -68,10 +64,8 @@ func TestNewManager_WithNilConfig(t *testing.T) { | |||
| 68 | 64 | ||
| 69 | 65 | func TestNewManager_UsesConfigScheme(t *testing.T) { | |
| 70 | 66 | ctx := context.Background() | |
| 71 | - | cfg := &config.Config{ | |
| 72 | - | Appearance: config.AppearanceConfig{ | |
| 73 | - | ColorScheme: "prefer-dark", | |
| 74 | - | }, | |
| 67 | + | appearance := &entity.AppearanceConfig{ | |
| 68 | + | ColorScheme: "prefer-dark", | |
| 75 | 69 | } | |
| 76 | 70 | ||
| 77 | 71 | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| @@ -80,14 +74,13 @@ func TestNewManager_UsesConfigScheme(t *testing.T) { | |||
| 80 | 74 | Source: "config", | |
| 81 | 75 | }) | |
| 82 | 76 | ||
| 83 | - | manager := NewManager(ctx, cfg, mockResolver) | |
| 77 | + | manager := NewManager(ctx, appearance, 1.0, nil, mockResolver) | |
| 84 | 78 | ||
| 85 | 79 | assert.Equal(t, "prefer-dark", manager.scheme) | |
| 86 | 80 | } | |
| 87 | 81 | ||
| 88 | 82 | func TestManager_GetCurrentPalette_Dark(t *testing.T) { | |
| 89 | 83 | ctx := context.Background() | |
| 90 | - | cfg := &config.Config{} | |
| 91 | 84 | ||
| 92 | 85 | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 93 | 86 | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | |
| @@ -95,7 +88,7 @@ func TestManager_GetCurrentPalette_Dark(t *testing.T) { | |||
| 95 | 88 | Source: "test", | |
| 96 | 89 | }) | |
| 97 | 90 | ||
| 98 | - | manager := NewManager(ctx, cfg, mockResolver) | |
| 91 | + | manager := NewManager(ctx, nil, 0, nil, mockResolver) | |
| 99 | 92 | palette := manager.GetCurrentPalette() | |
| 100 | 93 | ||
| 101 | 94 | // Dark palette should be returned | |
| @@ -104,7 +97,6 @@ func TestManager_GetCurrentPalette_Dark(t *testing.T) { | |||
| 104 | 97 | ||
| 105 | 98 | func TestManager_GetCurrentPalette_Light(t *testing.T) { | |
| 106 | 99 | ctx := context.Background() | |
| 107 | - | cfg := &config.Config{} | |
| 108 | 100 | ||
| 109 | 101 | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 110 | 102 | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | |
| @@ -112,7 +104,7 @@ func TestManager_GetCurrentPalette_Light(t *testing.T) { | |||
| 112 | 104 | Source: "test", | |
| 113 | 105 | }) | |
| 114 | 106 | ||
| 115 | - | manager := NewManager(ctx, cfg, mockResolver) | |
| 107 | + | manager := NewManager(ctx, nil, 0, nil, mockResolver) | |
| 116 | 108 | palette := manager.GetCurrentPalette() | |
| 117 | 109 | ||
| 118 | 110 | // Light palette should be returned | |
| @@ -121,7 +113,6 @@ func TestManager_GetCurrentPalette_Light(t *testing.T) { | |||
| 121 | 113 | ||
| 122 | 114 | func TestManager_SetColorScheme(t *testing.T) { | |
| 123 | 115 | ctx := context.Background() | |
| 124 | - | cfg := &config.Config{} | |
| 125 | 116 | ||
| 126 | 117 | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 127 | 118 | // Initial resolve call | |
| @@ -135,7 +126,7 @@ func TestManager_SetColorScheme(t *testing.T) { | |||
| 135 | 126 | Source: "config", | |
| 136 | 127 | }) | |
| 137 | 128 | ||
| 138 | - | manager := NewManager(ctx, cfg, mockResolver) | |
| 129 | + | manager := NewManager(ctx, nil, 0, nil, mockResolver) | |
| 139 | 130 | assert.True(t, manager.PrefersDark()) | |
| 140 | 131 | ||
| 141 | 132 | // Change scheme - this calls Refresh on resolver | |
| @@ -147,10 +138,8 @@ func TestManager_SetColorScheme(t *testing.T) { | |||
| 147 | 138 | ||
| 148 | 139 | func TestManager_UpdateFromConfig(t *testing.T) { | |
| 149 | 140 | ctx := context.Background() | |
| 150 | - | cfg := &config.Config{ | |
| 151 | - | Appearance: config.AppearanceConfig{ | |
| 152 | - | ColorScheme: "default", | |
| 153 | - | }, | |
| 141 | + | appearance := &entity.AppearanceConfig{ | |
| 142 | + | ColorScheme: "default", | |
| 154 | 143 | } | |
| 155 | 144 | ||
| 156 | 145 | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| @@ -165,16 +154,14 @@ func TestManager_UpdateFromConfig(t *testing.T) { | |||
| 165 | 154 | Source: "config", | |
| 166 | 155 | }) | |
| 167 | 156 | ||
| 168 | - | manager := NewManager(ctx, cfg, mockResolver) | |
| 157 | + | manager := NewManager(ctx, appearance, 1.0, nil, mockResolver) | |
| 169 | 158 | assert.True(t, manager.PrefersDark()) | |
| 170 | 159 | ||
| 171 | 160 | // 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", | |
| 176 | 163 | } | |
| 177 | - | manager.UpdateFromConfig(ctx, newCfg, nil) | |
| 164 | + | manager.UpdateFromConfig(ctx, newAppearance, 0, nil, nil) | |
| 178 | 165 | ||
| 179 | 166 | assert.False(t, manager.PrefersDark()) | |
| 180 | 167 | assert.Equal(t, "prefer-light", manager.scheme) | |
| @@ -182,7 +169,6 @@ func TestManager_UpdateFromConfig(t *testing.T) { | |||
| 182 | 169 | ||
| 183 | 170 | func TestManager_UpdateFromConfig_NilConfig(t *testing.T) { | |
| 184 | 171 | ctx := context.Background() | |
| 185 | - | cfg := &config.Config{} | |
| 186 | 172 | ||
| 187 | 173 | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 188 | 174 | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | |
| @@ -190,18 +176,17 @@ func TestManager_UpdateFromConfig_NilConfig(t *testing.T) { | |||
| 190 | 176 | Source: "test", | |
| 191 | 177 | }) | |
| 192 | 178 | ||
| 193 | - | manager := NewManager(ctx, cfg, mockResolver) | |
| 179 | + | manager := NewManager(ctx, nil, 0, nil, mockResolver) | |
| 194 | 180 | initialScheme := manager.scheme | |
| 195 | 181 | ||
| 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) | |
| 198 | 184 | ||
| 199 | 185 | assert.Equal(t, initialScheme, manager.scheme) | |
| 200 | 186 | } | |
| 201 | 187 | ||
| 202 | 188 | func TestManager_GetWebUIThemeCSS(t *testing.T) { | |
| 203 | 189 | ctx := context.Background() | |
| 204 | - | cfg := &config.Config{} | |
| 205 | 190 | ||
| 206 | 191 | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 207 | 192 | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | |
| @@ -209,7 +194,7 @@ func TestManager_GetWebUIThemeCSS(t *testing.T) { | |||
| 209 | 194 | Source: "test", | |
| 210 | 195 | }) | |
| 211 | 196 | ||
| 212 | - | manager := NewManager(ctx, cfg, mockResolver) | |
| 197 | + | manager := NewManager(ctx, nil, 0, nil, mockResolver) | |
| 213 | 198 | css := manager.GetWebUIThemeCSS() | |
| 214 | 199 | ||
| 215 | 200 | // Should contain both light and dark variables | |
| @@ -219,7 +204,6 @@ func TestManager_GetWebUIThemeCSS(t *testing.T) { | |||
| 219 | 204 | ||
| 220 | 205 | func TestManager_GetBackgroundRGBA(t *testing.T) { | |
| 221 | 206 | ctx := context.Background() | |
| 222 | - | cfg := &config.Config{} | |
| 223 | 207 | ||
| 224 | 208 | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 225 | 209 | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | |
| @@ -227,7 +211,7 @@ func TestManager_GetBackgroundRGBA(t *testing.T) { | |||
| 227 | 211 | Source: "test", | |
| 228 | 212 | }) | |
| 229 | 213 | ||
| 230 | - | manager := NewManager(ctx, cfg, mockResolver) | |
| 214 | + | manager := NewManager(ctx, nil, 0, nil, mockResolver) | |
| 231 | 215 | r, g, b, a := manager.GetBackgroundRGBA() | |
| 232 | 216 | ||
| 233 | 217 | // Should return valid RGBA values (0-1 range) | |
| @@ -243,16 +227,14 @@ func TestManager_GetBackgroundRGBA(t *testing.T) { | |||
| 243 | 227 | ||
| 244 | 228 | func TestManager_CustomPalettes(t *testing.T) { | |
| 245 | 229 | 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", | |
| 256 | 238 | }, | |
| 257 | 239 | } | |
| 258 | 240 | ||
| @@ -262,7 +244,7 @@ func TestManager_CustomPalettes(t *testing.T) { | |||
| 262 | 244 | Source: "test", | |
| 263 | 245 | }) | |
| 264 | 246 | ||
| 265 | - | manager := NewManager(ctx, cfg, mockResolver) | |
| 247 | + | manager := NewManager(ctx, appearance, 1.0, nil, mockResolver) | |
| 266 | 248 | ||
| 267 | 249 | lightPalette := manager.GetLightPalette() | |
| 268 | 250 | darkPalette := manager.GetDarkPalette() | |
| @@ -275,9 +257,6 @@ func TestManager_CustomPalettes(t *testing.T) { | |||
| 275 | 257 | ||
| 276 | 258 | func TestManager_UIScale(t *testing.T) { | |
| 277 | 259 | ctx := context.Background() | |
| 278 | - | cfg := &config.Config{ | |
| 279 | - | DefaultUIScale: 1.5, | |
| 280 | - | } | |
| 281 | 260 | ||
| 282 | 261 | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| 283 | 262 | mockResolver.EXPECT().Resolve().Return(port.ColorSchemePreference{ | |
| @@ -285,7 +264,7 @@ func TestManager_UIScale(t *testing.T) { | |||
| 285 | 264 | Source: "test", | |
| 286 | 265 | }) | |
| 287 | 266 | ||
| 288 | - | manager := NewManager(ctx, cfg, mockResolver) | |
| 267 | + | manager := NewManager(ctx, nil, 1.5, nil, mockResolver) | |
| 289 | 268 | ||
| 290 | 269 | // UI scale should be stored (we can't directly access it, but we can verify | |
| 291 | 270 | // the manager was created without error) | |
| @@ -294,11 +273,9 @@ func TestManager_UIScale(t *testing.T) { | |||
| 294 | 273 | ||
| 295 | 274 | func TestManager_Fonts(t *testing.T) { | |
| 296 | 275 | 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", | |
| 302 | 279 | } | |
| 303 | 280 | ||
| 304 | 281 | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| @@ -307,7 +284,7 @@ func TestManager_Fonts(t *testing.T) { | |||
| 307 | 284 | Source: "test", | |
| 308 | 285 | }) | |
| 309 | 286 | ||
| 310 | - | manager := NewManager(ctx, cfg, mockResolver) | |
| 287 | + | manager := NewManager(ctx, appearance, 1.0, nil, mockResolver) | |
| 311 | 288 | ||
| 312 | 289 | // Fonts should be stored (manager creation should succeed) | |
| 313 | 290 | require.NotNil(t, manager) | |
| @@ -315,15 +292,11 @@ func TestManager_Fonts(t *testing.T) { | |||
| 315 | 292 | ||
| 316 | 293 | func TestManager_ModeColors(t *testing.T) { | |
| 317 | 294 | 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", | |
| 327 | 300 | } | |
| 328 | 301 | ||
| 329 | 302 | mockResolver := mocks.NewMockColorSchemeResolver(t) | |
| @@ -332,7 +305,7 @@ func TestManager_ModeColors(t *testing.T) { | |||
| 332 | 305 | Source: "test", | |
| 333 | 306 | }) | |
| 334 | 307 | ||
| 335 | - | manager := NewManager(ctx, cfg, mockResolver) | |
| 308 | + | manager := NewManager(ctx, nil, 1.0, styling, mockResolver) | |
| 336 | 309 | modeColors := manager.GetModeColors() | |
| 337 | 310 | ||
| 338 | 311 | assert.Equal(t, "#ff0000", modeColors.PaneMode) | |
M internal/ui/theme/palette.go +2 -2
| @@ -6,7 +6,7 @@ import ( | |||
| 6 | 6 | "regexp" | |
| 7 | 7 | "strings" | |
| 8 | 8 | ||
| 9 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | // Palette holds semantic color tokens for theming. | |
| @@ -85,7 +85,7 @@ func DefaultLightPalette() Palette { | |||
| 85 | 85 | } | |
| 86 | 86 | ||
| 87 | 87 | // 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 { | |
| 89 | 89 | var defaults Palette | |
| 90 | 90 | if isDark { | |
| 91 | 91 | defaults = DefaultDarkPalette() | |
M internal/ui/window/main_window.go +21 -39
| @@ -4,7 +4,6 @@ package window | |||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | 6 | ||
| 7 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 8 | 7 | "github.com/bnema/dumber/internal/logging" | |
| 9 | 8 | "github.com/bnema/dumber/internal/ui/component" | |
| 10 | 9 | "github.com/jwijenbergh/puregotk/v4/gtk" | |
| @@ -26,31 +25,29 @@ type MainWindow struct { | |||
| 26 | 25 | contentArea *gtk.Box // Container for workspace content | |
| 27 | 26 | currentContent *gtk.Widget // Track current content for removal on tab switch | |
| 28 | 27 | ||
| 29 | - | cfg *config.Config | |
| 30 | - | logger zerolog.Logger | |
| 28 | + | tabBarPosition string // "top" or "bottom" | |
| 29 | + | logger zerolog.Logger | |
| 31 | 30 | } | |
| 32 | 31 | ||
| 33 | 32 | // 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) { | |
| 35 | 35 | log := logging.FromContext(ctx) | |
| 36 | 36 | ||
| 37 | 37 | 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(), | |
| 40 | 40 | } | |
| 41 | 41 | ||
| 42 | - | // Create the application window | |
| 43 | 42 | mw.window = gtk.NewApplicationWindow(app) | |
| 44 | 43 | if mw.window == nil { | |
| 45 | 44 | return nil, ErrWindowCreationFailed | |
| 46 | 45 | } | |
| 47 | 46 | ||
| 48 | - | // Configure window | |
| 49 | 47 | title := windowTitle | |
| 50 | 48 | mw.window.SetTitle(&title) | |
| 51 | 49 | mw.window.SetDefaultSize(defaultWidth, defaultHeight) | |
| 52 | 50 | ||
| 53 | - | // Create root container (vertical box) | |
| 54 | 51 | mw.rootBox = gtk.NewBox(gtk.OrientationVerticalValue, 0) | |
| 55 | 52 | if mw.rootBox == nil { | |
| 56 | 53 | mw.window.Unref() | |
| @@ -60,7 +57,6 @@ func New(ctx context.Context, app *gtk.Application, cfg *config.Config) (*MainWi | |||
| 60 | 57 | mw.rootBox.SetVexpand(true) | |
| 61 | 58 | mw.rootBox.SetVisible(true) | |
| 62 | 59 | ||
| 63 | - | // Create tab bar | |
| 64 | 60 | mw.tabBar = component.NewTabBar() | |
| 65 | 61 | if mw.tabBar == nil { | |
| 66 | 62 | mw.rootBox.Unref() | |
| @@ -68,7 +64,6 @@ func New(ctx context.Context, app *gtk.Application, cfg *config.Config) (*MainWi | |||
| 68 | 64 | return nil, ErrWidgetCreationFailed("tabBar") | |
| 69 | 65 | } | |
| 70 | 66 | ||
| 71 | - | // Create content overlay (for omnibox and other overlays) | |
| 72 | 67 | mw.contentOverlay = gtk.NewOverlay() | |
| 73 | 68 | if mw.contentOverlay == nil { | |
| 74 | 69 | mw.tabBar.Destroy() | |
| @@ -80,7 +75,6 @@ func New(ctx context.Context, app *gtk.Application, cfg *config.Config) (*MainWi | |||
| 80 | 75 | mw.contentOverlay.SetVexpand(true) | |
| 81 | 76 | mw.contentOverlay.SetVisible(true) | |
| 82 | 77 | ||
| 83 | - | // Create content area | |
| 84 | 78 | mw.contentArea = gtk.NewBox(gtk.OrientationVerticalValue, 0) | |
| 85 | 79 | if mw.contentArea == nil { | |
| 86 | 80 | mw.contentOverlay.Unref() | |
| @@ -94,31 +88,25 @@ func New(ctx context.Context, app *gtk.Application, cfg *config.Config) (*MainWi | |||
| 94 | 88 | mw.contentArea.SetVisible(true) | |
| 95 | 89 | mw.contentArea.AddCssClass("content-area") | |
| 96 | 90 | ||
| 97 | - | // Set content area as the main child of the overlay | |
| 98 | 91 | mw.contentOverlay.SetChild(&mw.contentArea.Widget) | |
| 99 | 92 | ||
| 100 | - | // Assemble layout based on tab bar position | |
| 101 | 93 | mw.assembleLayout() | |
| 102 | 94 | ||
| 103 | - | // Set root box as window content | |
| 104 | 95 | mw.window.SetChild(&mw.rootBox.Widget) | |
| 105 | 96 | ||
| 106 | 97 | return mw, nil | |
| 107 | 98 | } | |
| 108 | 99 | ||
| 109 | - | // assembleLayout arranges widgets based on configuration. | |
| 110 | 100 | func (mw *MainWindow) assembleLayout() { | |
| 111 | 101 | tabBarPos := "top" // default | |
| 112 | - | if mw.cfg != nil { | |
| 113 | - | tabBarPos = mw.cfg.Workspace.TabBarPosition | |
| 102 | + | if mw.tabBarPosition != "" { | |
| 103 | + | tabBarPos = mw.tabBarPosition | |
| 114 | 104 | } | |
| 115 | 105 | ||
| 116 | 106 | if tabBarPos == "bottom" { | |
| 117 | - | // Content first, then tab bar | |
| 118 | 107 | mw.rootBox.Append(&mw.contentOverlay.Widget) | |
| 119 | 108 | mw.rootBox.Append(mw.tabBar.Widget()) | |
| 120 | 109 | } else { | |
| 121 | - | // Tab bar first, then content (default: top) | |
| 122 | 110 | mw.rootBox.Append(mw.tabBar.Widget()) | |
| 123 | 111 | mw.rootBox.Append(&mw.contentOverlay.Widget) | |
| 124 | 112 | } | |
| @@ -128,7 +116,7 @@ func (mw *MainWindow) assembleLayout() { | |||
| 128 | 116 | Msg("window layout assembled") | |
| 129 | 117 | } | |
| 130 | 118 | ||
| 131 | - | // Show makes the window visible. | |
| 119 | + | // Show presents the window. | |
| 132 | 120 | func (mw *MainWindow) Show() { | |
| 133 | 121 | mw.window.Present() | |
| 134 | 122 | } | |
| @@ -138,26 +126,23 @@ func (mw *MainWindow) Close() { | |||
| 138 | 126 | mw.window.Close() | |
| 139 | 127 | } | |
| 140 | 128 | ||
| 141 | - | // TabBar returns the tab bar component. | |
| 129 | + | // TabBar returns the window's tab bar component. | |
| 142 | 130 | func (mw *MainWindow) TabBar() *component.TabBar { | |
| 143 | 131 | return mw.tabBar | |
| 144 | 132 | } | |
| 145 | 133 | ||
| 146 | - | // ContentArea returns the content area box. | |
| 134 | + | // ContentArea returns the main content container widget. | |
| 147 | 135 | func (mw *MainWindow) ContentArea() *gtk.Box { | |
| 148 | 136 | return mw.contentArea | |
| 149 | 137 | } | |
| 150 | 138 | ||
| 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. | |
| 153 | 140 | func (mw *MainWindow) SetContent(widget *gtk.Widget) { | |
| 154 | - | // Remove existing content first | |
| 155 | 141 | if mw.currentContent != nil { | |
| 156 | 142 | mw.contentArea.Remove(mw.currentContent) | |
| 157 | 143 | mw.currentContent = nil | |
| 158 | 144 | } | |
| 159 | 145 | ||
| 160 | - | // Add new content | |
| 161 | 146 | if widget != nil { | |
| 162 | 147 | widget.SetVisible(true) | |
| 163 | 148 | mw.contentArea.Append(widget) | |
| @@ -165,18 +150,16 @@ func (mw *MainWindow) SetContent(widget *gtk.Widget) { | |||
| 165 | 150 | } | |
| 166 | 151 | } | |
| 167 | 152 | ||
| 168 | - | // Window returns the underlying GTK window. | |
| 153 | + | // Window returns the underlying GTK application window. | |
| 169 | 154 | func (mw *MainWindow) Window() *gtk.ApplicationWindow { | |
| 170 | 155 | return mw.window | |
| 171 | 156 | } | |
| 172 | 157 | ||
| 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. | |
| 175 | 159 | func (mw *MainWindow) SetTitle(title string) { | |
| 176 | 160 | if mw.window == nil { | |
| 177 | 161 | return | |
| 178 | 162 | } | |
| 179 | - | // Truncate title to 255 characters max (by runes, to avoid breaking UTF-8) | |
| 180 | 163 | const maxTitleLen = 255 | |
| 181 | 164 | runes := []rune(title) | |
| 182 | 165 | if len(runes) > maxTitleLen { | |
| @@ -185,21 +168,19 @@ func (mw *MainWindow) SetTitle(title string) { | |||
| 185 | 168 | mw.window.SetTitle(&title) | |
| 186 | 169 | } | |
| 187 | 170 | ||
| 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. | |
| 190 | 172 | func (mw *MainWindow) ContentOverlay() *gtk.Overlay { | |
| 191 | 173 | return mw.contentOverlay | |
| 192 | 174 | } | |
| 193 | 175 | ||
| 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. | |
| 196 | 177 | func (mw *MainWindow) AddOverlay(widget *gtk.Widget) { | |
| 197 | 178 | if mw.contentOverlay != nil && widget != nil { | |
| 198 | 179 | mw.contentOverlay.AddOverlay(widget) | |
| 199 | 180 | } | |
| 200 | 181 | } | |
| 201 | 182 | ||
| 202 | - | // Destroy cleans up window resources. | |
| 183 | + | // Destroy releases all GTK resources held by the window. | |
| 203 | 184 | func (mw *MainWindow) Destroy() { | |
| 204 | 185 | if mw.tabBar != nil { | |
| 205 | 186 | mw.tabBar.Destroy() | |
| @@ -219,21 +200,22 @@ func (mw *MainWindow) Destroy() { | |||
| 219 | 200 | } | |
| 220 | 201 | } | |
| 221 | 202 | ||
| 222 | - | // WindowError represents a window-related error. | |
| 203 | + | // WindowError is a window-related error. | |
| 223 | 204 | type WindowError struct { | |
| 224 | 205 | Message string | |
| 225 | 206 | } | |
| 226 | 207 | ||
| 208 | + | // Error implements the error interface. | |
| 227 | 209 | func (e WindowError) Error() string { | |
| 228 | 210 | return e.Message | |
| 229 | 211 | } | |
| 230 | 212 | ||
| 231 | - | // Error constants. | |
| 232 | 213 | var ( | |
| 214 | + | // ErrWindowCreationFailed is returned when the GTK application window cannot be created. | |
| 233 | 215 | ErrWindowCreationFailed = WindowError{Message: "failed to create application window"} | |
| 234 | 216 | ) | |
| 235 | 217 | ||
| 236 | - | // ErrWidgetCreationFailed creates an error for widget creation failure. | |
| 218 | + | // ErrWidgetCreationFailed returns an error for a failed widget creation. | |
| 237 | 219 | func ErrWidgetCreationFailed(name string) error { | |
| 238 | 220 | return WindowError{Message: "failed to create widget: " + name} | |
| 239 | 221 | } | |