detached 72b6970

Finalize clipboard and runtime profile cleanup

commit
72b6970fb6a3a00de85edb4ec49d3c7a6c1ea643
author
brice · · 2026-04-18 10:23 UTC
parent
21f7418
signature
GPG VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
41 files · +1301 -555
M .mockery.yaml +1 -0
@@ -49,6 +49,7 @@ packages:
4949 AccentKeyHandler: {}
5050 AutoCopyConfig: {}
5151 Clipboard: {}
52+ ClipboardTextOrchestrator: {}
5253 ContextMenuBuilder: {}
5354 ContextMenuActionExecutor: {}
5455 ContextMenuActionExecutorFactory: {}
@@ -49,6 +49,7 @@ packages:
49 AccentKeyHandler: {}49 AccentKeyHandler: {}
50 AutoCopyConfig: {}50 AutoCopyConfig: {}
51 Clipboard: {}51 Clipboard: {}
52+ ClipboardTextOrchestrator: {}
52 ContextMenuBuilder: {}53 ContextMenuBuilder: {}
53 ContextMenuActionExecutor: {}54 ContextMenuActionExecutor: {}
54 ContextMenuActionExecutorFactory: {}55 ContextMenuActionExecutorFactory: {}
M cmd/dumber/main.go +44 -19
@@ -26,6 +26,7 @@ import (
2626 "github.com/bnema/dumber/internal/infrastructure/filesystem"
2727 "github.com/bnema/dumber/internal/infrastructure/idle"
2828 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
29+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
2930 "github.com/bnema/dumber/internal/infrastructure/snapshot"
3031 "github.com/bnema/dumber/internal/infrastructure/textinput"
3132 "github.com/bnema/dumber/internal/infrastructure/updater"
@@ -54,7 +55,7 @@ var initialURL string
5455 var restoreSessionID string
5556
5657 // browserLaunchRelay is shared for early browse handoff and in-app browser launches.
57-var browserLaunchRelay = desktop.NewBrowserLaunchRelay(xdg.New())
58+var browserLaunchRelay port.BrowserLaunchRelay
5859
5960 type launchMode string
6061
@@ -97,6 +98,24 @@ func tryForwardBrowseURLToRunningInstance(ctx context.Context, relay port.Browse
9798 return relay.DeliverOpenFreshWindow(ctx, browseURL)
9899 }
99100
101+func resolveBrowserLaunchRelay(cfg *config.Config) (port.BrowserLaunchRelay, error) {
102+ profile, err := bootstrap.ResolveRuntimeProfile(cfg)
103+ if err != nil {
104+ return nil, err
105+ }
106+ return desktop.NewBrowserLaunchRelay(profile.IPC), nil
107+}
108+
109+func configureBrowserLaunchRelay(cfg *config.Config) {
110+ relay, err := resolveBrowserLaunchRelay(cfg)
111+ if err != nil {
112+ fmt.Fprintf(os.Stderr, "warning: failed to resolve browser launch relay profile: %v\n", err)
113+ browserLaunchRelay = nil
114+ return
115+ }
116+ browserLaunchRelay = relay
117+}
118+
100119 func main() {
101120 enableCrashForensics()
102121
@@ -114,6 +133,8 @@ func main() {
114133 mode, browseURL := launchModeFromArgs(os.Args)
115134 // Run GUI mode for browse command
116135 if mode == launchModeBrowse {
136+ cfg := initConfig()
137+ configureBrowserLaunchRelay(cfg)
117138 if forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), browserLaunchRelay, browseURL); err != nil {
118139 fmt.Fprintf(
119140 os.Stderr,
@@ -128,7 +149,7 @@ func main() {
128149 initialURL = browseURL
129150 restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")
130151 os.Args = os.Args[:1]
131- os.Exit(runGUI())
152+ os.Exit(runGUI(cfg))
132153 return
133154 }
134155
@@ -149,14 +170,17 @@ func main() {
149170 cmd.Execute()
150171 }
151172
152-func runGUI() int {
173+func runGUI(cfg *config.Config) int {
153174 bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr)
154175
155176 runtime.LockOSThread()
156177 component.SetSkeletonVersion(version)
157178 timer := bootstrap.NewStartupTimer()
158179
159- cfg := initConfig()
180+ if cfg == nil {
181+ cfg = initConfig()
182+ configureBrowserLaunchRelay(cfg)
183+ }
160184 timer.Mark("config")
161185
162186 ctx := initStartupContextWithTrace(cfg)
@@ -232,6 +256,7 @@ func runStandaloneOmnibox() int {
232256 component.SetSkeletonVersion(version)
233257
234258 cfg := initConfig()
259+ configureBrowserLaunchRelay(cfg)
235260 ctx := initStartupContextWithTrace(cfg)
236261
237262 initResult, err := runParallelInitPhase(ctx, cfg)
@@ -260,6 +285,7 @@ func runStandaloneOmnibox() int {
260285 uiDeps, err := buildUIDependencies(
261286 ctx,
262287 cfg,
288+ initResult.RuntimeProfile,
263289 initResult.ThemeManager,
264290 initResult.ColorResolver,
265291 initResult.AdwaitaDetector,
@@ -391,7 +417,7 @@ func buildAndConfigureApp(
391417 browserSession *bootstrap.BrowserSession,
392418 ) (*ui.App, error) {
393419 uiDeps, err := buildUIDependencies(
394- ctx, cfg, initResult.ThemeManager,
420+ ctx, cfg, initResult.RuntimeProfile, initResult.ThemeManager,
395421 initResult.ColorResolver, initResult.AdwaitaDetector,
396422 engine, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.CrashReports(),
397423 )
@@ -427,12 +453,11 @@ func initStackAndRepos(
427453 if needsEagerDB {
428454 // Parallel phase 2: Database + engine initialize concurrently
429455 dbEngine, err := bootstrap.RunParallelDBEngine(bootstrap.ParallelDBEngineInput{
430- Ctx: ctx,
431- Config: cfg,
432- DataDir: initResult.DataDir,
433- CacheDir: initResult.CacheDir,
434- ThemeManager: initResult.ThemeManager,
435- ColorResolver: initResult.ColorResolver,
456+ Ctx: ctx,
457+ Config: cfg,
458+ RuntimeProfile: initResult.RuntimeProfile,
459+ ThemeManager: initResult.ThemeManager,
460+ ColorResolver: initResult.ColorResolver,
436461 })
437462 if err != nil {
438463 return nil, nil, nil, err
@@ -442,13 +467,12 @@ func initStackAndRepos(
442467
443468 log := logging.FromContext(ctx)
444469 engine, err := bootstrap.BuildEngine(bootstrap.EngineInput{
445- Ctx: ctx,
446- Config: cfg,
447- DataDir: initResult.DataDir,
448- CacheDir: initResult.CacheDir,
449- ThemeManager: initResult.ThemeManager,
450- ColorResolver: initResult.ColorResolver,
451- Logger: *log,
470+ Ctx: ctx,
471+ Config: cfg,
472+ RuntimeProfile: initResult.RuntimeProfile,
473+ ThemeManager: initResult.ThemeManager,
474+ ColorResolver: initResult.ColorResolver,
475+ Logger: *log,
452476 })
453477 if err != nil {
454478 return nil, nil, nil, err
@@ -717,6 +741,7 @@ func isCEFSubprocess() bool {
717741 func buildUIDependencies(
718742 ctx context.Context,
719743 cfg *config.Config,
744+ runtimeProfile runtimeprofile.Profile,
720745 themeManager *theme.Manager,
721746 colorResolver port.ColorSchemeResolver,
722747 adwaitaDetector port.ToolkitAvailabilityNotifier,
@@ -782,7 +807,7 @@ func buildUIDependencies(
782807 },
783808 CheckUpdateUC: uc.checkUpdate,
784809 ApplyUpdateUC: uc.applyUpdate,
785- SessionSpawner: bootstrap.NewSessionSpawner(ctx, cfg),
810+ SessionSpawner: bootstrap.NewSessionSpawner(ctx, runtimeProfile),
786811 FileSystem: filesystem.New(),
787812 AccentFocusProvider: focusProvider,
788813 NewGTKEntryTarget: func(entry *gtk.SearchEntry) port.TextInputTarget {
@@ -26,6 +26,7 @@ import (
26 "github.com/bnema/dumber/internal/infrastructure/filesystem"26 "github.com/bnema/dumber/internal/infrastructure/filesystem"
27 "github.com/bnema/dumber/internal/infrastructure/idle"27 "github.com/bnema/dumber/internal/infrastructure/idle"
28 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"28 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
29+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
29 "github.com/bnema/dumber/internal/infrastructure/snapshot"30 "github.com/bnema/dumber/internal/infrastructure/snapshot"
30 "github.com/bnema/dumber/internal/infrastructure/textinput"31 "github.com/bnema/dumber/internal/infrastructure/textinput"
31 "github.com/bnema/dumber/internal/infrastructure/updater"32 "github.com/bnema/dumber/internal/infrastructure/updater"
@@ -54,7 +55,7 @@ var initialURL string
54 var restoreSessionID string55 var restoreSessionID string
55 56
56 // browserLaunchRelay is shared for early browse handoff and in-app browser launches.57 // browserLaunchRelay is shared for early browse handoff and in-app browser launches.
57-var browserLaunchRelay = desktop.NewBrowserLaunchRelay(xdg.New())58+var browserLaunchRelay port.BrowserLaunchRelay
58 59
59 type launchMode string60 type launchMode string
60 61
@@ -97,6 +98,24 @@ func tryForwardBrowseURLToRunningInstance(ctx context.Context, relay port.Browse
97 return relay.DeliverOpenFreshWindow(ctx, browseURL)98 return relay.DeliverOpenFreshWindow(ctx, browseURL)
98 }99 }
99 100
101+func resolveBrowserLaunchRelay(cfg *config.Config) (port.BrowserLaunchRelay, error) {
102+ profile, err := bootstrap.ResolveRuntimeProfile(cfg)
103+ if err != nil {
104+ return nil, err
105+ }
106+ return desktop.NewBrowserLaunchRelay(profile.IPC), nil
107+}
108+
109+func configureBrowserLaunchRelay(cfg *config.Config) {
110+ relay, err := resolveBrowserLaunchRelay(cfg)
111+ if err != nil {
112+ fmt.Fprintf(os.Stderr, "warning: failed to resolve browser launch relay profile: %v\n", err)
113+ browserLaunchRelay = nil
114+ return
115+ }
116+ browserLaunchRelay = relay
117+}
118+
100 func main() {119 func main() {
101 enableCrashForensics()120 enableCrashForensics()
102 121
@@ -114,6 +133,8 @@ func main() {
114 mode, browseURL := launchModeFromArgs(os.Args)133 mode, browseURL := launchModeFromArgs(os.Args)
115 // Run GUI mode for browse command134 // Run GUI mode for browse command
116 if mode == launchModeBrowse {135 if mode == launchModeBrowse {
136+ cfg := initConfig()
137+ configureBrowserLaunchRelay(cfg)
117 if forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), browserLaunchRelay, browseURL); err != nil {138 if forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), browserLaunchRelay, browseURL); err != nil {
118 fmt.Fprintf(139 fmt.Fprintf(
119 os.Stderr,140 os.Stderr,
@@ -128,7 +149,7 @@ func main() {
128 initialURL = browseURL149 initialURL = browseURL
129 restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")150 restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION")
130 os.Args = os.Args[:1]151 os.Args = os.Args[:1]
131- os.Exit(runGUI())152+ os.Exit(runGUI(cfg))
132 return153 return
133 }154 }
134 155
@@ -149,14 +170,17 @@ func main() {
149 cmd.Execute()170 cmd.Execute()
150 }171 }
151 172
152-func runGUI() int {173+func runGUI(cfg *config.Config) int {
153 bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr)174 bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr)
154 175
155 runtime.LockOSThread()176 runtime.LockOSThread()
156 component.SetSkeletonVersion(version)177 component.SetSkeletonVersion(version)
157 timer := bootstrap.NewStartupTimer()178 timer := bootstrap.NewStartupTimer()
158 179
159- cfg := initConfig()180+ if cfg == nil {
181+ cfg = initConfig()
182+ configureBrowserLaunchRelay(cfg)
183+ }
160 timer.Mark("config")184 timer.Mark("config")
161 185
162 ctx := initStartupContextWithTrace(cfg)186 ctx := initStartupContextWithTrace(cfg)
@@ -232,6 +256,7 @@ func runStandaloneOmnibox() int {
232 component.SetSkeletonVersion(version)256 component.SetSkeletonVersion(version)
233 257
234 cfg := initConfig()258 cfg := initConfig()
259+ configureBrowserLaunchRelay(cfg)
235 ctx := initStartupContextWithTrace(cfg)260 ctx := initStartupContextWithTrace(cfg)
236 261
237 initResult, err := runParallelInitPhase(ctx, cfg)262 initResult, err := runParallelInitPhase(ctx, cfg)
@@ -260,6 +285,7 @@ func runStandaloneOmnibox() int {
260 uiDeps, err := buildUIDependencies(285 uiDeps, err := buildUIDependencies(
261 ctx,286 ctx,
262 cfg,287 cfg,
288+ initResult.RuntimeProfile,
263 initResult.ThemeManager,289 initResult.ThemeManager,
264 initResult.ColorResolver,290 initResult.ColorResolver,
265 initResult.AdwaitaDetector,291 initResult.AdwaitaDetector,
@@ -391,7 +417,7 @@ func buildAndConfigureApp(
391 browserSession *bootstrap.BrowserSession,417 browserSession *bootstrap.BrowserSession,
392 ) (*ui.App, error) {418 ) (*ui.App, error) {
393 uiDeps, err := buildUIDependencies(419 uiDeps, err := buildUIDependencies(
394- ctx, cfg, initResult.ThemeManager,420+ ctx, cfg, initResult.RuntimeProfile, initResult.ThemeManager,
395 initResult.ColorResolver, initResult.AdwaitaDetector,421 initResult.ColorResolver, initResult.AdwaitaDetector,
396 engine, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.CrashReports(),422 engine, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.CrashReports(),
397 )423 )
@@ -427,12 +453,11 @@ func initStackAndRepos(
427 if needsEagerDB {453 if needsEagerDB {
428 // Parallel phase 2: Database + engine initialize concurrently454 // Parallel phase 2: Database + engine initialize concurrently
429 dbEngine, err := bootstrap.RunParallelDBEngine(bootstrap.ParallelDBEngineInput{455 dbEngine, err := bootstrap.RunParallelDBEngine(bootstrap.ParallelDBEngineInput{
430- Ctx: ctx,456+ Ctx: ctx,
431- Config: cfg,457+ Config: cfg,
432- DataDir: initResult.DataDir,458+ RuntimeProfile: initResult.RuntimeProfile,
433- CacheDir: initResult.CacheDir,459+ ThemeManager: initResult.ThemeManager,
434- ThemeManager: initResult.ThemeManager,460+ ColorResolver: initResult.ColorResolver,
435- ColorResolver: initResult.ColorResolver,
436 })461 })
437 if err != nil {462 if err != nil {
438 return nil, nil, nil, err463 return nil, nil, nil, err
@@ -442,13 +467,12 @@ func initStackAndRepos(
442 467
443 log := logging.FromContext(ctx)468 log := logging.FromContext(ctx)
444 engine, err := bootstrap.BuildEngine(bootstrap.EngineInput{469 engine, err := bootstrap.BuildEngine(bootstrap.EngineInput{
445- Ctx: ctx,470+ Ctx: ctx,
446- Config: cfg,471+ Config: cfg,
447- DataDir: initResult.DataDir,472+ RuntimeProfile: initResult.RuntimeProfile,
448- CacheDir: initResult.CacheDir,473+ ThemeManager: initResult.ThemeManager,
449- ThemeManager: initResult.ThemeManager,474+ ColorResolver: initResult.ColorResolver,
450- ColorResolver: initResult.ColorResolver,475+ Logger: *log,
451- Logger: *log,
452 })476 })
453 if err != nil {477 if err != nil {
454 return nil, nil, nil, err478 return nil, nil, nil, err
@@ -717,6 +741,7 @@ func isCEFSubprocess() bool {
717 func buildUIDependencies(741 func buildUIDependencies(
718 ctx context.Context,742 ctx context.Context,
719 cfg *config.Config,743 cfg *config.Config,
744+ runtimeProfile runtimeprofile.Profile,
720 themeManager *theme.Manager,745 themeManager *theme.Manager,
721 colorResolver port.ColorSchemeResolver,746 colorResolver port.ColorSchemeResolver,
722 adwaitaDetector port.ToolkitAvailabilityNotifier,747 adwaitaDetector port.ToolkitAvailabilityNotifier,
@@ -782,7 +807,7 @@ func buildUIDependencies(
782 },807 },
783 CheckUpdateUC: uc.checkUpdate,808 CheckUpdateUC: uc.checkUpdate,
784 ApplyUpdateUC: uc.applyUpdate,809 ApplyUpdateUC: uc.applyUpdate,
785- SessionSpawner: bootstrap.NewSessionSpawner(ctx, cfg),810+ SessionSpawner: bootstrap.NewSessionSpawner(ctx, runtimeProfile),
786 FileSystem: filesystem.New(),811 FileSystem: filesystem.New(),
787 AccentFocusProvider: focusProvider,812 AccentFocusProvider: focusProvider,
788 NewGTKEntryTarget: func(entry *gtk.SearchEntry) port.TextInputTarget {813 NewGTKEntryTarget: func(entry *gtk.SearchEntry) port.TextInputTarget {
M internal/application/port/mocks/mocks.go +141 -0
@@ -730,6 +730,147 @@ func (_c *MockClipboard_WriteText_Call) RunAndReturn(run func(ctx context.Contex
730730 return _c
731731 }
732732
733+// NewMockClipboardTextOrchestrator creates a new instance of MockClipboardTextOrchestrator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
734+// The first argument is typically a *testing.T value.
735+func NewMockClipboardTextOrchestrator(t interface {
736+ mock.TestingT
737+ Cleanup(func())
738+}) *MockClipboardTextOrchestrator {
739+ mock := &MockClipboardTextOrchestrator{}
740+ mock.Mock.Test(t)
741+
742+ t.Cleanup(func() { mock.AssertExpectations(t) })
743+
744+ return mock
745+}
746+
747+// MockClipboardTextOrchestrator is an autogenerated mock type for the ClipboardTextOrchestrator type
748+type MockClipboardTextOrchestrator struct {
749+ mock.Mock
750+}
751+
752+type MockClipboardTextOrchestrator_Expecter struct {
753+ mock *mock.Mock
754+}
755+
756+func (_m *MockClipboardTextOrchestrator) EXPECT() *MockClipboardTextOrchestrator_Expecter {
757+ return &MockClipboardTextOrchestrator_Expecter{mock: &_m.Mock}
758+}
759+
760+// HandleExplicitCopy provides a mock function for the type MockClipboardTextOrchestrator
761+func (_mock *MockClipboardTextOrchestrator) HandleExplicitCopy(ctx context.Context, input port.ExplicitClipboardInput) error {
762+ ret := _mock.Called(ctx, input)
763+
764+ if len(ret) == 0 {
765+ panic("no return value specified for HandleExplicitCopy")
766+ }
767+
768+ var r0 error
769+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.ExplicitClipboardInput) error); ok {
770+ r0 = returnFunc(ctx, input)
771+ } else {
772+ r0 = ret.Error(0)
773+ }
774+ return r0
775+}
776+
777+// MockClipboardTextOrchestrator_HandleExplicitCopy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HandleExplicitCopy'
778+type MockClipboardTextOrchestrator_HandleExplicitCopy_Call struct {
779+ *mock.Call
780+}
781+
782+// HandleExplicitCopy is a helper method to define mock.On call
783+// - ctx context.Context
784+// - input port.ExplicitClipboardInput
785+func (_e *MockClipboardTextOrchestrator_Expecter) HandleExplicitCopy(ctx interface{}, input interface{}) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call {
786+ return &MockClipboardTextOrchestrator_HandleExplicitCopy_Call{Call: _e.mock.On("HandleExplicitCopy", ctx, input)}
787+}
788+
789+func (_c *MockClipboardTextOrchestrator_HandleExplicitCopy_Call) Run(run func(ctx context.Context, input port.ExplicitClipboardInput)) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call {
790+ _c.Call.Run(func(args mock.Arguments) {
791+ var arg0 context.Context
792+ if args[0] != nil {
793+ arg0 = args[0].(context.Context)
794+ }
795+ var arg1 port.ExplicitClipboardInput
796+ if args[1] != nil {
797+ arg1 = args[1].(port.ExplicitClipboardInput)
798+ }
799+ run(
800+ arg0,
801+ arg1,
802+ )
803+ })
804+ return _c
805+}
806+
807+func (_c *MockClipboardTextOrchestrator_HandleExplicitCopy_Call) Return(err error) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call {
808+ _c.Call.Return(err)
809+ return _c
810+}
811+
812+func (_c *MockClipboardTextOrchestrator_HandleExplicitCopy_Call) RunAndReturn(run func(ctx context.Context, input port.ExplicitClipboardInput) error) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call {
813+ _c.Call.Return(run)
814+ return _c
815+}
816+
817+// HandleSelectionUpdate provides a mock function for the type MockClipboardTextOrchestrator
818+func (_mock *MockClipboardTextOrchestrator) HandleSelectionUpdate(ctx context.Context, input port.SelectionClipboardInput) error {
819+ ret := _mock.Called(ctx, input)
820+
821+ if len(ret) == 0 {
822+ panic("no return value specified for HandleSelectionUpdate")
823+ }
824+
825+ var r0 error
826+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.SelectionClipboardInput) error); ok {
827+ r0 = returnFunc(ctx, input)
828+ } else {
829+ r0 = ret.Error(0)
830+ }
831+ return r0
832+}
833+
834+// MockClipboardTextOrchestrator_HandleSelectionUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HandleSelectionUpdate'
835+type MockClipboardTextOrchestrator_HandleSelectionUpdate_Call struct {
836+ *mock.Call
837+}
838+
839+// HandleSelectionUpdate is a helper method to define mock.On call
840+// - ctx context.Context
841+// - input port.SelectionClipboardInput
842+func (_e *MockClipboardTextOrchestrator_Expecter) HandleSelectionUpdate(ctx interface{}, input interface{}) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call {
843+ return &MockClipboardTextOrchestrator_HandleSelectionUpdate_Call{Call: _e.mock.On("HandleSelectionUpdate", ctx, input)}
844+}
845+
846+func (_c *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call) Run(run func(ctx context.Context, input port.SelectionClipboardInput)) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call {
847+ _c.Call.Run(func(args mock.Arguments) {
848+ var arg0 context.Context
849+ if args[0] != nil {
850+ arg0 = args[0].(context.Context)
851+ }
852+ var arg1 port.SelectionClipboardInput
853+ if args[1] != nil {
854+ arg1 = args[1].(port.SelectionClipboardInput)
855+ }
856+ run(
857+ arg0,
858+ arg1,
859+ )
860+ })
861+ return _c
862+}
863+
864+func (_c *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call) Return(err error) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call {
865+ _c.Call.Return(err)
866+ return _c
867+}
868+
869+func (_c *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call) RunAndReturn(run func(ctx context.Context, input port.SelectionClipboardInput) error) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call {
870+ _c.Call.Return(run)
871+ return _c
872+}
873+
733874 // NewMockColorSchemeDetector creates a new instance of MockColorSchemeDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
734875 // The first argument is typically a *testing.T value.
735876 func NewMockColorSchemeDetector(t interface {
@@ -730,6 +730,147 @@ func (_c *MockClipboard_WriteText_Call) RunAndReturn(run func(ctx context.Contex
730 return _c730 return _c
731 }731 }
732 732
733+// NewMockClipboardTextOrchestrator creates a new instance of MockClipboardTextOrchestrator. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
734+// The first argument is typically a *testing.T value.
735+func NewMockClipboardTextOrchestrator(t interface {
736+ mock.TestingT
737+ Cleanup(func())
738+}) *MockClipboardTextOrchestrator {
739+ mock := &MockClipboardTextOrchestrator{}
740+ mock.Mock.Test(t)
741+
742+ t.Cleanup(func() { mock.AssertExpectations(t) })
743+
744+ return mock
745+}
746+
747+// MockClipboardTextOrchestrator is an autogenerated mock type for the ClipboardTextOrchestrator type
748+type MockClipboardTextOrchestrator struct {
749+ mock.Mock
750+}
751+
752+type MockClipboardTextOrchestrator_Expecter struct {
753+ mock *mock.Mock
754+}
755+
756+func (_m *MockClipboardTextOrchestrator) EXPECT() *MockClipboardTextOrchestrator_Expecter {
757+ return &MockClipboardTextOrchestrator_Expecter{mock: &_m.Mock}
758+}
759+
760+// HandleExplicitCopy provides a mock function for the type MockClipboardTextOrchestrator
761+func (_mock *MockClipboardTextOrchestrator) HandleExplicitCopy(ctx context.Context, input port.ExplicitClipboardInput) error {
762+ ret := _mock.Called(ctx, input)
763+
764+ if len(ret) == 0 {
765+ panic("no return value specified for HandleExplicitCopy")
766+ }
767+
768+ var r0 error
769+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.ExplicitClipboardInput) error); ok {
770+ r0 = returnFunc(ctx, input)
771+ } else {
772+ r0 = ret.Error(0)
773+ }
774+ return r0
775+}
776+
777+// MockClipboardTextOrchestrator_HandleExplicitCopy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HandleExplicitCopy'
778+type MockClipboardTextOrchestrator_HandleExplicitCopy_Call struct {
779+ *mock.Call
780+}
781+
782+// HandleExplicitCopy is a helper method to define mock.On call
783+// - ctx context.Context
784+// - input port.ExplicitClipboardInput
785+func (_e *MockClipboardTextOrchestrator_Expecter) HandleExplicitCopy(ctx interface{}, input interface{}) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call {
786+ return &MockClipboardTextOrchestrator_HandleExplicitCopy_Call{Call: _e.mock.On("HandleExplicitCopy", ctx, input)}
787+}
788+
789+func (_c *MockClipboardTextOrchestrator_HandleExplicitCopy_Call) Run(run func(ctx context.Context, input port.ExplicitClipboardInput)) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call {
790+ _c.Call.Run(func(args mock.Arguments) {
791+ var arg0 context.Context
792+ if args[0] != nil {
793+ arg0 = args[0].(context.Context)
794+ }
795+ var arg1 port.ExplicitClipboardInput
796+ if args[1] != nil {
797+ arg1 = args[1].(port.ExplicitClipboardInput)
798+ }
799+ run(
800+ arg0,
801+ arg1,
802+ )
803+ })
804+ return _c
805+}
806+
807+func (_c *MockClipboardTextOrchestrator_HandleExplicitCopy_Call) Return(err error) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call {
808+ _c.Call.Return(err)
809+ return _c
810+}
811+
812+func (_c *MockClipboardTextOrchestrator_HandleExplicitCopy_Call) RunAndReturn(run func(ctx context.Context, input port.ExplicitClipboardInput) error) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call {
813+ _c.Call.Return(run)
814+ return _c
815+}
816+
817+// HandleSelectionUpdate provides a mock function for the type MockClipboardTextOrchestrator
818+func (_mock *MockClipboardTextOrchestrator) HandleSelectionUpdate(ctx context.Context, input port.SelectionClipboardInput) error {
819+ ret := _mock.Called(ctx, input)
820+
821+ if len(ret) == 0 {
822+ panic("no return value specified for HandleSelectionUpdate")
823+ }
824+
825+ var r0 error
826+ if returnFunc, ok := ret.Get(0).(func(context.Context, port.SelectionClipboardInput) error); ok {
827+ r0 = returnFunc(ctx, input)
828+ } else {
829+ r0 = ret.Error(0)
830+ }
831+ return r0
832+}
833+
834+// MockClipboardTextOrchestrator_HandleSelectionUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HandleSelectionUpdate'
835+type MockClipboardTextOrchestrator_HandleSelectionUpdate_Call struct {
836+ *mock.Call
837+}
838+
839+// HandleSelectionUpdate is a helper method to define mock.On call
840+// - ctx context.Context
841+// - input port.SelectionClipboardInput
842+func (_e *MockClipboardTextOrchestrator_Expecter) HandleSelectionUpdate(ctx interface{}, input interface{}) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call {
843+ return &MockClipboardTextOrchestrator_HandleSelectionUpdate_Call{Call: _e.mock.On("HandleSelectionUpdate", ctx, input)}
844+}
845+
846+func (_c *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call) Run(run func(ctx context.Context, input port.SelectionClipboardInput)) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call {
847+ _c.Call.Run(func(args mock.Arguments) {
848+ var arg0 context.Context
849+ if args[0] != nil {
850+ arg0 = args[0].(context.Context)
851+ }
852+ var arg1 port.SelectionClipboardInput
853+ if args[1] != nil {
854+ arg1 = args[1].(port.SelectionClipboardInput)
855+ }
856+ run(
857+ arg0,
858+ arg1,
859+ )
860+ })
861+ return _c
862+}
863+
864+func (_c *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call) Return(err error) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call {
865+ _c.Call.Return(err)
866+ return _c
867+}
868+
869+func (_c *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call) RunAndReturn(run func(ctx context.Context, input port.SelectionClipboardInput) error) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call {
870+ _c.Call.Return(run)
871+ return _c
872+}
873+
733 // NewMockColorSchemeDetector creates a new instance of MockColorSchemeDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.874 // NewMockColorSchemeDetector creates a new instance of MockColorSchemeDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations.
734 // The first argument is typically a *testing.T value.875 // The first argument is typically a *testing.T value.
735 func NewMockColorSchemeDetector(t interface {876 func NewMockColorSchemeDetector(t interface {
M internal/application/usecase/clipboard_text_orchestrator.go +10 -29
@@ -20,17 +20,12 @@ type ClipboardTextOrchestratorUseCase struct {
2020 autoCopyConfig port.AutoCopyConfig
2121 toast func(textLen int)
2222 mu sync.Mutex
23- lastSelection map[clipboardSelectionScope]string
24- lastExplicit map[clipboardExplicitScope]explicitCopyState
23+ lastSelection map[clipboardScope]string
24+ lastExplicit map[clipboardScope]explicitCopyState
2525 now func() time.Time
2626 }
2727
28-type clipboardSelectionScope struct {
29- source port.ClipboardSource
30- viewID port.WebViewID
31-}
32-
33-type clipboardExplicitScope struct {
28+type clipboardScope struct {
3429 source port.ClipboardSource
3530 viewID port.WebViewID
3631 }
@@ -52,8 +47,8 @@ func NewClipboardTextOrchestrator(
5247 clipboard: clipboard,
5348 autoCopyConfig: autoCopyConfig,
5449 toast: toast,
55- lastSelection: make(map[clipboardSelectionScope]string),
56- lastExplicit: make(map[clipboardExplicitScope]explicitCopyState),
50+ lastSelection: make(map[clipboardScope]string),
51+ lastExplicit: make(map[clipboardScope]explicitCopyState),
5752 now: time.Now,
5853 }
5954 }
@@ -66,9 +61,7 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate(
6661 if uc == nil || uc.autoCopyConfig == nil || !uc.autoCopyConfig.IsAutoCopyEnabled() {
6762 if uc != nil {
6863 uc.mu.Lock()
69- if uc.lastSelection != nil {
70- delete(uc.lastSelection, selectionScope(input))
71- }
64+ delete(uc.lastSelection, selectionScope(input))
7265 uc.mu.Unlock()
7366 }
7467 return nil
@@ -76,9 +69,6 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate(
7669
7770 if input.Text == "" {
7871 uc.mu.Lock()
79- if uc.lastSelection == nil {
80- uc.lastSelection = make(map[clipboardSelectionScope]string)
81- }
8272 delete(uc.lastSelection, selectionScope(input))
8373 uc.mu.Unlock()
8474 return nil
@@ -96,9 +86,6 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate(
9686 currentTime := now()
9787
9888 uc.mu.Lock()
99- if uc.lastSelection == nil {
100- uc.lastSelection = make(map[clipboardSelectionScope]string)
101- }
10289 if input.Text == uc.lastSelection[scope] {
10390 uc.mu.Unlock()
10491 return nil
@@ -113,9 +100,6 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate(
113100 }
114101
115102 uc.lastSelection[scope] = input.Text
116- if uc.lastExplicit == nil {
117- uc.lastExplicit = make(map[clipboardExplicitScope]explicitCopyState)
118- }
119103 explicitInput := port.ExplicitClipboardInput{SourceEngine: input.SourceEngine, ViewID: input.ViewID}
120104 uc.lastExplicit[explicitScope(explicitInput)] = explicitCopyState{
121105 text: input.Text,
@@ -151,9 +135,6 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy(
151135 scope := explicitScope(input)
152136
153137 uc.mu.Lock()
154- if uc.lastExplicit == nil {
155- uc.lastExplicit = make(map[clipboardExplicitScope]explicitCopyState)
156- }
157138 if state, ok := uc.lastExplicit[scope]; ok &&
158139 input.Text == state.text &&
159140 currentTime.Sub(state.at) < explicitClipboardDedupWindow {
@@ -186,10 +167,10 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy(
186167 return nil
187168 }
188169
189-func selectionScope(input port.SelectionClipboardInput) clipboardSelectionScope {
190- return clipboardSelectionScope{source: input.SourceEngine, viewID: input.ViewID}
170+func selectionScope(input port.SelectionClipboardInput) clipboardScope {
171+ return clipboardScope{source: input.SourceEngine, viewID: input.ViewID}
191172 }
192173
193-func explicitScope(input port.ExplicitClipboardInput) clipboardExplicitScope {
194- return clipboardExplicitScope{source: input.SourceEngine, viewID: input.ViewID}
174+func explicitScope(input port.ExplicitClipboardInput) clipboardScope {
175+ return clipboardScope{source: input.SourceEngine, viewID: input.ViewID}
195176 }
@@ -20,17 +20,12 @@ type ClipboardTextOrchestratorUseCase struct {
20 autoCopyConfig port.AutoCopyConfig20 autoCopyConfig port.AutoCopyConfig
21 toast func(textLen int)21 toast func(textLen int)
22 mu sync.Mutex22 mu sync.Mutex
23- lastSelection map[clipboardSelectionScope]string23+ lastSelection map[clipboardScope]string
24- lastExplicit map[clipboardExplicitScope]explicitCopyState24+ lastExplicit map[clipboardScope]explicitCopyState
25 now func() time.Time25 now func() time.Time
26 }26 }
27 27
28-type clipboardSelectionScope struct {28+type clipboardScope struct {
29- source port.ClipboardSource
30- viewID port.WebViewID
31-}
32-
33-type clipboardExplicitScope struct {
34 source port.ClipboardSource29 source port.ClipboardSource
35 viewID port.WebViewID30 viewID port.WebViewID
36 }31 }
@@ -52,8 +47,8 @@ func NewClipboardTextOrchestrator(
52 clipboard: clipboard,47 clipboard: clipboard,
53 autoCopyConfig: autoCopyConfig,48 autoCopyConfig: autoCopyConfig,
54 toast: toast,49 toast: toast,
55- lastSelection: make(map[clipboardSelectionScope]string),50+ lastSelection: make(map[clipboardScope]string),
56- lastExplicit: make(map[clipboardExplicitScope]explicitCopyState),51+ lastExplicit: make(map[clipboardScope]explicitCopyState),
57 now: time.Now,52 now: time.Now,
58 }53 }
59 }54 }
@@ -66,9 +61,7 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate(
66 if uc == nil || uc.autoCopyConfig == nil || !uc.autoCopyConfig.IsAutoCopyEnabled() {61 if uc == nil || uc.autoCopyConfig == nil || !uc.autoCopyConfig.IsAutoCopyEnabled() {
67 if uc != nil {62 if uc != nil {
68 uc.mu.Lock()63 uc.mu.Lock()
69- if uc.lastSelection != nil {64+ delete(uc.lastSelection, selectionScope(input))
70- delete(uc.lastSelection, selectionScope(input))
71- }
72 uc.mu.Unlock()65 uc.mu.Unlock()
73 }66 }
74 return nil67 return nil
@@ -76,9 +69,6 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate(
76 69
77 if input.Text == "" {70 if input.Text == "" {
78 uc.mu.Lock()71 uc.mu.Lock()
79- if uc.lastSelection == nil {
80- uc.lastSelection = make(map[clipboardSelectionScope]string)
81- }
82 delete(uc.lastSelection, selectionScope(input))72 delete(uc.lastSelection, selectionScope(input))
83 uc.mu.Unlock()73 uc.mu.Unlock()
84 return nil74 return nil
@@ -96,9 +86,6 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate(
96 currentTime := now()86 currentTime := now()
97 87
98 uc.mu.Lock()88 uc.mu.Lock()
99- if uc.lastSelection == nil {
100- uc.lastSelection = make(map[clipboardSelectionScope]string)
101- }
102 if input.Text == uc.lastSelection[scope] {89 if input.Text == uc.lastSelection[scope] {
103 uc.mu.Unlock()90 uc.mu.Unlock()
104 return nil91 return nil
@@ -113,9 +100,6 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate(
113 }100 }
114 101
115 uc.lastSelection[scope] = input.Text102 uc.lastSelection[scope] = input.Text
116- if uc.lastExplicit == nil {
117- uc.lastExplicit = make(map[clipboardExplicitScope]explicitCopyState)
118- }
119 explicitInput := port.ExplicitClipboardInput{SourceEngine: input.SourceEngine, ViewID: input.ViewID}103 explicitInput := port.ExplicitClipboardInput{SourceEngine: input.SourceEngine, ViewID: input.ViewID}
120 uc.lastExplicit[explicitScope(explicitInput)] = explicitCopyState{104 uc.lastExplicit[explicitScope(explicitInput)] = explicitCopyState{
121 text: input.Text,105 text: input.Text,
@@ -151,9 +135,6 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy(
151 scope := explicitScope(input)135 scope := explicitScope(input)
152 136
153 uc.mu.Lock()137 uc.mu.Lock()
154- if uc.lastExplicit == nil {
155- uc.lastExplicit = make(map[clipboardExplicitScope]explicitCopyState)
156- }
157 if state, ok := uc.lastExplicit[scope]; ok &&138 if state, ok := uc.lastExplicit[scope]; ok &&
158 input.Text == state.text &&139 input.Text == state.text &&
159 currentTime.Sub(state.at) < explicitClipboardDedupWindow {140 currentTime.Sub(state.at) < explicitClipboardDedupWindow {
@@ -186,10 +167,10 @@ func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy(
186 return nil167 return nil
187 }168 }
188 169
189-func selectionScope(input port.SelectionClipboardInput) clipboardSelectionScope {170+func selectionScope(input port.SelectionClipboardInput) clipboardScope {
190- return clipboardSelectionScope{source: input.SourceEngine, viewID: input.ViewID}171+ return clipboardScope{source: input.SourceEngine, viewID: input.ViewID}
191 }172 }
192 173
193-func explicitScope(input port.ExplicitClipboardInput) clipboardExplicitScope {174+func explicitScope(input port.ExplicitClipboardInput) clipboardScope {
194- return clipboardExplicitScope{source: input.SourceEngine, viewID: input.ViewID}175+ return clipboardScope{source: input.SourceEngine, viewID: input.ViewID}
195 }176 }
M internal/application/usecase/clipboard_text_orchestrator_test.go +3 -3
@@ -295,13 +295,13 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_KeepsBoundedStatePerScope(
295295 clipboard.EXPECT().WriteText(ctx, "three").Return(nil).Once()
296296
297297 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)
298- uc.now = func() time.Time { return time.Unix(0, 0) }
299298
300- for _, text := range []string{"one", "two", "three"} {
299+ for i, text := range []string{"one", "two", "three"} {
300+ advancedTime := time.Unix(0, 0).Add(time.Duration(i) * time.Second)
301+ uc.now = func() time.Time { return advancedTime }
301302 if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: text, SourceEngine: port.ClipboardSourceCEF, ViewID: 7, Action: "copy"}); err != nil {
302303 t.Fatalf("HandleExplicitCopy(%q) error = %v, want nil", text, err)
303304 }
304- uc.now = func() time.Time { return time.Unix(0, 0).Add(time.Second) }
305305 }
306306
307307 if got := len(uc.lastExplicit); got != 1 {
@@ -295,13 +295,13 @@ func TestClipboardTextOrchestrator_HandleExplicitCopy_KeepsBoundedStatePerScope(
295 clipboard.EXPECT().WriteText(ctx, "three").Return(nil).Once()295 clipboard.EXPECT().WriteText(ctx, "three").Return(nil).Once()
296 296
297 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)297 uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)
298- uc.now = func() time.Time { return time.Unix(0, 0) }
299 298
300- for _, text := range []string{"one", "two", "three"} {299+ for i, text := range []string{"one", "two", "three"} {
300+ advancedTime := time.Unix(0, 0).Add(time.Duration(i) * time.Second)
301+ uc.now = func() time.Time { return advancedTime }
301 if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: text, SourceEngine: port.ClipboardSourceCEF, ViewID: 7, Action: "copy"}); err != nil {302 if err := uc.HandleExplicitCopy(ctx, port.ExplicitClipboardInput{Text: text, SourceEngine: port.ClipboardSourceCEF, ViewID: 7, Action: "copy"}); err != nil {
302 t.Fatalf("HandleExplicitCopy(%q) error = %v, want nil", text, err)303 t.Fatalf("HandleExplicitCopy(%q) error = %v, want nil", text, err)
303 }304 }
304- uc.now = func() time.Time { return time.Unix(0, 0).Add(time.Second) }
305 }305 }
306 306
307 if got := len(uc.lastExplicit); got != 1 {307 if got := len(uc.lastExplicit); got != 1 {
M internal/bootstrap/engine.go +27 -13
@@ -13,6 +13,7 @@ import (
1313 "github.com/bnema/dumber/internal/infrastructure/config"
1414 "github.com/bnema/dumber/internal/infrastructure/env"
1515 "github.com/bnema/dumber/internal/infrastructure/handlers"
16+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
1617 "github.com/bnema/dumber/internal/infrastructure/transcoder"
1718 "github.com/bnema/dumber/internal/infrastructure/webkit"
1819 "github.com/bnema/dumber/internal/ui/theme"
@@ -21,13 +22,12 @@ import (
2122
2223 // EngineInput holds the input for BuildEngine.
2324 type EngineInput struct {
24- Ctx context.Context
25- Config *config.Config
26- DataDir string
27- CacheDir string
28- ThemeManager *theme.Manager
29- ColorResolver port.ColorSchemeResolver
30- Logger zerolog.Logger
25+ Ctx context.Context
26+ Config *config.Config
27+ RuntimeProfile runtimeprofile.Profile
28+ ThemeManager *theme.Manager
29+ ColorResolver port.ColorSchemeResolver
30+ Logger zerolog.Logger
3131 }
3232
3333 // BuildEngine constructs a port.Engine for the engine type specified in cfg.Engine.Type.
@@ -38,23 +38,27 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
3838 engineType := cfg.Engine.ResolveEngineType()
3939 switch engineType {
4040 case config.EngineTypeWebKit:
41+ profile, err := requireRuntimeProfile(input.RuntimeProfile, engineType)
42+ if err != nil {
43+ return nil, err
44+ }
4145 opts := port.EngineOptions{
42- DataDir: input.DataDir,
43- CacheDir: input.CacheDir,
4446 CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy),
4547 }
4648 wkCfg := webkit.EngineConfigFromConfig(cfg.Engine.WebKit)
4749
4850 return webkit.NewEngine(
49- input.Ctx, cfg, opts, wkCfg,
51+ input.Ctx, cfg, opts, profile, wkCfg,
5052 input.ThemeManager, input.ColorResolver,
5153 contextMenuBuilder, contextMenuExecutorFactory,
5254 input.Logger,
5355 )
5456 case config.EngineTypeCEF:
57+ profile, err := requireRuntimeProfile(input.RuntimeProfile, engineType)
58+ if err != nil {
59+ return nil, err
60+ }
5561 opts := port.EngineOptions{
56- DataDir: input.DataDir,
57- CacheDir: input.CacheDir,
5862 CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy),
5963 }
6064 cefCfg := cef.RuntimeConfig{
@@ -102,8 +106,18 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
102106 },
103107 }
104108 audioFactory := audiofactory.NewAudioOutputFactory()
105- return cef.NewEngine(input.Ctx, opts, cefCfg, transcodingCfg, audioFactory, deps)
109+ return cef.NewEngine(input.Ctx, opts, profile, cefCfg, transcodingCfg, audioFactory, deps)
106110 default:
107111 return nil, fmt.Errorf("unknown engine type: %q", engineType)
108112 }
109113 }
114+
115+func requireRuntimeProfile(profile runtimeprofile.Profile, engineType string) (runtimeprofile.Profile, error) {
116+ if profile.Engine == "" {
117+ return runtimeprofile.Profile{}, fmt.Errorf("missing runtime profile for %s engine", engineType)
118+ }
119+ if profile.Engine != engineType {
120+ return runtimeprofile.Profile{}, fmt.Errorf("runtime profile engine %q does not match %q", profile.Engine, engineType)
121+ }
122+ return profile, nil
123+}
@@ -13,6 +13,7 @@ import (
13 "github.com/bnema/dumber/internal/infrastructure/config"13 "github.com/bnema/dumber/internal/infrastructure/config"
14 "github.com/bnema/dumber/internal/infrastructure/env"14 "github.com/bnema/dumber/internal/infrastructure/env"
15 "github.com/bnema/dumber/internal/infrastructure/handlers"15 "github.com/bnema/dumber/internal/infrastructure/handlers"
16+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
16 "github.com/bnema/dumber/internal/infrastructure/transcoder"17 "github.com/bnema/dumber/internal/infrastructure/transcoder"
17 "github.com/bnema/dumber/internal/infrastructure/webkit"18 "github.com/bnema/dumber/internal/infrastructure/webkit"
18 "github.com/bnema/dumber/internal/ui/theme"19 "github.com/bnema/dumber/internal/ui/theme"
@@ -21,13 +22,12 @@ import (
21 22
22 // EngineInput holds the input for BuildEngine.23 // EngineInput holds the input for BuildEngine.
23 type EngineInput struct {24 type EngineInput struct {
24- Ctx context.Context25+ Ctx context.Context
25- Config *config.Config26+ Config *config.Config
26- DataDir string27+ RuntimeProfile runtimeprofile.Profile
27- CacheDir string28+ ThemeManager *theme.Manager
28- ThemeManager *theme.Manager29+ ColorResolver port.ColorSchemeResolver
29- ColorResolver port.ColorSchemeResolver30+ Logger zerolog.Logger
30- Logger zerolog.Logger
31 }31 }
32 32
33 // BuildEngine constructs a port.Engine for the engine type specified in cfg.Engine.Type.33 // BuildEngine constructs a port.Engine for the engine type specified in cfg.Engine.Type.
@@ -38,23 +38,27 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
38 engineType := cfg.Engine.ResolveEngineType()38 engineType := cfg.Engine.ResolveEngineType()
39 switch engineType {39 switch engineType {
40 case config.EngineTypeWebKit:40 case config.EngineTypeWebKit:
41+ profile, err := requireRuntimeProfile(input.RuntimeProfile, engineType)
42+ if err != nil {
43+ return nil, err
44+ }
41 opts := port.EngineOptions{45 opts := port.EngineOptions{
42- DataDir: input.DataDir,
43- CacheDir: input.CacheDir,
44 CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy),46 CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy),
45 }47 }
46 wkCfg := webkit.EngineConfigFromConfig(cfg.Engine.WebKit)48 wkCfg := webkit.EngineConfigFromConfig(cfg.Engine.WebKit)
47 49
48 return webkit.NewEngine(50 return webkit.NewEngine(
49- input.Ctx, cfg, opts, wkCfg,51+ input.Ctx, cfg, opts, profile, wkCfg,
50 input.ThemeManager, input.ColorResolver,52 input.ThemeManager, input.ColorResolver,
51 contextMenuBuilder, contextMenuExecutorFactory,53 contextMenuBuilder, contextMenuExecutorFactory,
52 input.Logger,54 input.Logger,
53 )55 )
54 case config.EngineTypeCEF:56 case config.EngineTypeCEF:
57+ profile, err := requireRuntimeProfile(input.RuntimeProfile, engineType)
58+ if err != nil {
59+ return nil, err
60+ }
55 opts := port.EngineOptions{61 opts := port.EngineOptions{
56- DataDir: input.DataDir,
57- CacheDir: input.CacheDir,
58 CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy),62 CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy),
59 }63 }
60 cefCfg := cef.RuntimeConfig{64 cefCfg := cef.RuntimeConfig{
@@ -102,8 +106,18 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
102 },106 },
103 }107 }
104 audioFactory := audiofactory.NewAudioOutputFactory()108 audioFactory := audiofactory.NewAudioOutputFactory()
105- return cef.NewEngine(input.Ctx, opts, cefCfg, transcodingCfg, audioFactory, deps)109+ return cef.NewEngine(input.Ctx, opts, profile, cefCfg, transcodingCfg, audioFactory, deps)
106 default:110 default:
107 return nil, fmt.Errorf("unknown engine type: %q", engineType)111 return nil, fmt.Errorf("unknown engine type: %q", engineType)
108 }112 }
109 }113 }
114+
115+func requireRuntimeProfile(profile runtimeprofile.Profile, engineType string) (runtimeprofile.Profile, error) {
116+ if profile.Engine == "" {
117+ return runtimeprofile.Profile{}, fmt.Errorf("missing runtime profile for %s engine", engineType)
118+ }
119+ if profile.Engine != engineType {
120+ return runtimeprofile.Profile{}, fmt.Errorf("runtime profile engine %q does not match %q", profile.Engine, engineType)
121+ }
122+ return profile, nil
123+}
M internal/bootstrap/engine_test.go +33 -3
@@ -2,10 +2,12 @@ package bootstrap
22
33 import (
44 "context"
5+ "path/filepath"
56 "testing"
67
78 "github.com/bnema/dumber/internal/infrastructure/cef"
89 "github.com/bnema/dumber/internal/infrastructure/config"
10+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
911 "github.com/stretchr/testify/require"
1012 )
1113
@@ -21,7 +23,7 @@ func TestBuildEngine_CEF_ReturnsErrorWhenRuntimeUnavailable(t *testing.T) {
2123 cfg := config.DefaultConfig()
2224 cfg.Engine.Type = "cef"
2325 cfg.Engine.CEF.CEFDir = t.TempDir()
24- _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background()})
26+ _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background(), RuntimeProfile: testRuntimeProfile(t)})
2527 require.Error(t, err)
2628 require.Contains(t, err.Error(), "cef.InitWithApp")
2729 }
@@ -30,7 +32,7 @@ func TestBuildEngine_CEF_ReturnsErrorForUnsupportedCookiePolicy(t *testing.T) {
3032 cfg := config.DefaultConfig()
3133 cfg.Engine.Type = "cef"
3234 cfg.Engine.CookiePolicy = config.CookiePolicyNever
33- _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background()})
35+ _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background(), RuntimeProfile: testRuntimeProfile(t)})
3436 require.ErrorIs(t, err, cef.ErrCookiePolicyUnsupported)
3537 }
3638
@@ -39,6 +41,34 @@ func TestBuildEngine_CEF_AllowsNoThirdPartyCookiePolicy(t *testing.T) {
3941 cfg.Engine.Type = "cef"
4042 cfg.Engine.CookiePolicy = config.CookiePolicyNoThirdParty
4143 cfg.Engine.CEF.CEFDir = t.TempDir()
42- _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background()})
44+ _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background(), RuntimeProfile: testRuntimeProfile(t)})
4345 require.NotErrorIs(t, err, cef.ErrCookiePolicyUnsupported)
4446 }
47+
48+func TestBuildEngine_ReceivesResolvedProfile(t *testing.T) {
49+ cfg := config.DefaultConfig()
50+ cfg.Engine.Type = config.EngineTypeCEF
51+ cfg.Engine.CEF.CEFDir = t.TempDir()
52+
53+ _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background(), RuntimeProfile: testRuntimeProfile(t)})
54+ require.Error(t, err)
55+ require.NotContains(t, err.Error(), "missing runtime profile")
56+}
57+
58+func testRuntimeProfile(t *testing.T) runtimeprofile.Profile {
59+ t.Helper()
60+ root := t.TempDir()
61+ profile, err := runtimeprofile.Resolve(runtimeprofile.ResolveInput{
62+ Env: func(string) string { return "" },
63+ Engine: config.EngineTypeCEF,
64+ CWD: func() (string, error) { return root, nil },
65+ Base: runtimeprofile.BasePaths{
66+ ConfigHome: filepath.Join(root, "config"),
67+ DataHome: filepath.Join(root, "data"),
68+ StateHome: filepath.Join(root, "state"),
69+ CacheHome: filepath.Join(root, "cache"),
70+ },
71+ })
72+ require.NoError(t, err)
73+ return profile
74+}
@@ -2,10 +2,12 @@ package bootstrap
2 2
3 import (3 import (
4 "context"4 "context"
5+ "path/filepath"
5 "testing"6 "testing"
6 7
7 "github.com/bnema/dumber/internal/infrastructure/cef"8 "github.com/bnema/dumber/internal/infrastructure/cef"
8 "github.com/bnema/dumber/internal/infrastructure/config"9 "github.com/bnema/dumber/internal/infrastructure/config"
10+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
9 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
10 )12 )
11 13
@@ -21,7 +23,7 @@ func TestBuildEngine_CEF_ReturnsErrorWhenRuntimeUnavailable(t *testing.T) {
21 cfg := config.DefaultConfig()23 cfg := config.DefaultConfig()
22 cfg.Engine.Type = "cef"24 cfg.Engine.Type = "cef"
23 cfg.Engine.CEF.CEFDir = t.TempDir()25 cfg.Engine.CEF.CEFDir = t.TempDir()
24- _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background()})26+ _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background(), RuntimeProfile: testRuntimeProfile(t)})
25 require.Error(t, err)27 require.Error(t, err)
26 require.Contains(t, err.Error(), "cef.InitWithApp")28 require.Contains(t, err.Error(), "cef.InitWithApp")
27 }29 }
@@ -30,7 +32,7 @@ func TestBuildEngine_CEF_ReturnsErrorForUnsupportedCookiePolicy(t *testing.T) {
30 cfg := config.DefaultConfig()32 cfg := config.DefaultConfig()
31 cfg.Engine.Type = "cef"33 cfg.Engine.Type = "cef"
32 cfg.Engine.CookiePolicy = config.CookiePolicyNever34 cfg.Engine.CookiePolicy = config.CookiePolicyNever
33- _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background()})35+ _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background(), RuntimeProfile: testRuntimeProfile(t)})
34 require.ErrorIs(t, err, cef.ErrCookiePolicyUnsupported)36 require.ErrorIs(t, err, cef.ErrCookiePolicyUnsupported)
35 }37 }
36 38
@@ -39,6 +41,34 @@ func TestBuildEngine_CEF_AllowsNoThirdPartyCookiePolicy(t *testing.T) {
39 cfg.Engine.Type = "cef"41 cfg.Engine.Type = "cef"
40 cfg.Engine.CookiePolicy = config.CookiePolicyNoThirdParty42 cfg.Engine.CookiePolicy = config.CookiePolicyNoThirdParty
41 cfg.Engine.CEF.CEFDir = t.TempDir()43 cfg.Engine.CEF.CEFDir = t.TempDir()
42- _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background()})44+ _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background(), RuntimeProfile: testRuntimeProfile(t)})
43 require.NotErrorIs(t, err, cef.ErrCookiePolicyUnsupported)45 require.NotErrorIs(t, err, cef.ErrCookiePolicyUnsupported)
44 }46 }
47+
48+func TestBuildEngine_ReceivesResolvedProfile(t *testing.T) {
49+ cfg := config.DefaultConfig()
50+ cfg.Engine.Type = config.EngineTypeCEF
51+ cfg.Engine.CEF.CEFDir = t.TempDir()
52+
53+ _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background(), RuntimeProfile: testRuntimeProfile(t)})
54+ require.Error(t, err)
55+ require.NotContains(t, err.Error(), "missing runtime profile")
56+}
57+
58+func testRuntimeProfile(t *testing.T) runtimeprofile.Profile {
59+ t.Helper()
60+ root := t.TempDir()
61+ profile, err := runtimeprofile.Resolve(runtimeprofile.ResolveInput{
62+ Env: func(string) string { return "" },
63+ Engine: config.EngineTypeCEF,
64+ CWD: func() (string, error) { return root, nil },
65+ Base: runtimeprofile.BasePaths{
66+ ConfigHome: filepath.Join(root, "config"),
67+ DataHome: filepath.Join(root, "data"),
68+ StateHome: filepath.Join(root, "state"),
69+ CacheHome: filepath.Join(root, "cache"),
70+ },
71+ })
72+ require.NoError(t, err)
73+ return profile
74+}
M internal/bootstrap/gui.go +31 -35
@@ -6,7 +6,6 @@ import (
66 "database/sql"
77 "fmt"
88 "os"
9- "path/filepath"
109 "sync"
1110 "time"
1211
@@ -18,6 +17,7 @@ import (
1817 "github.com/bnema/dumber/internal/infrastructure/env"
1918 "github.com/bnema/dumber/internal/infrastructure/media"
2019 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
20+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
2121 "github.com/bnema/dumber/internal/logging"
2222 "github.com/bnema/dumber/internal/ui/theme"
2323 )
@@ -30,8 +30,7 @@ type DatabaseResult struct {
3030
3131 // ParallelInitResult holds the results of parallel initialization phase.
3232 type ParallelInitResult struct {
33- DataDir string
34- CacheDir string
33+ RuntimeProfile runtimeprofile.Profile
3534 ThemeManager *theme.Manager
3635 ColorResolver port.ColorSchemeResolver
3736 AdwaitaDetector *colorscheme.AdwaitaDetector
@@ -91,11 +90,15 @@ func (e *RuntimeRequirementsError) LogDetails(ctx context.Context) {
9190 // Returns the first fatal error encountered, or nil with the results.
9291 func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
9392 var (
94- dataDir, cacheDir string
95- dirsErr error
96- wg sync.WaitGroup
93+ dirsErr error
94+ wg sync.WaitGroup
9795 )
9896
97+ profile, err := ResolveRuntimeProfile(input.Config)
98+ if err != nil {
99+ return nil, fmt.Errorf("resolve runtime profile: %w", err)
100+ }
101+
99102 start := time.Now()
100103
101104 // Create color scheme resolver with initial detectors.
@@ -116,7 +119,7 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
116119 // Resolve directories
117120 go func() {
118121 defer wg.Done()
119- dataDir, cacheDir, dirsErr = resolveWebKitDirs()
122+ dirsErr = resolveWebKitDirs(profile)
120123 }()
121124
122125 // Theme manager (CPU-bound, no I/O)
@@ -138,8 +141,7 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
138141 }
139142
140143 return &ParallelInitResult{
141- DataDir: dataDir,
142- CacheDir: cacheDir,
144+ RuntimeProfile: profile,
143145 ThemeManager: themeManager,
144146 ColorResolver: resolver,
145147 AdwaitaDetector: adwaitaDetector,
@@ -218,21 +220,17 @@ func CheckMediaRequirements(ctx context.Context, cfg *config.Config) error {
218220 }
219221
220222 // resolveWebKitDirs resolves and creates the data and cache directories for WebKit.
221-func resolveWebKitDirs() (dataDir, cacheDir string, err error) {
222- const cacheDirPerm = 0o755
223- dataDir, err = config.GetDataDir()
224- if err != nil {
225- return "", "", fmt.Errorf("resolve data directory: %w", err)
223+func resolveWebKitDirs(profile runtimeprofile.Profile) error {
224+ const dirPerm = 0o755
225+ dataDir := profile.WebKitDataDir()
226+ cacheDir := profile.WebKitCacheDir()
227+ if mkErr := os.MkdirAll(dataDir, dirPerm); mkErr != nil {
228+ return fmt.Errorf("create data directory %s: %w", dataDir, mkErr)
226229 }
227- stateDir, err := config.GetStateDir()
228- if err != nil {
229- return "", "", fmt.Errorf("resolve state directory: %w", err)
230- }
231- cacheDir = filepath.Join(stateDir, "webkit-cache")
232- if mkErr := os.MkdirAll(cacheDir, cacheDirPerm); mkErr != nil {
233- return "", "", fmt.Errorf("create cache directory %s: %w", cacheDir, mkErr)
230+ if mkErr := os.MkdirAll(cacheDir, dirPerm); mkErr != nil {
231+ return fmt.Errorf("create cache directory %s: %w", cacheDir, mkErr)
234232 }
235- return dataDir, cacheDir, nil
233+ return nil
236234 }
237235
238236 // OpenDatabase opens and initializes the SQLite database using XDG paths.
@@ -277,12 +275,11 @@ type ParallelDBEngineResult struct {
277275
278276 // ParallelDBEngineInput holds inputs for parallel DB and engine initialization.
279277 type ParallelDBEngineInput struct {
280- Ctx context.Context
281- Config *config.Config
282- DataDir string // For engine context
283- CacheDir string // For engine cache
284- ThemeManager *theme.Manager
285- ColorResolver port.ColorSchemeResolver
278+ Ctx context.Context
279+ Config *config.Config
280+ RuntimeProfile runtimeprofile.Profile
281+ ThemeManager *theme.Manager
282+ ColorResolver port.ColorSchemeResolver
286283 }
287284
288285 // Note: Database path is resolved via config.GetDatabaseFile() internally.
@@ -317,13 +314,12 @@ func RunParallelDBEngine(input ParallelDBEngineInput) (*ParallelDBEngineResult,
317314
318315 // Engine on main thread (GTK requirement)
319316 engine, err := BuildEngine(EngineInput{
320- Ctx: input.Ctx,
321- Config: input.Config,
322- DataDir: input.DataDir,
323- CacheDir: input.CacheDir,
324- ThemeManager: input.ThemeManager,
325- ColorResolver: input.ColorResolver,
326- Logger: *log,
317+ Ctx: input.Ctx,
318+ Config: input.Config,
319+ RuntimeProfile: input.RuntimeProfile,
320+ ThemeManager: input.ThemeManager,
321+ ColorResolver: input.ColorResolver,
322+ Logger: *log,
327323 })
328324 if err != nil {
329325 dbRes := <-dbCh
@@ -6,7 +6,6 @@ import (
6 "database/sql"6 "database/sql"
7 "fmt"7 "fmt"
8 "os"8 "os"
9- "path/filepath"
10 "sync"9 "sync"
11 "time"10 "time"
12 11
@@ -18,6 +17,7 @@ import (
18 "github.com/bnema/dumber/internal/infrastructure/env"17 "github.com/bnema/dumber/internal/infrastructure/env"
19 "github.com/bnema/dumber/internal/infrastructure/media"18 "github.com/bnema/dumber/internal/infrastructure/media"
20 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"19 "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite"
20+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
21 "github.com/bnema/dumber/internal/logging"21 "github.com/bnema/dumber/internal/logging"
22 "github.com/bnema/dumber/internal/ui/theme"22 "github.com/bnema/dumber/internal/ui/theme"
23 )23 )
@@ -30,8 +30,7 @@ type DatabaseResult struct {
30 30
31 // ParallelInitResult holds the results of parallel initialization phase.31 // ParallelInitResult holds the results of parallel initialization phase.
32 type ParallelInitResult struct {32 type ParallelInitResult struct {
33- DataDir string33+ RuntimeProfile runtimeprofile.Profile
34- CacheDir string
35 ThemeManager *theme.Manager34 ThemeManager *theme.Manager
36 ColorResolver port.ColorSchemeResolver35 ColorResolver port.ColorSchemeResolver
37 AdwaitaDetector *colorscheme.AdwaitaDetector36 AdwaitaDetector *colorscheme.AdwaitaDetector
@@ -91,11 +90,15 @@ func (e *RuntimeRequirementsError) LogDetails(ctx context.Context) {
91 // Returns the first fatal error encountered, or nil with the results.90 // Returns the first fatal error encountered, or nil with the results.
92 func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {91 func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
93 var (92 var (
94- dataDir, cacheDir string93+ dirsErr error
95- dirsErr error94+ wg sync.WaitGroup
96- wg sync.WaitGroup
97 )95 )
98 96
97+ profile, err := ResolveRuntimeProfile(input.Config)
98+ if err != nil {
99+ return nil, fmt.Errorf("resolve runtime profile: %w", err)
100+ }
101+
99 start := time.Now()102 start := time.Now()
100 103
101 // Create color scheme resolver with initial detectors.104 // Create color scheme resolver with initial detectors.
@@ -116,7 +119,7 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
116 // Resolve directories119 // Resolve directories
117 go func() {120 go func() {
118 defer wg.Done()121 defer wg.Done()
119- dataDir, cacheDir, dirsErr = resolveWebKitDirs()122+ dirsErr = resolveWebKitDirs(profile)
120 }()123 }()
121 124
122 // Theme manager (CPU-bound, no I/O)125 // Theme manager (CPU-bound, no I/O)
@@ -138,8 +141,7 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) {
138 }141 }
139 142
140 return &ParallelInitResult{143 return &ParallelInitResult{
141- DataDir: dataDir,144+ RuntimeProfile: profile,
142- CacheDir: cacheDir,
143 ThemeManager: themeManager,145 ThemeManager: themeManager,
144 ColorResolver: resolver,146 ColorResolver: resolver,
145 AdwaitaDetector: adwaitaDetector,147 AdwaitaDetector: adwaitaDetector,
@@ -218,21 +220,17 @@ func CheckMediaRequirements(ctx context.Context, cfg *config.Config) error {
218 }220 }
219 221
220 // resolveWebKitDirs resolves and creates the data and cache directories for WebKit.222 // resolveWebKitDirs resolves and creates the data and cache directories for WebKit.
221-func resolveWebKitDirs() (dataDir, cacheDir string, err error) {223+func resolveWebKitDirs(profile runtimeprofile.Profile) error {
222- const cacheDirPerm = 0o755224+ const dirPerm = 0o755
223- dataDir, err = config.GetDataDir()225+ dataDir := profile.WebKitDataDir()
224- if err != nil {226+ cacheDir := profile.WebKitCacheDir()
225- return "", "", fmt.Errorf("resolve data directory: %w", err)227+ if mkErr := os.MkdirAll(dataDir, dirPerm); mkErr != nil {
228+ return fmt.Errorf("create data directory %s: %w", dataDir, mkErr)
226 }229 }
227- stateDir, err := config.GetStateDir()230+ if mkErr := os.MkdirAll(cacheDir, dirPerm); mkErr != nil {
228- if err != nil {231+ return fmt.Errorf("create cache directory %s: %w", cacheDir, mkErr)
229- return "", "", fmt.Errorf("resolve state directory: %w", err)
230- }
231- cacheDir = filepath.Join(stateDir, "webkit-cache")
232- if mkErr := os.MkdirAll(cacheDir, cacheDirPerm); mkErr != nil {
233- return "", "", fmt.Errorf("create cache directory %s: %w", cacheDir, mkErr)
234 }232 }
235- return dataDir, cacheDir, nil233+ return nil
236 }234 }
237 235
238 // OpenDatabase opens and initializes the SQLite database using XDG paths.236 // OpenDatabase opens and initializes the SQLite database using XDG paths.
@@ -277,12 +275,11 @@ type ParallelDBEngineResult struct {
277 275
278 // ParallelDBEngineInput holds inputs for parallel DB and engine initialization.276 // ParallelDBEngineInput holds inputs for parallel DB and engine initialization.
279 type ParallelDBEngineInput struct {277 type ParallelDBEngineInput struct {
280- Ctx context.Context278+ Ctx context.Context
281- Config *config.Config279+ Config *config.Config
282- DataDir string // For engine context280+ RuntimeProfile runtimeprofile.Profile
283- CacheDir string // For engine cache281+ ThemeManager *theme.Manager
284- ThemeManager *theme.Manager282+ ColorResolver port.ColorSchemeResolver
285- ColorResolver port.ColorSchemeResolver
286 }283 }
287 284
288 // Note: Database path is resolved via config.GetDatabaseFile() internally.285 // Note: Database path is resolved via config.GetDatabaseFile() internally.
@@ -317,13 +314,12 @@ func RunParallelDBEngine(input ParallelDBEngineInput) (*ParallelDBEngineResult,
317 314
318 // Engine on main thread (GTK requirement)315 // Engine on main thread (GTK requirement)
319 engine, err := BuildEngine(EngineInput{316 engine, err := BuildEngine(EngineInput{
320- Ctx: input.Ctx,317+ Ctx: input.Ctx,
321- Config: input.Config,318+ Config: input.Config,
322- DataDir: input.DataDir,319+ RuntimeProfile: input.RuntimeProfile,
323- CacheDir: input.CacheDir,320+ ThemeManager: input.ThemeManager,
324- ThemeManager: input.ThemeManager,321+ ColorResolver: input.ColorResolver,
325- ColorResolver: input.ColorResolver,322+ Logger: *log,
326- Logger: *log,
327 })323 })
328 if err != nil {324 if err != nil {
329 dbRes := <-dbCh325 dbRes := <-dbCh
A internal/bootstrap/runtime_profile.go +33 -0
@@ -0,0 +1,33 @@
1+package bootstrap
2+
3+import (
4+ "os"
5+
6+ "github.com/bnema/dumber/internal/infrastructure/config"
7+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
8+)
9+
10+// ResolveRuntimeProfile resolves the current mode+engine profile once in bootstrap.
11+func ResolveRuntimeProfile(cfg *config.Config) (runtimeprofile.Profile, error) {
12+ engine := config.EngineTypeWebKit
13+ if cfg != nil {
14+ engine = cfg.Engine.ResolveEngineType()
15+ }
16+
17+ dirs, err := config.GetXDGDirs()
18+ if err != nil {
19+ return runtimeprofile.Profile{}, err
20+ }
21+
22+ return runtimeprofile.Resolve(runtimeprofile.ResolveInput{
23+ Env: os.Getenv,
24+ CWD: os.Getwd,
25+ Engine: engine,
26+ Base: runtimeprofile.BasePaths{
27+ ConfigHome: dirs.ConfigHome,
28+ DataHome: dirs.DataHome,
29+ StateHome: dirs.StateHome,
30+ CacheHome: dirs.CacheHome,
31+ },
32+ })
33+}
@@ -0,0 +1,33 @@
1+package bootstrap
2+
3+import (
4+ "os"
5+
6+ "github.com/bnema/dumber/internal/infrastructure/config"
7+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
8+)
9+
10+// ResolveRuntimeProfile resolves the current mode+engine profile once in bootstrap.
11+func ResolveRuntimeProfile(cfg *config.Config) (runtimeprofile.Profile, error) {
12+ engine := config.EngineTypeWebKit
13+ if cfg != nil {
14+ engine = cfg.Engine.ResolveEngineType()
15+ }
16+
17+ dirs, err := config.GetXDGDirs()
18+ if err != nil {
19+ return runtimeprofile.Profile{}, err
20+ }
21+
22+ return runtimeprofile.Resolve(runtimeprofile.ResolveInput{
23+ Env: os.Getenv,
24+ CWD: os.Getwd,
25+ Engine: engine,
26+ Base: runtimeprofile.BasePaths{
27+ ConfigHome: dirs.ConfigHome,
28+ DataHome: dirs.DataHome,
29+ StateHome: dirs.StateHome,
30+ CacheHome: dirs.CacheHome,
31+ },
32+ })
33+}
A internal/bootstrap/runtime_profile_test.go +60 -0
@@ -0,0 +1,60 @@
1+package bootstrap
2+
3+import (
4+ "path/filepath"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestResolveRuntimeProfile_UsesConfigEngineResolution(t *testing.T) {
12+ cfg := config.DefaultConfig()
13+ cfg.Engine.Type = "webkit"
14+ t.Setenv("DUMBER_ENGINE", "cef")
15+ t.Setenv("ENV", "")
16+ home := t.TempDir()
17+ t.Setenv("HOME", home)
18+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
19+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
20+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
21+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
22+
23+ profile, err := ResolveRuntimeProfile(cfg)
24+ require.NoError(t, err)
25+ require.Equal(t, "cef", profile.Engine)
26+}
27+
28+func TestResolveRuntimeProfile_ProdPreservesSharedPaths(t *testing.T) {
29+ cfg := config.DefaultConfig()
30+ cfg.Engine.Type = config.EngineTypeWebKit
31+ t.Setenv("ENV", "")
32+ home := t.TempDir()
33+ t.Setenv("HOME", home)
34+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
35+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
36+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
37+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
38+
39+ profile, err := ResolveRuntimeProfile(cfg)
40+ require.NoError(t, err)
41+ require.Equal(t, filepath.Join(home, ".config", "dumber"), profile.Shared.ConfigDir)
42+ require.Equal(t, filepath.Join(home, ".local", "share", "dumber"), profile.Shared.DataDir)
43+ require.Equal(t, filepath.Join(home, ".local", "state", "dumber"), profile.Shared.StateDir)
44+ require.Equal(t, filepath.Join(home, ".cache", "dumber"), profile.Shared.CacheDir)
45+}
46+
47+func TestResolveRuntimeProfile_DevUsesEngineSpecificNamespaces(t *testing.T) {
48+ cfg := config.DefaultConfig()
49+ cfg.Engine.Type = config.EngineTypeCEF
50+
51+ wd := t.TempDir()
52+ t.Chdir(wd)
53+
54+ t.Setenv("ENV", "dev")
55+ profile, err := ResolveRuntimeProfile(cfg)
56+ require.NoError(t, err)
57+ require.Equal(t, filepath.Join(wd, ".dev", "dumber", "state"), profile.Shared.StateDir)
58+ require.Equal(t, filepath.Join(wd, ".dev", "dumber", "engines", "cef", "data"), profile.CEFUserDataDir())
59+ require.Equal(t, filepath.Join(wd, ".dev", "dumber", "runtime", "cef", "browser-launch.sock"), profile.IPC.BrowserLaunchSocket)
60+}
@@ -0,0 +1,60 @@
1+package bootstrap
2+
3+import (
4+ "path/filepath"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/stretchr/testify/require"
9+)
10+
11+func TestResolveRuntimeProfile_UsesConfigEngineResolution(t *testing.T) {
12+ cfg := config.DefaultConfig()
13+ cfg.Engine.Type = "webkit"
14+ t.Setenv("DUMBER_ENGINE", "cef")
15+ t.Setenv("ENV", "")
16+ home := t.TempDir()
17+ t.Setenv("HOME", home)
18+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
19+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
20+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
21+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
22+
23+ profile, err := ResolveRuntimeProfile(cfg)
24+ require.NoError(t, err)
25+ require.Equal(t, "cef", profile.Engine)
26+}
27+
28+func TestResolveRuntimeProfile_ProdPreservesSharedPaths(t *testing.T) {
29+ cfg := config.DefaultConfig()
30+ cfg.Engine.Type = config.EngineTypeWebKit
31+ t.Setenv("ENV", "")
32+ home := t.TempDir()
33+ t.Setenv("HOME", home)
34+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
35+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
36+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
37+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
38+
39+ profile, err := ResolveRuntimeProfile(cfg)
40+ require.NoError(t, err)
41+ require.Equal(t, filepath.Join(home, ".config", "dumber"), profile.Shared.ConfigDir)
42+ require.Equal(t, filepath.Join(home, ".local", "share", "dumber"), profile.Shared.DataDir)
43+ require.Equal(t, filepath.Join(home, ".local", "state", "dumber"), profile.Shared.StateDir)
44+ require.Equal(t, filepath.Join(home, ".cache", "dumber"), profile.Shared.CacheDir)
45+}
46+
47+func TestResolveRuntimeProfile_DevUsesEngineSpecificNamespaces(t *testing.T) {
48+ cfg := config.DefaultConfig()
49+ cfg.Engine.Type = config.EngineTypeCEF
50+
51+ wd := t.TempDir()
52+ t.Chdir(wd)
53+
54+ t.Setenv("ENV", "dev")
55+ profile, err := ResolveRuntimeProfile(cfg)
56+ require.NoError(t, err)
57+ require.Equal(t, filepath.Join(wd, ".dev", "dumber", "state"), profile.Shared.StateDir)
58+ require.Equal(t, filepath.Join(wd, ".dev", "dumber", "engines", "cef", "data"), profile.CEFUserDataDir())
59+ require.Equal(t, filepath.Join(wd, ".dev", "dumber", "runtime", "cef", "browser-launch.sock"), profile.IPC.BrowserLaunchSocket)
60+}
M internal/bootstrap/session_spawner.go +4 -3
@@ -7,14 +7,15 @@ import (
77 "github.com/bnema/dumber/internal/infrastructure/cef"
88 "github.com/bnema/dumber/internal/infrastructure/config"
99 "github.com/bnema/dumber/internal/infrastructure/desktop"
10+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
1011 )
1112
1213 // NewSessionSpawner wires the session spawner with any engine-specific launch
1314 // environment required by the current browser backend.
14-func NewSessionSpawner(ctx context.Context, cfg *config.Config) port.SessionSpawner {
15+func NewSessionSpawner(ctx context.Context, profile runtimeprofile.Profile) port.SessionSpawner {
1516 var spawnEnv port.SessionSpawnEnvironment
16- if cfg != nil && cfg.Engine.ResolveEngineType() == config.EngineTypeCEF {
17- spawnEnv = cef.SessionSpawnEnvironment{}
17+ if profile.Engine == config.EngineTypeCEF {
18+ spawnEnv = cef.NewSessionSpawnEnvironment(profile)
1819 }
1920 return desktop.NewSessionSpawner(ctx, spawnEnv)
2021 }
@@ -7,14 +7,15 @@ import (
7 "github.com/bnema/dumber/internal/infrastructure/cef"7 "github.com/bnema/dumber/internal/infrastructure/cef"
8 "github.com/bnema/dumber/internal/infrastructure/config"8 "github.com/bnema/dumber/internal/infrastructure/config"
9 "github.com/bnema/dumber/internal/infrastructure/desktop"9 "github.com/bnema/dumber/internal/infrastructure/desktop"
10+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
10 )11 )
11 12
12 // NewSessionSpawner wires the session spawner with any engine-specific launch13 // NewSessionSpawner wires the session spawner with any engine-specific launch
13 // environment required by the current browser backend.14 // environment required by the current browser backend.
14-func NewSessionSpawner(ctx context.Context, cfg *config.Config) port.SessionSpawner {15+func NewSessionSpawner(ctx context.Context, profile runtimeprofile.Profile) port.SessionSpawner {
15 var spawnEnv port.SessionSpawnEnvironment16 var spawnEnv port.SessionSpawnEnvironment
16- if cfg != nil && cfg.Engine.ResolveEngineType() == config.EngineTypeCEF {17+ if profile.Engine == config.EngineTypeCEF {
17- spawnEnv = cef.SessionSpawnEnvironment{}18+ spawnEnv = cef.NewSessionSpawnEnvironment(profile)
18 }19 }
19 return desktop.NewSessionSpawner(ctx, spawnEnv)20 return desktop.NewSessionSpawner(ctx, spawnEnv)
20 }21 }
M internal/cli/app.go +5 -1
@@ -51,6 +51,10 @@ func NewApp() (*App, error) {
5151 var err error
5252 // Load config
5353 cfg := loadConfig()
54+ profile, err := bootstrap.ResolveRuntimeProfile(cfg)
55+ if err != nil {
56+ return nil, fmt.Errorf("resolve runtime profile: %w", err)
57+ }
5458
5559 // Create theme from config
5660 theme := styles.NewTheme(cfg)
@@ -133,7 +137,7 @@ func NewApp() (*App, error) {
133137 RestoreUC: restoreUC,
134138 DeleteSessionUC: deleteSessionUC,
135139 FaviconService: faviconService,
136- SessionSpawner: bootstrap.NewSessionSpawner(ctx, cfg),
140+ SessionSpawner: bootstrap.NewSessionSpawner(ctx, profile),
137141 ctx: ctx,
138142 logCleanup: logCleanup,
139143 }, nil
@@ -51,6 +51,10 @@ func NewApp() (*App, error) {
51 var err error51 var err error
52 // Load config52 // Load config
53 cfg := loadConfig()53 cfg := loadConfig()
54+ profile, err := bootstrap.ResolveRuntimeProfile(cfg)
55+ if err != nil {
56+ return nil, fmt.Errorf("resolve runtime profile: %w", err)
57+ }
54 58
55 // Create theme from config59 // Create theme from config
56 theme := styles.NewTheme(cfg)60 theme := styles.NewTheme(cfg)
@@ -133,7 +137,7 @@ func NewApp() (*App, error) {
133 RestoreUC: restoreUC,137 RestoreUC: restoreUC,
134 DeleteSessionUC: deleteSessionUC,138 DeleteSessionUC: deleteSessionUC,
135 FaviconService: faviconService,139 FaviconService: faviconService,
136- SessionSpawner: bootstrap.NewSessionSpawner(ctx, cfg),140+ SessionSpawner: bootstrap.NewSessionSpawner(ctx, profile),
137 ctx: ctx,141 ctx: ctx,
138 logCleanup: logCleanup,142 logCleanup: logCleanup,
139 }, nil143 }, nil
M internal/infrastructure/cef/engine.go +1 -2
@@ -296,7 +296,6 @@ func (e *Engine) handleExplicitClipboardBridgeText(viewID port.WebViewID, action
296296 Msg("cef: clipboard orchestration skipped — orchestrator nil")
297297 return
298298 }
299- textLen := utf8.RuneCountInString(text)
300299 if err := e.clipboardTextOrchestrator.HandleExplicitCopy(e.currentContext(), port.ExplicitClipboardInput{
301300 Text: text,
302301 Action: action,
@@ -306,7 +305,7 @@ func (e *Engine) handleExplicitClipboardBridgeText(viewID port.WebViewID, action
306305 logging.FromContext(e.currentContext()).Debug().
307306 Err(err).
308307 Str("action", action).
309- Int("text_len", textLen).
308+ Int("text_len", utf8.RuneCountInString(text)).
310309 Msg("cef: clipboard explicit copy handling failed")
311310 }
312311 }
@@ -296,7 +296,6 @@ func (e *Engine) handleExplicitClipboardBridgeText(viewID port.WebViewID, action
296 Msg("cef: clipboard orchestration skipped — orchestrator nil")296 Msg("cef: clipboard orchestration skipped — orchestrator nil")
297 return297 return
298 }298 }
299- textLen := utf8.RuneCountInString(text)
300 if err := e.clipboardTextOrchestrator.HandleExplicitCopy(e.currentContext(), port.ExplicitClipboardInput{299 if err := e.clipboardTextOrchestrator.HandleExplicitCopy(e.currentContext(), port.ExplicitClipboardInput{
301 Text: text,300 Text: text,
302 Action: action,301 Action: action,
@@ -306,7 +305,7 @@ func (e *Engine) handleExplicitClipboardBridgeText(viewID port.WebViewID, action
306 logging.FromContext(e.currentContext()).Debug().305 logging.FromContext(e.currentContext()).Debug().
307 Err(err).306 Err(err).
308 Str("action", action).307 Str("action", action).
309- Int("text_len", textLen).308+ Int("text_len", utf8.RuneCountInString(text)).
310 Msg("cef: clipboard explicit copy handling failed")309 Msg("cef: clipboard explicit copy handling failed")
311 }310 }
312 }311 }
M internal/infrastructure/cef/engine_init.go +24 -53
@@ -16,6 +16,7 @@ import (
1616 "github.com/bnema/dumber/assets"
1717 "github.com/bnema/dumber/internal/application/port"
1818 "github.com/bnema/dumber/internal/infrastructure/config"
19+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
1920 transcoderpkg "github.com/bnema/dumber/internal/infrastructure/transcoder"
2021 "github.com/bnema/dumber/internal/logging"
2122 )
@@ -27,6 +28,7 @@ const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH"
2728 func NewEngine(
2829 ctx context.Context,
2930 opts port.EngineOptions,
31+ profile runtimeprofile.Profile,
3032 cfg RuntimeConfig,
3133 transcodingCfg TranscodingRuntimeConfig,
3234 audioFactory port.AudioOutputFactory,
@@ -34,11 +36,11 @@ func NewEngine(
3436 ) (*Engine, error) {
3537 logger := logging.FromContext(ctx)
3638 deps.MediaClassifier = deps.MediaClassifier.normalize()
37- stateRoot := resolvedStateRoot(opts)
39+ stateRoot := resolvedStateRoot(profile, opts)
3840 cleanStaleSingletonLocks(logger, stateRoot)
3941 windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate()
4042
41- settings, err := prepareCEFSettings(opts, cfg, logger)
43+ settings, err := prepareCEFSettings(opts, profile, cfg, logger)
4244 if err != nil {
4345 return nil, err
4446 }
@@ -88,23 +90,23 @@ func NewEngine(
8890 )
8991 }
9092
91-func resolvedStateRoot(opts port.EngineOptions) string {
93+func resolvedStateRoot(profile runtimeprofile.Profile, opts port.EngineOptions) string {
9294 if root := os.Getenv(CEFRootCachePathEnvVar); root != "" {
9395 return root
9496 }
95-
96- switch {
97- case opts.DataDir != "":
97+ if opts.DataDir != "" {
9898 return opts.DataDir
99- case opts.CacheDir != "":
100- return opts.CacheDir
101- default:
102- return defaultCEFUserDataDir()
10399 }
100+ return profile.CEFUserDataDir()
104101 }
105102
106103 // prepareCEFSettings builds purecef.Settings from the engine config.
107-func prepareCEFSettings(opts port.EngineOptions, cfg RuntimeConfig, logger *zerolog.Logger) (purecef.Settings, error) {
104+func prepareCEFSettings(
105+ opts port.EngineOptions,
106+ profile runtimeprofile.Profile,
107+ cfg RuntimeConfig,
108+ logger *zerolog.Logger,
109+) (purecef.Settings, error) {
108110 switch opts.CookiePolicy {
109111 case "", port.CookiePolicyAlways:
110112 case port.CookiePolicyNoThirdParty:
@@ -120,16 +122,16 @@ func prepareCEFSettings(opts port.EngineOptions, cfg RuntimeConfig, logger *zero
120122 settings := purecef.DefaultSettings()
121123 settings.MultiThreadedMessageLoop = true
122124 settings.ExternalMessagePump = false
123- settings.RootCachePath = resolvedStateRoot(opts)
125+ settings.RootCachePath = resolvedStateRoot(profile, opts)
124126 if cfg.CEFDir != "" {
125127 settings.CEFDir = cfg.CEFDir
126128 }
127- if runtimeLogFile, err := prepareCEFLogFile(cfg.LogFile); err != nil {
129+ if runtimeLogFile, err := prepareCEFLogFile(profile, cfg.LogFile); err != nil {
128130 logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file")
129131 } else {
130132 settings.LogFile = runtimeLogFile
131133 }
132- if bootstrapLogFile, err := prepareCEFInitTraceFile(cfg.LogFile); err != nil {
134+ if bootstrapLogFile, err := prepareCEFInitTraceFile(profile, cfg.LogFile); err != nil {
133135 logger.Warn().Err(err).Msg("cef: failed to prepare init trace file")
134136 } else if bootstrapLogFile != "" {
135137 settings.InitTraceFile = bootstrapLogFile
@@ -150,24 +152,6 @@ func prepareCEFSettings(opts port.EngineOptions, cfg RuntimeConfig, logger *zero
150152 return settings, nil
151153 }
152154
153-func defaultCEFUserDataDir() string {
154- // Keep CEF state under Dumber's XDG data directory like the rest of runtime data.
155- dirs, err := config.GetXDGDirs()
156- if err != nil {
157- dataHome := os.Getenv("XDG_DATA_HOME")
158- if dataHome == "" {
159- dataHome = filepath.Join(os.Getenv("HOME"), ".local", "share")
160- }
161- return filepath.Clean(filepath.Join(dataHome, "dumber", "cef_user_data"))
162- }
163- return filepath.Join(dirs.DataHome, "cef_user_data")
164-}
165-
166-// DefaultCEFUserDataDir returns the default CEF state root.
167-func DefaultCEFUserDataDir() string {
168- return defaultCEFUserDataDir()
169-}
170-
171155 // initializeCEF calls cef_initialize with the App to register custom schemes
172156 // and browser-process callbacks (OnBeforeCommandLineProcessing, etc.).
173157 func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logger) error {
@@ -419,29 +403,20 @@ const (
419403 filePerm = 0o644
420404 )
421405
422-func prepareCEFLogFile(logFile string) (string, error) {
423- if logFile != "" {
424- runtimeLogFile := filepath.Clean(logFile)
425- if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil {
426- return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err)
427- }
428- return runtimeLogFile, nil
429- }
430-
431- // Dev-mode default: logs go into .dev/dumber/logs/ relative to CWD.
432- // Production deployments should set cfg.LogFile explicitly.
433- cwd, cwdErr := os.Getwd()
434- if cwdErr != nil {
435- return "", fmt.Errorf("getwd: %w", cwdErr)
406+func prepareCEFLogFile(profile runtimeprofile.Profile, logFile string) (string, error) {
407+ runtimeLogFile := logFile
408+ if runtimeLogFile != "" {
409+ runtimeLogFile = filepath.Clean(runtimeLogFile)
410+ } else {
411+ runtimeLogFile = profile.CEFLogFile()
436412 }
437- runtimeLogFile := filepath.Join(cwd, ".dev", "dumber", "logs", "cef_runtime.log")
438413 if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil {
439414 return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err)
440415 }
441416 return runtimeLogFile, nil
442417 }
443418
444-func prepareCEFInitTraceFile(logFile string) (string, error) {
419+func prepareCEFInitTraceFile(profile runtimeprofile.Profile, logFile string) (string, error) {
445420 if !puregoCEFInitTraceEnabled() {
446421 return "", nil
447422 }
@@ -450,11 +425,7 @@ func prepareCEFInitTraceFile(logFile string) (string, error) {
450425 if runtimeLogFile != "" {
451426 runtimeLogFile = filepath.Clean(runtimeLogFile)
452427 } else {
453- cwd, cwdErr := os.Getwd()
454- if cwdErr != nil {
455- return "", fmt.Errorf("getwd: %w", cwdErr)
456- }
457- runtimeLogFile = filepath.Join(cwd, ".dev", "dumber", "logs", "cef_runtime.log")
428+ runtimeLogFile = profile.CEFLogFile()
458429 }
459430 bootstrapLogFile := strings.TrimSuffix(runtimeLogFile, filepath.Ext(runtimeLogFile)) + ".bootstrap.log"
460431 if err := os.MkdirAll(filepath.Dir(bootstrapLogFile), dirPerm); err != nil {
@@ -16,6 +16,7 @@ import (
16 "github.com/bnema/dumber/assets"16 "github.com/bnema/dumber/assets"
17 "github.com/bnema/dumber/internal/application/port"17 "github.com/bnema/dumber/internal/application/port"
18 "github.com/bnema/dumber/internal/infrastructure/config"18 "github.com/bnema/dumber/internal/infrastructure/config"
19+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
19 transcoderpkg "github.com/bnema/dumber/internal/infrastructure/transcoder"20 transcoderpkg "github.com/bnema/dumber/internal/infrastructure/transcoder"
20 "github.com/bnema/dumber/internal/logging"21 "github.com/bnema/dumber/internal/logging"
21 )22 )
@@ -27,6 +28,7 @@ const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH"
27 func NewEngine(28 func NewEngine(
28 ctx context.Context,29 ctx context.Context,
29 opts port.EngineOptions,30 opts port.EngineOptions,
31+ profile runtimeprofile.Profile,
30 cfg RuntimeConfig,32 cfg RuntimeConfig,
31 transcodingCfg TranscodingRuntimeConfig,33 transcodingCfg TranscodingRuntimeConfig,
32 audioFactory port.AudioOutputFactory,34 audioFactory port.AudioOutputFactory,
@@ -34,11 +36,11 @@ func NewEngine(
34 ) (*Engine, error) {36 ) (*Engine, error) {
35 logger := logging.FromContext(ctx)37 logger := logging.FromContext(ctx)
36 deps.MediaClassifier = deps.MediaClassifier.normalize()38 deps.MediaClassifier = deps.MediaClassifier.normalize()
37- stateRoot := resolvedStateRoot(opts)39+ stateRoot := resolvedStateRoot(profile, opts)
38 cleanStaleSingletonLocks(logger, stateRoot)40 cleanStaleSingletonLocks(logger, stateRoot)
39 windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate()41 windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate()
40 42
41- settings, err := prepareCEFSettings(opts, cfg, logger)43+ settings, err := prepareCEFSettings(opts, profile, cfg, logger)
42 if err != nil {44 if err != nil {
43 return nil, err45 return nil, err
44 }46 }
@@ -88,23 +90,23 @@ func NewEngine(
88 )90 )
89 }91 }
90 92
91-func resolvedStateRoot(opts port.EngineOptions) string {93+func resolvedStateRoot(profile runtimeprofile.Profile, opts port.EngineOptions) string {
92 if root := os.Getenv(CEFRootCachePathEnvVar); root != "" {94 if root := os.Getenv(CEFRootCachePathEnvVar); root != "" {
93 return root95 return root
94 }96 }
95-97+ if opts.DataDir != "" {
96- switch {
97- case opts.DataDir != "":
98 return opts.DataDir98 return opts.DataDir
99- case opts.CacheDir != "":
100- return opts.CacheDir
101- default:
102- return defaultCEFUserDataDir()
103 }99 }
100+ return profile.CEFUserDataDir()
104 }101 }
105 102
106 // prepareCEFSettings builds purecef.Settings from the engine config.103 // prepareCEFSettings builds purecef.Settings from the engine config.
107-func prepareCEFSettings(opts port.EngineOptions, cfg RuntimeConfig, logger *zerolog.Logger) (purecef.Settings, error) {104+func prepareCEFSettings(
105+ opts port.EngineOptions,
106+ profile runtimeprofile.Profile,
107+ cfg RuntimeConfig,
108+ logger *zerolog.Logger,
109+) (purecef.Settings, error) {
108 switch opts.CookiePolicy {110 switch opts.CookiePolicy {
109 case "", port.CookiePolicyAlways:111 case "", port.CookiePolicyAlways:
110 case port.CookiePolicyNoThirdParty:112 case port.CookiePolicyNoThirdParty:
@@ -120,16 +122,16 @@ func prepareCEFSettings(opts port.EngineOptions, cfg RuntimeConfig, logger *zero
120 settings := purecef.DefaultSettings()122 settings := purecef.DefaultSettings()
121 settings.MultiThreadedMessageLoop = true123 settings.MultiThreadedMessageLoop = true
122 settings.ExternalMessagePump = false124 settings.ExternalMessagePump = false
123- settings.RootCachePath = resolvedStateRoot(opts)125+ settings.RootCachePath = resolvedStateRoot(profile, opts)
124 if cfg.CEFDir != "" {126 if cfg.CEFDir != "" {
125 settings.CEFDir = cfg.CEFDir127 settings.CEFDir = cfg.CEFDir
126 }128 }
127- if runtimeLogFile, err := prepareCEFLogFile(cfg.LogFile); err != nil {129+ if runtimeLogFile, err := prepareCEFLogFile(profile, cfg.LogFile); err != nil {
128 logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file")130 logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file")
129 } else {131 } else {
130 settings.LogFile = runtimeLogFile132 settings.LogFile = runtimeLogFile
131 }133 }
132- if bootstrapLogFile, err := prepareCEFInitTraceFile(cfg.LogFile); err != nil {134+ if bootstrapLogFile, err := prepareCEFInitTraceFile(profile, cfg.LogFile); err != nil {
133 logger.Warn().Err(err).Msg("cef: failed to prepare init trace file")135 logger.Warn().Err(err).Msg("cef: failed to prepare init trace file")
134 } else if bootstrapLogFile != "" {136 } else if bootstrapLogFile != "" {
135 settings.InitTraceFile = bootstrapLogFile137 settings.InitTraceFile = bootstrapLogFile
@@ -150,24 +152,6 @@ func prepareCEFSettings(opts port.EngineOptions, cfg RuntimeConfig, logger *zero
150 return settings, nil152 return settings, nil
151 }153 }
152 154
153-func defaultCEFUserDataDir() string {
154- // Keep CEF state under Dumber's XDG data directory like the rest of runtime data.
155- dirs, err := config.GetXDGDirs()
156- if err != nil {
157- dataHome := os.Getenv("XDG_DATA_HOME")
158- if dataHome == "" {
159- dataHome = filepath.Join(os.Getenv("HOME"), ".local", "share")
160- }
161- return filepath.Clean(filepath.Join(dataHome, "dumber", "cef_user_data"))
162- }
163- return filepath.Join(dirs.DataHome, "cef_user_data")
164-}
165-
166-// DefaultCEFUserDataDir returns the default CEF state root.
167-func DefaultCEFUserDataDir() string {
168- return defaultCEFUserDataDir()
169-}
170-
171 // initializeCEF calls cef_initialize with the App to register custom schemes155 // initializeCEF calls cef_initialize with the App to register custom schemes
172 // and browser-process callbacks (OnBeforeCommandLineProcessing, etc.).156 // and browser-process callbacks (OnBeforeCommandLineProcessing, etc.).
173 func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logger) error {157 func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logger) error {
@@ -419,29 +403,20 @@ const (
419 filePerm = 0o644403 filePerm = 0o644
420 )404 )
421 405
422-func prepareCEFLogFile(logFile string) (string, error) {406+func prepareCEFLogFile(profile runtimeprofile.Profile, logFile string) (string, error) {
423- if logFile != "" {407+ runtimeLogFile := logFile
424- runtimeLogFile := filepath.Clean(logFile)408+ if runtimeLogFile != "" {
425- if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil {409+ runtimeLogFile = filepath.Clean(runtimeLogFile)
426- return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err)410+ } else {
427- }411+ runtimeLogFile = profile.CEFLogFile()
428- return runtimeLogFile, nil
429- }
430-
431- // Dev-mode default: logs go into .dev/dumber/logs/ relative to CWD.
432- // Production deployments should set cfg.LogFile explicitly.
433- cwd, cwdErr := os.Getwd()
434- if cwdErr != nil {
435- return "", fmt.Errorf("getwd: %w", cwdErr)
436 }412 }
437- runtimeLogFile := filepath.Join(cwd, ".dev", "dumber", "logs", "cef_runtime.log")
438 if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil {413 if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil {
439 return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err)414 return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err)
440 }415 }
441 return runtimeLogFile, nil416 return runtimeLogFile, nil
442 }417 }
443 418
444-func prepareCEFInitTraceFile(logFile string) (string, error) {419+func prepareCEFInitTraceFile(profile runtimeprofile.Profile, logFile string) (string, error) {
445 if !puregoCEFInitTraceEnabled() {420 if !puregoCEFInitTraceEnabled() {
446 return "", nil421 return "", nil
447 }422 }
@@ -450,11 +425,7 @@ func prepareCEFInitTraceFile(logFile string) (string, error) {
450 if runtimeLogFile != "" {425 if runtimeLogFile != "" {
451 runtimeLogFile = filepath.Clean(runtimeLogFile)426 runtimeLogFile = filepath.Clean(runtimeLogFile)
452 } else {427 } else {
453- cwd, cwdErr := os.Getwd()428+ runtimeLogFile = profile.CEFLogFile()
454- if cwdErr != nil {
455- return "", fmt.Errorf("getwd: %w", cwdErr)
456- }
457- runtimeLogFile = filepath.Join(cwd, ".dev", "dumber", "logs", "cef_runtime.log")
458 }429 }
459 bootstrapLogFile := strings.TrimSuffix(runtimeLogFile, filepath.Ext(runtimeLogFile)) + ".bootstrap.log"430 bootstrapLogFile := strings.TrimSuffix(runtimeLogFile, filepath.Ext(runtimeLogFile)) + ".bootstrap.log"
460 if err := os.MkdirAll(filepath.Dir(bootstrapLogFile), dirPerm); err != nil {431 if err := os.MkdirAll(filepath.Dir(bootstrapLogFile), dirPerm); err != nil {
M internal/infrastructure/cef/engine_init_test.go +65 -55
@@ -1,111 +1,86 @@
11 package cef
22
33 import (
4- "os"
54 "path/filepath"
65 "testing"
76
87 "github.com/bnema/dumber/internal/application/port"
98 "github.com/bnema/dumber/internal/infrastructure/audio/factory"
10- "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
1110 "github.com/rs/zerolog"
1211 "github.com/stretchr/testify/require"
1312 )
1413
1514 func TestResolvedStateRoot_PrefersDataDir(t *testing.T) {
16- root := resolvedStateRoot(port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"})
15+ profile := testCEFDevProfile(t)
16+ root := resolvedStateRoot(profile, port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"})
1717 require.Equal(t, "/tmp/data", root)
1818 }
1919
20-func TestResolvedStateRoot_FallsBackToCacheDir(t *testing.T) {
21- root := resolvedStateRoot(port.EngineOptions{CacheDir: "/tmp/cache"})
22- require.Equal(t, "/tmp/cache", root)
23-}
24-
2520 func TestResolvedStateRoot_UsesEnvOverride(t *testing.T) {
21+ profile := testCEFDevProfile(t)
2622 t.Setenv(CEFRootCachePathEnvVar, "/tmp/override")
2723
28- root := resolvedStateRoot(port.EngineOptions{})
24+ root := resolvedStateRoot(profile, port.EngineOptions{})
2925 require.Equal(t, "/tmp/override", root)
3026
31- root = resolvedStateRoot(port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"})
27+ root = resolvedStateRoot(profile, port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"})
3228 require.Equal(t, "/tmp/override", root)
3329 }
3430
35-func TestPrepareCEFSettings_UsesResolvedStateRoot(t *testing.T) {
31+func TestResolvedStateRoot_UsesProfileDefault(t *testing.T) {
32+ profile := testCEFProdProfile(t)
33+ root := resolvedStateRoot(profile, port.EngineOptions{})
34+ require.Equal(t, profile.CEFUserDataDir(), root)
35+}
36+
37+func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) {
3638 logger := zerolog.Nop()
37- settings, err := prepareCEFSettings(port.EngineOptions{CacheDir: "/tmp/cache"}, RuntimeConfig{}, &logger)
39+ profile := testCEFDevProfile(t)
40+ settings, err := prepareCEFSettings(port.EngineOptions{}, profile, RuntimeConfig{}, &logger)
3841 require.NoError(t, err)
39- require.Equal(t, "/tmp/cache", settings.RootCachePath)
42+ require.Equal(t, profile.CEFUserDataDir(), settings.RootCachePath)
43+ require.Equal(t, profile.CEFLogFile(), settings.LogFile)
4044 }
4145
4246 func TestPrepareCEFSettings_RejectsNonDefaultCookiePolicy(t *testing.T) {
4347 logger := zerolog.Nop()
44- _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNever}, RuntimeConfig{}, &logger)
48+ _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNever}, testCEFDevProfile(t), RuntimeConfig{}, &logger)
4549 require.ErrorIs(t, err, ErrCookiePolicyUnsupported)
4650 }
4751
4852 func TestPrepareCEFSettings_AllowsNoThirdPartyCookiePolicy(t *testing.T) {
4953 logger := zerolog.Nop()
50- _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNoThirdParty}, RuntimeConfig{}, &logger)
54+ _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNoThirdParty}, testCEFDevProfile(t), RuntimeConfig{}, &logger)
5155 require.NoError(t, err)
5256 }
5357
5458 func TestPrepareCEFInitTraceFile_DisabledByDefault(t *testing.T) {
5559 t.Setenv(puregoCEFInitTraceEnvVar, "")
5660
57- path, err := prepareCEFInitTraceFile("")
61+ path, err := prepareCEFInitTraceFile(testCEFDevProfile(t), "")
5862 require.NoError(t, err)
5963 require.Empty(t, path)
6064 }
6165
62-func TestPrepareCEFInitTraceFile_EnabledViaEnv(t *testing.T) {
66+func TestPrepareCEFInitTraceFile_EnabledViaExplicitLogFile(t *testing.T) {
6367 t.Setenv(puregoCEFInitTraceEnvVar, "1")
64- cfg := config.CEFEngineConfig{
65- LogFile: filepath.Join(t.TempDir(), "cef_runtime.log"),
66- }
68+ logFile := filepath.Join(t.TempDir(), "custom", "cef_runtime.log")
6769
68- path, err := prepareCEFInitTraceFile(cfg.LogFile)
70+ path, err := prepareCEFInitTraceFile(testCEFDevProfile(t), logFile)
6971 require.NoError(t, err)
70- require.Equal(t, filepath.Join(filepath.Dir(cfg.LogFile), "cef_runtime.bootstrap.log"), path)
72+ require.Equal(t, filepath.Join(filepath.Dir(logFile), "cef_runtime.bootstrap.log"), path)
7173 require.FileExists(t, path)
7274 }
7375
74-func TestPrepareCEFSettings_RootCachePath_UsesXDGDataDir(t *testing.T) {
75- homeDir := filepath.Join(t.TempDir(), "home")
76- dataHome := filepath.Join(homeDir, ".local", "share")
77- t.Setenv("HOME", homeDir)
78- t.Setenv("ENV", "")
79- t.Setenv("XDG_DATA_HOME", dataHome)
80-
81- logger := zerolog.Nop()
82- settings, err := prepareCEFSettings(port.EngineOptions{}, RuntimeConfig{}, &logger)
83- require.NoError(t, err)
84-
85- require.Equal(t, filepath.Join(dataHome, "dumber", "cef_user_data"), settings.RootCachePath)
86-}
87-
88-func TestPrepareCEFSettings_RootCachePath_DevMode(t *testing.T) {
89- // Create a temp directory and change into it for the test
90- tempDir := t.TempDir()
91- originalDir, err := os.Getwd()
92- require.NoError(t, err)
93- defer func() {
94- _ = os.Chdir(originalDir)
95- }()
96-
97- err = os.Chdir(tempDir)
98- require.NoError(t, err)
99-
100- // Set ENV=dev to trigger dev mode
101- t.Setenv("ENV", "dev")
76+func TestPrepareCEFInitTraceFile_UsesResolvedProfileLogDirByDefault(t *testing.T) {
77+ t.Setenv(puregoCEFInitTraceEnvVar, "1")
78+ profile := testCEFDevProfile(t)
10279
103- logger := zerolog.Nop()
104- settings, err := prepareCEFSettings(port.EngineOptions{}, RuntimeConfig{}, &logger)
80+ path, err := prepareCEFInitTraceFile(profile, "")
10581 require.NoError(t, err)
106-
107- // In dev mode, should resolve under .dev/dumber/cef_user_data
108- require.Equal(t, filepath.Join(tempDir, ".dev", "dumber", "cef_user_data"), settings.RootCachePath)
82+ require.Equal(t, filepath.Join(profile.EnginePaths.LogDir, "cef_runtime.bootstrap.log"), path)
83+ require.FileExists(t, path)
10984 }
11085
11186 // TestCreateAudioOutputFactory_CreatesUsableFactory verifies that the audio
@@ -116,3 +91,38 @@ func TestCreateAudioOutputFactory_ReturnsFactory(t *testing.T) {
11691
11792 require.NotNil(t, audioFactory, "Audio factory should not be nil")
11893 }
94+
95+func testCEFDevProfile(t *testing.T) runtimeprofile.Profile {
96+ t.Helper()
97+ cwd := t.TempDir()
98+ profile, err := runtimeprofile.Resolve(runtimeprofile.ResolveInput{
99+ Env: func(key string) string {
100+ if key == "ENV" {
101+ return "dev"
102+ }
103+ return ""
104+ },
105+ Engine: "cef",
106+ CWD: func() (string, error) { return cwd, nil },
107+ })
108+ require.NoError(t, err)
109+ return profile
110+}
111+
112+func testCEFProdProfile(t *testing.T) runtimeprofile.Profile {
113+ t.Helper()
114+ root := t.TempDir()
115+ profile, err := runtimeprofile.Resolve(runtimeprofile.ResolveInput{
116+ Env: func(string) string { return "" },
117+ Engine: "cef",
118+ CWD: func() (string, error) { return root, nil },
119+ Base: runtimeprofile.BasePaths{
120+ ConfigHome: filepath.Join(root, "config"),
121+ DataHome: filepath.Join(root, "data"),
122+ StateHome: filepath.Join(root, "state"),
123+ CacheHome: filepath.Join(root, "cache"),
124+ },
125+ })
126+ require.NoError(t, err)
127+ return profile
128+}
@@ -1,111 +1,86 @@
1 package cef1 package cef
2 2
3 import (3 import (
4- "os"
5 "path/filepath"4 "path/filepath"
6 "testing"5 "testing"
7 6
8 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/infrastructure/audio/factory"8 "github.com/bnema/dumber/internal/infrastructure/audio/factory"
10- "github.com/bnema/dumber/internal/infrastructure/config"9+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
11 "github.com/rs/zerolog"10 "github.com/rs/zerolog"
12 "github.com/stretchr/testify/require"11 "github.com/stretchr/testify/require"
13 )12 )
14 13
15 func TestResolvedStateRoot_PrefersDataDir(t *testing.T) {14 func TestResolvedStateRoot_PrefersDataDir(t *testing.T) {
16- root := resolvedStateRoot(port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"})15+ profile := testCEFDevProfile(t)
16+ root := resolvedStateRoot(profile, port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"})
17 require.Equal(t, "/tmp/data", root)17 require.Equal(t, "/tmp/data", root)
18 }18 }
19 19
20-func TestResolvedStateRoot_FallsBackToCacheDir(t *testing.T) {
21- root := resolvedStateRoot(port.EngineOptions{CacheDir: "/tmp/cache"})
22- require.Equal(t, "/tmp/cache", root)
23-}
24-
25 func TestResolvedStateRoot_UsesEnvOverride(t *testing.T) {20 func TestResolvedStateRoot_UsesEnvOverride(t *testing.T) {
21+ profile := testCEFDevProfile(t)
26 t.Setenv(CEFRootCachePathEnvVar, "/tmp/override")22 t.Setenv(CEFRootCachePathEnvVar, "/tmp/override")
27 23
28- root := resolvedStateRoot(port.EngineOptions{})24+ root := resolvedStateRoot(profile, port.EngineOptions{})
29 require.Equal(t, "/tmp/override", root)25 require.Equal(t, "/tmp/override", root)
30 26
31- root = resolvedStateRoot(port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"})27+ root = resolvedStateRoot(profile, port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"})
32 require.Equal(t, "/tmp/override", root)28 require.Equal(t, "/tmp/override", root)
33 }29 }
34 30
35-func TestPrepareCEFSettings_UsesResolvedStateRoot(t *testing.T) {31+func TestResolvedStateRoot_UsesProfileDefault(t *testing.T) {
32+ profile := testCEFProdProfile(t)
33+ root := resolvedStateRoot(profile, port.EngineOptions{})
34+ require.Equal(t, profile.CEFUserDataDir(), root)
35+}
36+
37+func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) {
36 logger := zerolog.Nop()38 logger := zerolog.Nop()
37- settings, err := prepareCEFSettings(port.EngineOptions{CacheDir: "/tmp/cache"}, RuntimeConfig{}, &logger)39+ profile := testCEFDevProfile(t)
40+ settings, err := prepareCEFSettings(port.EngineOptions{}, profile, RuntimeConfig{}, &logger)
38 require.NoError(t, err)41 require.NoError(t, err)
39- require.Equal(t, "/tmp/cache", settings.RootCachePath)42+ require.Equal(t, profile.CEFUserDataDir(), settings.RootCachePath)
43+ require.Equal(t, profile.CEFLogFile(), settings.LogFile)
40 }44 }
41 45
42 func TestPrepareCEFSettings_RejectsNonDefaultCookiePolicy(t *testing.T) {46 func TestPrepareCEFSettings_RejectsNonDefaultCookiePolicy(t *testing.T) {
43 logger := zerolog.Nop()47 logger := zerolog.Nop()
44- _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNever}, RuntimeConfig{}, &logger)48+ _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNever}, testCEFDevProfile(t), RuntimeConfig{}, &logger)
45 require.ErrorIs(t, err, ErrCookiePolicyUnsupported)49 require.ErrorIs(t, err, ErrCookiePolicyUnsupported)
46 }50 }
47 51
48 func TestPrepareCEFSettings_AllowsNoThirdPartyCookiePolicy(t *testing.T) {52 func TestPrepareCEFSettings_AllowsNoThirdPartyCookiePolicy(t *testing.T) {
49 logger := zerolog.Nop()53 logger := zerolog.Nop()
50- _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNoThirdParty}, RuntimeConfig{}, &logger)54+ _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNoThirdParty}, testCEFDevProfile(t), RuntimeConfig{}, &logger)
51 require.NoError(t, err)55 require.NoError(t, err)
52 }56 }
53 57
54 func TestPrepareCEFInitTraceFile_DisabledByDefault(t *testing.T) {58 func TestPrepareCEFInitTraceFile_DisabledByDefault(t *testing.T) {
55 t.Setenv(puregoCEFInitTraceEnvVar, "")59 t.Setenv(puregoCEFInitTraceEnvVar, "")
56 60
57- path, err := prepareCEFInitTraceFile("")61+ path, err := prepareCEFInitTraceFile(testCEFDevProfile(t), "")
58 require.NoError(t, err)62 require.NoError(t, err)
59 require.Empty(t, path)63 require.Empty(t, path)
60 }64 }
61 65
62-func TestPrepareCEFInitTraceFile_EnabledViaEnv(t *testing.T) {66+func TestPrepareCEFInitTraceFile_EnabledViaExplicitLogFile(t *testing.T) {
63 t.Setenv(puregoCEFInitTraceEnvVar, "1")67 t.Setenv(puregoCEFInitTraceEnvVar, "1")
64- cfg := config.CEFEngineConfig{68+ logFile := filepath.Join(t.TempDir(), "custom", "cef_runtime.log")
65- LogFile: filepath.Join(t.TempDir(), "cef_runtime.log"),
66- }
67 69
68- path, err := prepareCEFInitTraceFile(cfg.LogFile)70+ path, err := prepareCEFInitTraceFile(testCEFDevProfile(t), logFile)
69 require.NoError(t, err)71 require.NoError(t, err)
70- require.Equal(t, filepath.Join(filepath.Dir(cfg.LogFile), "cef_runtime.bootstrap.log"), path)72+ require.Equal(t, filepath.Join(filepath.Dir(logFile), "cef_runtime.bootstrap.log"), path)
71 require.FileExists(t, path)73 require.FileExists(t, path)
72 }74 }
73 75
74-func TestPrepareCEFSettings_RootCachePath_UsesXDGDataDir(t *testing.T) {76+func TestPrepareCEFInitTraceFile_UsesResolvedProfileLogDirByDefault(t *testing.T) {
75- homeDir := filepath.Join(t.TempDir(), "home")77+ t.Setenv(puregoCEFInitTraceEnvVar, "1")
76- dataHome := filepath.Join(homeDir, ".local", "share")78+ profile := testCEFDevProfile(t)
77- t.Setenv("HOME", homeDir)
78- t.Setenv("ENV", "")
79- t.Setenv("XDG_DATA_HOME", dataHome)
80-
81- logger := zerolog.Nop()
82- settings, err := prepareCEFSettings(port.EngineOptions{}, RuntimeConfig{}, &logger)
83- require.NoError(t, err)
84-
85- require.Equal(t, filepath.Join(dataHome, "dumber", "cef_user_data"), settings.RootCachePath)
86-}
87-
88-func TestPrepareCEFSettings_RootCachePath_DevMode(t *testing.T) {
89- // Create a temp directory and change into it for the test
90- tempDir := t.TempDir()
91- originalDir, err := os.Getwd()
92- require.NoError(t, err)
93- defer func() {
94- _ = os.Chdir(originalDir)
95- }()
96-
97- err = os.Chdir(tempDir)
98- require.NoError(t, err)
99-
100- // Set ENV=dev to trigger dev mode
101- t.Setenv("ENV", "dev")
102 79
103- logger := zerolog.Nop()80+ path, err := prepareCEFInitTraceFile(profile, "")
104- settings, err := prepareCEFSettings(port.EngineOptions{}, RuntimeConfig{}, &logger)
105 require.NoError(t, err)81 require.NoError(t, err)
106-82+ require.Equal(t, filepath.Join(profile.EnginePaths.LogDir, "cef_runtime.bootstrap.log"), path)
107- // In dev mode, should resolve under .dev/dumber/cef_user_data83+ require.FileExists(t, path)
108- require.Equal(t, filepath.Join(tempDir, ".dev", "dumber", "cef_user_data"), settings.RootCachePath)
109 }84 }
110 85
111 // TestCreateAudioOutputFactory_CreatesUsableFactory verifies that the audio86 // TestCreateAudioOutputFactory_CreatesUsableFactory verifies that the audio
@@ -116,3 +91,38 @@ func TestCreateAudioOutputFactory_ReturnsFactory(t *testing.T) {
116 91
117 require.NotNil(t, audioFactory, "Audio factory should not be nil")92 require.NotNil(t, audioFactory, "Audio factory should not be nil")
118 }93 }
94+
95+func testCEFDevProfile(t *testing.T) runtimeprofile.Profile {
96+ t.Helper()
97+ cwd := t.TempDir()
98+ profile, err := runtimeprofile.Resolve(runtimeprofile.ResolveInput{
99+ Env: func(key string) string {
100+ if key == "ENV" {
101+ return "dev"
102+ }
103+ return ""
104+ },
105+ Engine: "cef",
106+ CWD: func() (string, error) { return cwd, nil },
107+ })
108+ require.NoError(t, err)
109+ return profile
110+}
111+
112+func testCEFProdProfile(t *testing.T) runtimeprofile.Profile {
113+ t.Helper()
114+ root := t.TempDir()
115+ profile, err := runtimeprofile.Resolve(runtimeprofile.ResolveInput{
116+ Env: func(string) string { return "" },
117+ Engine: "cef",
118+ CWD: func() (string, error) { return root, nil },
119+ Base: runtimeprofile.BasePaths{
120+ ConfigHome: filepath.Join(root, "config"),
121+ DataHome: filepath.Join(root, "data"),
122+ StateHome: filepath.Join(root, "state"),
123+ CacheHome: filepath.Join(root, "cache"),
124+ },
125+ })
126+ require.NoError(t, err)
127+ return profile
128+}
M internal/infrastructure/cef/engine_runtime_test.go +1 -1
@@ -85,7 +85,7 @@ func TestEngineExplicitClipboardCopy_ForwardsViewID(t *testing.T) {
8585
8686 eng.handleExplicitClipboardBridgeText(77, "copy", "copied")
8787
88- require.Equal(t, port.ExplicitClipboardInput{Text: "copied", SourceEngine: port.ClipboardSourceCEF, ViewID: 77, Action: "copy"}, orchestrator.explicit)
88+ require.Equal(t, port.ExplicitClipboardInput{Text: "copied", SourceEngine: port.ClipboardSourceCEF, ViewID: 77, Action: "copy", NativeHandled: false}, orchestrator.explicit)
8989 }
9090
9191 type stubClipboardTextOrchestrator struct{}
@@ -85,7 +85,7 @@ func TestEngineExplicitClipboardCopy_ForwardsViewID(t *testing.T) {
85 85
86 eng.handleExplicitClipboardBridgeText(77, "copy", "copied")86 eng.handleExplicitClipboardBridgeText(77, "copy", "copied")
87 87
88- require.Equal(t, port.ExplicitClipboardInput{Text: "copied", SourceEngine: port.ClipboardSourceCEF, ViewID: 77, Action: "copy"}, orchestrator.explicit)88+ require.Equal(t, port.ExplicitClipboardInput{Text: "copied", SourceEngine: port.ClipboardSourceCEF, ViewID: 77, Action: "copy", NativeHandled: false}, orchestrator.explicit)
89 }89 }
90 90
91 type stubClipboardTextOrchestrator struct{}91 type stubClipboardTextOrchestrator struct{}
M internal/infrastructure/cef/input_bridge.go +2 -1
@@ -4,6 +4,7 @@ import (
44 "context"
55 "sync"
66 "sync/atomic"
7+ "unicode/utf8"
78
89 purecef "github.com/bnema/purego-cef/cef"
910 "github.com/bnema/puregotk/v4/gdk"
@@ -392,7 +393,7 @@ func (ib *inputBridge) maybeMirrorClipboardShortcut(keyval, mods uint) {
392393 }
393394 logging.FromContext(ib.ctx).Debug().
394395 Str("action", action).
395- Int("text_len", len(text)).
396+ Int("text_len", utf8.RuneCountInString(text)).
396397 Msg("cef: clipboard shortcut routed through explicit copy handler")
397398 ib.explicitCopyText(action, text)
398399 }
@@ -4,6 +4,7 @@ import (
4 "context"4 "context"
5 "sync"5 "sync"
6 "sync/atomic"6 "sync/atomic"
7+ "unicode/utf8"
7 8
8 purecef "github.com/bnema/purego-cef/cef"9 purecef "github.com/bnema/purego-cef/cef"
9 "github.com/bnema/puregotk/v4/gdk"10 "github.com/bnema/puregotk/v4/gdk"
@@ -392,7 +393,7 @@ func (ib *inputBridge) maybeMirrorClipboardShortcut(keyval, mods uint) {
392 }393 }
393 logging.FromContext(ib.ctx).Debug().394 logging.FromContext(ib.ctx).Debug().
394 Str("action", action).395 Str("action", action).
395- Int("text_len", len(text)).396+ Int("text_len", utf8.RuneCountInString(text)).
396 Msg("cef: clipboard shortcut routed through explicit copy handler")397 Msg("cef: clipboard shortcut routed through explicit copy handler")
397 ib.explicitCopyText(action, text)398 ib.explicitCopyText(action, text)
398 }399 }
M internal/infrastructure/cef/renderer_bridge.go +5 -4
@@ -74,11 +74,12 @@ const rendererBridgeExtensionJS = `
7474
7575 function mirrorClipboardEvent(action, e) {
7676 if (!e.isTrusted) return;
77+ var capturedText = '';
78+ if (e.clipboardData && typeof e.clipboardData.getData === 'function') {
79+ try { capturedText = e.clipboardData.getData('text/plain'); } catch(_) {}
80+ }
7781 setTimeout(function() {
78- var text = '';
79- if (e.clipboardData && typeof e.clipboardData.getData === 'function') {
80- try { text = e.clipboardData.getData('text/plain'); } catch(_) {}
81- }
82+ var text = capturedText;
8283 if (!text) text = getSelectedText();
8384 if (text) notifyExplicitTextCopy(action, text);
8485 }, 0);
@@ -74,11 +74,12 @@ const rendererBridgeExtensionJS = `
74 74
75 function mirrorClipboardEvent(action, e) {75 function mirrorClipboardEvent(action, e) {
76 if (!e.isTrusted) return;76 if (!e.isTrusted) return;
77+ var capturedText = '';
78+ if (e.clipboardData && typeof e.clipboardData.getData === 'function') {
79+ try { capturedText = e.clipboardData.getData('text/plain'); } catch(_) {}
80+ }
77 setTimeout(function() {81 setTimeout(function() {
78- var text = '';82+ var text = capturedText;
79- if (e.clipboardData && typeof e.clipboardData.getData === 'function') {
80- try { text = e.clipboardData.getData('text/plain'); } catch(_) {}
81- }
82 if (!text) text = getSelectedText();83 if (!text) text = getSelectedText();
83 if (text) notifyExplicitTextCopy(action, text);84 if (text) notifyExplicitTextCopy(action, text);
84 }, 0);85 }, 0);
M internal/infrastructure/cef/renderer_bridge_test.go +58 -10
@@ -1,7 +1,11 @@
11 package cef
22
33 import (
4+ "go/ast"
5+ "go/parser"
6+ "go/token"
47 "os"
8+ "strings"
59 "testing"
610
711 purecef "github.com/bnema/purego-cef/cef"
@@ -100,18 +104,22 @@ func TestRendererBridgeExtensionJS_EncodesTrustedSuccessSemantics(t *testing.T)
100104 require.Contains(t, rendererBridgeExtensionJS, "return result;")
101105 }
102106
103-func TestRendererBridgeSourceDoesNotNeedGoLinkname(t *testing.T) {
104- src, err := os.ReadFile("renderer_bridge.go")
105- require.NoError(t, err)
106- require.NotContains(t, string(src), "go:linkname")
107+func TestRendererBridgePackageDoesNotNeedGoLinkname(t *testing.T) {
108+ files := parseCEFPackageFiles(t)
109+ for _, file := range files {
110+ for _, group := range file.Comments {
111+ for _, comment := range group.List {
112+ require.NotContains(t, comment.Text, "go:linkname")
113+ }
114+ }
115+ }
107116 }
108117
109-func TestCEFContentInjectorSourceDoesNotKeepDeprecatedClipboardBridgeConstants(t *testing.T) {
110- src, err := os.ReadFile("content_injector.go")
111- require.NoError(t, err)
112- require.NotContains(t, string(src), "autoCopySelectionBridgeJS")
113- require.NotContains(t, string(src), "clipboardCopyBridgeJS")
114- require.NotContains(t, string(src), "editableFocusBridgeJS")
118+func TestCEFPackageDoesNotKeepDeprecatedClipboardBridgeConstants(t *testing.T) {
119+ files := parseCEFPackageFiles(t)
120+ for _, banned := range []string{"autoCopySelectionBridgeJS", "clipboardCopyBridgeJS", "editableFocusBridgeJS"} {
121+ require.False(t, packageDeclaresIdentifier(files, banned), "unexpected deprecated identifier %q", banned)
122+ }
115123 }
116124
117125 func TestDecodeRendererBridgeExplicitTextCopyPayload(t *testing.T) {
@@ -121,6 +129,46 @@ func TestDecodeRendererBridgeExplicitTextCopyPayload(t *testing.T) {
121129 require.Equal(t, "cut", req.Action)
122130 }
123131
132+func parseCEFPackageFiles(t *testing.T) []*ast.File {
133+ t.Helper()
134+ entries, err := os.ReadDir(".")
135+ require.NoError(t, err)
136+
137+ fset := token.NewFileSet()
138+ files := make([]*ast.File, 0, len(entries))
139+ for _, entry := range entries {
140+ name := entry.Name()
141+ if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
142+ continue
143+ }
144+ file, err := parser.ParseFile(fset, name, nil, parser.ParseComments)
145+ require.NoError(t, err)
146+ if file.Name != nil && file.Name.Name == "cef" {
147+ files = append(files, file)
148+ }
149+ }
150+ require.NotEmpty(t, files)
151+ return files
152+}
153+
154+func packageDeclaresIdentifier(files []*ast.File, name string) bool {
155+ for _, file := range files {
156+ found := false
157+ ast.Inspect(file, func(node ast.Node) bool {
158+ ident, ok := node.(*ast.Ident)
159+ if ok && ident.Name == name {
160+ found = true
161+ return false
162+ }
163+ return true
164+ })
165+ if found {
166+ return true
167+ }
168+ }
169+ return false
170+}
171+
124172 func TestRendererBridgeProcessHandler_OnFocusedNodeChanged_ReportsEditableState(t *testing.T) {
125173 oldFactory := newRendererBridgeProcessMessage
126174 t.Cleanup(func() { newRendererBridgeProcessMessage = oldFactory })
@@ -1,7 +1,11 @@
1 package cef1 package cef
2 2
3 import (3 import (
4+ "go/ast"
5+ "go/parser"
6+ "go/token"
4 "os"7 "os"
8+ "strings"
5 "testing"9 "testing"
6 10
7 purecef "github.com/bnema/purego-cef/cef"11 purecef "github.com/bnema/purego-cef/cef"
@@ -100,18 +104,22 @@ func TestRendererBridgeExtensionJS_EncodesTrustedSuccessSemantics(t *testing.T)
100 require.Contains(t, rendererBridgeExtensionJS, "return result;")104 require.Contains(t, rendererBridgeExtensionJS, "return result;")
101 }105 }
102 106
103-func TestRendererBridgeSourceDoesNotNeedGoLinkname(t *testing.T) {107+func TestRendererBridgePackageDoesNotNeedGoLinkname(t *testing.T) {
104- src, err := os.ReadFile("renderer_bridge.go")108+ files := parseCEFPackageFiles(t)
105- require.NoError(t, err)109+ for _, file := range files {
106- require.NotContains(t, string(src), "go:linkname")110+ for _, group := range file.Comments {
111+ for _, comment := range group.List {
112+ require.NotContains(t, comment.Text, "go:linkname")
113+ }
114+ }
115+ }
107 }116 }
108 117
109-func TestCEFContentInjectorSourceDoesNotKeepDeprecatedClipboardBridgeConstants(t *testing.T) {118+func TestCEFPackageDoesNotKeepDeprecatedClipboardBridgeConstants(t *testing.T) {
110- src, err := os.ReadFile("content_injector.go")119+ files := parseCEFPackageFiles(t)
111- require.NoError(t, err)120+ for _, banned := range []string{"autoCopySelectionBridgeJS", "clipboardCopyBridgeJS", "editableFocusBridgeJS"} {
112- require.NotContains(t, string(src), "autoCopySelectionBridgeJS")121+ require.False(t, packageDeclaresIdentifier(files, banned), "unexpected deprecated identifier %q", banned)
113- require.NotContains(t, string(src), "clipboardCopyBridgeJS")122+ }
114- require.NotContains(t, string(src), "editableFocusBridgeJS")
115 }123 }
116 124
117 func TestDecodeRendererBridgeExplicitTextCopyPayload(t *testing.T) {125 func TestDecodeRendererBridgeExplicitTextCopyPayload(t *testing.T) {
@@ -121,6 +129,46 @@ func TestDecodeRendererBridgeExplicitTextCopyPayload(t *testing.T) {
121 require.Equal(t, "cut", req.Action)129 require.Equal(t, "cut", req.Action)
122 }130 }
123 131
132+func parseCEFPackageFiles(t *testing.T) []*ast.File {
133+ t.Helper()
134+ entries, err := os.ReadDir(".")
135+ require.NoError(t, err)
136+
137+ fset := token.NewFileSet()
138+ files := make([]*ast.File, 0, len(entries))
139+ for _, entry := range entries {
140+ name := entry.Name()
141+ if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
142+ continue
143+ }
144+ file, err := parser.ParseFile(fset, name, nil, parser.ParseComments)
145+ require.NoError(t, err)
146+ if file.Name != nil && file.Name.Name == "cef" {
147+ files = append(files, file)
148+ }
149+ }
150+ require.NotEmpty(t, files)
151+ return files
152+}
153+
154+func packageDeclaresIdentifier(files []*ast.File, name string) bool {
155+ for _, file := range files {
156+ found := false
157+ ast.Inspect(file, func(node ast.Node) bool {
158+ ident, ok := node.(*ast.Ident)
159+ if ok && ident.Name == name {
160+ found = true
161+ return false
162+ }
163+ return true
164+ })
165+ if found {
166+ return true
167+ }
168+ }
169+ return false
170+}
171+
124 func TestRendererBridgeProcessHandler_OnFocusedNodeChanged_ReportsEditableState(t *testing.T) {172 func TestRendererBridgeProcessHandler_OnFocusedNodeChanged_ReportsEditableState(t *testing.T) {
125 oldFactory := newRendererBridgeProcessMessage173 oldFactory := newRendererBridgeProcessMessage
126 t.Cleanup(func() { newRendererBridgeProcessMessage = oldFactory })174 t.Cleanup(func() { newRendererBridgeProcessMessage = oldFactory })
M internal/infrastructure/cef/scheme_handler.go +3 -1
@@ -42,6 +42,8 @@ var pageRootFiles = map[string]string{
4242 errorPath: "error.html",
4343 }
4444
45+var cefNewResourceHandler = purecef.NewResourceHandler
46+
4547 // dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual
4648 // internal https://dumber.invalid origin used by CEF.
4749 type dumbSchemeHandler struct {
@@ -419,7 +421,7 @@ type staticResourceHandler struct {
419421 }
420422
421423 func (h *dumbSchemeHandler) newRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler {
422- return purecef.NewResourceHandler(&staticResourceHandler{
424+ return cefNewResourceHandler(&staticResourceHandler{
423425 data: data,
424426 contentType: contentType,
425427 statusCode: status,
@@ -42,6 +42,8 @@ var pageRootFiles = map[string]string{
42 errorPath: "error.html",42 errorPath: "error.html",
43 }43 }
44 44
45+var cefNewResourceHandler = purecef.NewResourceHandler
46+
45 // dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual47 // dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual
46 // internal https://dumber.invalid origin used by CEF.48 // internal https://dumber.invalid origin used by CEF.
47 type dumbSchemeHandler struct {49 type dumbSchemeHandler struct {
@@ -419,7 +421,7 @@ type staticResourceHandler struct {
419 }421 }
420 422
421 func (h *dumbSchemeHandler) newRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler {423 func (h *dumbSchemeHandler) newRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler {
422- return purecef.NewResourceHandler(&staticResourceHandler{424+ return cefNewResourceHandler(&staticResourceHandler{
423 data: data,425 data: data,
424 contentType: contentType,426 contentType: contentType,
425 statusCode: status,427 statusCode: status,
M internal/infrastructure/cef/scheme_handler_test.go +21 -6
@@ -4,9 +4,11 @@ import (
44 "context"
55 "encoding/base64"
66 "errors"
7- "os"
7+ "net/http"
88 "testing"
9+ "unsafe"
910
11+ purecef "github.com/bnema/purego-cef/cef"
1012 cefmocks "github.com/bnema/purego-cef/cef/mocks"
1113 "github.com/stretchr/testify/require"
1214 )
@@ -57,10 +59,23 @@ func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) {
5759 require.JSONEq(t, `{"text":"copied from js"}`, string(body))
5860 }
5961
60-func TestSchemeHandlerSourceDoesNotKeepClipboardSetPath(t *testing.T) {
61- src, err := os.ReadFile("scheme_handler.go")
62+func TestSchemeHandler_APIClipboardSetPathReturnsNotFound(t *testing.T) {
63+ oldNewResourceHandler := cefNewResourceHandler
64+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
65+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
66+
67+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
6268 require.NoError(t, err)
63- require.NotContains(t, string(src), "/api/clipboard-set")
64- require.NotContains(t, string(src), "onClipboardSet")
65- require.NotContains(t, string(src), "decodeClipboardSetBody")
69+
70+ response := cefmocks.NewMockResponse(t)
71+ response.EXPECT().SetStatus(int32(http.StatusNotFound)).Once()
72+ response.EXPECT().SetStatusText(http.StatusText(http.StatusNotFound)).Once()
73+ response.EXPECT().SetMimeType("application/json").Once()
74+
75+ handler := h.handleAPI(nil, http.MethodPost, "/api/clipboard-set", nil)
76+ require.NotNil(t, handler)
77+
78+ var responseLength int64
79+ handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0)
80+ require.Positive(t, responseLength)
6681 }
@@ -4,9 +4,11 @@ import (
4 "context"4 "context"
5 "encoding/base64"5 "encoding/base64"
6 "errors"6 "errors"
7- "os"7+ "net/http"
8 "testing"8 "testing"
9+ "unsafe"
9 10
11+ purecef "github.com/bnema/purego-cef/cef"
10 cefmocks "github.com/bnema/purego-cef/cef/mocks"12 cefmocks "github.com/bnema/purego-cef/cef/mocks"
11 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
12 )14 )
@@ -57,10 +59,23 @@ func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) {
57 require.JSONEq(t, `{"text":"copied from js"}`, string(body))59 require.JSONEq(t, `{"text":"copied from js"}`, string(body))
58 }60 }
59 61
60-func TestSchemeHandlerSourceDoesNotKeepClipboardSetPath(t *testing.T) {62+func TestSchemeHandler_APIClipboardSetPathReturnsNotFound(t *testing.T) {
61- src, err := os.ReadFile("scheme_handler.go")63+ oldNewResourceHandler := cefNewResourceHandler
64+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
65+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
66+
67+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
62 require.NoError(t, err)68 require.NoError(t, err)
63- require.NotContains(t, string(src), "/api/clipboard-set")69+
64- require.NotContains(t, string(src), "onClipboardSet")70+ response := cefmocks.NewMockResponse(t)
65- require.NotContains(t, string(src), "decodeClipboardSetBody")71+ response.EXPECT().SetStatus(int32(http.StatusNotFound)).Once()
72+ response.EXPECT().SetStatusText(http.StatusText(http.StatusNotFound)).Once()
73+ response.EXPECT().SetMimeType("application/json").Once()
74+
75+ handler := h.handleAPI(nil, http.MethodPost, "/api/clipboard-set", nil)
76+ require.NotNil(t, handler)
77+
78+ var responseLength int64
79+ handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0)
80+ require.Positive(t, responseLength)
66 }81 }
M internal/infrastructure/cef/session_spawn_environment.go +10 -3
@@ -5,18 +5,25 @@ import (
55
66 "github.com/bnema/dumber/internal/application/port"
77 "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
89 )
910
1011 // SessionSpawnEnvironment exposes the engine-specific launch environment needed
1112 // when restoring a session into a freshly spawned browser process.
12-type SessionSpawnEnvironment struct{}
13+type SessionSpawnEnvironment struct {
14+ profile runtimeprofile.Profile
15+}
16+
17+func NewSessionSpawnEnvironment(profile runtimeprofile.Profile) SessionSpawnEnvironment {
18+ return SessionSpawnEnvironment{profile: profile}
19+}
1320
1421 func (SessionSpawnEnvironment) RootCacheEnvVar() string {
1522 return CEFRootCachePathEnvVar
1623 }
1724
18-func (SessionSpawnEnvironment) SessionRootCachePath(sessionID entity.SessionID) string {
19- return filepath.Join(DefaultCEFUserDataDir(), "sessions", string(sessionID))
25+func (e SessionSpawnEnvironment) SessionRootCachePath(sessionID entity.SessionID) string {
26+ return filepath.Join(e.profile.CEFUserDataDir(), "sessions", string(sessionID))
2027 }
2128
2229 var _ port.SessionSpawnEnvironment = SessionSpawnEnvironment{}
@@ -5,18 +5,25 @@ import (
5 5
6 "github.com/bnema/dumber/internal/application/port"6 "github.com/bnema/dumber/internal/application/port"
7 "github.com/bnema/dumber/internal/domain/entity"7 "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
8 )9 )
9 10
10 // SessionSpawnEnvironment exposes the engine-specific launch environment needed11 // SessionSpawnEnvironment exposes the engine-specific launch environment needed
11 // when restoring a session into a freshly spawned browser process.12 // when restoring a session into a freshly spawned browser process.
12-type SessionSpawnEnvironment struct{}13+type SessionSpawnEnvironment struct {
14+ profile runtimeprofile.Profile
15+}
16+
17+func NewSessionSpawnEnvironment(profile runtimeprofile.Profile) SessionSpawnEnvironment {
18+ return SessionSpawnEnvironment{profile: profile}
19+}
13 20
14 func (SessionSpawnEnvironment) RootCacheEnvVar() string {21 func (SessionSpawnEnvironment) RootCacheEnvVar() string {
15 return CEFRootCachePathEnvVar22 return CEFRootCachePathEnvVar
16 }23 }
17 24
18-func (SessionSpawnEnvironment) SessionRootCachePath(sessionID entity.SessionID) string {25+func (e SessionSpawnEnvironment) SessionRootCachePath(sessionID entity.SessionID) string {
19- return filepath.Join(DefaultCEFUserDataDir(), "sessions", string(sessionID))26+ return filepath.Join(e.profile.CEFUserDataDir(), "sessions", string(sessionID))
20 }27 }
21 28
22 var _ port.SessionSpawnEnvironment = SessionSpawnEnvironment{}29 var _ port.SessionSpawnEnvironment = SessionSpawnEnvironment{}
M internal/infrastructure/cef/webview.go +19 -8
@@ -51,6 +51,7 @@ var (
5151 cefNewTask = purecef.NewTask
5252 cefPostTask = purecef.PostTask
5353 cefPostDelayedTask = purecef.PostDelayedTask
54+ cefScheduleAfter = func(delay time.Duration, fn func()) { time.AfterFunc(delay, fn) }
5455 )
5556
5657 // WebView implements port.WebView using a CEF off-screen browser rendered
@@ -827,12 +828,25 @@ func (wv *WebView) schedulePendingNavigationReplay(browser purecef.Browser, atte
827828 } else {
828829 result = cefPostDelayedTask(purecef.ThreadIDTidUi, task, int64(pendingNavigationRetryDelay/time.Millisecond))
829830 }
830- if result != 1 && wv.ctx != nil {
831- logging.FromContext(wv.ctx).Warn().
831+ if result == 1 {
832+ return
833+ }
834+ if wv.ctx != nil {
835+ log := logging.FromContext(wv.ctx).Warn().
832836 Int("attempt", attempt).
833- Int32("result", result).
834- Msg("cef: failed to schedule pending navigation replay")
837+ Int32("result", result)
838+ if attempt >= pendingNavigationMaxRetries {
839+ log.Msg("cef: failed to schedule pending navigation replay; retries exhausted")
840+ } else {
841+ log.Msg("cef: failed to schedule pending navigation replay; retrying")
842+ }
835843 }
844+ if attempt >= pendingNavigationMaxRetries {
845+ return
846+ }
847+ cefScheduleAfter(pendingNavigationRetryDelay, func() {
848+ wv.schedulePendingNavigationReplay(browser, attempt+1)
849+ })
836850 }
837851
838852 func (wv *WebView) replayPendingNavigation(browser purecef.Browser, attempt int) {
@@ -865,10 +879,7 @@ func (wv *WebView) replayPendingNavigation(browser purecef.Browser, attempt int)
865879 wv.schedulePendingNavigationReplay(browser, attempt+1)
866880 return
867881 }
868- currentURL := ""
869- if frame != nil {
870- currentURL = frame.GetURL()
871- }
882+ currentURL := frame.GetURL()
872883 if pendingURIEquivalent(currentURL, uri) {
873884 wv.mu.Lock()
874885 if pendingURIEquivalent(wv.pendingURI, uri) {
@@ -51,6 +51,7 @@ var (
51 cefNewTask = purecef.NewTask51 cefNewTask = purecef.NewTask
52 cefPostTask = purecef.PostTask52 cefPostTask = purecef.PostTask
53 cefPostDelayedTask = purecef.PostDelayedTask53 cefPostDelayedTask = purecef.PostDelayedTask
54+ cefScheduleAfter = func(delay time.Duration, fn func()) { time.AfterFunc(delay, fn) }
54 )55 )
55 56
56 // WebView implements port.WebView using a CEF off-screen browser rendered57 // WebView implements port.WebView using a CEF off-screen browser rendered
@@ -827,12 +828,25 @@ func (wv *WebView) schedulePendingNavigationReplay(browser purecef.Browser, atte
827 } else {828 } else {
828 result = cefPostDelayedTask(purecef.ThreadIDTidUi, task, int64(pendingNavigationRetryDelay/time.Millisecond))829 result = cefPostDelayedTask(purecef.ThreadIDTidUi, task, int64(pendingNavigationRetryDelay/time.Millisecond))
829 }830 }
830- if result != 1 && wv.ctx != nil {831+ if result == 1 {
831- logging.FromContext(wv.ctx).Warn().832+ return
833+ }
834+ if wv.ctx != nil {
835+ log := logging.FromContext(wv.ctx).Warn().
832 Int("attempt", attempt).836 Int("attempt", attempt).
833- Int32("result", result).837+ Int32("result", result)
834- Msg("cef: failed to schedule pending navigation replay")838+ if attempt >= pendingNavigationMaxRetries {
839+ log.Msg("cef: failed to schedule pending navigation replay; retries exhausted")
840+ } else {
841+ log.Msg("cef: failed to schedule pending navigation replay; retrying")
842+ }
835 }843 }
844+ if attempt >= pendingNavigationMaxRetries {
845+ return
846+ }
847+ cefScheduleAfter(pendingNavigationRetryDelay, func() {
848+ wv.schedulePendingNavigationReplay(browser, attempt+1)
849+ })
836 }850 }
837 851
838 func (wv *WebView) replayPendingNavigation(browser purecef.Browser, attempt int) {852 func (wv *WebView) replayPendingNavigation(browser purecef.Browser, attempt int) {
@@ -865,10 +879,7 @@ func (wv *WebView) replayPendingNavigation(browser purecef.Browser, attempt int)
865 wv.schedulePendingNavigationReplay(browser, attempt+1)879 wv.schedulePendingNavigationReplay(browser, attempt+1)
866 return880 return
867 }881 }
868- currentURL := ""882+ currentURL := frame.GetURL()
869- if frame != nil {
870- currentURL = frame.GetURL()
871- }
872 if pendingURIEquivalent(currentURL, uri) {883 if pendingURIEquivalent(currentURL, uri) {
873 wv.mu.Lock()884 wv.mu.Lock()
874 if pendingURIEquivalent(wv.pendingURI, uri) {885 if pendingURIEquivalent(wv.pendingURI, uri) {
M internal/infrastructure/cef/webview_handlers.go +6 -2
@@ -700,6 +700,7 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
700700 }
701701 host := browser.GetHost()
702702
703+ shouldSyncFocus := false
703704 h.wv.mu.Lock()
704705 h.wv.browser = browser
705706 h.wv.host = host
@@ -714,11 +715,14 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
714715
715716 // If GTK focus entered before the browser existed, reassert browser focus
716717 // now. Otherwise CEF may stay internally unfocused and suppress caret paint.
717- if h.wv.input != nil && h.wv.input.hasGTKFocus() {
718- syncWindowlessBrowserFocus(host)
718+ if h.wv.input != nil {
719+ shouldSyncFocus = h.wv.input.hasGTKFocus()
719720 }
720721
721722 h.wv.mu.Unlock()
723+ if shouldSyncFocus {
724+ syncWindowlessBrowserFocus(host)
725+ }
722726
723727 h.wv.scheduleStartBeginFrameLoop()
724728 }
@@ -700,6 +700,7 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
700 }700 }
701 host := browser.GetHost()701 host := browser.GetHost()
702 702
703+ shouldSyncFocus := false
703 h.wv.mu.Lock()704 h.wv.mu.Lock()
704 h.wv.browser = browser705 h.wv.browser = browser
705 h.wv.host = host706 h.wv.host = host
@@ -714,11 +715,14 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
714 715
715 // If GTK focus entered before the browser existed, reassert browser focus716 // If GTK focus entered before the browser existed, reassert browser focus
716 // now. Otherwise CEF may stay internally unfocused and suppress caret paint.717 // now. Otherwise CEF may stay internally unfocused and suppress caret paint.
717- if h.wv.input != nil && h.wv.input.hasGTKFocus() {718+ if h.wv.input != nil {
718- syncWindowlessBrowserFocus(host)719+ shouldSyncFocus = h.wv.input.hasGTKFocus()
719 }720 }
720 721
721 h.wv.mu.Unlock()722 h.wv.mu.Unlock()
723+ if shouldSyncFocus {
724+ syncWindowlessBrowserFocus(host)
725+ }
722 726
723 h.wv.scheduleStartBeginFrameLoop()727 h.wv.scheduleStartBeginFrameLoop()
724 }728 }
M internal/infrastructure/cef/webview_handlers_test.go +60 -52
@@ -13,26 +13,13 @@ import (
1313 "github.com/stretchr/testify/require"
1414
1515 "github.com/bnema/dumber/internal/application/port"
16+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
1617 )
1718
18-type recordingClipboardOrchestrator struct {
19+type clipboardOrchestratorRecorder struct {
1920 mu sync.Mutex
2021 selection port.SelectionClipboardInput
2122 selectionCalls int
22- explicit port.ExplicitClipboardInput
23-}
24-
25-func (r *recordingClipboardOrchestrator) HandleSelectionUpdate(_ context.Context, input port.SelectionClipboardInput) error {
26- r.mu.Lock()
27- r.selection = input
28- r.selectionCalls++
29- r.mu.Unlock()
30- return nil
31-}
32-
33-func (r *recordingClipboardOrchestrator) HandleExplicitCopy(_ context.Context, input port.ExplicitClipboardInput) error {
34- r.explicit = input
35- return nil
3623 }
3724
3825 func newTestPipeline(w, h, s int32) *renderPipeline {
@@ -114,7 +101,14 @@ func TestOnLoadEndDoesNotDispatchBrowserLevelCompletion(t *testing.T) {
114101 }
115102
116103 func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *testing.T) {
117- orchestrator := &recordingClipboardOrchestrator{}
104+ recorder := &clipboardOrchestratorRecorder{}
105+ orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
106+ orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input port.SelectionClipboardInput) {
107+ recorder.mu.Lock()
108+ defer recorder.mu.Unlock()
109+ recorder.selection = input
110+ recorder.selectionCalls++
111+ }).Return(nil).Once()
118112 wv := &WebView{
119113 ctx: context.Background(),
120114 id: 42,
@@ -127,17 +121,24 @@ func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *test
127121 h.OnTextSelectionChanged(nil, "selected text", nil)
128122
129123 require.Eventually(t, func() bool {
130- orchestrator.mu.Lock()
131- defer orchestrator.mu.Unlock()
132- return orchestrator.selectionCalls == 1 &&
133- orchestrator.selection.Text == "selected text" &&
134- orchestrator.selection.SourceEngine == port.ClipboardSourceCEF &&
135- orchestrator.selection.ViewID == port.WebViewID(42)
124+ recorder.mu.Lock()
125+ defer recorder.mu.Unlock()
126+ return recorder.selectionCalls == 1 &&
127+ recorder.selection.Text == "selected text" &&
128+ recorder.selection.SourceEngine == port.ClipboardSourceCEF &&
129+ recorder.selection.ViewID == port.WebViewID(42)
136130 }, 2*time.Second, 10*time.Millisecond)
137131 }
138132
139133 func TestOnTextSelectionChanged_DebouncesAndCollapsesRapidUpdates(t *testing.T) {
140- orchestrator := &recordingClipboardOrchestrator{}
134+ recorder := &clipboardOrchestratorRecorder{}
135+ orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
136+ orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input port.SelectionClipboardInput) {
137+ recorder.mu.Lock()
138+ defer recorder.mu.Unlock()
139+ recorder.selection = input
140+ recorder.selectionCalls++
141+ }).Return(nil).Once()
141142 wv := &WebView{
142143 ctx: context.Background(),
143144 id: 42,
@@ -150,21 +151,28 @@ func TestOnTextSelectionChanged_DebouncesAndCollapsesRapidUpdates(t *testing.T)
150151 h.OnTextSelectionChanged(nil, "first selection", nil)
151152 h.OnTextSelectionChanged(nil, "second selection", nil)
152153
153- orchestrator.mu.Lock()
154- gotCalls := orchestrator.selectionCalls
155- orchestrator.mu.Unlock()
154+ recorder.mu.Lock()
155+ gotCalls := recorder.selectionCalls
156+ recorder.mu.Unlock()
156157 require.Zero(t, gotCalls)
157158
158159 require.Eventually(t, func() bool {
159- orchestrator.mu.Lock()
160- defer orchestrator.mu.Unlock()
161- return orchestrator.selectionCalls == 1 && orchestrator.selection.Text == "second selection"
160+ recorder.mu.Lock()
161+ defer recorder.mu.Unlock()
162+ return recorder.selectionCalls == 1 && recorder.selection.Text == "second selection"
162163 }, 2*time.Second, 10*time.Millisecond)
163164 require.Equal(t, "second selection", wv.selectedTextSnapshot())
164165 }
165166
166167 func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResumesWhenCleared(t *testing.T) {
167- orchestrator := &recordingClipboardOrchestrator{}
168+ recorder := &clipboardOrchestratorRecorder{}
169+ orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
170+ orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input port.SelectionClipboardInput) {
171+ recorder.mu.Lock()
172+ defer recorder.mu.Unlock()
173+ recorder.selection = input
174+ recorder.selectionCalls++
175+ }).Return(nil).Once()
168176 wv := &WebView{
169177 ctx: context.Background(),
170178 id: 42,
@@ -192,15 +200,16 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu
192200 h.OnTextSelectionChanged(nil, "editable selection", nil)
193201 require.Equal(t, "editable selection", wv.selectedTextSnapshot())
194202
195- orchestrator.mu.Lock()
196- gotCalls := orchestrator.selectionCalls
197- orchestrator.mu.Unlock()
198- require.Zero(t, gotCalls)
199- time.Sleep(350 * time.Millisecond)
200- orchestrator.mu.Lock()
201- gotCalls = orchestrator.selectionCalls
202- orchestrator.mu.Unlock()
203+ recorder.mu.Lock()
204+ gotCalls := recorder.selectionCalls
205+ recorder.mu.Unlock()
203206 require.Zero(t, gotCalls)
207+ start := time.Now()
208+ require.Eventually(t, func() bool {
209+ recorder.mu.Lock()
210+ defer recorder.mu.Unlock()
211+ return recorder.selectionCalls == 0 && time.Since(start) >= clipboardSelectionDebounceDelay+50*time.Millisecond
212+ }, 2*time.Second, 10*time.Millisecond)
204213
205214 newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage {
206215 return newTestBridgeProcessMessage(name, false)
@@ -216,16 +225,19 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu
216225
217226 h.OnTextSelectionChanged(nil, "free selection", nil)
218227 require.Equal(t, "free selection", wv.selectedTextSnapshot())
219- require.Zero(t, orchestrator.selectionCalls)
228+ recorder.mu.Lock()
229+ gotCalls = recorder.selectionCalls
230+ recorder.mu.Unlock()
231+ require.Zero(t, gotCalls)
220232 require.Eventually(t, func() bool {
221- orchestrator.mu.Lock()
222- defer orchestrator.mu.Unlock()
223- return orchestrator.selectionCalls == 1 && orchestrator.selection.Text == "free selection"
233+ recorder.mu.Lock()
234+ defer recorder.mu.Unlock()
235+ return recorder.selectionCalls == 1 && recorder.selection.Text == "free selection"
224236 }, 2*time.Second, 10*time.Millisecond)
225237 }
226238
227239 func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *testing.T) {
228- orchestrator := &recordingClipboardOrchestrator{}
240+ orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
229241 wv := &WebView{
230242 ctx: context.Background(),
231243 id: 42,
@@ -238,15 +250,11 @@ func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *te
238250 h.OnTextSelectionChanged(nil, "selected text", nil)
239251 wv.Destroy()
240252
241- orchestrator.mu.Lock()
242- gotCalls := orchestrator.selectionCalls
243- orchestrator.mu.Unlock()
244- require.Zero(t, gotCalls)
245- time.Sleep(350 * time.Millisecond)
246- orchestrator.mu.Lock()
247- gotCalls = orchestrator.selectionCalls
248- orchestrator.mu.Unlock()
249- require.Zero(t, gotCalls)
253+ start := time.Now()
254+ require.Eventually(t, func() bool {
255+ return time.Since(start) >= clipboardSelectionDebounceDelay+50*time.Millisecond
256+ }, 2*time.Second, 10*time.Millisecond)
257+ orchestrator.AssertNotCalled(t, "HandleSelectionUpdate", mock.Anything, mock.Anything)
250258 require.Equal(t, "selected text", wv.selectedTextSnapshot())
251259 }
252260
@@ -13,26 +13,13 @@ import (
13 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
14 14
15 "github.com/bnema/dumber/internal/application/port"15 "github.com/bnema/dumber/internal/application/port"
16+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
16 )17 )
17 18
18-type recordingClipboardOrchestrator struct {19+type clipboardOrchestratorRecorder struct {
19 mu sync.Mutex20 mu sync.Mutex
20 selection port.SelectionClipboardInput21 selection port.SelectionClipboardInput
21 selectionCalls int22 selectionCalls int
22- explicit port.ExplicitClipboardInput
23-}
24-
25-func (r *recordingClipboardOrchestrator) HandleSelectionUpdate(_ context.Context, input port.SelectionClipboardInput) error {
26- r.mu.Lock()
27- r.selection = input
28- r.selectionCalls++
29- r.mu.Unlock()
30- return nil
31-}
32-
33-func (r *recordingClipboardOrchestrator) HandleExplicitCopy(_ context.Context, input port.ExplicitClipboardInput) error {
34- r.explicit = input
35- return nil
36 }23 }
37 24
38 func newTestPipeline(w, h, s int32) *renderPipeline {25 func newTestPipeline(w, h, s int32) *renderPipeline {
@@ -114,7 +101,14 @@ func TestOnLoadEndDoesNotDispatchBrowserLevelCompletion(t *testing.T) {
114 }101 }
115 102
116 func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *testing.T) {103 func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *testing.T) {
117- orchestrator := &recordingClipboardOrchestrator{}104+ recorder := &clipboardOrchestratorRecorder{}
105+ orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
106+ orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input port.SelectionClipboardInput) {
107+ recorder.mu.Lock()
108+ defer recorder.mu.Unlock()
109+ recorder.selection = input
110+ recorder.selectionCalls++
111+ }).Return(nil).Once()
118 wv := &WebView{112 wv := &WebView{
119 ctx: context.Background(),113 ctx: context.Background(),
120 id: 42,114 id: 42,
@@ -127,17 +121,24 @@ func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *test
127 h.OnTextSelectionChanged(nil, "selected text", nil)121 h.OnTextSelectionChanged(nil, "selected text", nil)
128 122
129 require.Eventually(t, func() bool {123 require.Eventually(t, func() bool {
130- orchestrator.mu.Lock()124+ recorder.mu.Lock()
131- defer orchestrator.mu.Unlock()125+ defer recorder.mu.Unlock()
132- return orchestrator.selectionCalls == 1 &&126+ return recorder.selectionCalls == 1 &&
133- orchestrator.selection.Text == "selected text" &&127+ recorder.selection.Text == "selected text" &&
134- orchestrator.selection.SourceEngine == port.ClipboardSourceCEF &&128+ recorder.selection.SourceEngine == port.ClipboardSourceCEF &&
135- orchestrator.selection.ViewID == port.WebViewID(42)129+ recorder.selection.ViewID == port.WebViewID(42)
136 }, 2*time.Second, 10*time.Millisecond)130 }, 2*time.Second, 10*time.Millisecond)
137 }131 }
138 132
139 func TestOnTextSelectionChanged_DebouncesAndCollapsesRapidUpdates(t *testing.T) {133 func TestOnTextSelectionChanged_DebouncesAndCollapsesRapidUpdates(t *testing.T) {
140- orchestrator := &recordingClipboardOrchestrator{}134+ recorder := &clipboardOrchestratorRecorder{}
135+ orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
136+ orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input port.SelectionClipboardInput) {
137+ recorder.mu.Lock()
138+ defer recorder.mu.Unlock()
139+ recorder.selection = input
140+ recorder.selectionCalls++
141+ }).Return(nil).Once()
141 wv := &WebView{142 wv := &WebView{
142 ctx: context.Background(),143 ctx: context.Background(),
143 id: 42,144 id: 42,
@@ -150,21 +151,28 @@ func TestOnTextSelectionChanged_DebouncesAndCollapsesRapidUpdates(t *testing.T)
150 h.OnTextSelectionChanged(nil, "first selection", nil)151 h.OnTextSelectionChanged(nil, "first selection", nil)
151 h.OnTextSelectionChanged(nil, "second selection", nil)152 h.OnTextSelectionChanged(nil, "second selection", nil)
152 153
153- orchestrator.mu.Lock()154+ recorder.mu.Lock()
154- gotCalls := orchestrator.selectionCalls155+ gotCalls := recorder.selectionCalls
155- orchestrator.mu.Unlock()156+ recorder.mu.Unlock()
156 require.Zero(t, gotCalls)157 require.Zero(t, gotCalls)
157 158
158 require.Eventually(t, func() bool {159 require.Eventually(t, func() bool {
159- orchestrator.mu.Lock()160+ recorder.mu.Lock()
160- defer orchestrator.mu.Unlock()161+ defer recorder.mu.Unlock()
161- return orchestrator.selectionCalls == 1 && orchestrator.selection.Text == "second selection"162+ return recorder.selectionCalls == 1 && recorder.selection.Text == "second selection"
162 }, 2*time.Second, 10*time.Millisecond)163 }, 2*time.Second, 10*time.Millisecond)
163 require.Equal(t, "second selection", wv.selectedTextSnapshot())164 require.Equal(t, "second selection", wv.selectedTextSnapshot())
164 }165 }
165 166
166 func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResumesWhenCleared(t *testing.T) {167 func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResumesWhenCleared(t *testing.T) {
167- orchestrator := &recordingClipboardOrchestrator{}168+ recorder := &clipboardOrchestratorRecorder{}
169+ orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
170+ orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input port.SelectionClipboardInput) {
171+ recorder.mu.Lock()
172+ defer recorder.mu.Unlock()
173+ recorder.selection = input
174+ recorder.selectionCalls++
175+ }).Return(nil).Once()
168 wv := &WebView{176 wv := &WebView{
169 ctx: context.Background(),177 ctx: context.Background(),
170 id: 42,178 id: 42,
@@ -192,15 +200,16 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu
192 h.OnTextSelectionChanged(nil, "editable selection", nil)200 h.OnTextSelectionChanged(nil, "editable selection", nil)
193 require.Equal(t, "editable selection", wv.selectedTextSnapshot())201 require.Equal(t, "editable selection", wv.selectedTextSnapshot())
194 202
195- orchestrator.mu.Lock()203+ recorder.mu.Lock()
196- gotCalls := orchestrator.selectionCalls204+ gotCalls := recorder.selectionCalls
197- orchestrator.mu.Unlock()205+ recorder.mu.Unlock()
198- require.Zero(t, gotCalls)
199- time.Sleep(350 * time.Millisecond)
200- orchestrator.mu.Lock()
201- gotCalls = orchestrator.selectionCalls
202- orchestrator.mu.Unlock()
203 require.Zero(t, gotCalls)206 require.Zero(t, gotCalls)
207+ start := time.Now()
208+ require.Eventually(t, func() bool {
209+ recorder.mu.Lock()
210+ defer recorder.mu.Unlock()
211+ return recorder.selectionCalls == 0 && time.Since(start) >= clipboardSelectionDebounceDelay+50*time.Millisecond
212+ }, 2*time.Second, 10*time.Millisecond)
204 213
205 newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage {214 newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage {
206 return newTestBridgeProcessMessage(name, false)215 return newTestBridgeProcessMessage(name, false)
@@ -216,16 +225,19 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu
216 225
217 h.OnTextSelectionChanged(nil, "free selection", nil)226 h.OnTextSelectionChanged(nil, "free selection", nil)
218 require.Equal(t, "free selection", wv.selectedTextSnapshot())227 require.Equal(t, "free selection", wv.selectedTextSnapshot())
219- require.Zero(t, orchestrator.selectionCalls)228+ recorder.mu.Lock()
229+ gotCalls = recorder.selectionCalls
230+ recorder.mu.Unlock()
231+ require.Zero(t, gotCalls)
220 require.Eventually(t, func() bool {232 require.Eventually(t, func() bool {
221- orchestrator.mu.Lock()233+ recorder.mu.Lock()
222- defer orchestrator.mu.Unlock()234+ defer recorder.mu.Unlock()
223- return orchestrator.selectionCalls == 1 && orchestrator.selection.Text == "free selection"235+ return recorder.selectionCalls == 1 && recorder.selection.Text == "free selection"
224 }, 2*time.Second, 10*time.Millisecond)236 }, 2*time.Second, 10*time.Millisecond)
225 }237 }
226 238
227 func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *testing.T) {239 func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *testing.T) {
228- orchestrator := &recordingClipboardOrchestrator{}240+ orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
229 wv := &WebView{241 wv := &WebView{
230 ctx: context.Background(),242 ctx: context.Background(),
231 id: 42,243 id: 42,
@@ -238,15 +250,11 @@ func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *te
238 h.OnTextSelectionChanged(nil, "selected text", nil)250 h.OnTextSelectionChanged(nil, "selected text", nil)
239 wv.Destroy()251 wv.Destroy()
240 252
241- orchestrator.mu.Lock()253+ start := time.Now()
242- gotCalls := orchestrator.selectionCalls254+ require.Eventually(t, func() bool {
243- orchestrator.mu.Unlock()255+ return time.Since(start) >= clipboardSelectionDebounceDelay+50*time.Millisecond
244- require.Zero(t, gotCalls)256+ }, 2*time.Second, 10*time.Millisecond)
245- time.Sleep(350 * time.Millisecond)257+ orchestrator.AssertNotCalled(t, "HandleSelectionUpdate", mock.Anything, mock.Anything)
246- orchestrator.mu.Lock()
247- gotCalls = orchestrator.selectionCalls
248- orchestrator.mu.Unlock()
249- require.Zero(t, gotCalls)
250 require.Equal(t, "selected text", wv.selectedTextSnapshot())258 require.Equal(t, "selected text", wv.selectedTextSnapshot())
251 }259 }
252 260
M internal/infrastructure/cef/webview_navigation_test.go +40 -1
@@ -27,7 +27,7 @@ func TestWebViewReplayPendingNavigation_LoadsQueuedURIWhenMainFrameAvailable(t *
2727
2828 func TestWebViewReplayPendingNavigation_RetriesWhenMainFrameUnavailable(t *testing.T) {
2929 browser := cefmocks.NewMockBrowser(t)
30- browser.EXPECT().GetMainFrame().Return((purecef.Frame)(nil)).Once()
30+ browser.EXPECT().GetMainFrame().Return(purecef.Frame(nil)).Once()
3131
3232 oldTask := cefNewTask
3333 oldDelayed := cefPostDelayedTask
@@ -55,6 +55,45 @@ func TestWebViewReplayPendingNavigation_RetriesWhenMainFrameUnavailable(t *testi
5555 require.Equal(t, "https://github.com/bnema", wv.pendingURI)
5656 }
5757
58+func TestWebViewSchedulePendingNavigationReplay_RetriesWhenTaskPostFails(t *testing.T) {
59+ browser := cefmocks.NewMockBrowser(t)
60+
61+ oldTask := cefNewTask
62+ oldPost := cefPostTask
63+ oldDelayed := cefPostDelayedTask
64+ oldAfter := cefScheduleAfter
65+ defer func() {
66+ cefNewTask = oldTask
67+ cefPostTask = oldPost
68+ cefPostDelayedTask = oldDelayed
69+ cefScheduleAfter = oldAfter
70+ }()
71+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
72+
73+ retried := false
74+ cefPostTask = func(threadID purecef.ThreadID, task purecef.Task) int32 {
75+ require.Equal(t, purecef.ThreadIDTidUi, threadID)
76+ require.NotNil(t, task)
77+ return 0
78+ }
79+ cefScheduleAfter = func(delay time.Duration, fn func()) {
80+ require.Equal(t, pendingNavigationRetryDelay, delay)
81+ fn()
82+ }
83+ cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 {
84+ require.Equal(t, purecef.ThreadIDTidUi, threadID)
85+ require.NotNil(t, task)
86+ require.Equal(t, int64(pendingNavigationRetryDelay/time.Millisecond), delayMs)
87+ retried = true
88+ return 1
89+ }
90+
91+ wv := &WebView{ctx: context.Background(), pendingURI: "https://github.com/bnema"}
92+ wv.schedulePendingNavigationReplay(browser, 0)
93+
94+ require.True(t, retried)
95+}
96+
5897 func TestWebViewLoadURI_QueuesPendingNavigationReplayForExistingBrowser(t *testing.T) {
5998 browser := cefmocks.NewMockBrowser(t)
6099 frame := cefmocks.NewMockFrame(t)
@@ -27,7 +27,7 @@ func TestWebViewReplayPendingNavigation_LoadsQueuedURIWhenMainFrameAvailable(t *
27 27
28 func TestWebViewReplayPendingNavigation_RetriesWhenMainFrameUnavailable(t *testing.T) {28 func TestWebViewReplayPendingNavigation_RetriesWhenMainFrameUnavailable(t *testing.T) {
29 browser := cefmocks.NewMockBrowser(t)29 browser := cefmocks.NewMockBrowser(t)
30- browser.EXPECT().GetMainFrame().Return((purecef.Frame)(nil)).Once()30+ browser.EXPECT().GetMainFrame().Return(purecef.Frame(nil)).Once()
31 31
32 oldTask := cefNewTask32 oldTask := cefNewTask
33 oldDelayed := cefPostDelayedTask33 oldDelayed := cefPostDelayedTask
@@ -55,6 +55,45 @@ func TestWebViewReplayPendingNavigation_RetriesWhenMainFrameUnavailable(t *testi
55 require.Equal(t, "https://github.com/bnema", wv.pendingURI)55 require.Equal(t, "https://github.com/bnema", wv.pendingURI)
56 }56 }
57 57
58+func TestWebViewSchedulePendingNavigationReplay_RetriesWhenTaskPostFails(t *testing.T) {
59+ browser := cefmocks.NewMockBrowser(t)
60+
61+ oldTask := cefNewTask
62+ oldPost := cefPostTask
63+ oldDelayed := cefPostDelayedTask
64+ oldAfter := cefScheduleAfter
65+ defer func() {
66+ cefNewTask = oldTask
67+ cefPostTask = oldPost
68+ cefPostDelayedTask = oldDelayed
69+ cefScheduleAfter = oldAfter
70+ }()
71+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
72+
73+ retried := false
74+ cefPostTask = func(threadID purecef.ThreadID, task purecef.Task) int32 {
75+ require.Equal(t, purecef.ThreadIDTidUi, threadID)
76+ require.NotNil(t, task)
77+ return 0
78+ }
79+ cefScheduleAfter = func(delay time.Duration, fn func()) {
80+ require.Equal(t, pendingNavigationRetryDelay, delay)
81+ fn()
82+ }
83+ cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 {
84+ require.Equal(t, purecef.ThreadIDTidUi, threadID)
85+ require.NotNil(t, task)
86+ require.Equal(t, int64(pendingNavigationRetryDelay/time.Millisecond), delayMs)
87+ retried = true
88+ return 1
89+ }
90+
91+ wv := &WebView{ctx: context.Background(), pendingURI: "https://github.com/bnema"}
92+ wv.schedulePendingNavigationReplay(browser, 0)
93+
94+ require.True(t, retried)
95+}
96+
58 func TestWebViewLoadURI_QueuesPendingNavigationReplayForExistingBrowser(t *testing.T) {97 func TestWebViewLoadURI_QueuesPendingNavigationReplayForExistingBrowser(t *testing.T) {
59 browser := cefmocks.NewMockBrowser(t)98 browser := cefmocks.NewMockBrowser(t)
60 frame := cefmocks.NewMockFrame(t)99 frame := cefmocks.NewMockFrame(t)
M internal/infrastructure/config/engine.go +1 -1
@@ -85,7 +85,7 @@ type CEFEngineConfig struct {
8585 // libcef.so and the Resources/locales subdirectories.
8686 CEFDir string `mapstructure:"cef_dir" toml:"cef_dir" yaml:"cef_dir"`
8787 // LogFile overrides the CEF internal log destination. When empty,
88- // Dumber writes CEF startup logs to .dev/dumber/logs/cef_runtime.log.
88+ // Dumber uses the resolved runtime-profile default engine log path.
8989 LogFile string `mapstructure:"log_file" toml:"log_file" yaml:"log_file"`
9090 // LogSeverity controls CEF's internal log verbosity.
9191 // 0 = default, 1 = verbose, 2 = info, 3 = warning, 4 = error, 99 = disable.
@@ -85,7 +85,7 @@ type CEFEngineConfig struct {
85 // libcef.so and the Resources/locales subdirectories.85 // libcef.so and the Resources/locales subdirectories.
86 CEFDir string `mapstructure:"cef_dir" toml:"cef_dir" yaml:"cef_dir"`86 CEFDir string `mapstructure:"cef_dir" toml:"cef_dir" yaml:"cef_dir"`
87 // LogFile overrides the CEF internal log destination. When empty,87 // LogFile overrides the CEF internal log destination. When empty,
88- // Dumber writes CEF startup logs to .dev/dumber/logs/cef_runtime.log.88+ // Dumber uses the resolved runtime-profile default engine log path.
89 LogFile string `mapstructure:"log_file" toml:"log_file" yaml:"log_file"`89 LogFile string `mapstructure:"log_file" toml:"log_file" yaml:"log_file"`
90 // LogSeverity controls CEF's internal log verbosity.90 // LogSeverity controls CEF's internal log verbosity.
91 // 0 = default, 1 = verbose, 2 = info, 3 = warning, 4 = error, 99 = disable.91 // 0 = default, 1 = verbose, 2 = info, 3 = warning, 4 = error, 99 = disable.
M internal/infrastructure/config/xdg.go +39 -25
@@ -4,6 +4,8 @@ package config
44 import (
55 "os"
66 "path/filepath"
7+
8+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
79 )
810
911 // File permission constants
@@ -25,25 +27,42 @@ type XDGDirs struct {
2527 CacheHome string
2628 }
2729
28-// GetXDGDirs returns the XDG Base Directory paths for dumber.
29-// It follows the XDG Base Directory specification:
30-// - $XDG_CONFIG_HOME/dumber (default: ~/.config/dumber)
31-// - $XDG_DATA_HOME/dumber (default: ~/.local/share/dumber)
32-// - $XDG_STATE_HOME/dumber (default: ~/.local/state/dumber)
30+// GetXDGDirs returns the dumber-specific XDG directories.
31+// In prod they match the current XDG layout exactly.
32+// In dev they resolve into the shared sandbox under .dev/dumber/{config,data,state,cache}.
3333 func GetXDGDirs() (*XDGDirs, error) {
34- // Development mode: use .dev directory in current working directory
34+ profile, err := resolveSharedProfile()
35+ if err != nil {
36+ return nil, err
37+ }
38+ return &XDGDirs{
39+ ConfigHome: profile.Shared.ConfigDir,
40+ DataHome: profile.Shared.DataDir,
41+ StateHome: profile.Shared.StateDir,
42+ CacheHome: profile.Shared.CacheDir,
43+ }, nil
44+}
45+
46+func resolveSharedProfile() (runtimeprofile.Profile, error) {
47+ base, err := getBaseXDGDirs()
48+ if err != nil {
49+ return runtimeprofile.Profile{}, err
50+ }
51+ return runtimeprofile.Resolve(runtimeprofile.ResolveInput{
52+ Env: os.Getenv,
53+ CWD: os.Getwd,
54+ Base: runtimeprofile.BasePaths{
55+ ConfigHome: base.ConfigHome,
56+ DataHome: base.DataHome,
57+ StateHome: base.StateHome,
58+ CacheHome: base.CacheHome,
59+ },
60+ })
61+}
62+
63+func getBaseXDGDirs() (*XDGDirs, error) {
3564 if os.Getenv("ENV") == "dev" {
36- cwd, err := os.Getwd()
37- if err != nil {
38- return nil, err
39- }
40- devDir := filepath.Join(cwd, ".dev", appName)
41- return &XDGDirs{
42- ConfigHome: devDir,
43- DataHome: devDir,
44- StateHome: devDir,
45- CacheHome: devDir,
46- }, nil
65+ return &XDGDirs{}, nil
4766 }
4867
4968 homeDir, err := os.UserHomeDir()
@@ -51,28 +70,24 @@ func GetXDGDirs() (*XDGDirs, error) {
5170 return nil, err
5271 }
5372
54- // XDG_CONFIG_HOME
5573 configHome := os.Getenv("XDG_CONFIG_HOME")
5674 if configHome == "" {
5775 configHome = filepath.Join(homeDir, ".config")
5876 }
5977 configHome = filepath.Join(configHome, appName)
6078
61- // XDG_DATA_HOME
6279 dataHome := os.Getenv("XDG_DATA_HOME")
6380 if dataHome == "" {
6481 dataHome = filepath.Join(homeDir, ".local", "share")
6582 }
6683 dataHome = filepath.Join(dataHome, appName)
6784
68- // XDG_STATE_HOME
6985 stateHome := os.Getenv("XDG_STATE_HOME")
7086 if stateHome == "" {
7187 stateHome = filepath.Join(homeDir, ".local", "state")
7288 }
7389 stateHome = filepath.Join(stateHome, appName)
7490
75- // XDG_CACHE_HOME
7691 cacheHome := os.Getenv("XDG_CACHE_HOME")
7792 if cacheHome == "" {
7893 cacheHome = filepath.Join(homeDir, ".cache")
@@ -114,14 +129,13 @@ func GetStateDir() (string, error) {
114129 return dirs.StateHome, nil
115130 }
116131
117-// GetLogDir returns the XDG-compliant log directory for dumber.
118-// Logs are stored in XDG_STATE_HOME as per specification.
132+// GetLogDir returns the shared application log directory for dumber.
119133 func GetLogDir() (string, error) {
120- stateDir, err := GetStateDir()
134+ profile, err := resolveSharedProfile()
121135 if err != nil {
122136 return "", err
123137 }
124- return filepath.Join(stateDir, "logs"), nil
138+ return profile.Shared.LogDir, nil
125139 }
126140
127141 // GetConfigFile returns the path to the main configuration file.
@@ -4,6 +4,8 @@ package config
4 import (4 import (
5 "os"5 "os"
6 "path/filepath"6 "path/filepath"
7+
8+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
7 )9 )
8 10
9 // File permission constants11 // File permission constants
@@ -25,25 +27,42 @@ type XDGDirs struct {
25 CacheHome string27 CacheHome string
26 }28 }
27 29
28-// GetXDGDirs returns the XDG Base Directory paths for dumber.30+// GetXDGDirs returns the dumber-specific XDG directories.
29-// It follows the XDG Base Directory specification:31+// In prod they match the current XDG layout exactly.
30-// - $XDG_CONFIG_HOME/dumber (default: ~/.config/dumber)32+// In dev they resolve into the shared sandbox under .dev/dumber/{config,data,state,cache}.
31-// - $XDG_DATA_HOME/dumber (default: ~/.local/share/dumber)
32-// - $XDG_STATE_HOME/dumber (default: ~/.local/state/dumber)
33 func GetXDGDirs() (*XDGDirs, error) {33 func GetXDGDirs() (*XDGDirs, error) {
34- // Development mode: use .dev directory in current working directory34+ profile, err := resolveSharedProfile()
35+ if err != nil {
36+ return nil, err
37+ }
38+ return &XDGDirs{
39+ ConfigHome: profile.Shared.ConfigDir,
40+ DataHome: profile.Shared.DataDir,
41+ StateHome: profile.Shared.StateDir,
42+ CacheHome: profile.Shared.CacheDir,
43+ }, nil
44+}
45+
46+func resolveSharedProfile() (runtimeprofile.Profile, error) {
47+ base, err := getBaseXDGDirs()
48+ if err != nil {
49+ return runtimeprofile.Profile{}, err
50+ }
51+ return runtimeprofile.Resolve(runtimeprofile.ResolveInput{
52+ Env: os.Getenv,
53+ CWD: os.Getwd,
54+ Base: runtimeprofile.BasePaths{
55+ ConfigHome: base.ConfigHome,
56+ DataHome: base.DataHome,
57+ StateHome: base.StateHome,
58+ CacheHome: base.CacheHome,
59+ },
60+ })
61+}
62+
63+func getBaseXDGDirs() (*XDGDirs, error) {
35 if os.Getenv("ENV") == "dev" {64 if os.Getenv("ENV") == "dev" {
36- cwd, err := os.Getwd()65+ return &XDGDirs{}, nil
37- if err != nil {
38- return nil, err
39- }
40- devDir := filepath.Join(cwd, ".dev", appName)
41- return &XDGDirs{
42- ConfigHome: devDir,
43- DataHome: devDir,
44- StateHome: devDir,
45- CacheHome: devDir,
46- }, nil
47 }66 }
48 67
49 homeDir, err := os.UserHomeDir()68 homeDir, err := os.UserHomeDir()
@@ -51,28 +70,24 @@ func GetXDGDirs() (*XDGDirs, error) {
51 return nil, err70 return nil, err
52 }71 }
53 72
54- // XDG_CONFIG_HOME
55 configHome := os.Getenv("XDG_CONFIG_HOME")73 configHome := os.Getenv("XDG_CONFIG_HOME")
56 if configHome == "" {74 if configHome == "" {
57 configHome = filepath.Join(homeDir, ".config")75 configHome = filepath.Join(homeDir, ".config")
58 }76 }
59 configHome = filepath.Join(configHome, appName)77 configHome = filepath.Join(configHome, appName)
60 78
61- // XDG_DATA_HOME
62 dataHome := os.Getenv("XDG_DATA_HOME")79 dataHome := os.Getenv("XDG_DATA_HOME")
63 if dataHome == "" {80 if dataHome == "" {
64 dataHome = filepath.Join(homeDir, ".local", "share")81 dataHome = filepath.Join(homeDir, ".local", "share")
65 }82 }
66 dataHome = filepath.Join(dataHome, appName)83 dataHome = filepath.Join(dataHome, appName)
67 84
68- // XDG_STATE_HOME
69 stateHome := os.Getenv("XDG_STATE_HOME")85 stateHome := os.Getenv("XDG_STATE_HOME")
70 if stateHome == "" {86 if stateHome == "" {
71 stateHome = filepath.Join(homeDir, ".local", "state")87 stateHome = filepath.Join(homeDir, ".local", "state")
72 }88 }
73 stateHome = filepath.Join(stateHome, appName)89 stateHome = filepath.Join(stateHome, appName)
74 90
75- // XDG_CACHE_HOME
76 cacheHome := os.Getenv("XDG_CACHE_HOME")91 cacheHome := os.Getenv("XDG_CACHE_HOME")
77 if cacheHome == "" {92 if cacheHome == "" {
78 cacheHome = filepath.Join(homeDir, ".cache")93 cacheHome = filepath.Join(homeDir, ".cache")
@@ -114,14 +129,13 @@ func GetStateDir() (string, error) {
114 return dirs.StateHome, nil129 return dirs.StateHome, nil
115 }130 }
116 131
117-// GetLogDir returns the XDG-compliant log directory for dumber.132+// GetLogDir returns the shared application log directory for dumber.
118-// Logs are stored in XDG_STATE_HOME as per specification.
119 func GetLogDir() (string, error) {133 func GetLogDir() (string, error) {
120- stateDir, err := GetStateDir()134+ profile, err := resolveSharedProfile()
121 if err != nil {135 if err != nil {
122 return "", err136 return "", err
123 }137 }
124- return filepath.Join(stateDir, "logs"), nil138+ return profile.Shared.LogDir, nil
125 }139 }
126 140
127 // GetConfigFile returns the path to the main configuration file.141 // GetConfigFile returns the path to the main configuration file.
A internal/infrastructure/config/xdg_test.go +42 -0
@@ -0,0 +1,42 @@
1+package config
2+
3+import (
4+ "path/filepath"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestGetXDGDirs_ProdPreservesLayout(t *testing.T) {
11+ home := t.TempDir()
12+ t.Setenv("ENV", "")
13+ t.Setenv("HOME", home)
14+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
15+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
16+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
17+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
18+
19+ dirs, err := GetXDGDirs()
20+ require.NoError(t, err)
21+ require.Equal(t, filepath.Join(home, ".config", appName), dirs.ConfigHome)
22+ require.Equal(t, filepath.Join(home, ".local", "share", appName), dirs.DataHome)
23+ require.Equal(t, filepath.Join(home, ".local", "state", appName), dirs.StateHome)
24+ require.Equal(t, filepath.Join(home, ".cache", appName), dirs.CacheHome)
25+}
26+
27+func TestGetXDGDirs_DevUsesSharedSandboxLayout(t *testing.T) {
28+ wd := t.TempDir()
29+ t.Chdir(wd)
30+
31+ t.Setenv("ENV", "dev")
32+ dirs, err := GetXDGDirs()
33+ require.NoError(t, err)
34+ require.Equal(t, filepath.Join(wd, ".dev", appName, "config"), dirs.ConfigHome)
35+ require.Equal(t, filepath.Join(wd, ".dev", appName, "data"), dirs.DataHome)
36+ require.Equal(t, filepath.Join(wd, ".dev", appName, "state"), dirs.StateHome)
37+ require.Equal(t, filepath.Join(wd, ".dev", appName, "cache"), dirs.CacheHome)
38+
39+ logDir, err := GetLogDir()
40+ require.NoError(t, err)
41+ require.Equal(t, filepath.Join(wd, ".dev", appName, "logs"), logDir)
42+}
@@ -0,0 +1,42 @@
1+package config
2+
3+import (
4+ "path/filepath"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestGetXDGDirs_ProdPreservesLayout(t *testing.T) {
11+ home := t.TempDir()
12+ t.Setenv("ENV", "")
13+ t.Setenv("HOME", home)
14+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
15+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
16+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
17+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
18+
19+ dirs, err := GetXDGDirs()
20+ require.NoError(t, err)
21+ require.Equal(t, filepath.Join(home, ".config", appName), dirs.ConfigHome)
22+ require.Equal(t, filepath.Join(home, ".local", "share", appName), dirs.DataHome)
23+ require.Equal(t, filepath.Join(home, ".local", "state", appName), dirs.StateHome)
24+ require.Equal(t, filepath.Join(home, ".cache", appName), dirs.CacheHome)
25+}
26+
27+func TestGetXDGDirs_DevUsesSharedSandboxLayout(t *testing.T) {
28+ wd := t.TempDir()
29+ t.Chdir(wd)
30+
31+ t.Setenv("ENV", "dev")
32+ dirs, err := GetXDGDirs()
33+ require.NoError(t, err)
34+ require.Equal(t, filepath.Join(wd, ".dev", appName, "config"), dirs.ConfigHome)
35+ require.Equal(t, filepath.Join(wd, ".dev", appName, "data"), dirs.DataHome)
36+ require.Equal(t, filepath.Join(wd, ".dev", appName, "state"), dirs.StateHome)
37+ require.Equal(t, filepath.Join(wd, ".dev", appName, "cache"), dirs.CacheHome)
38+
39+ logDir, err := GetLogDir()
40+ require.NoError(t, err)
41+ require.Equal(t, filepath.Join(wd, ".dev", appName, "logs"), logDir)
42+}
M internal/infrastructure/desktop/browser_launch_relay.go +9 -22
@@ -14,6 +14,7 @@ import (
1414 "time"
1515
1616 "github.com/bnema/dumber/internal/application/port"
17+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
1718 "github.com/bnema/dumber/internal/logging"
1819 )
1920
@@ -24,7 +25,7 @@ const browserLaunchIOTimeout = 50 * time.Millisecond
2425 const browserLaunchDirPerm = 0o700
2526
2627 type browserLaunchRelay struct {
27- xdg port.XDGPaths
28+ ipc runtimeprofile.IPCPaths
2829 }
2930
3031 type browserLaunchRequest struct {
@@ -42,8 +43,8 @@ type browserLaunchRelayListener struct {
4243 err error
4344 }
4445
45-func NewBrowserLaunchRelay(xdg port.XDGPaths) port.BrowserLaunchRelay {
46- return &browserLaunchRelay{xdg: xdg}
46+func NewBrowserLaunchRelay(ipc runtimeprofile.IPCPaths) port.BrowserLaunchRelay {
47+ return &browserLaunchRelay{ipc: ipc}
4748 }
4849
4950 func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) {
@@ -171,27 +172,13 @@ func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWind
171172 }
172173
173174 func (r *browserLaunchRelay) socketPath() (string, error) {
174- if r == nil || r.xdg == nil {
175- return "", errors.New("browser launch relay missing XDG paths")
175+ if r == nil {
176+ return "", errors.New("browser launch relay missing IPC paths")
176177 }
177-
178- runtimeDir, err := r.xdg.RuntimeDir()
179- if err != nil {
180- return "", fmt.Errorf("get runtime dir: %w", err)
181- }
182- if runtimeDir != "" {
183- return filepath.Join(runtimeDir, browserLaunchSocketName), nil
184- }
185-
186- stateDir, err := r.xdg.StateDir()
187- if err != nil {
188- return "", fmt.Errorf("get state dir: %w", err)
178+ if r.ipc.BrowserLaunchSocket == "" {
179+ return "", errors.New("browser launch relay missing browser launch socket path")
189180 }
190- if stateDir == "" {
191- return "", errors.New("browser launch relay needs runtime or state dir")
192- }
193-
194- return filepath.Join(stateDir, "runtime", browserLaunchSocketName), nil
181+ return r.ipc.BrowserLaunchSocket, nil
195182 }
196183
197184 func (l *browserLaunchRelayListener) Close() error {
@@ -14,6 +14,7 @@ import (
14 "time"14 "time"
15 15
16 "github.com/bnema/dumber/internal/application/port"16 "github.com/bnema/dumber/internal/application/port"
17+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
17 "github.com/bnema/dumber/internal/logging"18 "github.com/bnema/dumber/internal/logging"
18 )19 )
19 20
@@ -24,7 +25,7 @@ const browserLaunchIOTimeout = 50 * time.Millisecond
24 const browserLaunchDirPerm = 0o70025 const browserLaunchDirPerm = 0o700
25 26
26 type browserLaunchRelay struct {27 type browserLaunchRelay struct {
27- xdg port.XDGPaths28+ ipc runtimeprofile.IPCPaths
28 }29 }
29 30
30 type browserLaunchRequest struct {31 type browserLaunchRequest struct {
@@ -42,8 +43,8 @@ type browserLaunchRelayListener struct {
42 err error43 err error
43 }44 }
44 45
45-func NewBrowserLaunchRelay(xdg port.XDGPaths) port.BrowserLaunchRelay {46+func NewBrowserLaunchRelay(ipc runtimeprofile.IPCPaths) port.BrowserLaunchRelay {
46- return &browserLaunchRelay{xdg: xdg}47+ return &browserLaunchRelay{ipc: ipc}
47 }48 }
48 49
49 func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) {50 func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) {
@@ -171,27 +172,13 @@ func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWind
171 }172 }
172 173
173 func (r *browserLaunchRelay) socketPath() (string, error) {174 func (r *browserLaunchRelay) socketPath() (string, error) {
174- if r == nil || r.xdg == nil {175+ if r == nil {
175- return "", errors.New("browser launch relay missing XDG paths")176+ return "", errors.New("browser launch relay missing IPC paths")
176 }177 }
177-178+ if r.ipc.BrowserLaunchSocket == "" {
178- runtimeDir, err := r.xdg.RuntimeDir()179+ return "", errors.New("browser launch relay missing browser launch socket path")
179- if err != nil {
180- return "", fmt.Errorf("get runtime dir: %w", err)
181- }
182- if runtimeDir != "" {
183- return filepath.Join(runtimeDir, browserLaunchSocketName), nil
184- }
185-
186- stateDir, err := r.xdg.StateDir()
187- if err != nil {
188- return "", fmt.Errorf("get state dir: %w", err)
189 }180 }
190- if stateDir == "" {181+ return r.ipc.BrowserLaunchSocket, nil
191- return "", errors.New("browser launch relay needs runtime or state dir")
192- }
193-
194- return filepath.Join(stateDir, "runtime", browserLaunchSocketName), nil
195 }182 }
196 183
197 func (l *browserLaunchRelayListener) Close() error {184 func (l *browserLaunchRelayListener) Close() error {
M internal/infrastructure/desktop/browser_launch_relay_test.go +85 -86
@@ -9,29 +9,11 @@ import (
99 "testing"
1010 "time"
1111
12- "github.com/bnema/dumber/internal/application/port"
12+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
1313 "github.com/stretchr/testify/assert"
1414 "github.com/stretchr/testify/require"
1515 )
1616
17-type testXDGPaths struct {
18- runtimeDir string
19- stateDir string
20-}
21-
22-var _ port.XDGPaths = testXDGPaths{}
23-
24-func (testXDGPaths) ConfigDir() (string, error) { return "", nil }
25-func (testXDGPaths) DataDir() (string, error) { return "", nil }
26-func (x testXDGPaths) StateDir() (string, error) { return x.stateDir, nil }
27-func (x testXDGPaths) RuntimeDir() (string, error) { return x.runtimeDir, nil }
28-func (testXDGPaths) CacheDir() (string, error) { return "", nil }
29-func (testXDGPaths) FilterJSONDir() (string, error) { return "", nil }
30-func (testXDGPaths) FilterStoreDir() (string, error) { return "", nil }
31-func (testXDGPaths) FilterCacheDir() (string, error) { return "", nil }
32-func (testXDGPaths) ManDir() (string, error) { return "", nil }
33-func (testXDGPaths) DownloadDir() (string, error) { return "", nil }
34-
3517 type browserWindowOpenerFunc func(context.Context, string) error
3618
3719 func (f browserWindowOpenerFunc) OpenFreshWindow(ctx context.Context, url string) error {
@@ -68,8 +50,24 @@ func shortTempDir(t *testing.T) string {
6850 return dir
6951 }
7052
53+func testIPC(root string) runtimeprofile.IPCPaths {
54+ runtimeDir := filepath.Join(root, "runtime")
55+ return runtimeprofile.IPCPaths{
56+ RuntimeDir: runtimeDir,
57+ BrowserLaunchSocket: filepath.Join(runtimeDir, browserLaunchSocketName),
58+ }
59+}
60+
61+func testNamespacedIPC(root, engine string) runtimeprofile.IPCPaths {
62+ runtimeDir := filepath.Join(root, "runtime", engine)
63+ return runtimeprofile.IPCPaths{
64+ RuntimeDir: runtimeDir,
65+ BrowserLaunchSocket: filepath.Join(runtimeDir, browserLaunchSocketName),
66+ }
67+}
68+
7169 func TestBrowserLaunchRelay_DeliverOpenFreshWindow_MissingListenerReturnsFalseNil(t *testing.T) {
72- relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: shortTempDir(t)})
70+ relay := NewBrowserLaunchRelay(testIPC(shortTempDir(t)))
7371
7472 delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com")
7573
@@ -77,39 +75,40 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_MissingListenerReturnsFalseNi
7775 assert.False(t, delivered)
7876 }
7977
80-func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) {
81- runtimeDir := filepath.Join(shortTempDir(t), "runtime")
82- relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
78+func TestBrowserLaunchRelay_SameProfileAndEngine_UsesSameSocket(t *testing.T) {
79+ ipc := testNamespacedIPC(shortTempDir(t), "cef")
80+ listenerRelay := NewBrowserLaunchRelay(ipc)
81+ clientRelay := NewBrowserLaunchRelay(ipc)
8382
8483 received := make(chan string, 1)
8584 ctx, cancel := context.WithCancel(context.Background())
8685 defer cancel()
8786
88- closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
87+ closer, err := listenerRelay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
8988 received <- url
9089 return nil
9190 }))
9291 require.NoError(t, err)
9392 defer closer.Close()
9493
95- waitForSocket(t, filepath.Join(runtimeDir, "browser-launch.sock"))
96-
97- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/new")
94+ waitForSocket(t, ipc.BrowserLaunchSocket)
9895
96+ delivered, err := clientRelay.DeliverOpenFreshWindow(context.Background(), "https://example.com/same-namespace")
9997 require.NoError(t, err)
100- assert.True(t, delivered)
98+ require.True(t, delivered)
99+ require.Equal(t, "https://example.com/same-namespace", <-received)
100+}
101101
102- select {
103- case got := <-received:
104- assert.Equal(t, "https://example.com/new", got)
105- case <-time.After(time.Second):
106- t.Fatal("expected opener to receive the URL")
107- }
102+func TestBrowserLaunchRelay_DevCEFAndDevWebKit_UseDifferentSockets(t *testing.T) {
103+ root := t.TempDir()
104+ cefIPC := testNamespacedIPC(root, "cef")
105+ wkIPC := testNamespacedIPC(root, "webkit")
106+ require.NotEqual(t, cefIPC.BrowserLaunchSocket, wkIPC.BrowserLaunchSocket)
108107 }
109108
110-func TestBrowserLaunchRelay_UsesStateFallbackWhenRuntimeDirMissing(t *testing.T) {
111- stateDir := shortTempDir(t)
112- relay := NewBrowserLaunchRelay(testXDGPaths{stateDir: stateDir})
109+func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) {
110+ ipc := testIPC(shortTempDir(t))
111+ relay := NewBrowserLaunchRelay(ipc)
113112
114113 received := make(chan string, 1)
115114 ctx, cancel := context.WithCancel(context.Background())
@@ -122,25 +121,32 @@ func TestBrowserLaunchRelay_UsesStateFallbackWhenRuntimeDirMissing(t *testing.T)
122121 require.NoError(t, err)
123122 defer closer.Close()
124123
125- socketPath := filepath.Join(stateDir, "runtime", "browser-launch.sock")
126- waitForSocket(t, socketPath)
124+ waitForSocket(t, ipc.BrowserLaunchSocket)
127125
128- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/fallback")
126+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/new")
129127
130128 require.NoError(t, err)
131129 assert.True(t, delivered)
132130
133131 select {
134132 case got := <-received:
135- assert.Equal(t, "https://example.com/fallback", got)
133+ assert.Equal(t, "https://example.com/new", got)
136134 case <-time.After(time.Second):
137135 t.Fatal("expected opener to receive the URL")
138136 }
139137 }
140138
139+func TestBrowserLaunchRelay_RequiresBrowserLaunchSocketPath(t *testing.T) {
140+ relay := NewBrowserLaunchRelay(runtimeprofile.IPCPaths{})
141+
142+ _, err := relay.Listen(context.Background(), browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
143+ require.Error(t, err)
144+ require.Contains(t, err.Error(), "browser launch socket path")
145+}
146+
141147 func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t *testing.T) {
142- runtimeDir := filepath.Join(shortTempDir(t), "runtime")
143- relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
148+ ipc := testIPC(shortTempDir(t))
149+ relay := NewBrowserLaunchRelay(ipc)
144150
145151 firstReceived := make(chan string, 1)
146152 ctx, cancel := context.WithCancel(context.Background())
@@ -153,7 +159,7 @@ func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t
153159 require.NoError(t, err)
154160 defer closer.Close()
155161
156- waitForSocket(t, filepath.Join(runtimeDir, "browser-launch.sock"))
162+ waitForSocket(t, ipc.BrowserLaunchSocket)
157163
158164 _, err = relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
159165 require.Error(t, err)
@@ -171,12 +177,11 @@ func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t
171177 }
172178
173179 func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) {
174- runtimeDir := filepath.Join(shortTempDir(t), "runtime")
175- relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
180+ ipc := testIPC(shortTempDir(t))
181+ relay := NewBrowserLaunchRelay(ipc)
176182
177- socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
178- require.NoError(t, os.MkdirAll(runtimeDir, 0o700))
179- require.NoError(t, os.WriteFile(socketPath, []byte("stale"), 0o600))
183+ require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700))
184+ require.NoError(t, os.WriteFile(ipc.BrowserLaunchSocket, []byte("stale"), 0o600))
180185
181186 received := make(chan string, 1)
182187 ctx, cancel := context.WithCancel(context.Background())
@@ -189,7 +194,7 @@ func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) {
189194 require.NoError(t, err)
190195 defer closer.Close()
191196
192- waitForSocket(t, socketPath)
197+ waitForSocket(t, ipc.BrowserLaunchSocket)
193198
194199 delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/stale")
195200 require.NoError(t, err)
@@ -204,8 +209,8 @@ func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) {
204209 }
205210
206211 func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) {
207- runtimeDir := filepath.Join(shortTempDir(t), "runtime")
208- relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
212+ ipc := testIPC(shortTempDir(t))
213+ relay := NewBrowserLaunchRelay(ipc)
209214
210215 received := make(chan string, 1)
211216 ctx, cancel := context.WithCancel(context.Background())
@@ -218,10 +223,9 @@ func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) {
218223 require.NoError(t, err)
219224 defer closer.Close()
220225
221- socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
222- waitForSocket(t, socketPath)
226+ waitForSocket(t, ipc.BrowserLaunchSocket)
223227
224- conn, err := net.Dial("unix", socketPath)
228+ conn, err := net.Dial("unix", ipc.BrowserLaunchSocket)
225229 require.NoError(t, err)
226230
227231 _, err = conn.Write([]byte("not-json"))
@@ -242,8 +246,8 @@ func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) {
242246 }
243247
244248 func TestBrowserLaunchRelay_CloseRemovesSocketPath(t *testing.T) {
245- runtimeDir := filepath.Join(shortTempDir(t), "runtime")
246- relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
249+ ipc := testIPC(shortTempDir(t))
250+ relay := NewBrowserLaunchRelay(ipc)
247251
248252 ctx, cancel := context.WithCancel(context.Background())
249253 defer cancel()
@@ -251,26 +255,24 @@ func TestBrowserLaunchRelay_CloseRemovesSocketPath(t *testing.T) {
251255 closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
252256 require.NoError(t, err)
253257
254- socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
255- waitForSocket(t, socketPath)
258+ waitForSocket(t, ipc.BrowserLaunchSocket)
256259
257260 require.NoError(t, closer.Close())
258- waitForSocketGone(t, socketPath)
261+ waitForSocketGone(t, ipc.BrowserLaunchSocket)
259262 }
260263
261264 func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) {
262- runtimeDir := filepath.Join(shortTempDir(t), "runtime")
263- relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
265+ ipc := testIPC(shortTempDir(t))
266+ relay := NewBrowserLaunchRelay(ipc)
264267
265268 ctx, cancel := context.WithCancel(context.Background())
266269 closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
267270 require.NoError(t, err)
268271
269- socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
270- waitForSocket(t, socketPath)
272+ waitForSocket(t, ipc.BrowserLaunchSocket)
271273
272274 cancel()
273- waitForSocketGone(t, socketPath)
275+ waitForSocketGone(t, ipc.BrowserLaunchSocket)
274276 require.NoError(t, closer.Close())
275277
276278 delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/after-cancel")
@@ -279,15 +281,14 @@ func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) {
279281 }
280282
281283 func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t *testing.T) {
282- runtimeDir := shortTempDir(t)
283- socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
284- require.NoError(t, os.MkdirAll(runtimeDir, 0o700))
284+ ipc := testIPC(shortTempDir(t))
285+ require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
285286
286- listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
287+ listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"})
287288 require.NoError(t, err)
288289 defer func() {
289290 _ = listener.Close()
290- _ = os.Remove(socketPath)
291+ _ = os.Remove(ipc.BrowserLaunchSocket)
291292 }()
292293
293294 ready := make(chan struct{})
@@ -302,7 +303,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t
302303 }()
303304 <-ready
304305
305- relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
306+ relay := NewBrowserLaunchRelay(ipc)
306307 ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
307308 defer cancel()
308309
@@ -313,15 +314,14 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t
313314 }
314315
315316 func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsTrueOnNoDeadlineTimeout(t *testing.T) {
316- runtimeDir := shortTempDir(t)
317- socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
318- require.NoError(t, os.MkdirAll(runtimeDir, 0o700))
317+ ipc := testIPC(shortTempDir(t))
318+ require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
319319
320- listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})
320+ listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"})
321321 require.NoError(t, err)
322322 defer func() {
323323 _ = listener.Close()
324- _ = os.Remove(socketPath)
324+ _ = os.Remove(ipc.BrowserLaunchSocket)
325325 }()
326326
327327 ready := make(chan struct{})
@@ -336,7 +336,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsTrueOnNoDeadlineTimeou
336336 }()
337337 <-ready
338338
339- relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
339+ relay := NewBrowserLaunchRelay(ipc)
340340
341341 started := make(chan struct{})
342342
@@ -362,8 +362,8 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsTrueOnNoDeadlineTimeou
362362 }
363363
364364 func TestBrowserLaunchRelay_AcknowledgesBeforeOpenerReturns(t *testing.T) {
365- runtimeDir := filepath.Join(shortTempDir(t), "runtime")
366- relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
365+ ipc := testIPC(shortTempDir(t))
366+ relay := NewBrowserLaunchRelay(ipc)
367367
368368 started := make(chan struct{})
369369 release := make(chan struct{})
@@ -378,9 +378,9 @@ func TestBrowserLaunchRelay_AcknowledgesBeforeOpenerReturns(t *testing.T) {
378378 require.NoError(t, err)
379379 defer closer.Close()
380380
381- waitForSocket(t, filepath.Join(runtimeDir, "browser-launch.sock"))
381+ waitForSocket(t, ipc.BrowserLaunchSocket)
382382
383- conn, err := net.Dial("unix", filepath.Join(runtimeDir, "browser-launch.sock"))
383+ conn, err := net.Dial("unix", ipc.BrowserLaunchSocket)
384384 require.NoError(t, err)
385385 defer conn.Close()
386386 require.NoError(t, conn.SetDeadline(time.Now().Add(300*time.Millisecond)))
@@ -399,8 +399,8 @@ func TestBrowserLaunchRelay_AcknowledgesBeforeOpenerReturns(t *testing.T) {
399399 }
400400
401401 func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) {
402- runtimeDir := filepath.Join(shortTempDir(t), "runtime")
403- relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})
402+ ipc := testIPC(shortTempDir(t))
403+ relay := NewBrowserLaunchRelay(ipc)
404404
405405 received := make(chan string, 1)
406406 ctx, cancel := context.WithCancel(context.Background())
@@ -413,10 +413,9 @@ func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) {
413413 require.NoError(t, err)
414414 defer closer.Close()
415415
416- socketPath := filepath.Join(runtimeDir, "browser-launch.sock")
417- waitForSocket(t, socketPath)
416+ waitForSocket(t, ipc.BrowserLaunchSocket)
418417
419- conn, err := net.Dial("unix", socketPath)
418+ conn, err := net.Dial("unix", ipc.BrowserLaunchSocket)
420419 require.NoError(t, err)
421420 defer conn.Close()
422421 time.Sleep(20 * time.Millisecond)
@@ -9,29 +9,11 @@ import (
9 "testing"9 "testing"
10 "time"10 "time"
11 11
12- "github.com/bnema/dumber/internal/application/port"12+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
13 "github.com/stretchr/testify/assert"13 "github.com/stretchr/testify/assert"
14 "github.com/stretchr/testify/require"14 "github.com/stretchr/testify/require"
15 )15 )
16 16
17-type testXDGPaths struct {
18- runtimeDir string
19- stateDir string
20-}
21-
22-var _ port.XDGPaths = testXDGPaths{}
23-
24-func (testXDGPaths) ConfigDir() (string, error) { return "", nil }
25-func (testXDGPaths) DataDir() (string, error) { return "", nil }
26-func (x testXDGPaths) StateDir() (string, error) { return x.stateDir, nil }
27-func (x testXDGPaths) RuntimeDir() (string, error) { return x.runtimeDir, nil }
28-func (testXDGPaths) CacheDir() (string, error) { return "", nil }
29-func (testXDGPaths) FilterJSONDir() (string, error) { return "", nil }
30-func (testXDGPaths) FilterStoreDir() (string, error) { return "", nil }
31-func (testXDGPaths) FilterCacheDir() (string, error) { return "", nil }
32-func (testXDGPaths) ManDir() (string, error) { return "", nil }
33-func (testXDGPaths) DownloadDir() (string, error) { return "", nil }
34-
35 type browserWindowOpenerFunc func(context.Context, string) error17 type browserWindowOpenerFunc func(context.Context, string) error
36 18
37 func (f browserWindowOpenerFunc) OpenFreshWindow(ctx context.Context, url string) error {19 func (f browserWindowOpenerFunc) OpenFreshWindow(ctx context.Context, url string) error {
@@ -68,8 +50,24 @@ func shortTempDir(t *testing.T) string {
68 return dir50 return dir
69 }51 }
70 52
53+func testIPC(root string) runtimeprofile.IPCPaths {
54+ runtimeDir := filepath.Join(root, "runtime")
55+ return runtimeprofile.IPCPaths{
56+ RuntimeDir: runtimeDir,
57+ BrowserLaunchSocket: filepath.Join(runtimeDir, browserLaunchSocketName),
58+ }
59+}
60+
61+func testNamespacedIPC(root, engine string) runtimeprofile.IPCPaths {
62+ runtimeDir := filepath.Join(root, "runtime", engine)
63+ return runtimeprofile.IPCPaths{
64+ RuntimeDir: runtimeDir,
65+ BrowserLaunchSocket: filepath.Join(runtimeDir, browserLaunchSocketName),
66+ }
67+}
68+
71 func TestBrowserLaunchRelay_DeliverOpenFreshWindow_MissingListenerReturnsFalseNil(t *testing.T) {69 func TestBrowserLaunchRelay_DeliverOpenFreshWindow_MissingListenerReturnsFalseNil(t *testing.T) {
72- relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: shortTempDir(t)})70+ relay := NewBrowserLaunchRelay(testIPC(shortTempDir(t)))
73 71
74 delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com")72 delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com")
75 73
@@ -77,39 +75,40 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_MissingListenerReturnsFalseNi
77 assert.False(t, delivered)75 assert.False(t, delivered)
78 }76 }
79 77
80-func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) {78+func TestBrowserLaunchRelay_SameProfileAndEngine_UsesSameSocket(t *testing.T) {
81- runtimeDir := filepath.Join(shortTempDir(t), "runtime")79+ ipc := testNamespacedIPC(shortTempDir(t), "cef")
82- relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})80+ listenerRelay := NewBrowserLaunchRelay(ipc)
81+ clientRelay := NewBrowserLaunchRelay(ipc)
83 82
84 received := make(chan string, 1)83 received := make(chan string, 1)
85 ctx, cancel := context.WithCancel(context.Background())84 ctx, cancel := context.WithCancel(context.Background())
86 defer cancel()85 defer cancel()
87 86
88- closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {87+ closer, err := listenerRelay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error {
89 received <- url88 received <- url
90 return nil89 return nil
91 }))90 }))
92 require.NoError(t, err)91 require.NoError(t, err)
93 defer closer.Close()92 defer closer.Close()
94 93
95- waitForSocket(t, filepath.Join(runtimeDir, "browser-launch.sock"))94+ waitForSocket(t, ipc.BrowserLaunchSocket)
96-
97- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/new")
98 95
96+ delivered, err := clientRelay.DeliverOpenFreshWindow(context.Background(), "https://example.com/same-namespace")
99 require.NoError(t, err)97 require.NoError(t, err)
100- assert.True(t, delivered)98+ require.True(t, delivered)
99+ require.Equal(t, "https://example.com/same-namespace", <-received)
100+}
101 101
102- select {102+func TestBrowserLaunchRelay_DevCEFAndDevWebKit_UseDifferentSockets(t *testing.T) {
103- case got := <-received:103+ root := t.TempDir()
104- assert.Equal(t, "https://example.com/new", got)104+ cefIPC := testNamespacedIPC(root, "cef")
105- case <-time.After(time.Second):105+ wkIPC := testNamespacedIPC(root, "webkit")
106- t.Fatal("expected opener to receive the URL")106+ require.NotEqual(t, cefIPC.BrowserLaunchSocket, wkIPC.BrowserLaunchSocket)
107- }
108 }107 }
109 108
110-func TestBrowserLaunchRelay_UsesStateFallbackWhenRuntimeDirMissing(t *testing.T) {109+func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) {
111- stateDir := shortTempDir(t)110+ ipc := testIPC(shortTempDir(t))
112- relay := NewBrowserLaunchRelay(testXDGPaths{stateDir: stateDir})111+ relay := NewBrowserLaunchRelay(ipc)
113 112
114 received := make(chan string, 1)113 received := make(chan string, 1)
115 ctx, cancel := context.WithCancel(context.Background())114 ctx, cancel := context.WithCancel(context.Background())
@@ -122,25 +121,32 @@ func TestBrowserLaunchRelay_UsesStateFallbackWhenRuntimeDirMissing(t *testing.T)
122 require.NoError(t, err)121 require.NoError(t, err)
123 defer closer.Close()122 defer closer.Close()
124 123
125- socketPath := filepath.Join(stateDir, "runtime", "browser-launch.sock")124+ waitForSocket(t, ipc.BrowserLaunchSocket)
126- waitForSocket(t, socketPath)
127 125
128- delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/fallback")126+ delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/new")
129 127
130 require.NoError(t, err)128 require.NoError(t, err)
131 assert.True(t, delivered)129 assert.True(t, delivered)
132 130
133 select {131 select {
134 case got := <-received:132 case got := <-received:
135- assert.Equal(t, "https://example.com/fallback", got)133+ assert.Equal(t, "https://example.com/new", got)
136 case <-time.After(time.Second):134 case <-time.After(time.Second):
137 t.Fatal("expected opener to receive the URL")135 t.Fatal("expected opener to receive the URL")
138 }136 }
139 }137 }
140 138
139+func TestBrowserLaunchRelay_RequiresBrowserLaunchSocketPath(t *testing.T) {
140+ relay := NewBrowserLaunchRelay(runtimeprofile.IPCPaths{})
141+
142+ _, err := relay.Listen(context.Background(), browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
143+ require.Error(t, err)
144+ require.Contains(t, err.Error(), "browser launch socket path")
145+}
146+
141 func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t *testing.T) {147 func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t *testing.T) {
142- runtimeDir := filepath.Join(shortTempDir(t), "runtime")148+ ipc := testIPC(shortTempDir(t))
143- relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})149+ relay := NewBrowserLaunchRelay(ipc)
144 150
145 firstReceived := make(chan string, 1)151 firstReceived := make(chan string, 1)
146 ctx, cancel := context.WithCancel(context.Background())152 ctx, cancel := context.WithCancel(context.Background())
@@ -153,7 +159,7 @@ func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t
153 require.NoError(t, err)159 require.NoError(t, err)
154 defer closer.Close()160 defer closer.Close()
155 161
156- waitForSocket(t, filepath.Join(runtimeDir, "browser-launch.sock"))162+ waitForSocket(t, ipc.BrowserLaunchSocket)
157 163
158 _, err = relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))164 _, err = relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
159 require.Error(t, err)165 require.Error(t, err)
@@ -171,12 +177,11 @@ func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t
171 }177 }
172 178
173 func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) {179 func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) {
174- runtimeDir := filepath.Join(shortTempDir(t), "runtime")180+ ipc := testIPC(shortTempDir(t))
175- relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})181+ relay := NewBrowserLaunchRelay(ipc)
176 182
177- socketPath := filepath.Join(runtimeDir, "browser-launch.sock")183+ require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700))
178- require.NoError(t, os.MkdirAll(runtimeDir, 0o700))184+ require.NoError(t, os.WriteFile(ipc.BrowserLaunchSocket, []byte("stale"), 0o600))
179- require.NoError(t, os.WriteFile(socketPath, []byte("stale"), 0o600))
180 185
181 received := make(chan string, 1)186 received := make(chan string, 1)
182 ctx, cancel := context.WithCancel(context.Background())187 ctx, cancel := context.WithCancel(context.Background())
@@ -189,7 +194,7 @@ func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) {
189 require.NoError(t, err)194 require.NoError(t, err)
190 defer closer.Close()195 defer closer.Close()
191 196
192- waitForSocket(t, socketPath)197+ waitForSocket(t, ipc.BrowserLaunchSocket)
193 198
194 delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/stale")199 delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/stale")
195 require.NoError(t, err)200 require.NoError(t, err)
@@ -204,8 +209,8 @@ func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) {
204 }209 }
205 210
206 func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) {211 func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) {
207- runtimeDir := filepath.Join(shortTempDir(t), "runtime")212+ ipc := testIPC(shortTempDir(t))
208- relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})213+ relay := NewBrowserLaunchRelay(ipc)
209 214
210 received := make(chan string, 1)215 received := make(chan string, 1)
211 ctx, cancel := context.WithCancel(context.Background())216 ctx, cancel := context.WithCancel(context.Background())
@@ -218,10 +223,9 @@ func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) {
218 require.NoError(t, err)223 require.NoError(t, err)
219 defer closer.Close()224 defer closer.Close()
220 225
221- socketPath := filepath.Join(runtimeDir, "browser-launch.sock")226+ waitForSocket(t, ipc.BrowserLaunchSocket)
222- waitForSocket(t, socketPath)
223 227
224- conn, err := net.Dial("unix", socketPath)228+ conn, err := net.Dial("unix", ipc.BrowserLaunchSocket)
225 require.NoError(t, err)229 require.NoError(t, err)
226 230
227 _, err = conn.Write([]byte("not-json"))231 _, err = conn.Write([]byte("not-json"))
@@ -242,8 +246,8 @@ func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) {
242 }246 }
243 247
244 func TestBrowserLaunchRelay_CloseRemovesSocketPath(t *testing.T) {248 func TestBrowserLaunchRelay_CloseRemovesSocketPath(t *testing.T) {
245- runtimeDir := filepath.Join(shortTempDir(t), "runtime")249+ ipc := testIPC(shortTempDir(t))
246- relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})250+ relay := NewBrowserLaunchRelay(ipc)
247 251
248 ctx, cancel := context.WithCancel(context.Background())252 ctx, cancel := context.WithCancel(context.Background())
249 defer cancel()253 defer cancel()
@@ -251,26 +255,24 @@ func TestBrowserLaunchRelay_CloseRemovesSocketPath(t *testing.T) {
251 closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))255 closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
252 require.NoError(t, err)256 require.NoError(t, err)
253 257
254- socketPath := filepath.Join(runtimeDir, "browser-launch.sock")258+ waitForSocket(t, ipc.BrowserLaunchSocket)
255- waitForSocket(t, socketPath)
256 259
257 require.NoError(t, closer.Close())260 require.NoError(t, closer.Close())
258- waitForSocketGone(t, socketPath)261+ waitForSocketGone(t, ipc.BrowserLaunchSocket)
259 }262 }
260 263
261 func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) {264 func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) {
262- runtimeDir := filepath.Join(shortTempDir(t), "runtime")265+ ipc := testIPC(shortTempDir(t))
263- relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})266+ relay := NewBrowserLaunchRelay(ipc)
264 267
265 ctx, cancel := context.WithCancel(context.Background())268 ctx, cancel := context.WithCancel(context.Background())
266 closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))269 closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil }))
267 require.NoError(t, err)270 require.NoError(t, err)
268 271
269- socketPath := filepath.Join(runtimeDir, "browser-launch.sock")272+ waitForSocket(t, ipc.BrowserLaunchSocket)
270- waitForSocket(t, socketPath)
271 273
272 cancel()274 cancel()
273- waitForSocketGone(t, socketPath)275+ waitForSocketGone(t, ipc.BrowserLaunchSocket)
274 require.NoError(t, closer.Close())276 require.NoError(t, closer.Close())
275 277
276 delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/after-cancel")278 delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/after-cancel")
@@ -279,15 +281,14 @@ func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) {
279 }281 }
280 282
281 func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t *testing.T) {283 func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t *testing.T) {
282- runtimeDir := shortTempDir(t)284+ ipc := testIPC(shortTempDir(t))
283- socketPath := filepath.Join(runtimeDir, "browser-launch.sock")285+ require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
284- require.NoError(t, os.MkdirAll(runtimeDir, 0o700))
285 286
286- listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})287+ listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"})
287 require.NoError(t, err)288 require.NoError(t, err)
288 defer func() {289 defer func() {
289 _ = listener.Close()290 _ = listener.Close()
290- _ = os.Remove(socketPath)291+ _ = os.Remove(ipc.BrowserLaunchSocket)
291 }()292 }()
292 293
293 ready := make(chan struct{})294 ready := make(chan struct{})
@@ -302,7 +303,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t
302 }()303 }()
303 <-ready304 <-ready
304 305
305- relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})306+ relay := NewBrowserLaunchRelay(ipc)
306 ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)307 ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond)
307 defer cancel()308 defer cancel()
308 309
@@ -313,15 +314,14 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t
313 }314 }
314 315
315 func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsTrueOnNoDeadlineTimeout(t *testing.T) {316 func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsTrueOnNoDeadlineTimeout(t *testing.T) {
316- runtimeDir := shortTempDir(t)317+ ipc := testIPC(shortTempDir(t))
317- socketPath := filepath.Join(runtimeDir, "browser-launch.sock")318+ require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700))
318- require.NoError(t, os.MkdirAll(runtimeDir, 0o700))
319 319
320- listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"})320+ listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"})
321 require.NoError(t, err)321 require.NoError(t, err)
322 defer func() {322 defer func() {
323 _ = listener.Close()323 _ = listener.Close()
324- _ = os.Remove(socketPath)324+ _ = os.Remove(ipc.BrowserLaunchSocket)
325 }()325 }()
326 326
327 ready := make(chan struct{})327 ready := make(chan struct{})
@@ -336,7 +336,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsTrueOnNoDeadlineTimeou
336 }()336 }()
337 <-ready337 <-ready
338 338
339- relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})339+ relay := NewBrowserLaunchRelay(ipc)
340 340
341 started := make(chan struct{})341 started := make(chan struct{})
342 342
@@ -362,8 +362,8 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsTrueOnNoDeadlineTimeou
362 }362 }
363 363
364 func TestBrowserLaunchRelay_AcknowledgesBeforeOpenerReturns(t *testing.T) {364 func TestBrowserLaunchRelay_AcknowledgesBeforeOpenerReturns(t *testing.T) {
365- runtimeDir := filepath.Join(shortTempDir(t), "runtime")365+ ipc := testIPC(shortTempDir(t))
366- relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})366+ relay := NewBrowserLaunchRelay(ipc)
367 367
368 started := make(chan struct{})368 started := make(chan struct{})
369 release := make(chan struct{})369 release := make(chan struct{})
@@ -378,9 +378,9 @@ func TestBrowserLaunchRelay_AcknowledgesBeforeOpenerReturns(t *testing.T) {
378 require.NoError(t, err)378 require.NoError(t, err)
379 defer closer.Close()379 defer closer.Close()
380 380
381- waitForSocket(t, filepath.Join(runtimeDir, "browser-launch.sock"))381+ waitForSocket(t, ipc.BrowserLaunchSocket)
382 382
383- conn, err := net.Dial("unix", filepath.Join(runtimeDir, "browser-launch.sock"))383+ conn, err := net.Dial("unix", ipc.BrowserLaunchSocket)
384 require.NoError(t, err)384 require.NoError(t, err)
385 defer conn.Close()385 defer conn.Close()
386 require.NoError(t, conn.SetDeadline(time.Now().Add(300*time.Millisecond)))386 require.NoError(t, conn.SetDeadline(time.Now().Add(300*time.Millisecond)))
@@ -399,8 +399,8 @@ func TestBrowserLaunchRelay_AcknowledgesBeforeOpenerReturns(t *testing.T) {
399 }399 }
400 400
401 func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) {401 func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) {
402- runtimeDir := filepath.Join(shortTempDir(t), "runtime")402+ ipc := testIPC(shortTempDir(t))
403- relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir})403+ relay := NewBrowserLaunchRelay(ipc)
404 404
405 received := make(chan string, 1)405 received := make(chan string, 1)
406 ctx, cancel := context.WithCancel(context.Background())406 ctx, cancel := context.WithCancel(context.Background())
@@ -413,10 +413,9 @@ func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) {
413 require.NoError(t, err)413 require.NoError(t, err)
414 defer closer.Close()414 defer closer.Close()
415 415
416- socketPath := filepath.Join(runtimeDir, "browser-launch.sock")416+ waitForSocket(t, ipc.BrowserLaunchSocket)
417- waitForSocket(t, socketPath)
418 417
419- conn, err := net.Dial("unix", socketPath)418+ conn, err := net.Dial("unix", ipc.BrowserLaunchSocket)
420 require.NoError(t, err)419 require.NoError(t, err)
421 defer conn.Close()420 defer conn.Close()
422 time.Sleep(20 * time.Millisecond)421 time.Sleep(20 * time.Millisecond)
M internal/infrastructure/handlers/clipboard_test.go +11 -30
@@ -7,6 +7,7 @@ import (
77 "testing"
88
99 "github.com/bnema/dumber/internal/application/port"
10+ "github.com/stretchr/testify/require"
1011 )
1112
1213 type recordingClipboardOrchestrator struct {
@@ -35,21 +36,13 @@ func TestClipboardHandler_HandleAutoCopySelection_DelegatesToOrchestrator(t *tes
3536 handler := NewClipboardHandler(orchestrator)
3637
3738 _, err := handler.HandleAutoCopySelection().Handle(ctx, 42, json.RawMessage(`{"text":"selected text"}`))
38- if err != nil {
39- t.Fatalf("Handle() error = %v, want nil", err)
40- }
39+ require.NoError(t, err)
4140
4241 orchestrator.mu.Lock()
4342 defer orchestrator.mu.Unlock()
44- if orchestrator.selection.Text != "selected text" {
45- t.Fatalf("selection text = %q, want %q", orchestrator.selection.Text, "selected text")
46- }
47- if orchestrator.selection.SourceEngine != port.ClipboardSourceWebKit {
48- t.Fatalf("selection source = %q, want %q", orchestrator.selection.SourceEngine, port.ClipboardSourceWebKit)
49- }
50- if orchestrator.selection.ViewID != 42 {
51- t.Fatalf("selection view id = %d, want %d", orchestrator.selection.ViewID, 42)
52- }
43+ require.Equal(t, "selected text", orchestrator.selection.Text)
44+ require.Equal(t, port.ClipboardSourceWebKit, orchestrator.selection.SourceEngine)
45+ require.Equal(t, port.WebViewID(42), orchestrator.selection.ViewID)
5346 }
5447
5548 func TestClipboardHandler_HandleExplicitCopy_DelegatesToOrchestrator(t *testing.T) {
@@ -58,25 +51,13 @@ func TestClipboardHandler_HandleExplicitCopy_DelegatesToOrchestrator(t *testing.
5851 handler := NewClipboardHandler(orchestrator)
5952
6053 _, err := handler.HandleExplicitCopy().Handle(ctx, 42, json.RawMessage(`{"text":"copied text","action":"copy"}`))
61- if err != nil {
62- t.Fatalf("Handle() error = %v, want nil", err)
63- }
54+ require.NoError(t, err)
6455
6556 orchestrator.mu.Lock()
6657 defer orchestrator.mu.Unlock()
67- if orchestrator.explicit.Text != "copied text" {
68- t.Fatalf("explicit text = %q, want %q", orchestrator.explicit.Text, "copied text")
69- }
70- if orchestrator.explicit.Action != "copy" {
71- t.Fatalf("explicit action = %q, want %q", orchestrator.explicit.Action, "copy")
72- }
73- if orchestrator.explicit.SourceEngine != port.ClipboardSourceWebKit {
74- t.Fatalf("explicit source = %q, want %q", orchestrator.explicit.SourceEngine, port.ClipboardSourceWebKit)
75- }
76- if orchestrator.explicit.ViewID != 42 {
77- t.Fatalf("explicit view id = %d, want %d", orchestrator.explicit.ViewID, 42)
78- }
79- if !orchestrator.explicit.NativeHandled {
80- t.Fatal("explicit native handled = false, want true")
81- }
58+ require.Equal(t, "copied text", orchestrator.explicit.Text)
59+ require.Equal(t, "copy", orchestrator.explicit.Action)
60+ require.Equal(t, port.ClipboardSourceWebKit, orchestrator.explicit.SourceEngine)
61+ require.Equal(t, port.WebViewID(42), orchestrator.explicit.ViewID)
62+ require.True(t, orchestrator.explicit.NativeHandled)
8263 }
@@ -7,6 +7,7 @@ import (
7 "testing"7 "testing"
8 8
9 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
10+ "github.com/stretchr/testify/require"
10 )11 )
11 12
12 type recordingClipboardOrchestrator struct {13 type recordingClipboardOrchestrator struct {
@@ -35,21 +36,13 @@ func TestClipboardHandler_HandleAutoCopySelection_DelegatesToOrchestrator(t *tes
35 handler := NewClipboardHandler(orchestrator)36 handler := NewClipboardHandler(orchestrator)
36 37
37 _, err := handler.HandleAutoCopySelection().Handle(ctx, 42, json.RawMessage(`{"text":"selected text"}`))38 _, err := handler.HandleAutoCopySelection().Handle(ctx, 42, json.RawMessage(`{"text":"selected text"}`))
38- if err != nil {39+ require.NoError(t, err)
39- t.Fatalf("Handle() error = %v, want nil", err)
40- }
41 40
42 orchestrator.mu.Lock()41 orchestrator.mu.Lock()
43 defer orchestrator.mu.Unlock()42 defer orchestrator.mu.Unlock()
44- if orchestrator.selection.Text != "selected text" {43+ require.Equal(t, "selected text", orchestrator.selection.Text)
45- t.Fatalf("selection text = %q, want %q", orchestrator.selection.Text, "selected text")44+ require.Equal(t, port.ClipboardSourceWebKit, orchestrator.selection.SourceEngine)
46- }45+ require.Equal(t, port.WebViewID(42), orchestrator.selection.ViewID)
47- if orchestrator.selection.SourceEngine != port.ClipboardSourceWebKit {
48- t.Fatalf("selection source = %q, want %q", orchestrator.selection.SourceEngine, port.ClipboardSourceWebKit)
49- }
50- if orchestrator.selection.ViewID != 42 {
51- t.Fatalf("selection view id = %d, want %d", orchestrator.selection.ViewID, 42)
52- }
53 }46 }
54 47
55 func TestClipboardHandler_HandleExplicitCopy_DelegatesToOrchestrator(t *testing.T) {48 func TestClipboardHandler_HandleExplicitCopy_DelegatesToOrchestrator(t *testing.T) {
@@ -58,25 +51,13 @@ func TestClipboardHandler_HandleExplicitCopy_DelegatesToOrchestrator(t *testing.
58 handler := NewClipboardHandler(orchestrator)51 handler := NewClipboardHandler(orchestrator)
59 52
60 _, err := handler.HandleExplicitCopy().Handle(ctx, 42, json.RawMessage(`{"text":"copied text","action":"copy"}`))53 _, err := handler.HandleExplicitCopy().Handle(ctx, 42, json.RawMessage(`{"text":"copied text","action":"copy"}`))
61- if err != nil {54+ require.NoError(t, err)
62- t.Fatalf("Handle() error = %v, want nil", err)
63- }
64 55
65 orchestrator.mu.Lock()56 orchestrator.mu.Lock()
66 defer orchestrator.mu.Unlock()57 defer orchestrator.mu.Unlock()
67- if orchestrator.explicit.Text != "copied text" {58+ require.Equal(t, "copied text", orchestrator.explicit.Text)
68- t.Fatalf("explicit text = %q, want %q", orchestrator.explicit.Text, "copied text")59+ require.Equal(t, "copy", orchestrator.explicit.Action)
69- }60+ require.Equal(t, port.ClipboardSourceWebKit, orchestrator.explicit.SourceEngine)
70- if orchestrator.explicit.Action != "copy" {61+ require.Equal(t, port.WebViewID(42), orchestrator.explicit.ViewID)
71- t.Fatalf("explicit action = %q, want %q", orchestrator.explicit.Action, "copy")62+ require.True(t, orchestrator.explicit.NativeHandled)
72- }
73- if orchestrator.explicit.SourceEngine != port.ClipboardSourceWebKit {
74- t.Fatalf("explicit source = %q, want %q", orchestrator.explicit.SourceEngine, port.ClipboardSourceWebKit)
75- }
76- if orchestrator.explicit.ViewID != 42 {
77- t.Fatalf("explicit view id = %d, want %d", orchestrator.explicit.ViewID, 42)
78- }
79- if !orchestrator.explicit.NativeHandled {
80- t.Fatal("explicit native handled = false, want true")
81- }
82 }63 }
A internal/infrastructure/runtimeprofile/profile.go +193 -0
@@ -0,0 +1,193 @@
1+package runtimeprofile
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "strings"
7+)
8+
9+const (
10+ browserLaunchSocketName = "browser-launch.sock"
11+ engineWebKit = "webkit"
12+ engineCEF = "cef"
13+)
14+
15+// Mode identifies the execution sandbox mode.
16+type Mode string
17+
18+const (
19+ ModeProd Mode = "prod"
20+ ModeDev Mode = "dev"
21+)
22+
23+// BasePaths holds the already-resolved dumber XDG homes used in prod.
24+type BasePaths struct {
25+ ConfigHome string
26+ DataHome string
27+ StateHome string
28+ CacheHome string
29+}
30+
31+// ResolveInput contains the external inputs needed to resolve a runtime profile.
32+type ResolveInput struct {
33+ Env func(string) string
34+ CWD func() (string, error)
35+ Engine string
36+ Base BasePaths
37+}
38+
39+// SharedPaths are shared by the whole runtime sandbox.
40+type SharedPaths struct {
41+ // RootDir is only populated when the shared sandbox has a single root
42+ // directory (for example ENV=dev under .dev/dumber). It is intentionally
43+ // empty in prod, where shared paths come from distinct XDG homes.
44+ RootDir string
45+ ConfigDir string
46+ DataDir string
47+ StateDir string
48+ CacheDir string
49+ LogDir string
50+}
51+
52+// EnginePaths are engine-specific technical paths.
53+type EnginePaths struct {
54+ Name string
55+ RootDir string
56+ RuntimeDir string
57+ LogDir string
58+}
59+
60+// IPCPaths are runtime coordination paths.
61+type IPCPaths struct {
62+ RuntimeDir string
63+ BrowserLaunchSocket string
64+}
65+
66+// Profile is the fully resolved runtime profile for one mode+engine namespace.
67+type Profile struct {
68+ Mode Mode
69+ Engine string
70+ Shared SharedPaths
71+ EnginePaths EnginePaths
72+ IPC IPCPaths
73+}
74+
75+// Resolve builds a runtime profile from pure inputs.
76+func Resolve(input ResolveInput) (Profile, error) {
77+ engine := normalizeEngine(input.Engine)
78+ if envValue(input.Env, "ENV") == string(ModeDev) {
79+ cwd, err := cwdValue(input.CWD)
80+ if err != nil {
81+ return Profile{}, fmt.Errorf("resolve cwd: %w", err)
82+ }
83+ root := filepath.Join(cwd, ".dev", "dumber")
84+ engineRoot := filepath.Join(root, "engines", engine)
85+ ipcRoot := filepath.Join(root, "runtime", engine)
86+ return Profile{
87+ Mode: ModeDev,
88+ Engine: engine,
89+ Shared: SharedPaths{
90+ RootDir: root,
91+ ConfigDir: filepath.Join(root, "config"),
92+ DataDir: filepath.Join(root, "data"),
93+ StateDir: filepath.Join(root, "state"),
94+ CacheDir: filepath.Join(root, "cache"),
95+ LogDir: filepath.Join(root, "logs"),
96+ },
97+ EnginePaths: EnginePaths{
98+ Name: engine,
99+ RootDir: engineRoot,
100+ RuntimeDir: filepath.Join(engineRoot, "runtime"),
101+ LogDir: filepath.Join(engineRoot, "logs"),
102+ },
103+ IPC: IPCPaths{
104+ RuntimeDir: ipcRoot,
105+ BrowserLaunchSocket: filepath.Join(ipcRoot, browserLaunchSocketName),
106+ },
107+ }, nil
108+ }
109+
110+ shared := SharedPaths{
111+ ConfigDir: input.Base.ConfigHome,
112+ DataDir: input.Base.DataHome,
113+ StateDir: input.Base.StateHome,
114+ CacheDir: input.Base.CacheHome,
115+ LogDir: filepath.Join(input.Base.StateHome, "logs"),
116+ }
117+ engineRoot := filepath.Join(shared.StateDir, "engines", engine)
118+ ipcRoot := filepath.Join(shared.StateDir, "runtime", engine)
119+ return Profile{
120+ Mode: ModeProd,
121+ Engine: engine,
122+ Shared: shared,
123+ EnginePaths: EnginePaths{
124+ Name: engine,
125+ RootDir: engineRoot,
126+ RuntimeDir: filepath.Join(engineRoot, "runtime"),
127+ LogDir: filepath.Join(engineRoot, "logs"),
128+ },
129+ IPC: IPCPaths{
130+ RuntimeDir: ipcRoot,
131+ BrowserLaunchSocket: filepath.Join(ipcRoot, browserLaunchSocketName),
132+ },
133+ }, nil
134+}
135+
136+// normalizeEngine trims and lowercases the runtime engine name.
137+// Supported engines are "webkit" and "cef"; any other value falls back to
138+// the default "webkit" runtime namespace.
139+func normalizeEngine(engine string) string {
140+ switch normalized := strings.ToLower(strings.TrimSpace(engine)); normalized {
141+ case "", engineWebKit:
142+ return engineWebKit
143+ case engineCEF:
144+ return engineCEF
145+ default:
146+ return engineWebKit
147+ }
148+}
149+
150+func envValue(get func(string) string, key string) string {
151+ if get == nil {
152+ return ""
153+ }
154+ return get(key)
155+}
156+
157+func cwdValue(get func() (string, error)) (string, error) {
158+ if get == nil {
159+ return "", fmt.Errorf("cwd getter is nil")
160+ }
161+ return get()
162+}
163+
164+// CEFUserDataDir returns the resolved CEF root cache/user-data directory.
165+func (p Profile) CEFUserDataDir() string {
166+ if p.Mode == ModeDev {
167+ return filepath.Join(p.EnginePaths.RootDir, "data")
168+ }
169+ return filepath.Join(p.Shared.DataDir, "cef_user_data")
170+}
171+
172+// CEFLogFile returns the resolved default CEF runtime log path.
173+func (p Profile) CEFLogFile() string {
174+ return filepath.Join(p.EnginePaths.LogDir, "cef_runtime.log")
175+}
176+
177+// WebKitDataDir returns the resolved WebKit website data directory.
178+func (p Profile) WebKitDataDir() string {
179+ if p.Mode == ModeDev {
180+ return filepath.Join(p.EnginePaths.RootDir, "data")
181+ }
182+ return p.Shared.DataDir
183+}
184+
185+// WebKitCacheDir returns the resolved WebKit cache directory.
186+func (p Profile) WebKitCacheDir() string {
187+ if p.Mode == ModeDev {
188+ return filepath.Join(p.EnginePaths.RootDir, "cache")
189+ }
190+ // Keep the current prod layout under state for non-user-managed technical
191+ // engine cache data; this intentionally preserves existing WebKit behavior.
192+ return filepath.Join(p.Shared.StateDir, "webkit-cache")
193+}
@@ -0,0 +1,193 @@
1+package runtimeprofile
2+
3+import (
4+ "fmt"
5+ "path/filepath"
6+ "strings"
7+)
8+
9+const (
10+ browserLaunchSocketName = "browser-launch.sock"
11+ engineWebKit = "webkit"
12+ engineCEF = "cef"
13+)
14+
15+// Mode identifies the execution sandbox mode.
16+type Mode string
17+
18+const (
19+ ModeProd Mode = "prod"
20+ ModeDev Mode = "dev"
21+)
22+
23+// BasePaths holds the already-resolved dumber XDG homes used in prod.
24+type BasePaths struct {
25+ ConfigHome string
26+ DataHome string
27+ StateHome string
28+ CacheHome string
29+}
30+
31+// ResolveInput contains the external inputs needed to resolve a runtime profile.
32+type ResolveInput struct {
33+ Env func(string) string
34+ CWD func() (string, error)
35+ Engine string
36+ Base BasePaths
37+}
38+
39+// SharedPaths are shared by the whole runtime sandbox.
40+type SharedPaths struct {
41+ // RootDir is only populated when the shared sandbox has a single root
42+ // directory (for example ENV=dev under .dev/dumber). It is intentionally
43+ // empty in prod, where shared paths come from distinct XDG homes.
44+ RootDir string
45+ ConfigDir string
46+ DataDir string
47+ StateDir string
48+ CacheDir string
49+ LogDir string
50+}
51+
52+// EnginePaths are engine-specific technical paths.
53+type EnginePaths struct {
54+ Name string
55+ RootDir string
56+ RuntimeDir string
57+ LogDir string
58+}
59+
60+// IPCPaths are runtime coordination paths.
61+type IPCPaths struct {
62+ RuntimeDir string
63+ BrowserLaunchSocket string
64+}
65+
66+// Profile is the fully resolved runtime profile for one mode+engine namespace.
67+type Profile struct {
68+ Mode Mode
69+ Engine string
70+ Shared SharedPaths
71+ EnginePaths EnginePaths
72+ IPC IPCPaths
73+}
74+
75+// Resolve builds a runtime profile from pure inputs.
76+func Resolve(input ResolveInput) (Profile, error) {
77+ engine := normalizeEngine(input.Engine)
78+ if envValue(input.Env, "ENV") == string(ModeDev) {
79+ cwd, err := cwdValue(input.CWD)
80+ if err != nil {
81+ return Profile{}, fmt.Errorf("resolve cwd: %w", err)
82+ }
83+ root := filepath.Join(cwd, ".dev", "dumber")
84+ engineRoot := filepath.Join(root, "engines", engine)
85+ ipcRoot := filepath.Join(root, "runtime", engine)
86+ return Profile{
87+ Mode: ModeDev,
88+ Engine: engine,
89+ Shared: SharedPaths{
90+ RootDir: root,
91+ ConfigDir: filepath.Join(root, "config"),
92+ DataDir: filepath.Join(root, "data"),
93+ StateDir: filepath.Join(root, "state"),
94+ CacheDir: filepath.Join(root, "cache"),
95+ LogDir: filepath.Join(root, "logs"),
96+ },
97+ EnginePaths: EnginePaths{
98+ Name: engine,
99+ RootDir: engineRoot,
100+ RuntimeDir: filepath.Join(engineRoot, "runtime"),
101+ LogDir: filepath.Join(engineRoot, "logs"),
102+ },
103+ IPC: IPCPaths{
104+ RuntimeDir: ipcRoot,
105+ BrowserLaunchSocket: filepath.Join(ipcRoot, browserLaunchSocketName),
106+ },
107+ }, nil
108+ }
109+
110+ shared := SharedPaths{
111+ ConfigDir: input.Base.ConfigHome,
112+ DataDir: input.Base.DataHome,
113+ StateDir: input.Base.StateHome,
114+ CacheDir: input.Base.CacheHome,
115+ LogDir: filepath.Join(input.Base.StateHome, "logs"),
116+ }
117+ engineRoot := filepath.Join(shared.StateDir, "engines", engine)
118+ ipcRoot := filepath.Join(shared.StateDir, "runtime", engine)
119+ return Profile{
120+ Mode: ModeProd,
121+ Engine: engine,
122+ Shared: shared,
123+ EnginePaths: EnginePaths{
124+ Name: engine,
125+ RootDir: engineRoot,
126+ RuntimeDir: filepath.Join(engineRoot, "runtime"),
127+ LogDir: filepath.Join(engineRoot, "logs"),
128+ },
129+ IPC: IPCPaths{
130+ RuntimeDir: ipcRoot,
131+ BrowserLaunchSocket: filepath.Join(ipcRoot, browserLaunchSocketName),
132+ },
133+ }, nil
134+}
135+
136+// normalizeEngine trims and lowercases the runtime engine name.
137+// Supported engines are "webkit" and "cef"; any other value falls back to
138+// the default "webkit" runtime namespace.
139+func normalizeEngine(engine string) string {
140+ switch normalized := strings.ToLower(strings.TrimSpace(engine)); normalized {
141+ case "", engineWebKit:
142+ return engineWebKit
143+ case engineCEF:
144+ return engineCEF
145+ default:
146+ return engineWebKit
147+ }
148+}
149+
150+func envValue(get func(string) string, key string) string {
151+ if get == nil {
152+ return ""
153+ }
154+ return get(key)
155+}
156+
157+func cwdValue(get func() (string, error)) (string, error) {
158+ if get == nil {
159+ return "", fmt.Errorf("cwd getter is nil")
160+ }
161+ return get()
162+}
163+
164+// CEFUserDataDir returns the resolved CEF root cache/user-data directory.
165+func (p Profile) CEFUserDataDir() string {
166+ if p.Mode == ModeDev {
167+ return filepath.Join(p.EnginePaths.RootDir, "data")
168+ }
169+ return filepath.Join(p.Shared.DataDir, "cef_user_data")
170+}
171+
172+// CEFLogFile returns the resolved default CEF runtime log path.
173+func (p Profile) CEFLogFile() string {
174+ return filepath.Join(p.EnginePaths.LogDir, "cef_runtime.log")
175+}
176+
177+// WebKitDataDir returns the resolved WebKit website data directory.
178+func (p Profile) WebKitDataDir() string {
179+ if p.Mode == ModeDev {
180+ return filepath.Join(p.EnginePaths.RootDir, "data")
181+ }
182+ return p.Shared.DataDir
183+}
184+
185+// WebKitCacheDir returns the resolved WebKit cache directory.
186+func (p Profile) WebKitCacheDir() string {
187+ if p.Mode == ModeDev {
188+ return filepath.Join(p.EnginePaths.RootDir, "cache")
189+ }
190+ // Keep the current prod layout under state for non-user-managed technical
191+ // engine cache data; this intentionally preserves existing WebKit behavior.
192+ return filepath.Join(p.Shared.StateDir, "webkit-cache")
193+}
A internal/infrastructure/runtimeprofile/profile_test.go +120 -0
@@ -0,0 +1,120 @@
1+package runtimeprofile
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestResolve_ProdPreservesSharedLayout(t *testing.T) {
11+ profile, err := Resolve(ResolveInput{
12+ Env: func(string) string { return "" },
13+ Engine: "webkit",
14+ CWD: func() (string, error) { return "/repo", nil },
15+ Base: BasePaths{
16+ ConfigHome: "/home/u/.config/dumber",
17+ DataHome: "/home/u/.local/share/dumber",
18+ StateHome: "/home/u/.local/state/dumber",
19+ CacheHome: "/home/u/.cache/dumber",
20+ },
21+ })
22+ require.NoError(t, err)
23+ require.Equal(t, ModeProd, profile.Mode)
24+ require.Equal(t, "/home/u/.config/dumber", profile.Shared.ConfigDir)
25+ require.Equal(t, "/home/u/.local/share/dumber", profile.Shared.DataDir)
26+ require.Equal(t, "/home/u/.local/state/dumber", profile.Shared.StateDir)
27+ require.Equal(t, "/home/u/.cache/dumber", profile.Shared.CacheDir)
28+ require.Equal(t, "/home/u/.local/state/dumber/logs", profile.Shared.LogDir)
29+ require.Equal(t, "/home/u/.local/share/dumber/cef_user_data", profile.CEFUserDataDir())
30+ require.Equal(t, "/home/u/.local/share/dumber", profile.WebKitDataDir())
31+ require.Equal(t, "/home/u/.local/state/dumber/webkit-cache", profile.WebKitCacheDir())
32+}
33+
34+func TestResolve_DevCEF_SeparatesSharedEngineAndIPC(t *testing.T) {
35+ profile, err := Resolve(ResolveInput{
36+ Env: func(key string) string {
37+ if key == "ENV" {
38+ return "dev"
39+ }
40+ return ""
41+ },
42+ Engine: "cef",
43+ CWD: func() (string, error) { return "/repo", nil },
44+ })
45+ require.NoError(t, err)
46+ require.Equal(t, ModeDev, profile.Mode)
47+ require.Equal(t, "/repo/.dev/dumber/config", profile.Shared.ConfigDir)
48+ require.Equal(t, "/repo/.dev/dumber/data", profile.Shared.DataDir)
49+ require.Equal(t, "/repo/.dev/dumber/engines/cef/data", profile.CEFUserDataDir())
50+ require.Equal(t, "/repo/.dev/dumber/runtime/cef/browser-launch.sock", profile.IPC.BrowserLaunchSocket)
51+}
52+
53+func TestResolve_DevWebKitAndCEF_HaveDifferentTechnicalNamespaces(t *testing.T) {
54+ devEnv := func(key string) string {
55+ if key == "ENV" {
56+ return "dev"
57+ }
58+ return ""
59+ }
60+ repoCWD := func() (string, error) { return "/repo", nil }
61+
62+ cefProfile, err := Resolve(ResolveInput{Env: devEnv, Engine: "cef", CWD: repoCWD})
63+ require.NoError(t, err)
64+ wkProfile, err := Resolve(ResolveInput{Env: devEnv, Engine: "webkit", CWD: repoCWD})
65+ require.NoError(t, err)
66+
67+ require.NotEqual(t, cefProfile.EnginePaths.RootDir, wkProfile.EnginePaths.RootDir)
68+ require.NotEqual(t, cefProfile.IPC.RuntimeDir, wkProfile.IPC.RuntimeDir)
69+ require.NotEqual(t, cefProfile.IPC.BrowserLaunchSocket, wkProfile.IPC.BrowserLaunchSocket)
70+}
71+
72+func TestResolve_DefaultsEmptyEngineToWebKit(t *testing.T) {
73+ profile, err := Resolve(ResolveInput{
74+ Env: func(string) string { return "" },
75+ CWD: func() (string, error) { return "/repo", nil },
76+ Base: BasePaths{StateHome: "/state", DataHome: "/data"},
77+ })
78+ require.NoError(t, err)
79+ require.Equal(t, "webkit", profile.Engine)
80+ require.Equal(t, "/state/runtime/webkit/browser-launch.sock", profile.IPC.BrowserLaunchSocket)
81+}
82+
83+func TestResolve_NormalizesEngineWhitespaceAndCase(t *testing.T) {
84+ profile, err := Resolve(ResolveInput{
85+ Env: func(string) string { return "" },
86+ Engine: " CEF ",
87+ CWD: func() (string, error) { return "/repo", nil },
88+ Base: BasePaths{StateHome: "/state", DataHome: "/data"},
89+ })
90+ require.NoError(t, err)
91+ require.Equal(t, "cef", profile.Engine)
92+ require.Equal(t, "/state/runtime/cef/browser-launch.sock", profile.IPC.BrowserLaunchSocket)
93+}
94+
95+func TestResolve_DefaultsUnknownEngineToWebKit(t *testing.T) {
96+ profile, err := Resolve(ResolveInput{
97+ Env: func(string) string { return "" },
98+ Engine: "wekbit",
99+ CWD: func() (string, error) { return "/repo", nil },
100+ Base: BasePaths{StateHome: "/state", DataHome: "/data"},
101+ })
102+ require.NoError(t, err)
103+ require.Equal(t, "webkit", profile.Engine)
104+ require.Equal(t, "/state/runtime/webkit/browser-launch.sock", profile.IPC.BrowserLaunchSocket)
105+}
106+
107+func TestResolve_DevFailsWhenCWDUnavailable(t *testing.T) {
108+ want := errors.New("boom")
109+ _, err := Resolve(ResolveInput{
110+ Env: func(key string) string {
111+ if key == "ENV" {
112+ return "dev"
113+ }
114+ return ""
115+ },
116+ Engine: "cef",
117+ CWD: func() (string, error) { return "", want },
118+ })
119+ require.ErrorIs(t, err, want)
120+}
@@ -0,0 +1,120 @@
1+package runtimeprofile
2+
3+import (
4+ "errors"
5+ "testing"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestResolve_ProdPreservesSharedLayout(t *testing.T) {
11+ profile, err := Resolve(ResolveInput{
12+ Env: func(string) string { return "" },
13+ Engine: "webkit",
14+ CWD: func() (string, error) { return "/repo", nil },
15+ Base: BasePaths{
16+ ConfigHome: "/home/u/.config/dumber",
17+ DataHome: "/home/u/.local/share/dumber",
18+ StateHome: "/home/u/.local/state/dumber",
19+ CacheHome: "/home/u/.cache/dumber",
20+ },
21+ })
22+ require.NoError(t, err)
23+ require.Equal(t, ModeProd, profile.Mode)
24+ require.Equal(t, "/home/u/.config/dumber", profile.Shared.ConfigDir)
25+ require.Equal(t, "/home/u/.local/share/dumber", profile.Shared.DataDir)
26+ require.Equal(t, "/home/u/.local/state/dumber", profile.Shared.StateDir)
27+ require.Equal(t, "/home/u/.cache/dumber", profile.Shared.CacheDir)
28+ require.Equal(t, "/home/u/.local/state/dumber/logs", profile.Shared.LogDir)
29+ require.Equal(t, "/home/u/.local/share/dumber/cef_user_data", profile.CEFUserDataDir())
30+ require.Equal(t, "/home/u/.local/share/dumber", profile.WebKitDataDir())
31+ require.Equal(t, "/home/u/.local/state/dumber/webkit-cache", profile.WebKitCacheDir())
32+}
33+
34+func TestResolve_DevCEF_SeparatesSharedEngineAndIPC(t *testing.T) {
35+ profile, err := Resolve(ResolveInput{
36+ Env: func(key string) string {
37+ if key == "ENV" {
38+ return "dev"
39+ }
40+ return ""
41+ },
42+ Engine: "cef",
43+ CWD: func() (string, error) { return "/repo", nil },
44+ })
45+ require.NoError(t, err)
46+ require.Equal(t, ModeDev, profile.Mode)
47+ require.Equal(t, "/repo/.dev/dumber/config", profile.Shared.ConfigDir)
48+ require.Equal(t, "/repo/.dev/dumber/data", profile.Shared.DataDir)
49+ require.Equal(t, "/repo/.dev/dumber/engines/cef/data", profile.CEFUserDataDir())
50+ require.Equal(t, "/repo/.dev/dumber/runtime/cef/browser-launch.sock", profile.IPC.BrowserLaunchSocket)
51+}
52+
53+func TestResolve_DevWebKitAndCEF_HaveDifferentTechnicalNamespaces(t *testing.T) {
54+ devEnv := func(key string) string {
55+ if key == "ENV" {
56+ return "dev"
57+ }
58+ return ""
59+ }
60+ repoCWD := func() (string, error) { return "/repo", nil }
61+
62+ cefProfile, err := Resolve(ResolveInput{Env: devEnv, Engine: "cef", CWD: repoCWD})
63+ require.NoError(t, err)
64+ wkProfile, err := Resolve(ResolveInput{Env: devEnv, Engine: "webkit", CWD: repoCWD})
65+ require.NoError(t, err)
66+
67+ require.NotEqual(t, cefProfile.EnginePaths.RootDir, wkProfile.EnginePaths.RootDir)
68+ require.NotEqual(t, cefProfile.IPC.RuntimeDir, wkProfile.IPC.RuntimeDir)
69+ require.NotEqual(t, cefProfile.IPC.BrowserLaunchSocket, wkProfile.IPC.BrowserLaunchSocket)
70+}
71+
72+func TestResolve_DefaultsEmptyEngineToWebKit(t *testing.T) {
73+ profile, err := Resolve(ResolveInput{
74+ Env: func(string) string { return "" },
75+ CWD: func() (string, error) { return "/repo", nil },
76+ Base: BasePaths{StateHome: "/state", DataHome: "/data"},
77+ })
78+ require.NoError(t, err)
79+ require.Equal(t, "webkit", profile.Engine)
80+ require.Equal(t, "/state/runtime/webkit/browser-launch.sock", profile.IPC.BrowserLaunchSocket)
81+}
82+
83+func TestResolve_NormalizesEngineWhitespaceAndCase(t *testing.T) {
84+ profile, err := Resolve(ResolveInput{
85+ Env: func(string) string { return "" },
86+ Engine: " CEF ",
87+ CWD: func() (string, error) { return "/repo", nil },
88+ Base: BasePaths{StateHome: "/state", DataHome: "/data"},
89+ })
90+ require.NoError(t, err)
91+ require.Equal(t, "cef", profile.Engine)
92+ require.Equal(t, "/state/runtime/cef/browser-launch.sock", profile.IPC.BrowserLaunchSocket)
93+}
94+
95+func TestResolve_DefaultsUnknownEngineToWebKit(t *testing.T) {
96+ profile, err := Resolve(ResolveInput{
97+ Env: func(string) string { return "" },
98+ Engine: "wekbit",
99+ CWD: func() (string, error) { return "/repo", nil },
100+ Base: BasePaths{StateHome: "/state", DataHome: "/data"},
101+ })
102+ require.NoError(t, err)
103+ require.Equal(t, "webkit", profile.Engine)
104+ require.Equal(t, "/state/runtime/webkit/browser-launch.sock", profile.IPC.BrowserLaunchSocket)
105+}
106+
107+func TestResolve_DevFailsWhenCWDUnavailable(t *testing.T) {
108+ want := errors.New("boom")
109+ _, err := Resolve(ResolveInput{
110+ Env: func(key string) string {
111+ if key == "ENV" {
112+ return "dev"
113+ }
114+ return ""
115+ },
116+ Engine: "cef",
117+ CWD: func() (string, error) { return "", want },
118+ })
119+ require.ErrorIs(t, err, want)
120+}
M internal/infrastructure/webkit/context_menu.go +2 -0
@@ -388,6 +388,8 @@ func (p *contextMenuPipeline) newExecutor(wv *WebView) port.ContextMenuActionExe
388388 )
389389 }
390390
391+var _ port.Clipboard = (*clipboardCopyNotifier)(nil)
392+
391393 type clipboardCopyNotifier struct {
392394 clipboard port.Clipboard
393395 onCopied func(textLen int)
@@ -388,6 +388,8 @@ func (p *contextMenuPipeline) newExecutor(wv *WebView) port.ContextMenuActionExe
388 )388 )
389 }389 }
390 390
391+var _ port.Clipboard = (*clipboardCopyNotifier)(nil)
392+
391 type clipboardCopyNotifier struct {393 type clipboardCopyNotifier struct {
392 clipboard port.Clipboard394 clipboard port.Clipboard
393 onCopied func(textLen int)395 onCopied func(textLen int)
M internal/infrastructure/webkit/context_menu_test.go +23 -41
@@ -12,9 +12,10 @@ import (
1212 "unicode/utf8"
1313
1414 "github.com/bnema/dumber/internal/application/port"
15+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
1516 "github.com/bnema/dumber/internal/application/usecase"
16- "github.com/bnema/dumber/internal/domain/entity"
1717 "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/mock"
1819 "github.com/stretchr/testify/require"
1920 )
2021
@@ -318,8 +319,21 @@ func TestWebkitMenuDelegator_OpenLinkNewTabRequiresHandlerSuccess(t *testing.T)
318319
319320 func TestContextMenuPipeline_NewExecutor_NotifiesOnCopiedText(t *testing.T) {
320321 var copiedLen int
321- baseClipboard := &recordingClipboard{}
322- factory := &capturingExecutorFactory{}
322+ ctx := context.Background()
323+ baseClipboard := portmocks.NewMockClipboard(t)
324+ factory := portmocks.NewMockContextMenuActionExecutorFactory(t)
325+ var capturedClipboard port.Clipboard
326+ factory.EXPECT().NewContextMenuActionExecutor(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
327+ func(
328+ clipboard port.Clipboard,
329+ resolver port.ImageDataResolver,
330+ saver port.ResolvedImageSaver,
331+ delegator port.MenuActionDelegator,
332+ ) port.ContextMenuActionExecutor {
333+ capturedClipboard = clipboard
334+ return usecase.NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
335+ },
336+ ).Once()
323337 pipeline := &contextMenuPipeline{
324338 clipboard: baseClipboard,
325339 onCopied: func(textLen int) { copiedLen = textLen },
@@ -331,23 +345,21 @@ func TestContextMenuPipeline_NewExecutor_NotifiesOnCopiedText(t *testing.T) {
331345
332346 t.Run("copy link", func(t *testing.T) {
333347 copiedLen = 0
334- require.NotNil(t, factory.clipboard)
348+ require.NotNil(t, capturedClipboard)
349+ baseClipboard.EXPECT().WriteText(ctx, "https://example.com/new").Return(nil).Once()
335350
336- err := executor.ExecuteMenuAction(context.Background(), port.MenuActionCopyLink, port.MenuContext{LinkURI: "https://example.com/new"})
351+ err := executor.ExecuteMenuAction(ctx, port.MenuActionCopyLink, port.MenuContext{LinkURI: "https://example.com/new"})
337352 require.NoError(t, err)
338- require.Equal(t, "https://example.com/new", baseClipboard.text)
339- require.Equal(t, 1, baseClipboard.writeTextCalls)
340353 require.Equal(t, utf8.RuneCountInString("https://example.com/new"), copiedLen)
341354 })
342355
343356 t.Run("copy selection", func(t *testing.T) {
344357 copiedLen = 0
345- require.NotNil(t, factory.clipboard)
358+ require.NotNil(t, capturedClipboard)
359+ baseClipboard.EXPECT().WriteText(ctx, "selected text").Return(nil).Once()
346360
347- err := executor.ExecuteMenuAction(context.Background(), port.MenuActionCopySelection, port.MenuContext{HasSelection: true, SelectionText: "selected text"})
361+ err := executor.ExecuteMenuAction(ctx, port.MenuActionCopySelection, port.MenuContext{HasSelection: true, SelectionText: "selected text"})
348362 require.NoError(t, err)
349- require.Equal(t, "selected text", baseClipboard.text)
350- require.Equal(t, 2, baseClipboard.writeTextCalls)
351363 require.Equal(t, utf8.RuneCountInString("selected text"), copiedLen)
352364 })
353365 }
@@ -374,36 +386,6 @@ func buildMenuContextFromStubHitTest(hit stubHitTest, canGoBack, canGoForward bo
374386 return ctx
375387 }
376388
377-type recordingClipboard struct {
378- writeTextCalls int
379- text string
380-}
381-
382-func (r *recordingClipboard) WriteText(_ context.Context, text string) error {
383- r.writeTextCalls++
384- r.text = text
385- return nil
386-}
387-
388-func (*recordingClipboard) WriteImage(context.Context, entity.ImageData) error { return nil }
389-func (*recordingClipboard) ReadText(context.Context) (string, error) { return "", nil }
390-func (*recordingClipboard) Clear(context.Context) error { return nil }
391-func (*recordingClipboard) HasText(context.Context) (bool, error) { return false, nil }
392-
393-type capturingExecutorFactory struct {
394- clipboard port.Clipboard
395-}
396-
397-func (f *capturingExecutorFactory) NewContextMenuActionExecutor(
398- clipboard port.Clipboard,
399- resolver port.ImageDataResolver,
400- saver port.ResolvedImageSaver,
401- delegator port.MenuActionDelegator,
402-) port.ContextMenuActionExecutor {
403- f.clipboard = clipboard
404- return usecase.NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
405-}
406-
407389 type roundTripperFunc func(*http.Request) (*http.Response, error)
408390
409391 func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
@@ -12,9 +12,10 @@ import (
12 "unicode/utf8"12 "unicode/utf8"
13 13
14 "github.com/bnema/dumber/internal/application/port"14 "github.com/bnema/dumber/internal/application/port"
15+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
15 "github.com/bnema/dumber/internal/application/usecase"16 "github.com/bnema/dumber/internal/application/usecase"
16- "github.com/bnema/dumber/internal/domain/entity"
17 "github.com/stretchr/testify/assert"17 "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/mock"
18 "github.com/stretchr/testify/require"19 "github.com/stretchr/testify/require"
19 )20 )
20 21
@@ -318,8 +319,21 @@ func TestWebkitMenuDelegator_OpenLinkNewTabRequiresHandlerSuccess(t *testing.T)
318 319
319 func TestContextMenuPipeline_NewExecutor_NotifiesOnCopiedText(t *testing.T) {320 func TestContextMenuPipeline_NewExecutor_NotifiesOnCopiedText(t *testing.T) {
320 var copiedLen int321 var copiedLen int
321- baseClipboard := &recordingClipboard{}322+ ctx := context.Background()
322- factory := &capturingExecutorFactory{}323+ baseClipboard := portmocks.NewMockClipboard(t)
324+ factory := portmocks.NewMockContextMenuActionExecutorFactory(t)
325+ var capturedClipboard port.Clipboard
326+ factory.EXPECT().NewContextMenuActionExecutor(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn(
327+ func(
328+ clipboard port.Clipboard,
329+ resolver port.ImageDataResolver,
330+ saver port.ResolvedImageSaver,
331+ delegator port.MenuActionDelegator,
332+ ) port.ContextMenuActionExecutor {
333+ capturedClipboard = clipboard
334+ return usecase.NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
335+ },
336+ ).Once()
323 pipeline := &contextMenuPipeline{337 pipeline := &contextMenuPipeline{
324 clipboard: baseClipboard,338 clipboard: baseClipboard,
325 onCopied: func(textLen int) { copiedLen = textLen },339 onCopied: func(textLen int) { copiedLen = textLen },
@@ -331,23 +345,21 @@ func TestContextMenuPipeline_NewExecutor_NotifiesOnCopiedText(t *testing.T) {
331 345
332 t.Run("copy link", func(t *testing.T) {346 t.Run("copy link", func(t *testing.T) {
333 copiedLen = 0347 copiedLen = 0
334- require.NotNil(t, factory.clipboard)348+ require.NotNil(t, capturedClipboard)
349+ baseClipboard.EXPECT().WriteText(ctx, "https://example.com/new").Return(nil).Once()
335 350
336- err := executor.ExecuteMenuAction(context.Background(), port.MenuActionCopyLink, port.MenuContext{LinkURI: "https://example.com/new"})351+ err := executor.ExecuteMenuAction(ctx, port.MenuActionCopyLink, port.MenuContext{LinkURI: "https://example.com/new"})
337 require.NoError(t, err)352 require.NoError(t, err)
338- require.Equal(t, "https://example.com/new", baseClipboard.text)
339- require.Equal(t, 1, baseClipboard.writeTextCalls)
340 require.Equal(t, utf8.RuneCountInString("https://example.com/new"), copiedLen)353 require.Equal(t, utf8.RuneCountInString("https://example.com/new"), copiedLen)
341 })354 })
342 355
343 t.Run("copy selection", func(t *testing.T) {356 t.Run("copy selection", func(t *testing.T) {
344 copiedLen = 0357 copiedLen = 0
345- require.NotNil(t, factory.clipboard)358+ require.NotNil(t, capturedClipboard)
359+ baseClipboard.EXPECT().WriteText(ctx, "selected text").Return(nil).Once()
346 360
347- err := executor.ExecuteMenuAction(context.Background(), port.MenuActionCopySelection, port.MenuContext{HasSelection: true, SelectionText: "selected text"})361+ err := executor.ExecuteMenuAction(ctx, port.MenuActionCopySelection, port.MenuContext{HasSelection: true, SelectionText: "selected text"})
348 require.NoError(t, err)362 require.NoError(t, err)
349- require.Equal(t, "selected text", baseClipboard.text)
350- require.Equal(t, 2, baseClipboard.writeTextCalls)
351 require.Equal(t, utf8.RuneCountInString("selected text"), copiedLen)363 require.Equal(t, utf8.RuneCountInString("selected text"), copiedLen)
352 })364 })
353 }365 }
@@ -374,36 +386,6 @@ func buildMenuContextFromStubHitTest(hit stubHitTest, canGoBack, canGoForward bo
374 return ctx386 return ctx
375 }387 }
376 388
377-type recordingClipboard struct {
378- writeTextCalls int
379- text string
380-}
381-
382-func (r *recordingClipboard) WriteText(_ context.Context, text string) error {
383- r.writeTextCalls++
384- r.text = text
385- return nil
386-}
387-
388-func (*recordingClipboard) WriteImage(context.Context, entity.ImageData) error { return nil }
389-func (*recordingClipboard) ReadText(context.Context) (string, error) { return "", nil }
390-func (*recordingClipboard) Clear(context.Context) error { return nil }
391-func (*recordingClipboard) HasText(context.Context) (bool, error) { return false, nil }
392-
393-type capturingExecutorFactory struct {
394- clipboard port.Clipboard
395-}
396-
397-func (f *capturingExecutorFactory) NewContextMenuActionExecutor(
398- clipboard port.Clipboard,
399- resolver port.ImageDataResolver,
400- saver port.ResolvedImageSaver,
401- delegator port.MenuActionDelegator,
402-) port.ContextMenuActionExecutor {
403- f.clipboard = clipboard
404- return usecase.NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
405-}
406-
407 type roundTripperFunc func(*http.Request) (*http.Response, error)389 type roundTripperFunc func(*http.Request) (*http.Response, error)
408 390
409 func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {391 func (f roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) {
M internal/infrastructure/webkit/engine_init.go +16 -4
@@ -10,6 +10,7 @@ import (
1010 "github.com/bnema/dumber/internal/infrastructure/config"
1111 "github.com/bnema/dumber/internal/infrastructure/env"
1212 "github.com/bnema/dumber/internal/infrastructure/filtering"
13+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
1314 "github.com/bnema/dumber/internal/logging"
1415 "github.com/bnema/dumber/internal/ui/theme"
1516 "github.com/bnema/puregotk/v4/gdk"
@@ -22,6 +23,7 @@ func NewEngine(
2223 ctx context.Context,
2324 cfg *config.Config,
2425 opts port.EngineOptions,
26+ profile runtimeprofile.Profile,
2527 wkCfg WebKitEngineConfig,
2628 themeManager *theme.Manager,
2729 colorResolver port.ColorSchemeResolver,
@@ -37,7 +39,7 @@ func NewEngine(
3739 logging.Trace().Mark("render_env")
3840
3941 // --- Build webKitContextOptions from opts + wkCfg + perfSettings ---
40- wkOpts := engineBuildContextOptions(opts, wkCfg, &perfSettings)
42+ wkOpts := engineBuildContextOptions(opts, profile, wkCfg, &perfSettings)
4143 logger.Info().
4244 Str("cookie_policy", string(wkOpts.CookiePolicy)).
4345 Bool("itp_enabled", wkOpts.ITPEnabled).
@@ -50,7 +52,7 @@ func NewEngine(
5052 logging.Trace().Mark("webkit_context")
5153
5254 // --- Filter manager ---
53- filterManager := engineInitFilterManager(ctx, cfg, opts.DataDir, logger)
55+ filterManager := engineInitFilterManager(ctx, cfg, profile.Shared.DataDir, logger)
5456
5557 // --- Scheme handler ---
5658 schemeHandler := NewDumbSchemeHandler(ctx)
@@ -203,14 +205,24 @@ func engineConfigureRenderingEnvironment(
203205 // engineBuildContextOptions builds webKitContextOptions from EngineOptions, wkCfg and perfSettings.
204206 func engineBuildContextOptions(
205207 opts port.EngineOptions,
208+ profile runtimeprofile.Profile,
206209 wkCfg WebKitEngineConfig,
207210 perfSettings *config.ResolvedPerformanceSettings,
208211 ) webKitContextOptions {
209212 cp := opts.CookiePolicy // empty preserves runtime default per port contract
210213
214+ dataDir := profile.WebKitDataDir()
215+ if opts.DataDir != "" {
216+ dataDir = opts.DataDir
217+ }
218+ cacheDir := profile.WebKitCacheDir()
219+ if opts.CacheDir != "" {
220+ cacheDir = opts.CacheDir
221+ }
222+
211223 wkOpts := webKitContextOptions{
212- DataDir: opts.DataDir,
213- CacheDir: opts.CacheDir,
224+ DataDir: dataDir,
225+ CacheDir: cacheDir,
214226 CookiePolicy: cp,
215227 ITPEnabled: wkCfg.ITPEnabled,
216228 }
@@ -10,6 +10,7 @@ import (
10 "github.com/bnema/dumber/internal/infrastructure/config"10 "github.com/bnema/dumber/internal/infrastructure/config"
11 "github.com/bnema/dumber/internal/infrastructure/env"11 "github.com/bnema/dumber/internal/infrastructure/env"
12 "github.com/bnema/dumber/internal/infrastructure/filtering"12 "github.com/bnema/dumber/internal/infrastructure/filtering"
13+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
13 "github.com/bnema/dumber/internal/logging"14 "github.com/bnema/dumber/internal/logging"
14 "github.com/bnema/dumber/internal/ui/theme"15 "github.com/bnema/dumber/internal/ui/theme"
15 "github.com/bnema/puregotk/v4/gdk"16 "github.com/bnema/puregotk/v4/gdk"
@@ -22,6 +23,7 @@ func NewEngine(
22 ctx context.Context,23 ctx context.Context,
23 cfg *config.Config,24 cfg *config.Config,
24 opts port.EngineOptions,25 opts port.EngineOptions,
26+ profile runtimeprofile.Profile,
25 wkCfg WebKitEngineConfig,27 wkCfg WebKitEngineConfig,
26 themeManager *theme.Manager,28 themeManager *theme.Manager,
27 colorResolver port.ColorSchemeResolver,29 colorResolver port.ColorSchemeResolver,
@@ -37,7 +39,7 @@ func NewEngine(
37 logging.Trace().Mark("render_env")39 logging.Trace().Mark("render_env")
38 40
39 // --- Build webKitContextOptions from opts + wkCfg + perfSettings ---41 // --- Build webKitContextOptions from opts + wkCfg + perfSettings ---
40- wkOpts := engineBuildContextOptions(opts, wkCfg, &perfSettings)42+ wkOpts := engineBuildContextOptions(opts, profile, wkCfg, &perfSettings)
41 logger.Info().43 logger.Info().
42 Str("cookie_policy", string(wkOpts.CookiePolicy)).44 Str("cookie_policy", string(wkOpts.CookiePolicy)).
43 Bool("itp_enabled", wkOpts.ITPEnabled).45 Bool("itp_enabled", wkOpts.ITPEnabled).
@@ -50,7 +52,7 @@ func NewEngine(
50 logging.Trace().Mark("webkit_context")52 logging.Trace().Mark("webkit_context")
51 53
52 // --- Filter manager ---54 // --- Filter manager ---
53- filterManager := engineInitFilterManager(ctx, cfg, opts.DataDir, logger)55+ filterManager := engineInitFilterManager(ctx, cfg, profile.Shared.DataDir, logger)
54 56
55 // --- Scheme handler ---57 // --- Scheme handler ---
56 schemeHandler := NewDumbSchemeHandler(ctx)58 schemeHandler := NewDumbSchemeHandler(ctx)
@@ -203,14 +205,24 @@ func engineConfigureRenderingEnvironment(
203 // engineBuildContextOptions builds webKitContextOptions from EngineOptions, wkCfg and perfSettings.205 // engineBuildContextOptions builds webKitContextOptions from EngineOptions, wkCfg and perfSettings.
204 func engineBuildContextOptions(206 func engineBuildContextOptions(
205 opts port.EngineOptions,207 opts port.EngineOptions,
208+ profile runtimeprofile.Profile,
206 wkCfg WebKitEngineConfig,209 wkCfg WebKitEngineConfig,
207 perfSettings *config.ResolvedPerformanceSettings,210 perfSettings *config.ResolvedPerformanceSettings,
208 ) webKitContextOptions {211 ) webKitContextOptions {
209 cp := opts.CookiePolicy // empty preserves runtime default per port contract212 cp := opts.CookiePolicy // empty preserves runtime default per port contract
210 213
214+ dataDir := profile.WebKitDataDir()
215+ if opts.DataDir != "" {
216+ dataDir = opts.DataDir
217+ }
218+ cacheDir := profile.WebKitCacheDir()
219+ if opts.CacheDir != "" {
220+ cacheDir = opts.CacheDir
221+ }
222+
211 wkOpts := webKitContextOptions{223 wkOpts := webKitContextOptions{
212- DataDir: opts.DataDir,224+ DataDir: dataDir,
213- CacheDir: opts.CacheDir,225+ CacheDir: cacheDir,
214 CookiePolicy: cp,226 CookiePolicy: cp,
215 ITPEnabled: wkCfg.ITPEnabled,227 ITPEnabled: wkCfg.ITPEnabled,
216 }228 }
A internal/infrastructure/webkit/engine_init_test.go +39 -0
@@ -0,0 +1,39 @@
1+package webkit
2+
3+import (
4+ "path/filepath"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestEngineBuildContextOptions_UsesResolvedWebKitPaths(t *testing.T) {
14+ profile := runtimeprofile.Profile{
15+ Mode: runtimeprofile.ModeDev,
16+ Engine: "webkit",
17+ EnginePaths: runtimeprofile.EnginePaths{
18+ RootDir: filepath.Join(t.TempDir(), "engines", "webkit"),
19+ },
20+ }
21+
22+ wkOpts := engineBuildContextOptions(port.EngineOptions{}, profile, WebKitEngineConfig{}, &config.ResolvedPerformanceSettings{})
23+ require.Equal(t, profile.WebKitDataDir(), wkOpts.DataDir)
24+ require.Equal(t, profile.WebKitCacheDir(), wkOpts.CacheDir)
25+}
26+
27+func TestEngineBuildContextOptions_PrefersExplicitEngineOptionsDirs(t *testing.T) {
28+ profile := runtimeprofile.Profile{
29+ Mode: runtimeprofile.ModeDev,
30+ Engine: "webkit",
31+ EnginePaths: runtimeprofile.EnginePaths{
32+ RootDir: filepath.Join(t.TempDir(), "engines", "webkit"),
33+ },
34+ }
35+
36+ wkOpts := engineBuildContextOptions(port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}, profile, WebKitEngineConfig{}, &config.ResolvedPerformanceSettings{})
37+ require.Equal(t, "/tmp/data", wkOpts.DataDir)
38+ require.Equal(t, "/tmp/cache", wkOpts.CacheDir)
39+}
@@ -0,0 +1,39 @@
1+package webkit
2+
3+import (
4+ "path/filepath"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ "github.com/bnema/dumber/internal/infrastructure/config"
9+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestEngineBuildContextOptions_UsesResolvedWebKitPaths(t *testing.T) {
14+ profile := runtimeprofile.Profile{
15+ Mode: runtimeprofile.ModeDev,
16+ Engine: "webkit",
17+ EnginePaths: runtimeprofile.EnginePaths{
18+ RootDir: filepath.Join(t.TempDir(), "engines", "webkit"),
19+ },
20+ }
21+
22+ wkOpts := engineBuildContextOptions(port.EngineOptions{}, profile, WebKitEngineConfig{}, &config.ResolvedPerformanceSettings{})
23+ require.Equal(t, profile.WebKitDataDir(), wkOpts.DataDir)
24+ require.Equal(t, profile.WebKitCacheDir(), wkOpts.CacheDir)
25+}
26+
27+func TestEngineBuildContextOptions_PrefersExplicitEngineOptionsDirs(t *testing.T) {
28+ profile := runtimeprofile.Profile{
29+ Mode: runtimeprofile.ModeDev,
30+ Engine: "webkit",
31+ EnginePaths: runtimeprofile.EnginePaths{
32+ RootDir: filepath.Join(t.TempDir(), "engines", "webkit"),
33+ },
34+ }
35+
36+ wkOpts := engineBuildContextOptions(port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}, profile, WebKitEngineConfig{}, &config.ResolvedPerformanceSettings{})
37+ require.Equal(t, "/tmp/data", wkOpts.DataDir)
38+ require.Equal(t, "/tmp/cache", wkOpts.CacheDir)
39+}
M internal/infrastructure/xdg/adapter.go +3 -3
@@ -29,9 +29,6 @@ func (a *Adapter) StateDir() (string, error) {
2929 }
3030
3131 func (a *Adapter) RuntimeDir() (string, error) {
32- // In dev mode, keep runtime IPC isolated inside the .dev sandbox even when
33- // XDG_RUNTIME_DIR is set. This prevents browser-launch relay collisions
34- // between the installed app and a repo-local ENV=dev instance.
3532 if os.Getenv("ENV") != "dev" {
3633 if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {
3734 return dir, nil
@@ -42,6 +39,9 @@ func (a *Adapter) RuntimeDir() (string, error) {
4239 if err != nil {
4340 return "", err
4441 }
42+ if os.Getenv("ENV") == "dev" {
43+ return filepath.Join(filepath.Dir(stateDir), "runtime"), nil
44+ }
4545 return filepath.Join(stateDir, "runtime"), nil
4646 }
4747
@@ -29,9 +29,6 @@ func (a *Adapter) StateDir() (string, error) {
29 }29 }
30 30
31 func (a *Adapter) RuntimeDir() (string, error) {31 func (a *Adapter) RuntimeDir() (string, error) {
32- // In dev mode, keep runtime IPC isolated inside the .dev sandbox even when
33- // XDG_RUNTIME_DIR is set. This prevents browser-launch relay collisions
34- // between the installed app and a repo-local ENV=dev instance.
35 if os.Getenv("ENV") != "dev" {32 if os.Getenv("ENV") != "dev" {
36 if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {33 if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {
37 return dir, nil34 return dir, nil
@@ -42,6 +39,9 @@ func (a *Adapter) RuntimeDir() (string, error) {
42 if err != nil {39 if err != nil {
43 return "", err40 return "", err
44 }41 }
42+ if os.Getenv("ENV") == "dev" {
43+ return filepath.Join(filepath.Dir(stateDir), "runtime"), nil
44+ }
45 return filepath.Join(stateDir, "runtime"), nil45 return filepath.Join(stateDir, "runtime"), nil
46 }46 }
47 47
M internal/ui/app_clipboard_test.go +11 -35
@@ -5,47 +5,23 @@ import (
55 "testing"
66
77 "github.com/bnema/dumber/internal/application/port"
8+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
89 "github.com/bnema/dumber/internal/domain/entity"
910 "github.com/bnema/dumber/internal/infrastructure/config"
11+ "github.com/stretchr/testify/mock"
1012 "github.com/stretchr/testify/require"
1113 )
1214
13-type fakeClipboardEngine struct {
14- registerDeps port.HandlerDependencies
15-}
16-
17-var _ port.Engine = (*fakeClipboardEngine)(nil)
18-
19-func (f *fakeClipboardEngine) Factory() port.WebViewFactory { return nil }
20-func (f *fakeClipboardEngine) Pool() port.WebViewPool { return nil }
21-func (f *fakeClipboardEngine) ContentInjector() port.ContentInjector { return nil }
22-func (f *fakeClipboardEngine) SettingsApplier() port.SettingsApplier { return nil }
23-func (f *fakeClipboardEngine) FilterApplier() port.FilterApplier { return nil }
24-func (f *fakeClipboardEngine) FaviconDatabase() port.FaviconDatabase { return nil }
25-func (f *fakeClipboardEngine) InternalSchemePath() string { return "" }
26-func (f *fakeClipboardEngine) Close() error { return nil }
27-func (f *fakeClipboardEngine) RegisterHandlers(_ context.Context, deps port.HandlerDependencies) error {
28- f.registerDeps = deps
29- return nil
30-}
31-func (f *fakeClipboardEngine) RegisterAccentHandlers(context.Context, port.AccentKeyHandler) error {
32- return nil
33-}
34-func (f *fakeClipboardEngine) ConfigureDownloads(context.Context, string, port.DownloadEventHandler, port.DownloadPreparer) error {
35- return nil
36-}
37-func (f *fakeClipboardEngine) OnToolkitReady(context.Context) error { return nil }
38-func (f *fakeClipboardEngine) UpdateAppearance(context.Context, float64, float64, float64, float64) error {
39- return nil
40-}
41-func (f *fakeClipboardEngine) UpdateSettings(context.Context, port.EngineSettingsUpdate) error {
42- return nil
43-}
44-func (f *fakeClipboardEngine) SetHandlerContext(context.Context) {}
45-
4615 func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T) {
4716 ctx := context.Background()
48- engine := &fakeClipboardEngine{}
17+ engine := portmocks.NewMockEngine(t)
18+ var registerDeps port.HandlerDependencies
19+ engine.EXPECT().RegisterHandlers(mock.Anything, mock.Anything).
20+ Run(func(_ context.Context, deps port.HandlerDependencies) {
21+ registerDeps = deps
22+ }).
23+ Return(nil).
24+ Once()
4925
5026 deps := &Dependencies{
5127 Ctx: ctx,
@@ -61,5 +37,5 @@ func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T)
6137 app, err := New(deps)
6238 require.NoError(t, err)
6339 require.NotNil(t, app)
64- require.Nil(t, engine.registerDeps.ClipboardTextOrchestrator)
40+ require.Nil(t, registerDeps.ClipboardTextOrchestrator)
6541 }
@@ -5,47 +5,23 @@ import (
5 "testing"5 "testing"
6 6
7 "github.com/bnema/dumber/internal/application/port"7 "github.com/bnema/dumber/internal/application/port"
8+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
8 "github.com/bnema/dumber/internal/domain/entity"9 "github.com/bnema/dumber/internal/domain/entity"
9 "github.com/bnema/dumber/internal/infrastructure/config"10 "github.com/bnema/dumber/internal/infrastructure/config"
11+ "github.com/stretchr/testify/mock"
10 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
11 )13 )
12 14
13-type fakeClipboardEngine struct {
14- registerDeps port.HandlerDependencies
15-}
16-
17-var _ port.Engine = (*fakeClipboardEngine)(nil)
18-
19-func (f *fakeClipboardEngine) Factory() port.WebViewFactory { return nil }
20-func (f *fakeClipboardEngine) Pool() port.WebViewPool { return nil }
21-func (f *fakeClipboardEngine) ContentInjector() port.ContentInjector { return nil }
22-func (f *fakeClipboardEngine) SettingsApplier() port.SettingsApplier { return nil }
23-func (f *fakeClipboardEngine) FilterApplier() port.FilterApplier { return nil }
24-func (f *fakeClipboardEngine) FaviconDatabase() port.FaviconDatabase { return nil }
25-func (f *fakeClipboardEngine) InternalSchemePath() string { return "" }
26-func (f *fakeClipboardEngine) Close() error { return nil }
27-func (f *fakeClipboardEngine) RegisterHandlers(_ context.Context, deps port.HandlerDependencies) error {
28- f.registerDeps = deps
29- return nil
30-}
31-func (f *fakeClipboardEngine) RegisterAccentHandlers(context.Context, port.AccentKeyHandler) error {
32- return nil
33-}
34-func (f *fakeClipboardEngine) ConfigureDownloads(context.Context, string, port.DownloadEventHandler, port.DownloadPreparer) error {
35- return nil
36-}
37-func (f *fakeClipboardEngine) OnToolkitReady(context.Context) error { return nil }
38-func (f *fakeClipboardEngine) UpdateAppearance(context.Context, float64, float64, float64, float64) error {
39- return nil
40-}
41-func (f *fakeClipboardEngine) UpdateSettings(context.Context, port.EngineSettingsUpdate) error {
42- return nil
43-}
44-func (f *fakeClipboardEngine) SetHandlerContext(context.Context) {}
45-
46 func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T) {15 func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T) {
47 ctx := context.Background()16 ctx := context.Background()
48- engine := &fakeClipboardEngine{}17+ engine := portmocks.NewMockEngine(t)
18+ var registerDeps port.HandlerDependencies
19+ engine.EXPECT().RegisterHandlers(mock.Anything, mock.Anything).
20+ Run(func(_ context.Context, deps port.HandlerDependencies) {
21+ registerDeps = deps
22+ }).
23+ Return(nil).
24+ Once()
49 25
50 deps := &Dependencies{26 deps := &Dependencies{
51 Ctx: ctx,27 Ctx: ctx,
@@ -61,5 +37,5 @@ func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T)
61 app, err := New(deps)37 app, err := New(deps)
62 require.NoError(t, err)38 require.NoError(t, err)
63 require.NotNil(t, app)39 require.NotNil(t, app)
64- require.Nil(t, engine.registerDeps.ClipboardTextOrchestrator)40+ require.Nil(t, registerDeps.ClipboardTextOrchestrator)
65 }41 }
M internal/ui/component/progress_bar.go +0 -3
@@ -37,7 +37,6 @@ type ProgressBar struct {
3737 timeoutTimer uint // Timer source ID for auto-hide timeout
3838 lastShowAt time.Time
3939 lastProgressAt time.Time
40- lastHideReason string
4140 progressEventSeq uint64
4241
4342 mu sync.Mutex
@@ -191,7 +190,6 @@ func (pb *ProgressBar) Show() {
191190 if !pb.visible {
192191 pb.visible = true
193192 pb.lastShowAt = time.Now()
194- pb.lastHideReason = ""
195193 // Set an initial non-zero fraction so the bar is visually noticeable
196194 // immediately. Without this, the bar is technically visible but
197195 // renders as empty (0% fill) until the first progress callback,
@@ -282,7 +280,6 @@ func (pb *ProgressBar) hideInternal(reason string) {
282280
283281 if pb.visible {
284282 pb.visible = false
285- pb.lastHideReason = reason
286283 pb.progressBar.SetVisible(false)
287284
288285 // Stop any running animation
@@ -37,7 +37,6 @@ type ProgressBar struct {
37 timeoutTimer uint // Timer source ID for auto-hide timeout37 timeoutTimer uint // Timer source ID for auto-hide timeout
38 lastShowAt time.Time38 lastShowAt time.Time
39 lastProgressAt time.Time39 lastProgressAt time.Time
40- lastHideReason string
41 progressEventSeq uint6440 progressEventSeq uint64
42 41
43 mu sync.Mutex42 mu sync.Mutex
@@ -191,7 +190,6 @@ func (pb *ProgressBar) Show() {
191 if !pb.visible {190 if !pb.visible {
192 pb.visible = true191 pb.visible = true
193 pb.lastShowAt = time.Now()192 pb.lastShowAt = time.Now()
194- pb.lastHideReason = ""
195 // Set an initial non-zero fraction so the bar is visually noticeable193 // Set an initial non-zero fraction so the bar is visually noticeable
196 // immediately. Without this, the bar is technically visible but194 // immediately. Without this, the bar is technically visible but
197 // renders as empty (0% fill) until the first progress callback,195 // renders as empty (0% fill) until the first progress callback,
@@ -282,7 +280,6 @@ func (pb *ProgressBar) hideInternal(reason string) {
282 280
283 if pb.visible {281 if pb.visible {
284 pb.visible = false282 pb.visible = false
285- pb.lastHideReason = reason
286 pb.progressBar.SetVisible(false)283 pb.progressBar.SetVisible(false)
287 284
288 // Stop any running animation285 // Stop any running animation

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close