detached 34487b0

Merge pull request #224 from bnema/fix/cef-clipboard-input-focus

fix: stabilize CEF clipboard, focus sync, and startup paints
commit
34487b030ae6a1ae4cac3133ed0542aca0af59d1
author
bnema · · 2026-04-18 19:04 UTC
committer
GitHub · · 2026-04-18 19:04 UTC
merge of
97d87de + 6345e20 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
71 files · +5380 -567
M .mockery.yaml +1 -0
@@ -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/cef-helper/main.go +5 -4
@@ -13,6 +13,7 @@ import (
13 "os"13 "os"
14 "runtime"14 "runtime"
15 15
16+ infracef "github.com/bnema/dumber/internal/infrastructure/cef"
16 cef "github.com/bnema/purego-cef/cef"17 cef "github.com/bnema/purego-cef/cef"
17 )18 )
18 19
@@ -22,10 +23,10 @@ func main() {
22 23
23 // cef_execute_process will handle the subprocess lifecycle and call24 // cef_execute_process will handle the subprocess lifecycle and call
24 // os.Exit internally if this is a valid CEF subprocess invocation.25 // os.Exit internally if this is a valid CEF subprocess invocation.
25- // If it returns (code < 0), this is not a subprocess — shouldn't26+ // We pass the lightweight subprocess app, which intentionally returns nil
26- // happen in normal operation since CEF only launches this binary27+ // from GetRenderProcessHandler(), so renderer-side bridges remain disabled
27- // with --type= flags.28+ // in helper subprocesses while the OSR startup regression is under repair.
28- cef.MaybeExitSubprocess()29+ cef.MaybeExitSubprocessWithApp(infracef.NewSubprocessApp())
29 30
30 // If we get here, something went wrong.31 // If we get here, something went wrong.
31 os.Exit(1)32 os.Exit(1)
M cmd/dumber/main.go +64 -36
@@ -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,
@@ -749,23 +774,26 @@ func buildUIDependencies(
749 Theme: themeManager,774 Theme: themeManager,
750 ColorResolver: colorResolver,775 ColorResolver: colorResolver,
751 AdwaitaDetector: adwaitaDetector,776 AdwaitaDetector: adwaitaDetector,
752- XDG: xdg.New(),777+ XDG: xdg.New(
753- Engine: engine,778+ runtimeProfile.Mode == runtimeprofile.ModeDev,
754- FilterManager: filterManager,779+ bootstrap.ResolveXDGRuntimeDir(runtimeProfile),
755- HistoryRepo: repos.history,780+ ),
756- FavoriteRepo: repos.favorite,781+ Engine: engine,
757- ZoomRepo: repos.zoom,782+ FilterManager: filterManager,
758- PermissionRepo: repos.permission,783+ HistoryRepo: repos.history,
759- TabsUC: uc.tabs,784+ FavoriteRepo: repos.favorite,
760- PanesUC: uc.panes,785+ ZoomRepo: repos.zoom,
761- HistoryUC: uc.history,786+ PermissionRepo: repos.permission,
762- FavoritesUC: uc.favorites,787+ TabsUC: uc.tabs,
763- ZoomUC: uc.zoom,788+ PanesUC: uc.panes,
764- PermissionUC: uc.permission,789+ HistoryUC: uc.history,
765- NavigateUC: uc.navigate,790+ FavoritesUC: uc.favorites,
766- CopyURLUC: uc.copyURL,791+ ZoomUC: uc.zoom,
767- Clipboard: uc.clipboard,792+ PermissionUC: uc.permission,
768- FaviconService: uc.favicon,793+ NavigateUC: uc.navigate,
794+ CopyURLUC: uc.copyURL,
795+ Clipboard: uc.clipboard,
796+ FaviconService: uc.favicon,
769 FaviconAdapterConfig: adapter.FaviconAdapterConfig{797 FaviconAdapterConfig: adapter.FaviconAdapterConfig{
770 IsInternalURL: favicon.IsInternalURL,798 IsInternalURL: favicon.IsInternalURL,
771 InternalDomain: favicon.InternalDomain,799 InternalDomain: favicon.InternalDomain,
@@ -782,7 +810,7 @@ func buildUIDependencies(
782 },810 },
783 CheckUpdateUC: uc.checkUpdate,811 CheckUpdateUC: uc.checkUpdate,
784 ApplyUpdateUC: uc.applyUpdate,812 ApplyUpdateUC: uc.applyUpdate,
785- SessionSpawner: bootstrap.NewSessionSpawner(ctx, cfg),813+ SessionSpawner: bootstrap.NewSessionSpawner(ctx, runtimeProfile),
786 FileSystem: filesystem.New(),814 FileSystem: filesystem.New(),
787 AccentFocusProvider: focusProvider,815 AccentFocusProvider: focusProvider,
788 NewGTKEntryTarget: func(entry *gtk.SearchEntry) port.TextInputTarget {816 NewGTKEntryTarget: func(entry *gtk.SearchEntry) port.TextInputTarget {
M go.mod +1 -1
@@ -4,7 +4,7 @@ go 1.26.1
4 4
5 require (5 require (
6 github.com/bnema/purego v0.11.0-bnema.26 github.com/bnema/purego v0.11.0-bnema.2
7- github.com/bnema/purego-cef v0.8.27+ github.com/bnema/purego-cef v0.9.0
8 github.com/bnema/purego-ffmpeg v0.3.18 github.com/bnema/purego-ffmpeg v0.3.1
9 github.com/bnema/purego-pipewire v0.1.29 github.com/bnema/purego-pipewire v0.1.2
10 github.com/bnema/purego-sqlite v0.1.210 github.com/bnema/purego-sqlite v0.1.2
M go.sum +2 -2 generated file: collapsed
A internal/application/dto/clipboard.go +27 -0
@@ -0,0 +1,27 @@
1+package dto
2+
3+// ClipboardSource identifies the originating engine for clipboard events.
4+type ClipboardSource string
5+
6+const (
7+ // ClipboardSourceCEF marks events from the CEF engine.
8+ ClipboardSourceCEF ClipboardSource = "cef"
9+ // ClipboardSourceWebKit marks events from the WebKit engine.
10+ ClipboardSourceWebKit ClipboardSource = "webkit"
11+)
12+
13+// SelectionClipboardInput carries auto-copy selection updates.
14+type SelectionClipboardInput struct {
15+ Text string
16+ SourceEngine ClipboardSource
17+ ViewID uint64
18+}
19+
20+// ExplicitClipboardInput carries explicit copy requests.
21+type ExplicitClipboardInput struct {
22+ Text string
23+ SourceEngine ClipboardSource
24+ ViewID uint64
25+ Action string
26+ NativeHandled bool
27+}
A internal/application/port/clipboard_events.go +13 -0
@@ -0,0 +1,13 @@
1+package port
2+
3+import (
4+ "context"
5+
6+ "github.com/bnema/dumber/internal/application/dto"
7+)
8+
9+// ClipboardTextOrchestrator coordinates shared clipboard behavior.
10+type ClipboardTextOrchestrator interface {
11+ HandleSelectionUpdate(ctx context.Context, input dto.SelectionClipboardInput) error
12+ HandleExplicitCopy(ctx context.Context, input dto.ExplicitClipboardInput) error
13+}
M internal/application/port/context_menu.go +10 -9
@@ -19,15 +19,16 @@ const (
19 19
20 // MenuContext captures the state needed to build and execute a context menu.20 // MenuContext captures the state needed to build and execute a context menu.
21 type MenuContext struct {21 type MenuContext struct {
22- PageURI string22+ PageURI string
23- LinkURI string23+ LinkURI string
24- ImageURI string24+ ImageURI string
25- HasSelection bool25+ SelectionText string
26- IsEditable bool26+ HasSelection bool
27- CanGoBack bool27+ IsEditable bool
28- CanGoForward bool28+ CanGoBack bool
29- X int29+ CanGoForward bool
30- Y int30+ X int
31+ Y int
31 }32 }
32 33
33 // MenuItem is a normalized context menu entry.34 // MenuItem is a normalized context menu entry.
M internal/application/port/engine.go +6 -5
@@ -193,11 +193,12 @@ type WebUIMessage struct {
193 193
194 // HandlerDependencies holds use cases needed by WebUI message handlers.194 // HandlerDependencies holds use cases needed by WebUI message handlers.
195 type HandlerDependencies struct {195 type HandlerDependencies struct {
196- HistoryUC HomepageHistory196+ HistoryUC HomepageHistory
197- FavoritesUC HomepageFavorites197+ FavoritesUC HomepageFavorites
198- Clipboard Clipboard198+ Clipboard Clipboard
199- AutoCopyConfig AutoCopyConfig199+ AutoCopyConfig AutoCopyConfig
200- OnClipboardCopied func(textLen int)200+ ClipboardTextOrchestrator ClipboardTextOrchestrator
201+ OnClipboardCopied func(textLen int)
201 HandlerDeps202 HandlerDeps
202 }203 }
203 204
M internal/application/port/mocks/mocks.go +142 -0
@@ -11,6 +11,7 @@ import (
11 "io"11 "io"
12 "time"12 "time"
13 13
14+ "github.com/bnema/dumber/internal/application/dto"
14 "github.com/bnema/dumber/internal/application/port"15 "github.com/bnema/dumber/internal/application/port"
15 "github.com/bnema/dumber/internal/domain/entity"16 "github.com/bnema/dumber/internal/domain/entity"
16 "github.com/rs/zerolog"17 "github.com/rs/zerolog"
@@ -730,6 +731,147 @@ func (_c *MockClipboard_WriteText_Call) RunAndReturn(run func(ctx context.Contex
730 return _c731 return _c
731 }732 }
732 733
734+// 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.
735+// The first argument is typically a *testing.T value.
736+func NewMockClipboardTextOrchestrator(t interface {
737+ mock.TestingT
738+ Cleanup(func())
739+}) *MockClipboardTextOrchestrator {
740+ mock := &MockClipboardTextOrchestrator{}
741+ mock.Mock.Test(t)
742+
743+ t.Cleanup(func() { mock.AssertExpectations(t) })
744+
745+ return mock
746+}
747+
748+// MockClipboardTextOrchestrator is an autogenerated mock type for the ClipboardTextOrchestrator type
749+type MockClipboardTextOrchestrator struct {
750+ mock.Mock
751+}
752+
753+type MockClipboardTextOrchestrator_Expecter struct {
754+ mock *mock.Mock
755+}
756+
757+func (_m *MockClipboardTextOrchestrator) EXPECT() *MockClipboardTextOrchestrator_Expecter {
758+ return &MockClipboardTextOrchestrator_Expecter{mock: &_m.Mock}
759+}
760+
761+// HandleExplicitCopy provides a mock function for the type MockClipboardTextOrchestrator
762+func (_mock *MockClipboardTextOrchestrator) HandleExplicitCopy(ctx context.Context, input dto.ExplicitClipboardInput) error {
763+ ret := _mock.Called(ctx, input)
764+
765+ if len(ret) == 0 {
766+ panic("no return value specified for HandleExplicitCopy")
767+ }
768+
769+ var r0 error
770+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.ExplicitClipboardInput) error); ok {
771+ r0 = returnFunc(ctx, input)
772+ } else {
773+ r0 = ret.Error(0)
774+ }
775+ return r0
776+}
777+
778+// MockClipboardTextOrchestrator_HandleExplicitCopy_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HandleExplicitCopy'
779+type MockClipboardTextOrchestrator_HandleExplicitCopy_Call struct {
780+ *mock.Call
781+}
782+
783+// HandleExplicitCopy is a helper method to define mock.On call
784+// - ctx context.Context
785+// - input dto.ExplicitClipboardInput
786+func (_e *MockClipboardTextOrchestrator_Expecter) HandleExplicitCopy(ctx interface{}, input interface{}) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call {
787+ return &MockClipboardTextOrchestrator_HandleExplicitCopy_Call{Call: _e.mock.On("HandleExplicitCopy", ctx, input)}
788+}
789+
790+func (_c *MockClipboardTextOrchestrator_HandleExplicitCopy_Call) Run(run func(ctx context.Context, input dto.ExplicitClipboardInput)) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call {
791+ _c.Call.Run(func(args mock.Arguments) {
792+ var arg0 context.Context
793+ if args[0] != nil {
794+ arg0 = args[0].(context.Context)
795+ }
796+ var arg1 dto.ExplicitClipboardInput
797+ if args[1] != nil {
798+ arg1 = args[1].(dto.ExplicitClipboardInput)
799+ }
800+ run(
801+ arg0,
802+ arg1,
803+ )
804+ })
805+ return _c
806+}
807+
808+func (_c *MockClipboardTextOrchestrator_HandleExplicitCopy_Call) Return(err error) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call {
809+ _c.Call.Return(err)
810+ return _c
811+}
812+
813+func (_c *MockClipboardTextOrchestrator_HandleExplicitCopy_Call) RunAndReturn(run func(ctx context.Context, input dto.ExplicitClipboardInput) error) *MockClipboardTextOrchestrator_HandleExplicitCopy_Call {
814+ _c.Call.Return(run)
815+ return _c
816+}
817+
818+// HandleSelectionUpdate provides a mock function for the type MockClipboardTextOrchestrator
819+func (_mock *MockClipboardTextOrchestrator) HandleSelectionUpdate(ctx context.Context, input dto.SelectionClipboardInput) error {
820+ ret := _mock.Called(ctx, input)
821+
822+ if len(ret) == 0 {
823+ panic("no return value specified for HandleSelectionUpdate")
824+ }
825+
826+ var r0 error
827+ if returnFunc, ok := ret.Get(0).(func(context.Context, dto.SelectionClipboardInput) error); ok {
828+ r0 = returnFunc(ctx, input)
829+ } else {
830+ r0 = ret.Error(0)
831+ }
832+ return r0
833+}
834+
835+// MockClipboardTextOrchestrator_HandleSelectionUpdate_Call is a *mock.Call that shadows Run/Return methods with type explicit version for method 'HandleSelectionUpdate'
836+type MockClipboardTextOrchestrator_HandleSelectionUpdate_Call struct {
837+ *mock.Call
838+}
839+
840+// HandleSelectionUpdate is a helper method to define mock.On call
841+// - ctx context.Context
842+// - input dto.SelectionClipboardInput
843+func (_e *MockClipboardTextOrchestrator_Expecter) HandleSelectionUpdate(ctx interface{}, input interface{}) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call {
844+ return &MockClipboardTextOrchestrator_HandleSelectionUpdate_Call{Call: _e.mock.On("HandleSelectionUpdate", ctx, input)}
845+}
846+
847+func (_c *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call) Run(run func(ctx context.Context, input dto.SelectionClipboardInput)) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call {
848+ _c.Call.Run(func(args mock.Arguments) {
849+ var arg0 context.Context
850+ if args[0] != nil {
851+ arg0 = args[0].(context.Context)
852+ }
853+ var arg1 dto.SelectionClipboardInput
854+ if args[1] != nil {
855+ arg1 = args[1].(dto.SelectionClipboardInput)
856+ }
857+ run(
858+ arg0,
859+ arg1,
860+ )
861+ })
862+ return _c
863+}
864+
865+func (_c *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call) Return(err error) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call {
866+ _c.Call.Return(err)
867+ return _c
868+}
869+
870+func (_c *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call) RunAndReturn(run func(ctx context.Context, input dto.SelectionClipboardInput) error) *MockClipboardTextOrchestrator_HandleSelectionUpdate_Call {
871+ _c.Call.Return(run)
872+ return _c
873+}
874+
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.875 // 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.876 // The first argument is typically a *testing.T value.
735 func NewMockColorSchemeDetector(t interface {877 func NewMockColorSchemeDetector(t interface {
A internal/application/usecase/clipboard_text_orchestrator.go +190 -0
@@ -0,0 +1,190 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "fmt"
6+ "strings"
7+ "sync"
8+ "time"
9+ "unicode/utf8"
10+
11+ "github.com/bnema/dumber/internal/application/dto"
12+ "github.com/bnema/dumber/internal/application/port"
13+ "github.com/bnema/dumber/internal/logging"
14+)
15+
16+const explicitClipboardDedupWindow = 250 * time.Millisecond
17+
18+// ClipboardTextOrchestratorUseCase applies shared clipboard business rules.
19+type ClipboardTextOrchestratorUseCase struct {
20+ clipboard port.Clipboard
21+ autoCopyConfig port.AutoCopyConfig
22+ toast func(textLen int)
23+ mu sync.Mutex
24+ lastSelection map[clipboardScope]string
25+ lastExplicit map[clipboardScope]explicitCopyState
26+ now func() time.Time
27+}
28+
29+type clipboardScope struct {
30+ source dto.ClipboardSource
31+ viewID uint64
32+}
33+
34+type explicitCopyState struct {
35+ text string
36+ action string
37+ at time.Time
38+}
39+
40+var _ port.ClipboardTextOrchestrator = (*ClipboardTextOrchestratorUseCase)(nil)
41+
42+// NewClipboardTextOrchestrator creates a shared clipboard orchestrator.
43+func NewClipboardTextOrchestrator(
44+ clipboard port.Clipboard,
45+ autoCopyConfig port.AutoCopyConfig,
46+ toast func(textLen int),
47+) *ClipboardTextOrchestratorUseCase {
48+ return &ClipboardTextOrchestratorUseCase{
49+ clipboard: clipboard,
50+ autoCopyConfig: autoCopyConfig,
51+ toast: toast,
52+ lastSelection: make(map[clipboardScope]string),
53+ lastExplicit: make(map[clipboardScope]explicitCopyState),
54+ now: time.Now,
55+ }
56+}
57+
58+// HandleSelectionUpdate applies auto-copy selection rules.
59+func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate(
60+ ctx context.Context,
61+ input dto.SelectionClipboardInput,
62+) error {
63+ if uc == nil || uc.autoCopyConfig == nil || !uc.autoCopyConfig.IsAutoCopyEnabled() {
64+ if uc != nil {
65+ uc.mu.Lock()
66+ delete(uc.lastSelection, selectionScope(input))
67+ uc.mu.Unlock()
68+ }
69+ return nil
70+ }
71+
72+ if input.Text == "" {
73+ uc.mu.Lock()
74+ delete(uc.lastSelection, selectionScope(input))
75+ uc.mu.Unlock()
76+ return nil
77+ }
78+
79+ if utf8.RuneCountInString(strings.TrimSpace(input.Text)) < 2 {
80+ return nil
81+ }
82+ textLen := utf8.RuneCountInString(input.Text)
83+ scope := selectionScope(input)
84+ currentTime := uc.currentTime()
85+
86+ uc.mu.Lock()
87+ if input.Text == uc.lastSelection[scope] {
88+ uc.mu.Unlock()
89+ return nil
90+ }
91+ if uc.clipboard == nil {
92+ uc.mu.Unlock()
93+ return fmt.Errorf("clipboard not available")
94+ }
95+ selectionState := input.Text
96+ explicitInput := dto.ExplicitClipboardInput{SourceEngine: input.SourceEngine, ViewID: input.ViewID, Action: "copy"}
97+ explicitState := explicitCopyState{text: input.Text, action: explicitInput.Action, at: currentTime}
98+ uc.lastSelection[scope] = selectionState
99+ uc.lastExplicit[explicitScope(explicitInput)] = explicitState
100+ uc.mu.Unlock()
101+
102+ if err := uc.clipboard.WriteText(ctx, input.Text); err != nil {
103+ uc.mu.Lock()
104+ if uc.lastSelection[scope] == selectionState {
105+ delete(uc.lastSelection, scope)
106+ }
107+ if state, ok := uc.lastExplicit[explicitScope(explicitInput)]; ok && state == explicitState {
108+ delete(uc.lastExplicit, explicitScope(explicitInput))
109+ }
110+ uc.mu.Unlock()
111+ return fmt.Errorf("clipboard write failed: %w", err)
112+ }
113+
114+ if uc.toast != nil {
115+ uc.toast(textLen)
116+ }
117+ logging.FromContext(ctx).Debug().
118+ Int("text_len", textLen).
119+ Str("source_engine", string(input.SourceEngine)).
120+ Msg("clipboard selection copied")
121+ return nil
122+}
123+
124+// HandleExplicitCopy applies explicit copy rules.
125+func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy(
126+ ctx context.Context,
127+ input dto.ExplicitClipboardInput,
128+) error {
129+ if uc == nil || input.Text == "" {
130+ return nil
131+ }
132+
133+ currentTime := uc.currentTime()
134+ textLen := utf8.RuneCountInString(input.Text)
135+ scope := explicitScope(input)
136+
137+ state := explicitCopyState{text: input.Text, action: input.Action, at: currentTime}
138+
139+ uc.mu.Lock()
140+ if previous, ok := uc.lastExplicit[scope]; ok &&
141+ input.Text == previous.text &&
142+ input.Action == previous.action &&
143+ currentTime.Sub(previous.at) < explicitClipboardDedupWindow {
144+ uc.mu.Unlock()
145+ return nil
146+ }
147+ if !input.NativeHandled && uc.clipboard == nil {
148+ uc.mu.Unlock()
149+ return fmt.Errorf("clipboard not available")
150+ }
151+ uc.lastExplicit[scope] = state
152+ uc.mu.Unlock()
153+
154+ if !input.NativeHandled {
155+ if err := uc.clipboard.WriteText(ctx, input.Text); err != nil {
156+ uc.mu.Lock()
157+ if current, ok := uc.lastExplicit[scope]; ok && current == state {
158+ delete(uc.lastExplicit, scope)
159+ }
160+ uc.mu.Unlock()
161+ return fmt.Errorf("clipboard write failed: %w", err)
162+ }
163+ }
164+
165+ if uc.toast != nil {
166+ uc.toast(textLen)
167+ }
168+ logging.FromContext(ctx).Debug().
169+ Int("text_len", textLen).
170+ Str("source_engine", string(input.SourceEngine)).
171+ Str("action", input.Action).
172+ Bool("native_handled", input.NativeHandled).
173+ Msg("clipboard explicit copied")
174+ return nil
175+}
176+
177+func selectionScope(input dto.SelectionClipboardInput) clipboardScope {
178+ return clipboardScope{source: input.SourceEngine, viewID: input.ViewID}
179+}
180+
181+func explicitScope(input dto.ExplicitClipboardInput) clipboardScope {
182+ return clipboardScope{source: input.SourceEngine, viewID: input.ViewID}
183+}
184+
185+func (uc *ClipboardTextOrchestratorUseCase) currentTime() time.Time {
186+ if uc == nil || uc.now == nil {
187+ return time.Now()
188+ }
189+ return uc.now()
190+}
A internal/application/usecase/clipboard_text_orchestrator_test.go +473 -0
@@ -0,0 +1,473 @@
1+package usecase
2+
3+import (
4+ "context"
5+ "errors"
6+ "sync"
7+ "testing"
8+ "time"
9+
10+ "github.com/bnema/dumber/internal/application/dto"
11+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
12+ "github.com/bnema/dumber/internal/domain/entity"
13+)
14+
15+type blockingClipboard struct {
16+ started chan struct{}
17+ release chan struct{}
18+ mu sync.Mutex
19+ writes []string
20+}
21+
22+type countingClipboard struct {
23+ mu sync.Mutex
24+ writes int
25+}
26+
27+type staticAutoCopyConfig struct {
28+ enabled bool
29+}
30+
31+func (s *staticAutoCopyConfig) IsAutoCopyEnabled() bool { return s.enabled }
32+
33+func newBlockingClipboard() *blockingClipboard {
34+ return &blockingClipboard{
35+ started: make(chan struct{}, 2),
36+ release: make(chan struct{}),
37+ }
38+}
39+
40+func (b *blockingClipboard) WriteText(_ context.Context, text string) error {
41+ b.mu.Lock()
42+ b.writes = append(b.writes, text)
43+ b.mu.Unlock()
44+ b.started <- struct{}{}
45+ <-b.release
46+ return nil
47+}
48+
49+func (*blockingClipboard) WriteImage(context.Context, entity.ImageData) error { return nil }
50+func (*blockingClipboard) ReadText(context.Context) (string, error) { return "", nil }
51+func (*blockingClipboard) Clear(context.Context) error { return nil }
52+func (*blockingClipboard) HasText(context.Context) (bool, error) { return false, nil }
53+
54+func (b *blockingClipboard) writeCount() int {
55+ b.mu.Lock()
56+ defer b.mu.Unlock()
57+ return len(b.writes)
58+}
59+
60+func (c *countingClipboard) WriteText(_ context.Context, _ string) error {
61+ c.mu.Lock()
62+ c.writes++
63+ c.mu.Unlock()
64+ return nil
65+}
66+
67+func (*countingClipboard) WriteImage(context.Context, entity.ImageData) error { return nil }
68+func (*countingClipboard) ReadText(context.Context) (string, error) { return "", nil }
69+func (*countingClipboard) Clear(context.Context) error { return nil }
70+func (*countingClipboard) HasText(context.Context) (bool, error) { return false, nil }
71+
72+func (c *countingClipboard) writeCount() int {
73+ c.mu.Lock()
74+ defer c.mu.Unlock()
75+ return c.writes
76+}
77+
78+func TestClipboardTextOrchestrator_HandleSelectionUpdate_AutoCopyDisabledDoesNothing(t *testing.T) {
79+ ctx := context.Background()
80+ clipboard := portmocks.NewMockClipboard(t)
81+ autoCopyConfig := &staticAutoCopyConfig{enabled: false}
82+ var toastCalls []int
83+
84+ uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) {
85+ toastCalls = append(toastCalls, textLen)
86+ })
87+
88+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "hello", SourceEngine: dto.ClipboardSourceCEF}); err != nil {
89+ t.Fatalf("HandleSelectionUpdate() error = %v, want nil", err)
90+ }
91+
92+ if len(toastCalls) != 0 {
93+ t.Fatalf("toast calls = %v, want none", toastCalls)
94+ }
95+}
96+
97+func TestClipboardTextOrchestrator_HandleSelectionUpdate_WritesOncePerUniqueSelectionAndResetsAfterEmptySelection(t *testing.T) {
98+ ctx := context.Background()
99+ clipboard := portmocks.NewMockClipboard(t)
100+ autoCopyConfig := &staticAutoCopyConfig{enabled: true}
101+ var toastCalls []int
102+
103+ clipboard.EXPECT().WriteText(ctx, "éé").Return(nil).Times(2)
104+
105+ uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) {
106+ toastCalls = append(toastCalls, textLen)
107+ })
108+
109+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "é", SourceEngine: dto.ClipboardSourceCEF}); err != nil {
110+ t.Fatalf("HandleSelectionUpdate(short) error = %v, want nil", err)
111+ }
112+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF}); err != nil {
113+ t.Fatalf("HandleSelectionUpdate(first) error = %v, want nil", err)
114+ }
115+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF}); err != nil {
116+ t.Fatalf("HandleSelectionUpdate(duplicate) error = %v, want nil", err)
117+ }
118+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "", SourceEngine: dto.ClipboardSourceCEF}); err != nil {
119+ t.Fatalf("HandleSelectionUpdate(reset) error = %v, want nil", err)
120+ }
121+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF}); err != nil {
122+ t.Fatalf("HandleSelectionUpdate(after reset) error = %v, want nil", err)
123+ }
124+
125+ if len(toastCalls) != 2 || toastCalls[0] != 2 || toastCalls[1] != 2 {
126+ t.Fatalf("toast calls = %v, want [2 2]", toastCalls)
127+ }
128+}
129+
130+func TestClipboardTextOrchestrator_HandleSelectionUpdate_DoesNotDedupAcrossEngines(t *testing.T) {
131+ ctx := context.Background()
132+ clipboard := portmocks.NewMockClipboard(t)
133+ autoCopyConfig := &staticAutoCopyConfig{enabled: true}
134+
135+ clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2)
136+
137+ uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)
138+
139+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceWebKit}); err != nil {
140+ t.Fatalf("HandleSelectionUpdate(webkit) error = %v, want nil", err)
141+ }
142+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF}); err != nil {
143+ t.Fatalf("HandleSelectionUpdate(cef) error = %v, want nil", err)
144+ }
145+}
146+
147+func TestClipboardTextOrchestrator_HandleSelectionUpdate_DoesNotDedupAcrossViews(t *testing.T) {
148+ ctx := context.Background()
149+ clipboard := portmocks.NewMockClipboard(t)
150+ autoCopyConfig := &staticAutoCopyConfig{enabled: true}
151+
152+ clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2)
153+
154+ uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)
155+
156+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil {
157+ t.Fatalf("HandleSelectionUpdate(view 1) error = %v, want nil", err)
158+ }
159+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 2}); err != nil {
160+ t.Fatalf("HandleSelectionUpdate(view 2) error = %v, want nil", err)
161+ }
162+}
163+
164+func TestClipboardTextOrchestrator_HandleExplicitCopy_DeduplicatesIdenticalPayloadsWithinWindow(t *testing.T) {
165+ ctx := context.Background()
166+ clipboard := portmocks.NewMockClipboard(t)
167+ autoCopyConfig := &staticAutoCopyConfig{enabled: true}
168+ var toastCalls []int
169+
170+ clipboard.EXPECT().WriteText(ctx, "éé").Return(nil).Times(2)
171+
172+ uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) {
173+ toastCalls = append(toastCalls, textLen)
174+ })
175+
176+ now := time.Unix(0, 0)
177+ uc.now = func() time.Time { return now }
178+
179+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"}); err != nil {
180+ t.Fatalf("HandleExplicitCopy(first) error = %v, want nil", err)
181+ }
182+ now = now.Add(100 * time.Millisecond)
183+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"}); err != nil {
184+ t.Fatalf("HandleExplicitCopy(duplicate) error = %v, want nil", err)
185+ }
186+ now = now.Add(151 * time.Millisecond)
187+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"}); err != nil {
188+ t.Fatalf("HandleExplicitCopy(after window) error = %v, want nil", err)
189+ }
190+
191+ if len(toastCalls) != 2 || toastCalls[0] != 2 || toastCalls[1] != 2 {
192+ t.Fatalf("toast calls = %v, want [2 2]", toastCalls)
193+ }
194+}
195+
196+func TestClipboardTextOrchestrator_HandleExplicitCopy_NativeHandledSkipsBackendWrite(t *testing.T) {
197+ ctx := context.Background()
198+ clipboard := &countingClipboard{}
199+ autoCopyConfig := &staticAutoCopyConfig{enabled: true}
200+ var toastCalls []int
201+
202+ uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) {
203+ toastCalls = append(toastCalls, textLen)
204+ })
205+
206+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy", NativeHandled: true}); err != nil {
207+ t.Fatalf("HandleExplicitCopy() error = %v, want nil", err)
208+ }
209+
210+ if got := clipboard.writeCount(); got != 0 {
211+ t.Fatalf("clipboard writes = %d, want 0", got)
212+ }
213+ if len(toastCalls) != 1 || toastCalls[0] != 6 {
214+ t.Fatalf("toast calls = %v, want [6]", toastCalls)
215+ }
216+ if got := len(uc.lastExplicit); got != 1 {
217+ t.Fatalf("lastExplicit entries = %d, want 1", got)
218+ }
219+}
220+
221+func TestClipboardTextOrchestrator_HandleExplicitCopy_DoesNotDedupDifferentActionsWithinWindow(t *testing.T) {
222+ ctx := context.Background()
223+ clipboard := portmocks.NewMockClipboard(t)
224+ autoCopyConfig := &staticAutoCopyConfig{enabled: true}
225+
226+ clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2)
227+
228+ uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)
229+ now := time.Unix(0, 0)
230+ uc.now = func() time.Time { return now }
231+
232+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1, Action: "copy"}); err != nil {
233+ t.Fatalf("HandleExplicitCopy(copy) error = %v, want nil", err)
234+ }
235+ now = now.Add(100 * time.Millisecond)
236+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1, Action: "cut"}); err != nil {
237+ t.Fatalf("HandleExplicitCopy(cut) error = %v, want nil", err)
238+ }
239+}
240+
241+func TestClipboardTextOrchestrator_HandleExplicitCopy_DeduplicatesRecentAutoCopySelection(t *testing.T) {
242+ ctx := context.Background()
243+ clipboard := portmocks.NewMockClipboard(t)
244+ autoCopyConfig := &staticAutoCopyConfig{enabled: true}
245+ var toastCalls []int
246+
247+ clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Once()
248+
249+ uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) {
250+ toastCalls = append(toastCalls, textLen)
251+ })
252+
253+ now := time.Unix(0, 0)
254+ uc.now = func() time.Time { return now }
255+
256+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil {
257+ t.Fatalf("HandleSelectionUpdate() error = %v, want nil", err)
258+ }
259+ now = now.Add(100 * time.Millisecond)
260+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1, Action: "copy"}); err != nil {
261+ t.Fatalf("HandleExplicitCopy() error = %v, want nil", err)
262+ }
263+
264+ if len(toastCalls) != 1 || toastCalls[0] != 6 {
265+ t.Fatalf("toast calls = %v, want [6]", toastCalls)
266+ }
267+ if got := len(uc.lastExplicit); got != 1 {
268+ t.Fatalf("lastExplicit entries = %d, want 1", got)
269+ }
270+}
271+
272+func TestClipboardTextOrchestrator_HandleExplicitCopy_DoesNotDedupAcrossEngines(t *testing.T) {
273+ ctx := context.Background()
274+ clipboard := portmocks.NewMockClipboard(t)
275+ autoCopyConfig := &staticAutoCopyConfig{enabled: true}
276+
277+ clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2)
278+
279+ uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)
280+ uc.now = func() time.Time { return time.Unix(0, 0) }
281+
282+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"}); err != nil {
283+ t.Fatalf("HandleExplicitCopy(webkit) error = %v, want nil", err)
284+ }
285+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, Action: "copy"}); err != nil {
286+ t.Fatalf("HandleExplicitCopy(cef) error = %v, want nil", err)
287+ }
288+}
289+
290+func TestClipboardTextOrchestrator_HandleExplicitCopy_DoesNotDedupAcrossViews(t *testing.T) {
291+ ctx := context.Background()
292+ clipboard := portmocks.NewMockClipboard(t)
293+ autoCopyConfig := &staticAutoCopyConfig{enabled: true}
294+
295+ clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2)
296+
297+ uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)
298+ uc.now = func() time.Time { return time.Unix(0, 0) }
299+
300+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1, Action: "copy"}); err != nil {
301+ t.Fatalf("HandleExplicitCopy(view 1) error = %v, want nil", err)
302+ }
303+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 2, Action: "copy"}); err != nil {
304+ t.Fatalf("HandleExplicitCopy(view 2) error = %v, want nil", err)
305+ }
306+}
307+
308+func TestClipboardTextOrchestrator_HandleExplicitCopy_KeepsBoundedStatePerScope(t *testing.T) {
309+ ctx := context.Background()
310+ clipboard := portmocks.NewMockClipboard(t)
311+ autoCopyConfig := &staticAutoCopyConfig{enabled: true}
312+
313+ clipboard.EXPECT().WriteText(ctx, "one").Return(nil).Once()
314+ clipboard.EXPECT().WriteText(ctx, "two").Return(nil).Once()
315+ clipboard.EXPECT().WriteText(ctx, "three").Return(nil).Once()
316+
317+ uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)
318+
319+ for i, text := range []string{"one", "two", "three"} {
320+ advancedTime := time.Unix(0, 0).Add(time.Duration(i) * time.Second)
321+ uc.now = func() time.Time { return advancedTime }
322+ if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: text, SourceEngine: dto.ClipboardSourceCEF, ViewID: 7, Action: "copy"}); err != nil {
323+ t.Fatalf("HandleExplicitCopy(%q) error = %v, want nil", text, err)
324+ }
325+ }
326+
327+ if got := len(uc.lastExplicit); got != 1 {
328+ t.Fatalf("lastExplicit entries = %d, want 1", got)
329+ }
330+}
331+
332+func TestClipboardTextOrchestrator_HandleSelectionUpdate_ReenableClearsDedupState(t *testing.T) {
333+ ctx := context.Background()
334+ clipboard := portmocks.NewMockClipboard(t)
335+ autoCopyConfig := &staticAutoCopyConfig{enabled: true}
336+
337+ clipboard.EXPECT().WriteText(ctx, "toggle").Return(nil).Times(2)
338+
339+ uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil)
340+
341+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "toggle", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil {
342+ t.Fatalf("HandleSelectionUpdate(enabled) error = %v, want nil", err)
343+ }
344+ autoCopyConfig.enabled = false
345+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "toggle", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil {
346+ t.Fatalf("HandleSelectionUpdate(disabled) error = %v, want nil", err)
347+ }
348+ autoCopyConfig.enabled = true
349+ if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "toggle", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil {
350+ t.Fatalf("HandleSelectionUpdate(reenabled) error = %v, want nil", err)
351+ }
352+}
353+
354+func TestClipboardTextOrchestrator_HandleExplicitCopy_ClipboardFailureDoesNotToast(t *testing.T) {
355+ ctx := context.Background()
356+ clipboard := portmocks.NewMockClipboard(t)
357+ clipboard.EXPECT().WriteText(ctx, "éé").Return(errors.New("clipboard failed")).Once()
358+ autoCopyConfig := &staticAutoCopyConfig{enabled: true}
359+
360+ var toastCalls []int
361+ uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) {
362+ toastCalls = append(toastCalls, textLen)
363+ })
364+
365+ err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF, Action: "copy"})
366+ if err == nil {
367+ t.Fatal("HandleExplicitCopy() error = nil, want non-nil")
368+ }
369+ if len(toastCalls) != 0 {
370+ t.Fatalf("toast calls = %v, want none", toastCalls)
371+ }
372+}
373+
374+func TestClipboardTextOrchestrator_HandleSelectionUpdate_DedupIsAtomic(t *testing.T) {
375+ ctx := context.Background()
376+ clipboard := newBlockingClipboard()
377+ autoCopyConfig := &staticAutoCopyConfig{enabled: true}
378+ var (
379+ toastCalls []int
380+ toastMu sync.Mutex
381+ )
382+ uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) {
383+ toastMu.Lock()
384+ defer toastMu.Unlock()
385+ toastCalls = append(toastCalls, textLen)
386+ })
387+
388+ done1 := make(chan error, 1)
389+ go func() {
390+ done1 <- uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF})
391+ }()
392+ <-clipboard.started
393+
394+ done2 := make(chan error, 1)
395+ go func() {
396+ done2 <- uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF})
397+ }()
398+
399+ select {
400+ case <-clipboard.started:
401+ t.Fatal("second selection write started before first completed")
402+ default:
403+ }
404+
405+ close(clipboard.release)
406+
407+ if err := <-done1; err != nil {
408+ t.Fatalf("first HandleSelectionUpdate() error = %v, want nil", err)
409+ }
410+ if err := <-done2; err != nil {
411+ t.Fatalf("second HandleSelectionUpdate() error = %v, want nil", err)
412+ }
413+
414+ if got := clipboard.writeCount(); got != 1 {
415+ t.Fatalf("clipboard writes = %d, want 1", got)
416+ }
417+ toastMu.Lock()
418+ defer toastMu.Unlock()
419+ if len(toastCalls) != 1 || toastCalls[0] != 2 {
420+ t.Fatalf("toast calls = %v, want [2]", toastCalls)
421+ }
422+}
423+
424+func TestClipboardTextOrchestrator_HandleExplicitCopy_DedupIsAtomic(t *testing.T) {
425+ ctx := context.Background()
426+ clipboard := newBlockingClipboard()
427+ autoCopyConfig := &staticAutoCopyConfig{enabled: true}
428+ var (
429+ toastCalls []int
430+ toastMu sync.Mutex
431+ )
432+ uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) {
433+ toastMu.Lock()
434+ defer toastMu.Unlock()
435+ toastCalls = append(toastCalls, textLen)
436+ })
437+ uc.now = func() time.Time { return time.Unix(0, 0) }
438+
439+ done1 := make(chan error, 1)
440+ go func() {
441+ done1 <- uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"})
442+ }()
443+ <-clipboard.started
444+
445+ done2 := make(chan error, 1)
446+ go func() {
447+ done2 <- uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"})
448+ }()
449+
450+ select {
451+ case <-clipboard.started:
452+ t.Fatal("second explicit write started before first completed")
453+ default:
454+ }
455+
456+ close(clipboard.release)
457+
458+ if err := <-done1; err != nil {
459+ t.Fatalf("first HandleExplicitCopy() error = %v, want nil", err)
460+ }
461+ if err := <-done2; err != nil {
462+ t.Fatalf("second HandleExplicitCopy() error = %v, want nil", err)
463+ }
464+
465+ if got := clipboard.writeCount(); got != 1 {
466+ t.Fatalf("clipboard writes = %d, want 1", got)
467+ }
468+ toastMu.Lock()
469+ defer toastMu.Unlock()
470+ if len(toastCalls) != 1 || toastCalls[0] != 2 {
471+ t.Fatalf("toast calls = %v, want [2]", toastCalls)
472+ }
473+}
M internal/application/usecase/execute_context_menu_action.go +18 -8
@@ -105,16 +105,26 @@ func (uc *ExecuteContextMenuActionUseCase) executeMenuAction(
105 return fmt.Errorf("save image: %w", err)105 return fmt.Errorf("save image: %w", err)
106 }106 }
107 return nil107 return nil
108- default:108+ case port.MenuActionCopySelection:
109- // All other normalized actions are delegated to the engine/UI layer.109+ // When selection text is present and clipboard wiring is available, copy it here.
110- if uc.delegator == nil {110+ // Otherwise intentionally fall through to DelegateMenuAction so native browser copy
111- return fmt.Errorf("delegate action %s: menu action delegator not available", action)111+ // behavior can handle document selections that are only accessible inside the engine.
112+ if menuContext.SelectionText != "" && uc.clipboard != nil {
113+ if err := uc.clipboard.WriteText(ctx, menuContext.SelectionText); err != nil {
114+ return fmt.Errorf("copy selection: %w", err)
115+ }
116+ return nil
112 }117 }
113- if err := uc.delegator.DelegateMenuAction(ctx, action, menuContext); err != nil {
114- return fmt.Errorf("delegate action %s: %w", action, err)
115- }
116- return nil
117 }118 }
119+
120+ // All other normalized actions are delegated to the engine/UI layer.
121+ if uc.delegator == nil {
122+ return fmt.Errorf("delegate action %s: menu action delegator not available", action)
123+ }
124+ if err := uc.delegator.DelegateMenuAction(ctx, action, menuContext); err != nil {
125+ return fmt.Errorf("delegate action %s: %w", action, err)
126+ }
127+ return nil
118 }128 }
119 129
120 func (uc *ExecuteContextMenuActionUseCase) resolveImageData(ctx context.Context, uri string) (entity.ImageData, error) {130 func (uc *ExecuteContextMenuActionUseCase) resolveImageData(ctx context.Context, uri string) (entity.ImageData, error) {
M internal/application/usecase/execute_context_menu_action_test.go +40 -0
@@ -217,6 +217,46 @@ func TestExecuteContextMenuActionUseCase_WrapsDelegateErrors(t *testing.T) {
217 require.Contains(t, err.Error(), "boom")217 require.Contains(t, err.Error(), "boom")
218 }218 }
219 219
220+func TestExecuteContextMenuActionUseCase_CopySelectionWritesSelectedText(t *testing.T) {
221+ clipboard := &fakeClipboard{}
222+ resolver := &fakeImageResolver{}
223+ saver := &fakeResolvedImageSaver{}
224+ delegator := &fakeMenuActionDelegator{}
225+ uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator)
226+
227+ err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{
228+ Action: port.MenuActionCopySelection,
229+ Context: port.MenuContext{
230+ HasSelection: true,
231+ SelectionText: "selected text",
232+ },
233+ })
234+
235+ require.NoError(t, err)
236+ require.Equal(t, 1, clipboard.writeTextCalls)
237+ require.Equal(t, "selected text", clipboard.text)
238+ require.Zero(t, delegator.delegateCalls)
239+}
240+
241+func TestExecuteContextMenuActionUseCase_CopySelectionFallsBackToDelegatorWhenClipboardMissing(t *testing.T) {
242+ resolver := &fakeImageResolver{}
243+ saver := &fakeResolvedImageSaver{}
244+ delegator := &fakeMenuActionDelegator{}
245+ uc := NewExecuteContextMenuActionUseCase(nil, resolver, saver, delegator)
246+
247+ err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{
248+ Action: port.MenuActionCopySelection,
249+ Context: port.MenuContext{
250+ HasSelection: true,
251+ SelectionText: "selected text",
252+ },
253+ })
254+
255+ require.NoError(t, err)
256+ require.Equal(t, 1, delegator.delegateCalls)
257+ require.Equal(t, port.MenuActionCopySelection, delegator.action)
258+}
259+
220 func TestExecuteContextMenuActionUseCase_CopyLinkWritesText(t *testing.T) {260 func TestExecuteContextMenuActionUseCase_CopyLinkWritesText(t *testing.T) {
221 clipboard := &fakeClipboard{}261 clipboard := &fakeClipboard{}
222 resolver := &fakeImageResolver{}262 resolver := &fakeImageResolver{}
M internal/bootstrap/engine.go +27 -13
@@ -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
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 (
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 +47 -0
@@ -0,0 +1,47 @@
1+package bootstrap
2+
3+import (
4+ "os"
5+ "path/filepath"
6+
7+ "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
9+)
10+
11+// ResolveRuntimeProfile resolves the current mode+engine profile once in bootstrap.
12+func ResolveRuntimeProfile(cfg *config.Config) (runtimeprofile.Profile, error) {
13+ engine := config.EngineTypeWebKit
14+ if cfg != nil {
15+ engine = cfg.Engine.ResolveEngineType()
16+ }
17+
18+ dirs, err := config.GetXDGDirs()
19+ if err != nil {
20+ return runtimeprofile.Profile{}, err
21+ }
22+
23+ return runtimeprofile.Resolve(runtimeprofile.ResolveInput{
24+ Env: os.Getenv,
25+ CWD: os.Getwd,
26+ Engine: engine,
27+ Base: runtimeprofile.BasePaths{
28+ ConfigHome: dirs.ConfigHome,
29+ DataHome: dirs.DataHome,
30+ StateHome: dirs.StateHome,
31+ CacheHome: dirs.CacheHome,
32+ },
33+ })
34+}
35+
36+// ResolveXDGRuntimeDir returns the shared XDG runtime dir for the current mode.
37+func ResolveXDGRuntimeDir(profile runtimeprofile.Profile) string {
38+ if profile.Mode == runtimeprofile.ModeDev {
39+ if profile.Shared.RootDir != "" {
40+ return filepath.Join(profile.Shared.RootDir, "runtime")
41+ }
42+ }
43+ if profile.Shared.StateDir != "" {
44+ return filepath.Join(profile.Shared.StateDir, "runtime")
45+ }
46+ return ""
47+}
A internal/bootstrap/runtime_profile_test.go +84 -0
@@ -0,0 +1,84 @@
1+package bootstrap
2+
3+import (
4+ "path/filepath"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/infrastructure/config"
8+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+func TestResolveRuntimeProfile_UsesConfigEngineResolution(t *testing.T) {
13+ cfg := config.DefaultConfig()
14+ cfg.Engine.Type = "webkit"
15+ t.Setenv("DUMBER_ENGINE", "cef")
16+ t.Setenv("ENV", "")
17+ home := t.TempDir()
18+ t.Setenv("HOME", home)
19+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
20+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
21+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
22+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
23+
24+ profile, err := ResolveRuntimeProfile(cfg)
25+ require.NoError(t, err)
26+ require.Equal(t, "cef", profile.Engine)
27+}
28+
29+func TestResolveRuntimeProfile_ProdPreservesSharedPaths(t *testing.T) {
30+ cfg := config.DefaultConfig()
31+ cfg.Engine.Type = config.EngineTypeWebKit
32+ t.Setenv("ENV", "")
33+ home := t.TempDir()
34+ t.Setenv("HOME", home)
35+ t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
36+ t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share"))
37+ t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state"))
38+ t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache"))
39+
40+ profile, err := ResolveRuntimeProfile(cfg)
41+ require.NoError(t, err)
42+ require.Equal(t, filepath.Join(home, ".config", "dumber"), profile.Shared.ConfigDir)
43+ require.Equal(t, filepath.Join(home, ".local", "share", "dumber"), profile.Shared.DataDir)
44+ require.Equal(t, filepath.Join(home, ".local", "state", "dumber"), profile.Shared.StateDir)
45+ require.Equal(t, filepath.Join(home, ".cache", "dumber"), profile.Shared.CacheDir)
46+}
47+
48+func TestResolveRuntimeProfile_DevUsesEngineSpecificNamespaces(t *testing.T) {
49+ cfg := config.DefaultConfig()
50+ cfg.Engine.Type = config.EngineTypeCEF
51+
52+ wd := t.TempDir()
53+ t.Chdir(wd)
54+
55+ t.Setenv("ENV", "dev")
56+ profile, err := ResolveRuntimeProfile(cfg)
57+ require.NoError(t, err)
58+ require.Equal(t, filepath.Join(wd, ".dev", "dumber", "state"), profile.Shared.StateDir)
59+ require.Equal(t, filepath.Join(wd, ".dev", "dumber", "engines", "cef", "data"), profile.CEFUserDataDir())
60+ require.Equal(t, filepath.Join(wd, ".dev", "dumber", "runtime", "cef", "browser-launch.sock"), profile.IPC.BrowserLaunchSocket)
61+}
62+
63+func TestResolveXDGRuntimeDir_UsesSharedRuntimeRootInDev(t *testing.T) {
64+ profile := runtimeprofile.Profile{
65+ Mode: runtimeprofile.ModeDev,
66+ Shared: runtimeprofile.SharedPaths{
67+ RootDir: "/tmp/project/.dev/dumber",
68+ StateDir: "/tmp/project/.dev/dumber/state",
69+ },
70+ }
71+
72+ require.Equal(t, "/tmp/project/.dev/dumber/runtime", ResolveXDGRuntimeDir(profile))
73+}
74+
75+func TestResolveXDGRuntimeDir_UsesStateRuntimeRootInProd(t *testing.T) {
76+ profile := runtimeprofile.Profile{
77+ Mode: runtimeprofile.ModeProd,
78+ Shared: runtimeprofile.SharedPaths{
79+ StateDir: "/tmp/dumber-state/dumber",
80+ },
81+ }
82+
83+ require.Equal(t, "/tmp/dumber-state/dumber/runtime", ResolveXDGRuntimeDir(profile))
84+}
M internal/bootstrap/session_spawner.go +4 -3
@@ -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.CEFUserDataDir())
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) {
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/cli/cmd/gen_docs.go +1 -1
@@ -57,7 +57,7 @@ func runGenDocs(_ *cobra.Command, _ []string) error {
57 if outputDir == "" {57 if outputDir == "" {
58 switch genDocsFormat {58 switch genDocsFormat {
59 case "man":59 case "man":
60- xdg := xdgadapter.New()60+ xdg := xdgadapter.New(false, "")
61 manDir, err := xdg.ManDir()61 manDir, err := xdg.ManDir()
62 if err != nil {62 if err != nil {
63 return fmt.Errorf("resolve man directory: %w", err)63 return fmt.Errorf("resolve man directory: %w", err)
M internal/cli/cmd/purge.go +11 -2
@@ -7,11 +7,13 @@ import (
7 "github.com/spf13/cobra"7 "github.com/spf13/cobra"
8 8
9 "github.com/bnema/dumber/internal/application/usecase"9 "github.com/bnema/dumber/internal/application/usecase"
10+ "github.com/bnema/dumber/internal/bootstrap"
10 "github.com/bnema/dumber/internal/cli"11 "github.com/bnema/dumber/internal/cli"
11 "github.com/bnema/dumber/internal/cli/model"12 "github.com/bnema/dumber/internal/cli/model"
12 "github.com/bnema/dumber/internal/cli/styles"13 "github.com/bnema/dumber/internal/cli/styles"
13 "github.com/bnema/dumber/internal/infrastructure/desktop"14 "github.com/bnema/dumber/internal/infrastructure/desktop"
14 "github.com/bnema/dumber/internal/infrastructure/filesystem"15 "github.com/bnema/dumber/internal/infrastructure/filesystem"
16+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
15 xdgadapter "github.com/bnema/dumber/internal/infrastructure/xdg"17 xdgadapter "github.com/bnema/dumber/internal/infrastructure/xdg"
16 )18 )
17 19
@@ -47,7 +49,14 @@ func runPurge(_ *cobra.Command, _ []string) error {
47 }49 }
48 50
49 fsAdapter := filesystem.New()51 fsAdapter := filesystem.New()
50- xdgAdapter := xdgadapter.New()52+ profile, err := bootstrap.ResolveRuntimeProfile(app.Config)
53+ if err != nil {
54+ return fmt.Errorf("resolve runtime profile: %w", err)
55+ }
56+ xdgAdapter := xdgadapter.New(
57+ profile.Mode == runtimeprofile.ModeDev,
58+ bootstrap.ResolveXDGRuntimeDir(profile),
59+ )
51 desktopAdapter := desktop.New()60 desktopAdapter := desktop.New()
52 purgeUC := usecase.NewPurgeDataUseCase(fsAdapter, xdgAdapter, desktopAdapter)61 purgeUC := usecase.NewPurgeDataUseCase(fsAdapter, xdgAdapter, desktopAdapter)
53 62
@@ -63,7 +72,7 @@ func runPurge(_ *cobra.Command, _ []string) error {
63 72
64 m := model.NewPurgeModel(app.Ctx(), app.Theme, purgeUC, cfg)73 m := model.NewPurgeModel(app.Ctx(), app.Theme, purgeUC, cfg)
65 p := tea.NewProgram(m, tea.WithAltScreen())74 p := tea.NewProgram(m, tea.WithAltScreen())
66- _, err := p.Run()75+ _, err = p.Run()
67 return err76 return err
68 }77 }
69 78
M internal/infrastructure/cef/app.go +5 -3
@@ -56,10 +56,12 @@ func newDumberApp(engine *Engine) purecef.App {
56 return app56 return app
57 }57 }
58 58
59-// NewSubprocessApp returns a lightweight App for helper processes so CEF sees59+// NewSubprocessApp returns a lightweight raw App implementation for helper
60-// the same custom scheme registration in renderer/GPU/utility processes.60+// processes so cef_execute_process can wrap it itself. Keep helper processes
61+// free of custom render handlers for now: the renderer bridge introduced a
62+// startup regression where OSR pages stopped producing their first paint.
61 func NewSubprocessApp() purecef.App {63 func NewSubprocessApp() purecef.App {
62- return purecef.NewApp(&subprocessApp{})64+ return &subprocessApp{}
63 }65 }
64 66
65 // maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths.67 // maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths.
M internal/infrastructure/cef/content_injector.go +236 -17
@@ -385,33 +385,251 @@ const redditDirectVideoJS = `(function(){
385 }, 1000);385 }, 1000);
386 })();`386 })();`
387 387
388-// clipboardCopyBridgeJS intercepts copy/cut events and sends the selected text388+// clipboardSelectionFetchBridgeJS keeps the lightweight fetch bridge enabled
389-// back to Go via the message bridge so it can be written to the GDK clipboard.389+// while the native renderer bridge remains disabled due to an OSR startup
390-// Without this, Ctrl+C in CEF OSR mode writes to CEF's internal clipboard but390+// regression. It mirrors explicit clipboard text from DOM selections,
391-// not the Wayland/X11 system clipboard.391+// safe input/textarea selections, and async Clipboard API writes, and also
392-const clipboardCopyBridgeJS = `(function(){392+// reasserts browser focus when an editable element gains DOM focus.
393- if (window.__dumberClipboardBridge) return;393+const clipboardSelectionFetchBridgeJS = `(function(){
394- window.__dumberClipboardBridge = true;394+ var bridgeNonce = '__DUMBER_BRIDGE_NONCE__';
395+ if (window.__dumberClipboardBridge && typeof window.__dumberClipboardBridge.setBridgeNonce === 'function') {
396+ window.__dumberClipboardBridge.setBridgeNonce(bridgeNonce);
397+ return;
398+ }
399+ window.__dumberClipboardBridge = {
400+ setBridgeNonce: function(nextBridgeNonce) {
401+ bridgeNonce = nextBridgeNonce == null ? '' : String(nextBridgeNonce);
402+ }
403+ };
404+
405+ function encodeBody(body) {
406+ return typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : '';
407+ }
408+
409+ function inputType(node) {
410+ return node && node.type ? String(node.type).toLowerCase() : 'text';
411+ }
412+
413+ function isTextEditableInputType(type) {
414+ switch ((type || 'text').toLowerCase()) {
415+ case 'button':
416+ case 'submit':
417+ case 'reset':
418+ case 'checkbox':
419+ case 'radio':
420+ case 'range':
421+ case 'color':
422+ case 'file':
423+ case 'image':
424+ case 'hidden':
425+ return false;
426+ default:
427+ return true;
428+ }
429+ }
430+
431+ function isSelectionReadableInputType(type) {
432+ return isTextEditableInputType(type) && type !== 'password';
433+ }
434+
435+ function getActiveElementSelection() {
436+ var el = document.activeElement;
437+ if (!el || el.nodeType !== 1 || typeof el.value !== 'string') return '';
438+ var tag = el.tagName;
439+ if (el.disabled || el.readOnly) return '';
440+ if (tag === 'INPUT' && !isSelectionReadableInputType(inputType(el))) return '';
441+ if (tag !== 'INPUT' && tag !== 'TEXTAREA') return '';
442+ var start = typeof el.selectionStart === 'number' ? el.selectionStart : 0;
443+ var end = typeof el.selectionEnd === 'number' ? el.selectionEnd : 0;
444+ if (end > start) return el.value.slice(start, end);
445+ return '';
446+ }
447+
448+ function getSelectedText() {
449+ var activeSelection = getActiveElementSelection();
450+ if (activeSelection) return activeSelection;
451+ var sel = window.getSelection();
452+ return sel ? sel.toString() : '';
453+ }
454+
455+ function isEditable(node) {
456+ if (!node || node.nodeType !== 1) return false;
457+ if (node.isContentEditable) return true;
458+ var tag = node.tagName;
459+ if (tag === 'TEXTAREA') return !node.disabled && !node.readOnly;
460+ if (tag !== 'INPUT') return false;
461+ if (node.disabled || node.readOnly) return false;
462+ return isTextEditableInputType(inputType(node));
463+ }
464+
395 function sendToClipboard(text) {465 function sendToClipboard(text) {
396 if (!text) return;466 if (!text) return;
397 var body = JSON.stringify({text: text});467 var body = JSON.stringify({text: text});
398- var encoded = typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : '';468+ var encoded = encodeBody(body);
399 if (!encoded) return;469 if (!encoded) return;
400 fetch('dumb:///api/clipboard-set', {470 fetch('dumb:///api/clipboard-set', {
401 method: 'POST',471 method: 'POST',
402- headers: {'X-Dumber-Body': encoded}472+ headers: {
473+ 'X-Dumber-Body': encoded,
474+ 'X-Dumber-Bridge-Nonce': bridgeNonce
475+ }
403 }).catch(function(){});476 }).catch(function(){});
404 }477 }
405- document.addEventListener('copy', function() {478+
406- var sel = window.getSelection();479+ function sendFocusSync() {
407- if (sel) sendToClipboard(sel.toString());480+ fetch('dumb:///api/focus-sync', {
481+ method: 'POST',
482+ headers: {
483+ 'X-Dumber-Bridge-Action': 'focus-sync',
484+ 'X-Dumber-Bridge-Nonce': bridgeNonce
485+ }
486+ }).catch(function(){});
487+ }
488+
489+ function mirrorClipboardItems(items) {
490+ try {
491+ if (!items || typeof items.length !== 'number') return;
492+ Array.prototype.forEach.call(items, function(item) {
493+ if (!item || !item.types || item.types.indexOf('text/plain') === -1 || typeof item.getType !== 'function') return;
494+ Promise.resolve(item.getType('text/plain'))
495+ .then(function(blob) {
496+ if (!blob || typeof blob.text !== 'function') return '';
497+ return blob.text();
498+ })
499+ .then(function(text) {
500+ if (text) sendToClipboard(text);
501+ })
502+ .catch(function(){});
503+ });
504+ } catch (_) {}
505+ }
506+
507+ document.addEventListener('copy', function(event) {
508+ if (event && event.isTrusted === false) return;
509+ var text = getSelectedText();
510+ if (text) sendToClipboard(text);
408 });511 });
409- document.addEventListener('cut', function() {512+ document.addEventListener('cut', function(event) {
410- var sel = window.getSelection();513+ if (event && event.isTrusted === false) return;
411- if (sel) sendToClipboard(sel.toString());514+ var text = getSelectedText();
515+ if (text) sendToClipboard(text);
412 });516 });
517+ document.addEventListener('focusin', function(event) {
518+ if (event && event.isTrusted === false) return;
519+ if (isEditable(event && event.target)) sendFocusSync();
520+ }, true);
521+
522+ try {
523+ var clipboardObj = navigator && navigator.clipboard ? navigator.clipboard : null;
524+ var clipboardProto = (typeof Clipboard !== 'undefined' && Clipboard.prototype) ||
525+ (clipboardObj && typeof Object.getPrototypeOf === 'function' ? Object.getPrototypeOf(clipboardObj) : null);
526+
527+ if (clipboardProto && typeof clipboardProto.writeText === 'function' && !clipboardProto.__dumberWriteTextPatched) {
528+ clipboardProto.__dumberWriteTextPatched = true;
529+ var originalProtoWriteText = clipboardProto.writeText;
530+ var wrappedProtoWriteText = function(text) {
531+ var normalized = text == null ? '' : String(text);
532+ var result = originalProtoWriteText.call(this, normalized);
533+ if (result && typeof result.then === 'function') {
534+ return result.then(function(value) {
535+ if (normalized) sendToClipboard(normalized);
536+ return value;
537+ });
538+ }
539+ if (normalized) sendToClipboard(normalized);
540+ return result;
541+ };
542+ try {
543+ Object.defineProperty(clipboardProto, 'writeText', {
544+ configurable: true,
545+ writable: true,
546+ value: wrappedProtoWriteText
547+ });
548+ } catch (_) {
549+ clipboardProto.writeText = wrappedProtoWriteText;
550+ }
551+ } else if (clipboardObj && typeof clipboardObj.writeText === 'function' && !window.__dumberClipboardWriteTextPatched) {
552+ window.__dumberClipboardWriteTextPatched = true;
553+ var originalWriteText = clipboardObj.writeText.bind(clipboardObj);
554+ var wrappedWriteText = function(text) {
555+ var normalized = text == null ? '' : String(text);
556+ var result = originalWriteText(normalized);
557+ if (result && typeof result.then === 'function') {
558+ return result.then(function(value) {
559+ if (normalized) sendToClipboard(normalized);
560+ return value;
561+ });
562+ }
563+ if (normalized) sendToClipboard(normalized);
564+ return result;
565+ };
566+ try {
567+ Object.defineProperty(clipboardObj, 'writeText', {
568+ configurable: true,
569+ writable: true,
570+ value: wrappedWriteText
571+ });
572+ } catch (_) {
573+ clipboardObj.writeText = wrappedWriteText;
574+ }
575+ }
576+
577+ if (clipboardProto && typeof clipboardProto.write === 'function' && !clipboardProto.__dumberWritePatched) {
578+ clipboardProto.__dumberWritePatched = true;
579+ var originalProtoWrite = clipboardProto.write;
580+ var wrappedProtoWrite = function(items) {
581+ var result = originalProtoWrite.apply(this, arguments);
582+ if (result && typeof result.then === 'function') {
583+ return result.then(function(value) {
584+ mirrorClipboardItems(items);
585+ return value;
586+ });
587+ }
588+ mirrorClipboardItems(items);
589+ return result;
590+ };
591+ try {
592+ Object.defineProperty(clipboardProto, 'write', {
593+ configurable: true,
594+ writable: true,
595+ value: wrappedProtoWrite
596+ });
597+ } catch (_) {
598+ clipboardProto.write = wrappedProtoWrite;
599+ }
600+ } else if (clipboardObj && typeof clipboardObj.write === 'function' && !window.__dumberClipboardWritePatched) {
601+ window.__dumberClipboardWritePatched = true;
602+ var originalWrite = clipboardObj.write.bind(clipboardObj);
603+ var wrappedWrite = function(items) {
604+ var result = originalWrite(items);
605+ if (result && typeof result.then === 'function') {
606+ return result.then(function(value) {
607+ mirrorClipboardItems(items);
608+ return value;
609+ });
610+ }
611+ mirrorClipboardItems(items);
612+ return result;
613+ };
614+ try {
615+ Object.defineProperty(clipboardObj, 'write', {
616+ configurable: true,
617+ writable: true,
618+ value: wrappedWrite
619+ });
620+ } catch (_) {
621+ clipboardObj.write = wrappedWrite;
622+ }
623+ }
624+ } catch (_) {}
625+
626+ if (isEditable(document.activeElement)) sendFocusSync();
413 })();`627 })();`
414 628
629+func buildClipboardSelectionFetchBridgeJS(bridgeNonce string) string {
630+ return strings.ReplaceAll(clipboardSelectionFetchBridgeJS, "__DUMBER_BRIDGE_NONCE__", bridgeNonce)
631+}
632+
415 // contentInjector implements port.ContentInjector for the CEF engine.633 // contentInjector implements port.ContentInjector for the CEF engine.
416 // It stores CSS strings and injects them into webviews via ExecuteJavaScript.634 // It stores CSS strings and injects them into webviews via ExecuteJavaScript.
417 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd635 // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd
@@ -532,8 +750,9 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) {
532 ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS)750 ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS)
533 wv.RunJavaScript(context.Background(), scrollbarAutoHideJS)751 wv.RunJavaScript(context.Background(), scrollbarAutoHideJS)
534 752
535- // All pages get clipboard copy bridge (CEF OSR can't write to Wayland clipboard).753+ // Clipboard copy/cut and editable focus sync still need a JS bridge in OSR
536- wv.RunJavaScript(context.Background(), clipboardCopyBridgeJS)754+ // mode while the native renderer bridge remains disabled.
755+ wv.RunJavaScript(context.Background(), buildClipboardSelectionFetchBridgeJS(wv.rotateBridgeNonce()))
537 756
538 // Video playback diagnostic — logs video element state changes.757 // Video playback diagnostic — logs video element state changes.
539 // Gated behind DUMBER_VIDEO_DIAG=1 environment variable.758 // Gated behind DUMBER_VIDEO_DIAG=1 environment variable.
A internal/infrastructure/cef/content_injector_test.go +61 -0
@@ -0,0 +1,61 @@
1+package cef
2+
3+import (
4+ "testing"
5+
6+ "github.com/stretchr/testify/require"
7+)
8+
9+func testClipboardSelectionFetchBridgeJS() string {
10+ return buildClipboardSelectionFetchBridgeJS("test-bridge-nonce")
11+}
12+
13+func TestClipboardSelectionFetchBridgeJS_CapturesInputAndTextareaSelections(t *testing.T) {
14+ script := testClipboardSelectionFetchBridgeJS()
15+
16+ require.Contains(t, script, "document.activeElement")
17+ require.Contains(t, script, "selectionStart")
18+ require.Contains(t, script, "selectionEnd")
19+ require.Contains(t, script, "window.getSelection()")
20+ require.Contains(t, script, "type !== 'password'")
21+}
22+
23+func TestClipboardSelectionFetchBridgeJS_PostsTrustedFocusSyncRequests(t *testing.T) {
24+ script := testClipboardSelectionFetchBridgeJS()
25+
26+ require.Contains(t, script, "dumb:///api/focus-sync")
27+ require.Contains(t, script, "X-Dumber-Bridge-Action")
28+ require.Contains(t, script, "X-Dumber-Bridge-Nonce")
29+ require.Contains(t, script, "focus-sync")
30+ require.Contains(t, script, "focusin")
31+ require.Contains(t, script, "event.isTrusted === false")
32+ require.Contains(t, script, "event && event.target")
33+ require.Contains(t, script, "document.activeElement")
34+ require.Contains(t, script, "isEditable(document.activeElement)")
35+ require.Contains(t, script, "sendFocusSync()")
36+}
37+
38+func TestClipboardSelectionFetchBridgeJS_HandlesTrustedClipboardEvents(t *testing.T) {
39+ script := testClipboardSelectionFetchBridgeJS()
40+
41+ require.Contains(t, script, "dumb:///api/clipboard-set")
42+ require.Contains(t, script, "X-Dumber-Body")
43+ require.Contains(t, script, "X-Dumber-Bridge-Nonce")
44+ require.Contains(t, script, "document.addEventListener('copy', function(event)")
45+ require.Contains(t, script, "document.addEventListener('cut', function(event)")
46+ require.Contains(t, script, "event.isTrusted === false")
47+}
48+
49+func TestClipboardSelectionFetchBridgeJS_PatchesAsyncClipboardAPIs(t *testing.T) {
50+ script := testClipboardSelectionFetchBridgeJS()
51+
52+ require.Contains(t, script, "navigator && navigator.clipboard")
53+ require.Contains(t, script, "clipboardProto.writeText")
54+ require.Contains(t, script, "clipboardObj.writeText")
55+ require.Contains(t, script, "clipboardProto.write")
56+ require.Contains(t, script, "clipboardObj.write")
57+ require.Contains(t, script, "sendToClipboard(normalized)")
58+ require.Contains(t, script, "mirrorClipboardItems(items)")
59+ require.NotContains(t, script, "__DUMBER_BRIDGE_NONCE__")
60+ require.Contains(t, script, "test-bridge-nonce")
61+}
M internal/infrastructure/cef/context_menu.go +35 -3
@@ -82,7 +82,11 @@ func (h *handlerSet) RunContextMenu(
82 x,82 x,
83 y,83 y,
84 func(item port.MenuItem) {84 func(item port.MenuItem) {
85- dispatchContextMenuSelection(h.wv.ctx, executor, callback, commandIDByAction, item, menuContext)85+ var copiedCallback func(string)
86+ if h.wv.engine != nil {
87+ copiedCallback = h.wv.engine.notifyClipboardCopied
88+ }
89+ dispatchContextMenuSelection(h.wv.ctx, executor, callback, copiedCallback, commandIDByAction, item, menuContext)
86 },90 },
87 func() {91 func() {
88 callback.Cancel()92 callback.Cancel()
@@ -162,6 +166,7 @@ func dispatchContextMenuSelection(
162 ctx context.Context,166 ctx context.Context,
163 executor port.ContextMenuActionExecutor,167 executor port.ContextMenuActionExecutor,
164 callback purecef.RunContextMenuCallback,168 callback purecef.RunContextMenuCallback,
169+ copiedCallback func(text string),
165 commandIDByAction map[port.MenuAction]int32,170 commandIDByAction map[port.MenuAction]int32,
166 item port.MenuItem,171 item port.MenuItem,
167 menuContext port.MenuContext,172 menuContext port.MenuContext,
@@ -171,14 +176,29 @@ func dispatchContextMenuSelection(
171 return176 return
172 }177 }
173 log.Debug().Str("action", string(item.Action)).Str("label", item.Label).Msg("cef: context menu item selected")178 log.Debug().Str("action", string(item.Action)).Str("label", item.Label).Msg("cef: context menu item selected")
179+ if item.Action == port.MenuActionCopySelection && menuContext.SelectionText == "" {
180+ if cmdID, ok := commandIDByAction[item.Action]; ok {
181+ log.Debug().Str("action", string(item.Action)).Int32("command_id", cmdID).Msg("cef: continuing native context menu command")
182+ callback.Cont(cmdID, 0)
183+ return
184+ }
185+ log.Warn().Str("action", string(item.Action)).Msg("cef: no matching native context menu command")
186+ callback.Cancel()
187+ return
188+ }
174 if shouldExecuteDirectCEFAction(item.Action) && executor != nil {189 if shouldExecuteDirectCEFAction(item.Action) && executor != nil {
175 if err := executor.ExecuteMenuAction(ctx, item.Action, menuContext); err != nil {190 if err := executor.ExecuteMenuAction(ctx, item.Action, menuContext); err != nil {
176 log.Warn().Err(err).Str("action", string(item.Action)).Msg("cef: context menu action failed")191 log.Warn().Err(err).Str("action", string(item.Action)).Msg("cef: context menu action failed")
177 callback.Cancel()192 callback.Cancel()
178 return193 return
179 }194 }
195+ if copiedCallback != nil {
196+ if copiedText := contextMenuCopiedText(item.Action, menuContext); copiedText != "" {
197+ copiedCallback(copiedText)
198+ }
199+ }
180 log.Debug().Str("action", string(item.Action)).Msg("cef: context menu action executed directly")200 log.Debug().Str("action", string(item.Action)).Msg("cef: context menu action executed directly")
181- callback.Cont(0, 0)201+ callback.Cancel()
182 return202 return
183 }203 }
184 if cmdID, ok := commandIDByAction[item.Action]; ok {204 if cmdID, ok := commandIDByAction[item.Action]; ok {
@@ -190,6 +210,17 @@ func dispatchContextMenuSelection(
190 callback.Cancel()210 callback.Cancel()
191 }211 }
192 212
213+func contextMenuCopiedText(action port.MenuAction, menuContext port.MenuContext) string {
214+ switch action {
215+ case port.MenuActionCopySelection:
216+ return menuContext.SelectionText
217+ case port.MenuActionCopyLink:
218+ return menuContext.LinkURI
219+ default:
220+ return ""
221+ }
222+}
223+
193 func shouldExecuteDirectCEFAction(action port.MenuAction) bool {224 func shouldExecuteDirectCEFAction(action port.MenuAction) bool {
194 switch action {225 switch action {
195 case port.MenuActionBack,226 case port.MenuActionBack,
@@ -296,7 +327,8 @@ func buildMenuContext(wv *WebView, params purecef.ContextMenuParams) port.MenuCo
296 if params.HasImageContents() {327 if params.HasImageContents() {
297 menuContext.ImageURI = params.GetSourceURL()328 menuContext.ImageURI = params.GetSourceURL()
298 }329 }
299- menuContext.HasSelection = strings.TrimSpace(params.GetSelectionText()) != ""330+ menuContext.SelectionText = params.GetSelectionText()
331+ menuContext.HasSelection = strings.TrimSpace(menuContext.SelectionText) != ""
300 menuContext.IsEditable = params.IsEditable()332 menuContext.IsEditable = params.IsEditable()
301 menuContext.X = int(params.GetXcoord())333 menuContext.X = int(params.GetXcoord())
302 menuContext.Y = int(params.GetYcoord())334 menuContext.Y = int(params.GetYcoord())
M internal/infrastructure/cef/context_menu_adapter_test.go +53 -5
@@ -64,6 +64,7 @@ func TestBuildMenuContextFromCEFParams(t *testing.T) {
64 require.Equal(t, "https://example.com/page", ctx.PageURI)64 require.Equal(t, "https://example.com/page", ctx.PageURI)
65 require.Equal(t, "https://example.com/link", ctx.LinkURI)65 require.Equal(t, "https://example.com/link", ctx.LinkURI)
66 require.Equal(t, "https://example.com/image.png", ctx.ImageURI)66 require.Equal(t, "https://example.com/image.png", ctx.ImageURI)
67+ require.Equal(t, "selected text", ctx.SelectionText)
67 require.True(t, ctx.HasSelection)68 require.True(t, ctx.HasSelection)
68 require.True(t, ctx.IsEditable)69 require.True(t, ctx.IsEditable)
69 require.True(t, ctx.CanGoBack)70 require.True(t, ctx.CanGoBack)
@@ -104,7 +105,7 @@ func (c *stubRunContextMenuCallback) Cancel() {
104 func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) {105 func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) {
105 callback := &stubRunContextMenuCallback{}106 callback := &stubRunContextMenuCallback{}
106 107
107- dispatchContextMenuSelection(context.Background(), nil, callback, map[port.MenuAction]int32{108+ dispatchContextMenuSelection(context.Background(), nil, callback, nil, map[port.MenuAction]int32{
108 port.MenuActionReload: 102,109 port.MenuActionReload: 102,
109 }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{})110 }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{})
110 111
@@ -115,7 +116,7 @@ func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) {
115 func TestContextMenuSelectionContinuesWhenCEFCommandPresent(t *testing.T) {116 func TestContextMenuSelectionContinuesWhenCEFCommandPresent(t *testing.T) {
116 callback := &stubRunContextMenuCallback{}117 callback := &stubRunContextMenuCallback{}
117 118
118- dispatchContextMenuSelection(context.Background(), nil, callback, map[port.MenuAction]int32{119+ dispatchContextMenuSelection(context.Background(), nil, callback, nil, map[port.MenuAction]int32{
119 port.MenuActionInspectElement: 204,120 port.MenuActionInspectElement: 204,
120 }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{})121 }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{})
121 122
@@ -133,6 +134,7 @@ func TestContextMenuSelectionExecutesDirectActionWhenExecutorAvailable(t *testin
133 context.Background(),134 context.Background(),
134 executor,135 executor,
135 callback,136 callback,
137+ nil,
136 map[port.MenuAction]int32{port.MenuActionReload: 102},138 map[port.MenuAction]int32{port.MenuActionReload: 102},
137 port.MenuItem{Action: port.MenuActionReload, Label: "Reload"},139 port.MenuItem{Action: port.MenuActionReload, Label: "Reload"},
138 menuContext,140 menuContext,
@@ -141,9 +143,9 @@ func TestContextMenuSelectionExecutesDirectActionWhenExecutorAvailable(t *testin
141 require.Equal(t, 1, executor.executeCalls)143 require.Equal(t, 1, executor.executeCalls)
142 require.Equal(t, port.MenuActionReload, executor.action)144 require.Equal(t, port.MenuActionReload, executor.action)
143 require.Equal(t, menuContext, executor.menuContext)145 require.Equal(t, menuContext, executor.menuContext)
144- require.Equal(t, 1, callback.contCalls)146+ require.Zero(t, callback.contCalls)
145 require.Zero(t, callback.commandID)147 require.Zero(t, callback.commandID)
146- require.Zero(t, callback.cancelCalls)148+ require.Equal(t, 1, callback.cancelCalls)
147 }149 }
148 150
149 func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *testing.T) {151 func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *testing.T) {
@@ -155,6 +157,7 @@ func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *t
155 context.Background(),157 context.Background(),
156 executor,158 executor,
157 callback,159 callback,
160+ nil,
158 map[port.MenuAction]int32{},161 map[port.MenuAction]int32{},
159 port.MenuItem{Action: port.MenuActionCopyImage, Label: "Copy Image"},162 port.MenuItem{Action: port.MenuActionCopyImage, Label: "Copy Image"},
160 menuContext,163 menuContext,
@@ -163,9 +166,54 @@ func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *t
163 require.Equal(t, 1, executor.executeCalls)166 require.Equal(t, 1, executor.executeCalls)
164 require.Equal(t, port.MenuActionCopyImage, executor.action)167 require.Equal(t, port.MenuActionCopyImage, executor.action)
165 require.Equal(t, menuContext, executor.menuContext)168 require.Equal(t, menuContext, executor.menuContext)
166- require.Equal(t, 1, callback.contCalls)169+ require.Zero(t, callback.contCalls)
170+ require.Zero(t, callback.commandID)
171+ require.Equal(t, 1, callback.cancelCalls)
172+}
173+
174+func TestContextMenuSelectionExecutesCopySelectionDirectlyWhenSelectionTextAvailable(t *testing.T) {
175+ callback := &stubRunContextMenuCallback{}
176+ executor := &stubContextMenuExecutor{}
177+ var copiedLens []int
178+ menuContext := port.MenuContext{SelectionText: "selected text", HasSelection: true}
179+
180+ dispatchContextMenuSelection(
181+ context.Background(),
182+ executor,
183+ callback,
184+ func(text string) { copiedLens = append(copiedLens, len(text)) },
185+ map[port.MenuAction]int32{port.MenuActionCopySelection: 333},
186+ port.MenuItem{Action: port.MenuActionCopySelection, Label: "Copy"},
187+ menuContext,
188+ )
189+
190+ require.Equal(t, 1, executor.executeCalls)
191+ require.Equal(t, port.MenuActionCopySelection, executor.action)
192+ require.Equal(t, menuContext, executor.menuContext)
193+ require.Zero(t, callback.contCalls)
167 require.Zero(t, callback.commandID)194 require.Zero(t, callback.commandID)
195+ require.Equal(t, 1, callback.cancelCalls)
196+ require.Equal(t, []int{len("selected text")}, copiedLens)
197+}
198+
199+func TestContextMenuSelectionFallsBackToNativeCopySelectionWhenTextUnavailable(t *testing.T) {
200+ callback := &stubRunContextMenuCallback{}
201+ executor := &stubContextMenuExecutor{}
202+
203+ dispatchContextMenuSelection(
204+ context.Background(),
205+ executor,
206+ callback,
207+ nil,
208+ map[port.MenuAction]int32{port.MenuActionCopySelection: 333},
209+ port.MenuItem{Action: port.MenuActionCopySelection, Label: "Copy"},
210+ port.MenuContext{HasSelection: true},
211+ )
212+
213+ require.Equal(t, 1, callback.contCalls)
168 require.Zero(t, callback.cancelCalls)214 require.Zero(t, callback.cancelCalls)
215+ require.Equal(t, int32(333), callback.commandID)
216+ require.Zero(t, executor.executeCalls)
169 }217 }
170 218
171 func TestContextMenuAnchorPositionScalesCEFCoordinates(t *testing.T) {219 func TestContextMenuAnchorPositionScalesCEFCoordinates(t *testing.T) {
M internal/infrastructure/cef/engine.go +89 -1
@@ -2,12 +2,15 @@ package cef
2 2
3 import (3 import (
4 "context"4 "context"
5+ "crypto/subtle"
5 "sync"6 "sync"
6 "sync/atomic"7 "sync/atomic"
7 "time"8 "time"
9+ "unicode/utf8"
8 10
9 purecef "github.com/bnema/purego-cef/cef"11 purecef "github.com/bnema/purego-cef/cef"
10 12
13+ "github.com/bnema/dumber/internal/application/dto"
11 "github.com/bnema/dumber/internal/application/port"14 "github.com/bnema/dumber/internal/application/port"
12 "github.com/bnema/dumber/internal/logging"15 "github.com/bnema/dumber/internal/logging"
13 )16 )
@@ -35,6 +38,8 @@ type Engine struct {
35 ctxMenuBuilder port.ContextMenuBuilder38 ctxMenuBuilder port.ContextMenuBuilder
36 ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory39 ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory
37 clipboard port.Clipboard40 clipboard port.Clipboard
41+ clipboardTextOrchestrator port.ClipboardTextOrchestrator
42+ onClipboardCopied func(textLen int)
38 resolver port.ImageDataResolver43 resolver port.ImageDataResolver
39 mediaClassifier MediaClassifier44 mediaClassifier MediaClassifier
40 alreadyRunningAppRelaunchMu sync.RWMutex45 alreadyRunningAppRelaunchMu sync.RWMutex
@@ -236,6 +241,8 @@ func (e *Engine) closeActiveWebViews() {
236 // each handler is registered by message type with the router, which dispatches241 // each handler is registered by message type with the router, which dispatches
237 // incoming /api/message POSTs to the correct handler based on Message.Type.242 // incoming /api/message POSTs to the correct handler based on Message.Type.
238 func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependencies) error {243 func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependencies) error {
244+ e.clipboardTextOrchestrator = deps.ClipboardTextOrchestrator
245+ e.onClipboardCopied = deps.OnClipboardCopied
239 if e.messageRouter == nil || e.registerHandlers == nil {246 if e.messageRouter == nil || e.registerHandlers == nil {
240 return nil247 return nil
241 }248 }
@@ -250,7 +257,12 @@ func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.Accent
250 }257 }
251 258
252 // ConfigureDownloads sets up download handling (Phase 1 stub).259 // ConfigureDownloads sets up download handling (Phase 1 stub).
253-func (e *Engine) ConfigureDownloads(_ context.Context, _ string, _ port.DownloadEventHandler, _ port.DownloadPreparer) error {260+func (e *Engine) ConfigureDownloads(
261+ _ context.Context,
262+ _ string,
263+ _ port.DownloadEventHandler,
264+ _ port.DownloadPreparer,
265+) error {
254 return ErrDownloadsUnsupported266 return ErrDownloadsUnsupported
255 }267 }
256 268
@@ -276,6 +288,53 @@ func (e *Engine) UpdateSettings(_ context.Context, _ port.EngineSettingsUpdate)
276 return nil288 return nil
277 }289 }
278 290
291+func (e *Engine) handleExplicitClipboardBridgeText(viewID port.WebViewID, action, text string) {
292+ if e == nil || text == "" {
293+ return
294+ }
295+ if e.clipboardTextOrchestrator == nil {
296+ logging.FromContext(e.currentContext()).Debug().
297+ Str("action", action).
298+ Msg("cef: clipboard orchestration skipped — orchestrator nil")
299+ return
300+ }
301+ if err := e.clipboardTextOrchestrator.HandleExplicitCopy(e.currentContext(), dto.ExplicitClipboardInput{
302+ Text: text,
303+ Action: action,
304+ SourceEngine: dto.ClipboardSourceCEF,
305+ ViewID: uint64(viewID),
306+ }); err != nil {
307+ logging.FromContext(e.currentContext()).Debug().
308+ Err(err).
309+ Str("action", action).
310+ Int("text_len", utf8.RuneCountInString(text)).
311+ Msg("cef: clipboard explicit copy handling failed")
312+ }
313+}
314+
315+func (e *Engine) handleClipboardSelectionUpdate(viewID port.WebViewID, text string) {
316+ if e == nil || e.clipboardTextOrchestrator == nil {
317+ return
318+ }
319+ if err := e.clipboardTextOrchestrator.HandleSelectionUpdate(e.currentContext(), dto.SelectionClipboardInput{
320+ Text: text,
321+ SourceEngine: dto.ClipboardSourceCEF,
322+ ViewID: uint64(viewID),
323+ }); err != nil {
324+ logging.FromContext(e.currentContext()).Debug().
325+ Err(err).
326+ Int("text_len", utf8.RuneCountInString(text)).
327+ Msg("cef: clipboard selection handling failed")
328+ }
329+}
330+
331+func (e *Engine) notifyClipboardCopied(text string) {
332+ if e == nil || e.onClipboardCopied == nil || text == "" {
333+ return
334+ }
335+ e.onClipboardCopied(utf8.RuneCountInString(text))
336+}
337+
279 // SetHandlerContext sets the base context for message handler dispatch.338 // SetHandlerContext sets the base context for message handler dispatch.
280 func (e *Engine) SetHandlerContext(ctx context.Context) {339 func (e *Engine) SetHandlerContext(ctx context.Context) {
281 e.ctx = ctx340 e.ctx = ctx
@@ -359,6 +418,35 @@ func (e *Engine) currentContext() context.Context {
359 return e.ctx418 return e.ctx
360 }419 }
361 420
421+func (e *Engine) validateBridgeRequest(browser purecef.Browser, bridgeNonce string) bool {
422+ if e == nil || browser == nil || bridgeNonce == "" {
423+ return false
424+ }
425+
426+ browserID := browser.GetIdentifier()
427+ valid := false
428+ e.activeWebViews.Range(func(_, value any) bool {
429+ wv, ok := value.(*WebView)
430+ if !ok || wv == nil {
431+ return true
432+ }
433+
434+ wv.mu.RLock()
435+ wvBrowser := wv.browser
436+ wvBridgeNonce := wv.bridgeNonce
437+ wv.mu.RUnlock()
438+ if wvBrowser == nil || wvBridgeNonce == "" || wvBrowser.GetIdentifier() != browserID {
439+ return true
440+ }
441+ if subtle.ConstantTimeCompare([]byte(wvBridgeNonce), []byte(bridgeNonce)) == 1 {
442+ valid = true
443+ return false
444+ }
445+ return true
446+ })
447+ return valid
448+}
449+
362 func (e *Engine) logTranscoderStartupState() {450 func (e *Engine) logTranscoderStartupState() {
363 if e == nil || !e.transcoderStateLogged.CompareAndSwap(false, true) {451 if e == nil || !e.transcoderStateLogged.CompareAndSwap(false, true) {
364 return452 return
M internal/infrastructure/cef/engine_init.go +35 -58
@@ -17,6 +17,7 @@ import (
17 "github.com/bnema/dumber/assets"17 "github.com/bnema/dumber/assets"
18 "github.com/bnema/dumber/internal/application/port"18 "github.com/bnema/dumber/internal/application/port"
19 "github.com/bnema/dumber/internal/infrastructure/config"19 "github.com/bnema/dumber/internal/infrastructure/config"
20+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
20 transcoderpkg "github.com/bnema/dumber/internal/infrastructure/transcoder"21 transcoderpkg "github.com/bnema/dumber/internal/infrastructure/transcoder"
21 "github.com/bnema/dumber/internal/logging"22 "github.com/bnema/dumber/internal/logging"
22 )23 )
@@ -28,6 +29,7 @@ const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH"
28 func NewEngine(29 func NewEngine(
29 ctx context.Context,30 ctx context.Context,
30 opts port.EngineOptions,31 opts port.EngineOptions,
32+ profile runtimeprofile.Profile,
31 cfg RuntimeConfig,33 cfg RuntimeConfig,
32 transcodingCfg TranscodingRuntimeConfig,34 transcodingCfg TranscodingRuntimeConfig,
33 audioFactory port.AudioOutputFactory,35 audioFactory port.AudioOutputFactory,
@@ -35,11 +37,11 @@ func NewEngine(
35 ) (*Engine, error) {37 ) (*Engine, error) {
36 logger := logging.FromContext(ctx)38 logger := logging.FromContext(ctx)
37 deps.MediaClassifier = deps.MediaClassifier.normalize()39 deps.MediaClassifier = deps.MediaClassifier.normalize()
38- stateRoot := resolvedStateRoot(opts)40+ stateRoot := resolvedStateRoot(profile, opts)
39 cleanStaleSingletonLocks(logger, stateRoot)41 cleanStaleSingletonLocks(logger, stateRoot)
40 windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate()42 windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate()
41 43
42- settings, err := prepareCEFSettings(opts, cfg, logger)44+ settings, err := prepareCEFSettings(opts, profile, cfg, logger)
43 if err != nil {45 if err != nil {
44 return nil, err46 return nil, err
45 }47 }
@@ -89,23 +91,26 @@ func NewEngine(
89 )91 )
90 }92 }
91 93
92-func resolvedStateRoot(opts port.EngineOptions) string {94+func resolvedStateRoot(profile runtimeprofile.Profile, opts port.EngineOptions) string {
93 if root := os.Getenv(CEFRootCachePathEnvVar); root != "" {95 if root := os.Getenv(CEFRootCachePathEnvVar); root != "" {
94 return root96 return root
95 }97 }
96-98+ if opts.CacheDir != "" {
97- switch {
98- case opts.DataDir != "":
99- return opts.DataDir
100- case opts.CacheDir != "":
101 return opts.CacheDir99 return opts.CacheDir
102- default:
103- return defaultCEFUserDataDir()
104 }100 }
101+ if opts.DataDir != "" {
102+ return opts.DataDir
103+ }
104+ return profile.CEFUserDataDir()
105 }105 }
106 106
107 // prepareCEFSettings builds purecef.Settings from the engine config.107 // prepareCEFSettings builds purecef.Settings from the engine config.
108-func prepareCEFSettings(opts port.EngineOptions, cfg RuntimeConfig, logger *zerolog.Logger) (purecef.Settings, error) {108+func prepareCEFSettings(
109+ opts port.EngineOptions,
110+ profile runtimeprofile.Profile,
111+ cfg RuntimeConfig,
112+ logger *zerolog.Logger,
113+) (purecef.Settings, error) {
109 switch opts.CookiePolicy {114 switch opts.CookiePolicy {
110 case "", port.CookiePolicyAlways:115 case "", port.CookiePolicyAlways:
111 case port.CookiePolicyNoThirdParty:116 case port.CookiePolicyNoThirdParty:
@@ -121,16 +126,16 @@ func prepareCEFSettings(opts port.EngineOptions, cfg RuntimeConfig, logger *zero
121 settings := purecef.DefaultSettings()126 settings := purecef.DefaultSettings()
122 settings.MultiThreadedMessageLoop = true127 settings.MultiThreadedMessageLoop = true
123 settings.ExternalMessagePump = false128 settings.ExternalMessagePump = false
124- settings.RootCachePath = resolvedStateRoot(opts)129+ settings.RootCachePath = resolvedStateRoot(profile, opts)
125 if cfg.CEFDir != "" {130 if cfg.CEFDir != "" {
126 settings.CEFDir = cfg.CEFDir131 settings.CEFDir = cfg.CEFDir
127 }132 }
128- if runtimeLogFile, err := prepareCEFLogFile(cfg.LogFile); err != nil {133+ if runtimeLogFile, err := prepareCEFLogFile(profile, cfg.LogFile); err != nil {
129 logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file")134 logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file")
130 } else {135 } else {
131 settings.LogFile = runtimeLogFile136 settings.LogFile = runtimeLogFile
132 }137 }
133- if bootstrapLogFile, err := prepareCEFInitTraceFile(cfg.LogFile); err != nil {138+ if bootstrapLogFile, err := prepareCEFInitTraceFile(profile, cfg.LogFile); err != nil {
134 logger.Warn().Err(err).Msg("cef: failed to prepare init trace file")139 logger.Warn().Err(err).Msg("cef: failed to prepare init trace file")
135 } else if bootstrapLogFile != "" {140 } else if bootstrapLogFile != "" {
136 settings.InitTraceFile = bootstrapLogFile141 settings.InitTraceFile = bootstrapLogFile
@@ -151,24 +156,6 @@ func prepareCEFSettings(opts port.EngineOptions, cfg RuntimeConfig, logger *zero
151 return settings, nil156 return settings, nil
152 }157 }
153 158
154-func defaultCEFUserDataDir() string {
155- // Keep CEF state under Dumber's XDG data directory like the rest of runtime data.
156- dirs, err := config.GetXDGDirs()
157- if err != nil {
158- dataHome := os.Getenv("XDG_DATA_HOME")
159- if dataHome == "" {
160- dataHome = filepath.Join(os.Getenv("HOME"), ".local", "share")
161- }
162- return filepath.Clean(filepath.Join(dataHome, "dumber", "cef_user_data"))
163- }
164- return filepath.Join(dirs.DataHome, "cef_user_data")
165-}
166-
167-// DefaultCEFUserDataDir returns the default CEF state root.
168-func DefaultCEFUserDataDir() string {
169- return defaultCEFUserDataDir()
170-}
171-
172 // initializeCEF calls cef_initialize with the App to register custom schemes159 // initializeCEF calls cef_initialize with the App to register custom schemes
173 // and browser-process callbacks (OnBeforeCommandLineProcessing, etc.).160 // and browser-process callbacks (OnBeforeCommandLineProcessing, etc.).
174 func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logger) error {161 func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logger) error {
@@ -253,9 +240,9 @@ func wireEngine(
253 }240 }
254 schemeHandler.setAssets(assets.WebUIAssets)241 schemeHandler.setAssets(assets.WebUIAssets)
255 242
256- // Bridge clipboard writes from CEF JS → GDK system clipboard.243+ // Bridge clipboard writes from page JS → GDK system clipboard.
257- // The callback is invoked on the CEF IO thread, so we schedule the244+ // The callback is invoked on the CEF IO thread, so schedule the actual GDK
258- // GDK write on the GTK main loop via glib.IdleAddOnce.245+ // write on the GTK main loop.
259 schemeHandler.onClipboardSet = func(text string) {246 schemeHandler.onClipboardSet = func(text string) {
260 fn := glib.SourceOnceFunc(func(_ uintptr) {247 fn := glib.SourceOnceFunc(func(_ uintptr) {
261 if display := gdk.DisplayGetDefault(); display != nil {248 if display := gdk.DisplayGetDefault(); display != nil {
@@ -272,6 +259,9 @@ func wireEngine(
272 glib.IdleAddOnce(&fn, 0)259 glib.IdleAddOnce(&fn, 0)
273 }260 }
274 261
262+ schemeHandler.onEditableFocus = eng.handleEditableFocusBridge
263+ schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest
264+
275 schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)265 schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler)
276 266
277 result := purecef.RegisterSchemeHandlerFactory("dumb", "", schemeFactory)267 result := purecef.RegisterSchemeHandlerFactory("dumb", "", schemeFactory)
@@ -433,33 +423,24 @@ func findHelperBinary() string {
433 }423 }
434 424
435 const (425 const (
436- dirPerm = 0o755426+ dirPerm = 0o700
437- filePerm = 0o644427+ filePerm = 0o600
438 )428 )
439 429
440-func prepareCEFLogFile(logFile string) (string, error) {430+func prepareCEFLogFile(profile runtimeprofile.Profile, logFile string) (string, error) {
441- if logFile != "" {431+ runtimeLogFile := logFile
442- runtimeLogFile := filepath.Clean(logFile)432+ if runtimeLogFile != "" {
443- if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil {433+ runtimeLogFile = filepath.Clean(runtimeLogFile)
444- return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err)434+ } else {
445- }435+ runtimeLogFile = profile.CEFLogFile()
446- return runtimeLogFile, nil
447- }
448-
449- // Dev-mode default: logs go into .dev/dumber/logs/ relative to CWD.
450- // Production deployments should set cfg.LogFile explicitly.
451- cwd, cwdErr := os.Getwd()
452- if cwdErr != nil {
453- return "", fmt.Errorf("getwd: %w", cwdErr)
454 }436 }
455- runtimeLogFile := filepath.Join(cwd, ".dev", "dumber", "logs", "cef_runtime.log")
456 if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil {437 if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil {
457 return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err)438 return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err)
458 }439 }
459 return runtimeLogFile, nil440 return runtimeLogFile, nil
460 }441 }
461 442
462-func prepareCEFInitTraceFile(logFile string) (string, error) {443+func prepareCEFInitTraceFile(profile runtimeprofile.Profile, logFile string) (string, error) {
463 if !puregoCEFInitTraceEnabled() {444 if !puregoCEFInitTraceEnabled() {
464 return "", nil445 return "", nil
465 }446 }
@@ -468,11 +449,7 @@ func prepareCEFInitTraceFile(logFile string) (string, error) {
468 if runtimeLogFile != "" {449 if runtimeLogFile != "" {
469 runtimeLogFile = filepath.Clean(runtimeLogFile)450 runtimeLogFile = filepath.Clean(runtimeLogFile)
470 } else {451 } else {
471- cwd, cwdErr := os.Getwd()452+ runtimeLogFile = profile.CEFLogFile()
472- if cwdErr != nil {
473- return "", fmt.Errorf("getwd: %w", cwdErr)
474- }
475- runtimeLogFile = filepath.Join(cwd, ".dev", "dumber", "logs", "cef_runtime.log")
476 }453 }
477 bootstrapLogFile := strings.TrimSuffix(runtimeLogFile, filepath.Ext(runtimeLogFile)) + ".bootstrap.log"454 bootstrapLogFile := strings.TrimSuffix(runtimeLogFile, filepath.Ext(runtimeLogFile)) + ".bootstrap.log"
478 if err := os.MkdirAll(filepath.Dir(bootstrapLogFile), dirPerm); err != nil {455 if err := os.MkdirAll(filepath.Dir(bootstrapLogFile), dirPerm); err != nil {
M internal/infrastructure/cef/engine_init_test.go +81 -50
@@ -7,105 +7,101 @@ import (
7 7
8 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
9 "github.com/bnema/dumber/internal/infrastructure/audio/factory"9 "github.com/bnema/dumber/internal/infrastructure/audio/factory"
10- "github.com/bnema/dumber/internal/infrastructure/config"10+ "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
11 "github.com/rs/zerolog"11 "github.com/rs/zerolog"
12 "github.com/stretchr/testify/require"12 "github.com/stretchr/testify/require"
13 )13 )
14 14
15-func TestResolvedStateRoot_PrefersDataDir(t *testing.T) {15+func TestResolvedStateRoot_PrefersCacheDir(t *testing.T) {
16- root := resolvedStateRoot(port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"})16+ profile := testCEFDevProfile(t)
17- require.Equal(t, "/tmp/data", root)17+ root := resolvedStateRoot(profile, port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"})
18-}
19-
20-func TestResolvedStateRoot_FallsBackToCacheDir(t *testing.T) {
21- root := resolvedStateRoot(port.EngineOptions{CacheDir: "/tmp/cache"})
22 require.Equal(t, "/tmp/cache", root)18 require.Equal(t, "/tmp/cache", root)
23 }19 }
24 20
25 func TestResolvedStateRoot_UsesEnvOverride(t *testing.T) {21 func TestResolvedStateRoot_UsesEnvOverride(t *testing.T) {
22+ profile := testCEFDevProfile(t)
26 t.Setenv(CEFRootCachePathEnvVar, "/tmp/override")23 t.Setenv(CEFRootCachePathEnvVar, "/tmp/override")
27 24
28- root := resolvedStateRoot(port.EngineOptions{})25+ root := resolvedStateRoot(profile, port.EngineOptions{})
29 require.Equal(t, "/tmp/override", root)26 require.Equal(t, "/tmp/override", root)
30 27
31- root = resolvedStateRoot(port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"})28+ root = resolvedStateRoot(profile, port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"})
32 require.Equal(t, "/tmp/override", root)29 require.Equal(t, "/tmp/override", root)
33 }30 }
34 31
35-func TestPrepareCEFSettings_UsesResolvedStateRoot(t *testing.T) {32+func TestResolvedStateRoot_UsesProfileDefault(t *testing.T) {
33+ profile := testCEFProdProfile(t)
34+ root := resolvedStateRoot(profile, port.EngineOptions{})
35+ require.Equal(t, profile.CEFUserDataDir(), root)
36+}
37+
38+func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) {
36 logger := zerolog.Nop()39 logger := zerolog.Nop()
37- settings, err := prepareCEFSettings(port.EngineOptions{CacheDir: "/tmp/cache"}, RuntimeConfig{}, &logger)40+ profile := testCEFDevProfile(t)
41+ settings, err := prepareCEFSettings(port.EngineOptions{}, profile, RuntimeConfig{}, &logger)
38 require.NoError(t, err)42 require.NoError(t, err)
39- require.Equal(t, "/tmp/cache", settings.RootCachePath)43+ require.Equal(t, profile.CEFUserDataDir(), settings.RootCachePath)
44+ require.Equal(t, profile.CEFLogFile(), settings.LogFile)
40 }45 }
41 46
42 func TestPrepareCEFSettings_RejectsNonDefaultCookiePolicy(t *testing.T) {47 func TestPrepareCEFSettings_RejectsNonDefaultCookiePolicy(t *testing.T) {
43 logger := zerolog.Nop()48 logger := zerolog.Nop()
44- _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNever}, RuntimeConfig{}, &logger)49+ _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNever}, testCEFDevProfile(t), RuntimeConfig{}, &logger)
45 require.ErrorIs(t, err, ErrCookiePolicyUnsupported)50 require.ErrorIs(t, err, ErrCookiePolicyUnsupported)
46 }51 }
47 52
48 func TestPrepareCEFSettings_AllowsNoThirdPartyCookiePolicy(t *testing.T) {53 func TestPrepareCEFSettings_AllowsNoThirdPartyCookiePolicy(t *testing.T) {
49 logger := zerolog.Nop()54 logger := zerolog.Nop()
50- _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNoThirdParty}, RuntimeConfig{}, &logger)55+ _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNoThirdParty}, testCEFDevProfile(t), RuntimeConfig{}, &logger)
51 require.NoError(t, err)56 require.NoError(t, err)
52 }57 }
53 58
54 func TestPrepareCEFInitTraceFile_DisabledByDefault(t *testing.T) {59 func TestPrepareCEFInitTraceFile_DisabledByDefault(t *testing.T) {
55 t.Setenv(puregoCEFInitTraceEnvVar, "")60 t.Setenv(puregoCEFInitTraceEnvVar, "")
56 61
57- path, err := prepareCEFInitTraceFile("")62+ path, err := prepareCEFInitTraceFile(testCEFDevProfile(t), "")
58 require.NoError(t, err)63 require.NoError(t, err)
59 require.Empty(t, path)64 require.Empty(t, path)
60 }65 }
61 66
62-func TestPrepareCEFInitTraceFile_EnabledViaEnv(t *testing.T) {67+func TestPrepareCEFLogFile_CreatesPrivateDirectory(t *testing.T) {
63- t.Setenv(puregoCEFInitTraceEnvVar, "1")68+ logFile := filepath.Join(t.TempDir(), "private", "cef_runtime.log")
64- cfg := config.CEFEngineConfig{
65- LogFile: filepath.Join(t.TempDir(), "cef_runtime.log"),
66- }
67 69
68- path, err := prepareCEFInitTraceFile(cfg.LogFile)70+ path, err := prepareCEFLogFile(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, logFile, path)
71- require.FileExists(t, path)73+
74+ info, err := os.Stat(filepath.Dir(logFile))
75+ require.NoError(t, err)
76+ require.Equal(t, os.FileMode(dirPerm), info.Mode().Perm())
72 }77 }
73 78
74-func TestPrepareCEFSettings_RootCachePath_UsesXDGDataDir(t *testing.T) {79+func TestPrepareCEFInitTraceFile_EnabledViaExplicitLogFile(t *testing.T) {
75- homeDir := filepath.Join(t.TempDir(), "home")80+ t.Setenv(puregoCEFInitTraceEnvVar, "1")
76- dataHome := filepath.Join(homeDir, ".local", "share")81+ logFile := filepath.Join(t.TempDir(), "custom", "cef_runtime.log")
77- t.Setenv("HOME", homeDir)
78- t.Setenv("ENV", "")
79- t.Setenv("XDG_DATA_HOME", dataHome)
80 82
81- logger := zerolog.Nop()83+ path, err := prepareCEFInitTraceFile(testCEFDevProfile(t), logFile)
82- settings, err := prepareCEFSettings(port.EngineOptions{}, RuntimeConfig{}, &logger)
83 require.NoError(t, err)84 require.NoError(t, err)
85+ require.Equal(t, filepath.Join(filepath.Dir(logFile), "cef_runtime.bootstrap.log"), path)
86+ require.FileExists(t, path)
84 87
85- require.Equal(t, filepath.Join(dataHome, "dumber", "cef_user_data"), settings.RootCachePath)88+ info, err := os.Stat(path)
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)89 require.NoError(t, err)
93- defer func() {90+ require.Equal(t, os.FileMode(filePerm), info.Mode().Perm())
94- _ = os.Chdir(originalDir)
95- }()
96 91
97- err = os.Chdir(tempDir)92+ dirInfo, err := os.Stat(filepath.Dir(path))
98 require.NoError(t, err)93 require.NoError(t, err)
94+ require.Equal(t, os.FileMode(dirPerm), dirInfo.Mode().Perm())
95+}
99 96
100- // Set ENV=dev to trigger dev mode97+func TestPrepareCEFInitTraceFile_UsesResolvedProfileLogDirByDefault(t *testing.T) {
101- t.Setenv("ENV", "dev")98+ t.Setenv(puregoCEFInitTraceEnvVar, "1")
99+ profile := testCEFDevProfile(t)
102 100
103- logger := zerolog.Nop()101+ path, err := prepareCEFInitTraceFile(profile, "")
104- settings, err := prepareCEFSettings(port.EngineOptions{}, RuntimeConfig{}, &logger)
105 require.NoError(t, err)102 require.NoError(t, err)
106-103+ require.Equal(t, filepath.Join(profile.EnginePaths.LogDir, "cef_runtime.bootstrap.log"), path)
107- // In dev mode, should resolve under .dev/dumber/cef_user_data104+ require.FileExists(t, path)
108- require.Equal(t, filepath.Join(tempDir, ".dev", "dumber", "cef_user_data"), settings.RootCachePath)
109 }105 }
110 106
111 // TestCreateAudioOutputFactory_CreatesUsableFactory verifies that the audio107 // TestCreateAudioOutputFactory_CreatesUsableFactory verifies that the audio
@@ -116,3 +112,38 @@ func TestCreateAudioOutputFactory_ReturnsFactory(t *testing.T) {
116 112
117 require.NotNil(t, audioFactory, "Audio factory should not be nil")113 require.NotNil(t, audioFactory, "Audio factory should not be nil")
118 }114 }
115+
116+func testCEFDevProfile(t *testing.T) runtimeprofile.Profile {
117+ t.Helper()
118+ cwd := t.TempDir()
119+ profile, err := runtimeprofile.Resolve(runtimeprofile.ResolveInput{
120+ Env: func(key string) string {
121+ if key == "ENV" {
122+ return "dev"
123+ }
124+ return ""
125+ },
126+ Engine: "cef",
127+ CWD: func() (string, error) { return cwd, nil },
128+ })
129+ require.NoError(t, err)
130+ return profile
131+}
132+
133+func testCEFProdProfile(t *testing.T) runtimeprofile.Profile {
134+ t.Helper()
135+ root := t.TempDir()
136+ profile, err := runtimeprofile.Resolve(runtimeprofile.ResolveInput{
137+ Env: func(string) string { return "" },
138+ Engine: "cef",
139+ CWD: func() (string, error) { return root, nil },
140+ Base: runtimeprofile.BasePaths{
141+ ConfigHome: filepath.Join(root, "config"),
142+ DataHome: filepath.Join(root, "data"),
143+ StateHome: filepath.Join(root, "state"),
144+ CacheHome: filepath.Join(root, "cache"),
145+ },
146+ })
147+ require.NoError(t, err)
148+ return profile
149+}
M internal/infrastructure/cef/engine_runtime_test.go +52 -0
@@ -5,6 +5,7 @@ import (
5 "errors"5 "errors"
6 "testing"6 "testing"
7 7
8+ "github.com/bnema/dumber/internal/application/dto"
8 "github.com/bnema/dumber/internal/application/port"9 "github.com/bnema/dumber/internal/application/port"
9 "github.com/stretchr/testify/require"10 "github.com/stretchr/testify/require"
10 )11 )
@@ -51,3 +52,54 @@ func TestEngineRegisterHandlers_PropagatesRegistrarError(t *testing.T) {
51 err := eng.RegisterHandlers(context.Background(), port.HandlerDependencies{})52 err := eng.RegisterHandlers(context.Background(), port.HandlerDependencies{})
52 require.ErrorIs(t, err, want)53 require.ErrorIs(t, err, want)
53 }54 }
55+
56+func TestEngineRegisterHandlers_StoresClipboardWiring(t *testing.T) {
57+ orchestrator := &recordingClipboardTextOrchestrator{}
58+ onCopied := func(int) {}
59+ eng := &Engine{
60+ messageRouter: NewMessageRouter(context.Background()),
61+ registerHandlers: func(context.Context, port.WebUIHandlerRouter, port.HandlerDependencies) error {
62+ return nil
63+ },
64+ }
65+
66+ require.NoError(t, eng.RegisterHandlers(context.Background(), port.HandlerDependencies{
67+ ClipboardTextOrchestrator: orchestrator,
68+ OnClipboardCopied: onCopied,
69+ }))
70+ require.Same(t, orchestrator, eng.clipboardTextOrchestrator)
71+ require.NotNil(t, eng.onClipboardCopied)
72+}
73+
74+func TestEngineClipboardSelectionUpdate_ForwardsViewID(t *testing.T) {
75+ orchestrator := &recordingClipboardTextOrchestrator{}
76+ eng := &Engine{clipboardTextOrchestrator: orchestrator}
77+
78+ eng.handleClipboardSelectionUpdate(99, "selected")
79+
80+ require.Equal(t, dto.SelectionClipboardInput{Text: "selected", SourceEngine: dto.ClipboardSourceCEF, ViewID: 99}, orchestrator.selection)
81+}
82+
83+func TestEngineExplicitClipboardCopy_ForwardsViewID(t *testing.T) {
84+ orchestrator := &recordingClipboardTextOrchestrator{}
85+ eng := &Engine{clipboardTextOrchestrator: orchestrator}
86+
87+ eng.handleExplicitClipboardBridgeText(77, "copy", "copied")
88+
89+ require.Equal(t, dto.ExplicitClipboardInput{Text: "copied", SourceEngine: dto.ClipboardSourceCEF, ViewID: 77, Action: "copy", NativeHandled: false}, orchestrator.explicit)
90+}
91+
92+type recordingClipboardTextOrchestrator struct {
93+ selection dto.SelectionClipboardInput
94+ explicit dto.ExplicitClipboardInput
95+}
96+
97+func (r *recordingClipboardTextOrchestrator) HandleSelectionUpdate(_ context.Context, input dto.SelectionClipboardInput) error {
98+ r.selection = input
99+ return nil
100+}
101+
102+func (r *recordingClipboardTextOrchestrator) HandleExplicitCopy(_ context.Context, input dto.ExplicitClipboardInput) error {
103+ r.explicit = input
104+ return nil
105+}
M internal/infrastructure/cef/factory.go +17 -1
@@ -103,6 +103,12 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
103 wv.findCtrl = newFindController()103 wv.findCtrl = newFindController()
104 104
105 input := newInputBridge(ctx, f.scale)105 input := newInputBridge(ctx, f.scale)
106+ input.selectionText = wv.selectedTextSnapshot
107+ input.explicitCopyText = func(action, text string) {
108+ if f.engine != nil {
109+ f.engine.handleExplicitClipboardBridgeText(wv.id, action, text)
110+ }
111+ }
106 input.attachTo(pipeline.glArea)112 input.attachTo(pipeline.glArea)
107 wv.input = input113 wv.input = input
108 114
@@ -172,14 +178,24 @@ func (f *WebViewFactory) configureInitialBrowserCreation(
172 log.Debug().Msg("cef: skipping browser creation for destroyed webview")178 log.Debug().Msg("cef: skipping browser creation for destroyed webview")
173 return179 return
174 }180 }
181+ pendingURL := wv.pendingNavigationURI()
182+ // Always bootstrap OSR browsers on about:blank first. Starting directly on
183+ // the target URL can race host visibility/focus setup and strand the
184+ // renderer without an initial paint. We replay pending navigation from
185+ // OnAfterCreated once the host is fully wired.
186+ initialURL := "about:blank"
175 result := purecef.BrowserHostCreateBrowser(187 result := purecef.BrowserHostCreateBrowser(
176 pc.windowInfo,188 pc.windowInfo,
177 pc.client,189 pc.client,
178- "about:blank",190+ initialURL,
179 pc.settings,191 pc.settings,
180 nil, // extraInfo192 nil, // extraInfo
181 nil, // requestContext193 nil, // requestContext
182 )194 )
195+ log.Debug().
196+ Str("initial_url", initialURL).
197+ Str("pending_url", pendingURL).
198+ Msg("cef: BrowserHostCreateBrowser initial URL")
183 if f.engine != nil {199 if f.engine != nil {
184 f.engine.recordBrowserCreateRequest(w, h, result)200 f.engine.recordBrowserCreateRequest(w, h, result)
185 }201 }
M internal/infrastructure/cef/input_bridge.go +86 -4
@@ -3,6 +3,8 @@ package cef
3 import (3 import (
4 "context"4 "context"
5 "sync"5 "sync"
6+ "sync/atomic"
7+ "unicode/utf8"
6 8
7 purecef "github.com/bnema/purego-cef/cef"9 purecef "github.com/bnema/purego-cef/cef"
8 "github.com/bnema/puregotk/v4/gdk"10 "github.com/bnema/puregotk/v4/gdk"
@@ -56,6 +58,14 @@ type inputBridge struct {
56 // ImeCommitText. Set once during attachTo, read-only afterwards.58 // ImeCommitText. Set once during attachTo, read-only afterwards.
57 clipboard *gdk.Clipboard59 clipboard *gdk.Clipboard
58 60
61+ // selectionText returns the browser's latest known text selection so Ctrl+C
62+ // / Ctrl+X can route the selection through the shared explicit-copy path.
63+ selectionText func() string
64+
65+ // explicitCopyText routes explicit copy/cut text through the shared
66+ // clipboard orchestration path.
67+ explicitCopyText func(action, text string)
68+
59 // IMContext for dead key / compose sequence support. Held as a field69 // IMContext for dead key / compose sequence support. Held as a field
60 // to prevent garbage collection while the controller is alive.70 // to prevent garbage collection while the controller is alive.
61 imContext *gtk.IMContextSimple71 imContext *gtk.IMContextSimple
@@ -67,12 +77,32 @@ type inputBridge struct {
67 // show the text caret.77 // show the text caret.
68 glArea *gtk.GLArea78 glArea *gtk.GLArea
69 79
80+ // gtkFocused mirrors the latest GTK focus-controller state so non-GTK
81+ // threads (CEF callbacks, scheme handlers) can safely know whether this
82+ // widget is still the active focused view.
83+ gtkFocused atomic.Bool
84+
70 // onMiddleClick is called when button 2 (middle) is pressed on a link.85 // onMiddleClick is called when button 2 (middle) is pressed on a link.
71 // The callback receives the hovered URI. Set by the factory.86 // The callback receives the hovered URI. Set by the factory.
72 onMiddleClick func(uri string)87 onMiddleClick func(uri string)
73 }88 }
74 89
75 // newInputBridge creates an input bridge with the given HiDPI scale factor.90 // newInputBridge creates an input bridge with the given HiDPI scale factor.
91+type browserFocusSyncHost interface {
92+ WasHidden(hidden int32)
93+ SetFocus(focus int32)
94+ Invalidate(elementType purecef.PaintElementType)
95+}
96+
97+func syncWindowlessBrowserFocus(host browserFocusSyncHost) {
98+ if host == nil {
99+ return
100+ }
101+ host.WasHidden(0)
102+ host.SetFocus(1)
103+ host.Invalidate(purecef.PaintElementTypePetView)
104+}
105+
76 func newInputBridge(ctx context.Context, scale int32) *inputBridge {106 func newInputBridge(ctx context.Context, scale int32) *inputBridge {
77 return &inputBridge{ctx: ctx, scale: scale}107 return &inputBridge{ctx: ctx, scale: scale}
78 }108 }
@@ -163,6 +193,7 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) {
163 // Focus — forward to CEF and IMContext.193 // Focus — forward to CEF and IMContext.
164 focus := gtk.NewEventControllerFocus()194 focus := gtk.NewEventControllerFocus()
165 focusEnterCb := func(_ gtk.EventControllerFocus) {195 focusEnterCb := func(_ gtk.EventControllerFocus) {
196+ ib.gtkFocused.Store(true)
166 if ib.imContext != nil {197 if ib.imContext != nil {
167 ib.imContext.FocusIn()198 ib.imContext.FocusIn()
168 }199 }
@@ -170,6 +201,7 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) {
170 }201 }
171 focus.ConnectEnter(&focusEnterCb)202 focus.ConnectEnter(&focusEnterCb)
172 focusLeaveCb := func(_ gtk.EventControllerFocus) {203 focusLeaveCb := func(_ gtk.EventControllerFocus) {
204+ ib.gtkFocused.Store(false)
173 if ib.imContext != nil {205 if ib.imContext != nil {
174 ib.imContext.Reset()206 ib.imContext.Reset()
175 ib.imContext.FocusOut()207 ib.imContext.FocusOut()
@@ -200,6 +232,8 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) {
200 keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool {232 keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool {
201 mods := uint(state)233 mods := uint(state)
202 234
235+ ib.maybeMirrorClipboardShortcut(keyval, mods)
236+
203 // Intercept Ctrl+V / Ctrl+Shift+V: CEF OSR cannot access the237 // Intercept Ctrl+V / Ctrl+Shift+V: CEF OSR cannot access the
204 // Wayland/X11 clipboard, so we read from GDK and inject via238 // Wayland/X11 clipboard, so we read from GDK and inject via
205 // ImeCommitText instead of letting CEF handle the paste.239 // ImeCommitText instead of letting CEF handle the paste.
@@ -285,6 +319,13 @@ func (ib *inputBridge) onMousePress(x, y float64, button, mods uint, clickCount
285 return319 return
286 }320 }
287 321
322+ // Reassert windowless browser focus before forwarding left-clicks. GTK may
323+ // still consider the GLArea focused while Chromium forgot that state after a
324+ // navigation or DOM transition, which prevents the caret from painting.
325+ if button == 1 {
326+ syncWindowlessBrowserFocus(host)
327+ }
328+
288 evt := buildMouseEvent(x, y, mods, ib.scale)329 evt := buildMouseEvent(x, y, mods, ib.scale)
289 btn := translateMouseButton(button)330 btn := translateMouseButton(button)
290 host.SendMouseClickEvent(&evt, btn, 0, int32(clickCount))331 host.SendMouseClickEvent(&evt, btn, 0, int32(clickCount))
@@ -321,10 +362,7 @@ func (ib *inputBridge) onFocusIn() {
321 ib.mu.Lock()362 ib.mu.Lock()
322 host := ib.host363 host := ib.host
323 ib.mu.Unlock()364 ib.mu.Unlock()
324- if host == nil {365+ syncWindowlessBrowserFocus(host)
325- return
326- }
327- host.SetFocus(1)
328 }366 }
329 367
330 func (ib *inputBridge) onFocusOut() {368 func (ib *inputBridge) onFocusOut() {
@@ -337,6 +375,46 @@ func (ib *inputBridge) onFocusOut() {
337 host.SetFocus(0)375 host.SetFocus(0)
338 }376 }
339 377
378+func (ib *inputBridge) hasGTKFocus() bool {
379+ if ib == nil {
380+ return false
381+ }
382+ return ib.gtkFocused.Load()
383+}
384+
385+func (ib *inputBridge) maybeMirrorClipboardShortcut(keyval, mods uint) {
386+ action, ok := clipboardShortcutAction(keyval, mods)
387+ if !ok || ib == nil || ib.selectionText == nil || ib.explicitCopyText == nil {
388+ return
389+ }
390+ text := ib.selectionText()
391+ if text == "" {
392+ return
393+ }
394+ logging.FromContext(ib.ctx).Debug().
395+ Str("action", action).
396+ Int("text_len", utf8.RuneCountInString(text)).
397+ Msg("cef: clipboard shortcut routed through explicit copy handler")
398+ ib.explicitCopyText(action, text)
399+}
400+
401+func clipboardShortcutAction(keyval, mods uint) (string, bool) {
402+ if mods&uint(gdk.ControlMaskValue) == 0 {
403+ return "", false
404+ }
405+ if mods&uint(gdk.ShiftMaskValue) != 0 || mods&uint(gdk.AltMaskValue) != 0 {
406+ return "", false
407+ }
408+ switch keyval {
409+ case gdkKeyLowercaseC, gdkKeyUppercaseC:
410+ return "copy", true
411+ case gdkKeyLowercaseX, gdkKeyUppercaseX:
412+ return "cut", true
413+ default:
414+ return "", false
415+ }
416+}
417+
340 func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) {418 func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) {
341 ib.mu.Lock()419 ib.mu.Lock()
342 host := ib.host420 host := ib.host
@@ -558,8 +636,12 @@ const (
558 gdkKeyPageDown = 0xff56636 gdkKeyPageDown = 0xff56
559 637
560 // Clipboard shortcut keyvals.638 // Clipboard shortcut keyvals.
639+ gdkKeyLowercaseC = 0x063
640+ gdkKeyUppercaseC = 0x043
561 gdkKeyLowercaseV = 0x076641 gdkKeyLowercaseV = 0x076
562 gdkKeyUppercaseV = 0x056642 gdkKeyUppercaseV = 0x056
643+ gdkKeyLowercaseX = 0x078
644+ gdkKeyUppercaseX = 0x058
563 )645 )
564 646
565 // keyvalToChar converts a GDK keyval to a UTF-16 character for CEF CHAR events.647 // keyvalToChar converts a GDK keyval to a UTF-16 character for CEF CHAR events.
A internal/infrastructure/cef/input_bridge_test.go +85 -0
@@ -0,0 +1,85 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ purecef "github.com/bnema/purego-cef/cef"
8+ "github.com/bnema/puregotk/v4/gdk"
9+ "github.com/stretchr/testify/require"
10+)
11+
12+type focusSyncHostRecorder struct {
13+ calls []string
14+ hiddenValues []int32
15+ focusValues []int32
16+ invalidations []purecef.PaintElementType
17+}
18+
19+func (h *focusSyncHostRecorder) WasHidden(hidden int32) {
20+ h.calls = append(h.calls, "WasHidden")
21+ h.hiddenValues = append(h.hiddenValues, hidden)
22+}
23+
24+func (h *focusSyncHostRecorder) SetFocus(focus int32) {
25+ h.calls = append(h.calls, "SetFocus")
26+ h.focusValues = append(h.focusValues, focus)
27+}
28+
29+func (h *focusSyncHostRecorder) Invalidate(elementType purecef.PaintElementType) {
30+ h.calls = append(h.calls, "Invalidate")
31+ h.invalidations = append(h.invalidations, elementType)
32+}
33+
34+func TestSyncWindowlessBrowserFocus_ReassertsVisibleFocusedAndInvalidates(t *testing.T) {
35+ host := &focusSyncHostRecorder{}
36+
37+ syncWindowlessBrowserFocus(host)
38+
39+ require.Equal(t, []string{"WasHidden", "SetFocus", "Invalidate"}, host.calls)
40+ require.Equal(t, []int32{0}, host.hiddenValues)
41+ require.Equal(t, []int32{1}, host.focusValues)
42+ require.Equal(t, []purecef.PaintElementType{purecef.PaintElementTypePetView}, host.invalidations)
43+}
44+
45+func TestClipboardShortcutAction_RecognizesCopyAndCut(t *testing.T) {
46+ action, ok := clipboardShortcutAction(gdkKeyLowercaseC, uint(gdk.ControlMaskValue))
47+ require.True(t, ok)
48+ require.Equal(t, "copy", action)
49+
50+ action, ok = clipboardShortcutAction(gdkKeyUppercaseX, uint(gdk.ControlMaskValue))
51+ require.True(t, ok)
52+ require.Equal(t, "cut", action)
53+
54+ _, ok = clipboardShortcutAction(gdkKeyLowercaseC, uint(gdk.ControlMaskValue|gdk.ShiftMaskValue))
55+ require.False(t, ok)
56+}
57+
58+func TestInputBridgeMaybeMirrorClipboardShortcut_RoutesSelectionThroughExplicitHandler(t *testing.T) {
59+ ib := newInputBridge(context.Background(), 1)
60+ ib.selectionText = func() string { return "selected text" }
61+ routed := struct {
62+ action string
63+ text string
64+ }{}
65+ ib.explicitCopyText = func(action, text string) {
66+ routed.action = action
67+ routed.text = text
68+ }
69+
70+ ib.maybeMirrorClipboardShortcut(gdkKeyLowercaseC, uint(gdk.ControlMaskValue))
71+
72+ require.Equal(t, "copy", routed.action)
73+ require.Equal(t, "selected text", routed.text)
74+}
75+
76+func TestInputBridgeMaybeMirrorClipboardShortcut_IgnoresEmptySelection(t *testing.T) {
77+ ib := newInputBridge(context.Background(), 1)
78+ ib.selectionText = func() string { return "" }
79+ called := false
80+ ib.explicitCopyText = func(string, string) { called = true }
81+
82+ ib.maybeMirrorClipboardShortcut(gdkKeyLowercaseX, uint(gdk.ControlMaskValue))
83+
84+ require.False(t, called)
85+}
M internal/infrastructure/cef/internal_urls.go +33 -1
@@ -11,6 +11,8 @@ const (
11 actualInternalScheme = "https"11 actualInternalScheme = "https"
12 actualInternalHost = "dumber.invalid"12 actualInternalHost = "dumber.invalid"
13 actualInternalOrigin = actualInternalScheme + "://" + actualInternalHost13 actualInternalOrigin = actualInternalScheme + "://" + actualInternalHost
14+ internalAPIPathPrefix = "/api/"
15+ internalAPIPathPrefixTrimmed = "api/"
14 )16 )
15 17
16 func isConceptualInternalURL(raw string) bool {18 func isConceptualInternalURL(raw string) bool {
@@ -49,7 +51,7 @@ func toActualInternalURL(raw string) string {
49 case trimmedPath == "":51 case trimmedPath == "":
50 // Keep page roots at /home instead of /home/ so relative assets resolve52 // Keep page roots at /home instead of /home/ so relative assets resolve
51 // from the origin root.53 // from the origin root.
52- case strings.HasPrefix(trimmedPath, "api/"):54+ case strings.HasPrefix(trimmedPath, internalAPIPathPrefixTrimmed):
53 actualPath = "/" + trimmedPath55 actualPath = "/" + trimmedPath
54 case path.Ext(trimmedPath) != "":56 case path.Ext(trimmedPath) != "":
55 actualPath = "/" + trimmedPath57 actualPath = "/" + trimmedPath
@@ -67,6 +69,36 @@ func toActualInternalURL(raw string) string {
67 return actual.String()69 return actual.String()
68 }70 }
69 71
72+func resolveAPIPath(u *url.URL) (string, bool) {
73+ if u == nil {
74+ return "", false
75+ }
76+
77+ if strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost) {
78+ if strings.HasPrefix(u.Path, internalAPIPathPrefix) {
79+ return u.Path, true
80+ }
81+ return "", false
82+ }
83+
84+ if !strings.EqualFold(u.Scheme, "dumb") {
85+ return "", false
86+ }
87+
88+ if strings.EqualFold(u.Host, "api") {
89+ if u.Path == "" {
90+ return "/api", true
91+ }
92+ return internalAPIPathPrefix[:len(internalAPIPathPrefix)-1] + u.Path, true
93+ }
94+
95+ if strings.HasPrefix(u.Path, internalAPIPathPrefix) {
96+ return u.Path, true
97+ }
98+
99+ return "", false
100+}
101+
70 func toConceptualInternalURL(raw string) string {102 func toConceptualInternalURL(raw string) string {
71 if raw == "" || !isActualInternalURL(raw) {103 if raw == "" || !isActualInternalURL(raw) {
72 return raw104 return raw
M internal/infrastructure/cef/internal_urls_test.go +64 -1
@@ -1,6 +1,9 @@
1 package cef1 package cef
2 2
3-import "testing"3+import (
4+ "net/url"
5+ "testing"
6+)
4 7
5 func TestToActualInternalURL(t *testing.T) {8 func TestToActualInternalURL(t *testing.T) {
6 t.Parallel()9 t.Parallel()
@@ -47,6 +50,66 @@ func TestToActualInternalURL(t *testing.T) {
47 }50 }
48 }51 }
49 52
53+func TestResolveAPIPath(t *testing.T) {
54+ t.Parallel()
55+
56+ tests := []struct {
57+ name string
58+ in string
59+ want string
60+ ok bool
61+ }{
62+ {
63+ name: "nil url returns false",
64+ in: "",
65+ want: "",
66+ ok: false,
67+ },
68+ {
69+ name: "conceptual api host",
70+ in: "dumb://api/clipboard-set",
71+ want: "/api/clipboard-set",
72+ ok: true,
73+ },
74+ {
75+ name: "conceptual page api path",
76+ in: "dumb://home/api/message",
77+ want: "/api/message",
78+ ok: true,
79+ },
80+ {
81+ name: "actual internal api path",
82+ in: "https://dumber.invalid/api/focus-sync",
83+ want: "/api/focus-sync",
84+ ok: true,
85+ },
86+ {
87+ name: "non api path",
88+ in: "https://dumber.invalid/home",
89+ want: "",
90+ ok: false,
91+ },
92+ }
93+
94+ for _, tt := range tests {
95+ t.Run(tt.name, func(t *testing.T) {
96+ t.Parallel()
97+ var parsed *url.URL
98+ if tt.in != "" {
99+ var err error
100+ parsed, err = url.Parse(tt.in)
101+ if err != nil {
102+ t.Fatalf("url.Parse(%q) error = %v", tt.in, err)
103+ }
104+ }
105+ got, ok := resolveAPIPath(parsed)
106+ if got != tt.want || ok != tt.ok {
107+ t.Fatalf("resolveAPIPath(%q) = (%q, %v), want (%q, %v)", tt.in, got, ok, tt.want, tt.ok)
108+ }
109+ })
110+ }
111+}
112+
50 func TestToConceptualInternalURL(t *testing.T) {113 func TestToConceptualInternalURL(t *testing.T) {
51 t.Parallel()114 t.Parallel()
52 115
M internal/infrastructure/cef/render_pipeline.go +55 -0
@@ -114,6 +114,27 @@ type renderPipeline struct {
114 onResizeCB func(width, height int32)114 onResizeCB func(width, height int32)
115 }115 }
116 116
117+type renderPipelineSnapshot struct {
118+ Width int32
119+ Height int32
120+ Scale int32
121+ GLReady bool
122+ NeedsUpload bool
123+ SizeChanged bool
124+ ForceFullUpload bool
125+ ViewRectSeq uint64
126+ ScreenInfoSeq uint64
127+ PaintSeq uint64
128+ ResizeSeq uint64
129+ LastQueuedPaintSeq uint64
130+ GLRenderSeq uint64
131+ PaintCount uint64
132+ QueueRenderCount uint64
133+ RenderCount uint64
134+ UploadCount uint64
135+ FullUploadCount uint64
136+}
137+
117 // Shader sources for the fullscreen textured quad.138 // Shader sources for the fullscreen textured quad.
118 const vertexShaderSource = "" +139 const vertexShaderSource = "" +
119 "#version 330 core\n" +140 "#version 330 core\n" +
@@ -806,6 +827,40 @@ func (rp *renderPipeline) maybeLogDiagnostics() {
806 }827 }
807 828
808 // buildShaderProgram compiles and links the vertex+fragment shaders.829 // buildShaderProgram compiles and links the vertex+fragment shaders.
830+func (rp *renderPipeline) diagnosticSnapshot() renderPipelineSnapshot {
831+ if rp == nil {
832+ return renderPipelineSnapshot{}
833+ }
834+
835+ rp.diagMu.Lock()
836+ snap := renderPipelineSnapshot{
837+ Width: rp.widthAtomic.Load(),
838+ Height: rp.heightAtomic.Load(),
839+ ViewRectSeq: rp.viewRectSeq.Load(),
840+ ScreenInfoSeq: rp.screenInfoSeq.Load(),
841+ PaintSeq: rp.paintSeq.Load(),
842+ ResizeSeq: rp.resizeSeq.Load(),
843+ LastQueuedPaintSeq: rp.lastQueuedPaintSeq.Load(),
844+ GLRenderSeq: rp.glRenderSeq.Load(),
845+ PaintCount: rp.paintCount,
846+ QueueRenderCount: rp.queueRenderCount,
847+ RenderCount: rp.glRenderCount,
848+ UploadCount: rp.uploadCount,
849+ FullUploadCount: rp.fullUploadCount,
850+ }
851+ rp.diagMu.Unlock()
852+
853+ rp.mu.Lock()
854+ snap.Scale = rp.scale
855+ snap.GLReady = rp.glReady
856+ snap.NeedsUpload = rp.needsUpload
857+ snap.SizeChanged = rp.sizeChanged
858+ snap.ForceFullUpload = rp.forceFullUpload
859+ rp.mu.Unlock()
860+
861+ return snap
862+}
863+
809 func (rp *renderPipeline) buildShaderProgram() uint32 {864 func (rp *renderPipeline) buildShaderProgram() uint32 {
810 gl := rp.gl865 gl := rp.gl
811 866
A internal/infrastructure/cef/renderer_bridge.go +376 -0
@@ -0,0 +1,376 @@
1+package cef
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "fmt"
7+ "sync"
8+ "unsafe"
9+
10+ purecef "github.com/bnema/purego-cef/cef"
11+
12+ "github.com/bnema/dumber/internal/logging"
13+)
14+
15+const (
16+ rendererBridgeMessageName = "dumber.renderer_bridge"
17+ rendererBridgeActionExplicitTextCopy = "explicit_text_copy"
18+ rendererBridgeActionFocusSync = "focus_sync"
19+ rendererBridgeActionEditableFocusChanged = "editable_focus_changed"
20+ rendererBridgeActionReady = "bridge_ready"
21+ rendererBridgeExtensionName = "dumber.renderer_bridge"
22+)
23+
24+var newRendererBridgeProcessMessage = purecef.ProcessMessageCreate
25+
26+const rendererBridgeExtensionJS = `
27+(function() {
28+ native function Dispatch(action, payload);
29+
30+ function send(action, payload) {
31+ return Dispatch(action, payload == null ? '' : String(payload));
32+ }
33+
34+ function isEditable(node) {
35+ if (!node || node.nodeType !== 1) return false;
36+ if (node.isContentEditable) return true;
37+ var tag = node.tagName;
38+ if (tag !== 'INPUT' && tag !== 'TEXTAREA') return false;
39+ if (node.disabled || node.readOnly) return false;
40+ return true;
41+ }
42+
43+ function getActiveElementSelection() {
44+ var el = document.activeElement;
45+ if (!el) return '';
46+ var tag = el.tagName;
47+ if ((tag === 'INPUT' || tag === 'TEXTAREA') && typeof el.value === 'string') {
48+ var start = typeof el.selectionStart === 'number' ? el.selectionStart : 0;
49+ var end = typeof el.selectionEnd === 'number' ? el.selectionEnd : 0;
50+ if (end > start) return el.value.slice(start, end);
51+ }
52+ return '';
53+ }
54+
55+ function getSelectedText() {
56+ var activeSelection = getActiveElementSelection();
57+ if (activeSelection) return activeSelection;
58+ var sel = window.getSelection();
59+ return sel ? sel.toString() : '';
60+ }
61+
62+ function notifyExplicitTextCopy(action, text) {
63+ var normalizedText = text == null ? '' : String(text);
64+ var normalizedAction = action == null || action === '' ? 'copy' : String(action);
65+ return send('explicit_text_copy', JSON.stringify({ text: normalizedText, action: normalizedAction }));
66+ }
67+
68+ if (typeof document !== 'undefined' && document.addEventListener) {
69+ document.addEventListener('focusin', function(event) {
70+ if (isEditable(event.target)) {
71+ send('focus_sync', '');
72+ }
73+ }, true);
74+
75+ function mirrorClipboardEvent(action, e) {
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+ }
81+ setTimeout(function() {
82+ var text = capturedText;
83+ if (!text && !e.defaultPrevented) text = getSelectedText();
84+ if (text) notifyExplicitTextCopy(action, text);
85+ }, 0);
86+ }
87+
88+ document.addEventListener('copy', function(e) {
89+ mirrorClipboardEvent('copy', e);
90+ }, true);
91+ document.addEventListener('cut', function(e) {
92+ mirrorClipboardEvent('cut', e);
93+ }, true);
94+ }
95+ try {
96+ var clipboardObj = navigator && navigator.clipboard ? navigator.clipboard : null;
97+ var clipboardProto = (typeof Clipboard !== 'undefined' && Clipboard.prototype) ||
98+ (clipboardObj && typeof Object.getPrototypeOf === 'function' ? Object.getPrototypeOf(clipboardObj) : null);
99+
100+ function mirrorClipboardItems(items) {
101+ try {
102+ if (!items || typeof items.length !== 'number') return;
103+ Array.prototype.forEach.call(items, function(item) {
104+ if (!item || !item.types || item.types.indexOf('text/plain') === -1 || typeof item.getType !== 'function') return;
105+ Promise.resolve(item.getType('text/plain'))
106+ .then(function(blob) {
107+ if (!blob || typeof blob.text !== 'function') return '';
108+ return blob.text();
109+ })
110+ .then(function(text) {
111+ if (text) notifyExplicitTextCopy('copy', text);
112+ })
113+ .catch(function() {});
114+ });
115+ } catch (_) {}
116+ }
117+
118+ if (clipboardProto && typeof clipboardProto.writeText === 'function' && !clipboardProto.__dumberWriteTextPatched) {
119+ clipboardProto.__dumberWriteTextPatched = true;
120+ var originalProtoWriteText = clipboardProto.writeText;
121+ var wrappedProtoWriteText = function(text) {
122+ var normalized = text == null ? '' : String(text);
123+ var result = originalProtoWriteText.call(this, normalized);
124+ if (result && typeof result.then === 'function') {
125+ return result.then(function(value) {
126+ if (normalized) notifyExplicitTextCopy('copy', normalized);
127+ return value;
128+ });
129+ }
130+ if (normalized) notifyExplicitTextCopy('copy', normalized);
131+ return result;
132+ };
133+ try {
134+ Object.defineProperty(clipboardProto, 'writeText', {
135+ configurable: true,
136+ writable: true,
137+ value: wrappedProtoWriteText
138+ });
139+ } catch (_) {
140+ clipboardProto.writeText = wrappedProtoWriteText;
141+ }
142+ } else if (clipboardObj && typeof clipboardObj.writeText === 'function' && !window.__dumberClipboardWriteTextPatched) {
143+ window.__dumberClipboardWriteTextPatched = true;
144+ var originalWriteText = clipboardObj.writeText.bind(clipboardObj);
145+ var wrappedWriteText = function(text) {
146+ var normalized = text == null ? '' : String(text);
147+ var result = originalWriteText(normalized);
148+ if (result && typeof result.then === 'function') {
149+ return result.then(function(value) {
150+ if (normalized) notifyExplicitTextCopy('copy', normalized);
151+ return value;
152+ });
153+ }
154+ if (normalized) notifyExplicitTextCopy('copy', normalized);
155+ return result;
156+ };
157+ try {
158+ Object.defineProperty(clipboardObj, 'writeText', {
159+ configurable: true,
160+ writable: true,
161+ value: wrappedWriteText
162+ });
163+ } catch (_) {
164+ clipboardObj.writeText = wrappedWriteText;
165+ }
166+ }
167+
168+ if (clipboardProto && typeof clipboardProto.write === 'function' && !clipboardProto.__dumberWritePatched) {
169+ clipboardProto.__dumberWritePatched = true;
170+ var originalProtoWrite = clipboardProto.write;
171+ var wrappedProtoWrite = function(items) {
172+ var result = originalProtoWrite.apply(this, arguments);
173+ if (result && typeof result.then === 'function') {
174+ return result.then(function(value) {
175+ mirrorClipboardItems(items);
176+ return value;
177+ });
178+ }
179+ mirrorClipboardItems(items);
180+ return result;
181+ };
182+ try {
183+ Object.defineProperty(clipboardProto, 'write', {
184+ configurable: true,
185+ writable: true,
186+ value: wrappedProtoWrite
187+ });
188+ } catch (_) {
189+ clipboardProto.write = wrappedProtoWrite;
190+ }
191+ } else if (clipboardObj && typeof clipboardObj.write === 'function' && !window.__dumberClipboardWritePatched) {
192+ window.__dumberClipboardWritePatched = true;
193+ var originalWrite = clipboardObj.write.bind(clipboardObj);
194+ var wrappedWrite = function(items) {
195+ var result = originalWrite(items);
196+ if (result && typeof result.then === 'function') {
197+ return result.then(function(value) {
198+ mirrorClipboardItems(items);
199+ return value;
200+ });
201+ }
202+ mirrorClipboardItems(items);
203+ return result;
204+ };
205+ try {
206+ Object.defineProperty(clipboardObj, 'write', {
207+ configurable: true,
208+ writable: true,
209+ value: wrappedWrite
210+ });
211+ } catch (_) {
212+ clipboardObj.write = wrappedWrite;
213+ }
214+ }
215+ } catch (_) {}
216+
217+ send('bridge_ready', (typeof location !== 'undefined' && location && location.href) ? location.href : '');
218+})();
219+`
220+
221+type rendererBridgeProcessHandler struct {
222+ registerOnce sync.Once
223+ bridgeHandler purecef.V8Handler
224+}
225+
226+func (h *rendererBridgeProcessHandler) ensureExtensionRegistered() {
227+ if h == nil {
228+ return
229+ }
230+ h.registerOnce.Do(func() {
231+ if h.bridgeHandler == nil {
232+ h.bridgeHandler = &rendererBridgeV8Handler{}
233+ }
234+ if result := purecef.RegisterExtension(rendererBridgeExtensionName, rendererBridgeExtensionJS, h.bridgeHandler); result != 1 {
235+ logging.FromContext(context.Background()).Error().Int32("result", result).Msg("cef: failed to register renderer bridge extension")
236+ }
237+ })
238+}
239+
240+func (h *rendererBridgeProcessHandler) OnWebKitInitialized() {
241+ h.ensureExtensionRegistered()
242+}
243+
244+func (*rendererBridgeProcessHandler) OnBrowserCreated(_ purecef.Browser, _ purecef.DictionaryValue) {}
245+func (*rendererBridgeProcessHandler) OnBrowserDestroyed(_ purecef.Browser) {}
246+func (*rendererBridgeProcessHandler) GetLoadHandler() purecef.LoadHandler { return nil }
247+func (*rendererBridgeProcessHandler) OnContextCreated(_ purecef.Browser, _ purecef.Frame, _ purecef.V8Context) {
248+}
249+func (*rendererBridgeProcessHandler) OnContextReleased(_ purecef.Browser, _ purecef.Frame, _ purecef.V8Context) {
250+}
251+func (*rendererBridgeProcessHandler) OnUncaughtException(
252+ _ purecef.Browser, _ purecef.Frame, _ purecef.V8Context, _ purecef.V8Exception, _ purecef.V8StackTrace,
253+) {
254+}
255+func (*rendererBridgeProcessHandler) OnFocusedNodeChanged(_ purecef.Browser, frame purecef.Frame, node purecef.Domnode) {
256+ if frame == nil {
257+ return
258+ }
259+ message := newRendererBridgeProcessMessage(rendererBridgeMessageName)
260+ if message == nil {
261+ return
262+ }
263+ args := message.GetArgumentList()
264+ if args == nil {
265+ return
266+ }
267+ args.SetString(0, rendererBridgeActionEditableFocusChanged)
268+ if node != nil && node.IsEditable() {
269+ args.SetString(1, "1")
270+ } else {
271+ args.SetString(1, "0")
272+ }
273+ frame.SendProcessMessage(purecef.ProcessIDPidBrowser, message)
274+}
275+func (*rendererBridgeProcessHandler) OnProcessMessageReceived(
276+ _ purecef.Browser, _ purecef.Frame, _ purecef.ProcessID, _ purecef.ProcessMessage,
277+) int32 {
278+ return 0
279+}
280+
281+type rendererBridgeV8Handler struct{}
282+
283+func (*rendererBridgeV8Handler) Execute(
284+ name string, _ purecef.V8Value, arguments []purecef.V8Value, _ unsafe.Pointer, _ uintptr,
285+) int32 {
286+ if name != "Dispatch" || len(arguments) < 2 {
287+ return 0
288+ }
289+ if arguments[0] == nil || arguments[1] == nil || !arguments[0].IsString() || !arguments[1].IsString() {
290+ return 0
291+ }
292+ action := arguments[0].GetStringValue()
293+ payload := arguments[1].GetStringValue()
294+ if action == "" {
295+ return 0
296+ }
297+
298+ ctx := purecef.V8ContextGetCurrentContext()
299+ if ctx == nil || !ctx.IsValid() {
300+ return 0
301+ }
302+ frame := ctx.GetFrame()
303+ if frame == nil {
304+ return 0
305+ }
306+ message := purecef.ProcessMessageCreate(rendererBridgeMessageName)
307+ if message == nil {
308+ return 0
309+ }
310+ args := message.GetArgumentList()
311+ if args == nil {
312+ return 0
313+ }
314+ args.SetString(0, action)
315+ args.SetString(1, payload)
316+ frame.SendProcessMessage(purecef.ProcessIDPidBrowser, message)
317+ return 1
318+}
319+
320+func decodeRendererBridgeProcessMessage(message purecef.ProcessMessage) (string, string, bool) {
321+ if message == nil || message.GetName() != rendererBridgeMessageName {
322+ return "", "", false
323+ }
324+ args := message.GetArgumentList()
325+ if args == nil || args.GetSize() < 2 {
326+ return "", "", false
327+ }
328+ action := args.GetString(0)
329+ payload := args.GetString(1)
330+ if action == "" {
331+ return "", "", false
332+ }
333+ return action, payload, true
334+}
335+
336+type rendererBridgeExplicitTextCopyPayload struct {
337+ Text string `json:"text"`
338+ Action string `json:"action"`
339+}
340+
341+func decodeRendererBridgeExplicitTextCopyPayload(payload []byte) (rendererBridgeExplicitTextCopyPayload, error) {
342+ var req rendererBridgeExplicitTextCopyPayload
343+ if len(payload) == 0 {
344+ return req, fmt.Errorf("empty payload")
345+ }
346+ if err := json.Unmarshal(payload, &req); err != nil {
347+ return req, err
348+ }
349+ if req.Action == "" {
350+ req.Action = "copy"
351+ }
352+ if req.Text == "" {
353+ return req, fmt.Errorf("missing text")
354+ }
355+ return req, nil
356+}
357+
358+func (e *Engine) handleEditableFocusBridge(browser purecef.Browser) {
359+ logger := logging.FromContext(e.currentContext())
360+ task := purecef.NewTask(cefTaskFunc(func() {
361+ if browser == nil {
362+ logger.Debug().Msg("cef: editable focus sync skipped — browser nil")
363+ return
364+ }
365+ host := browser.GetHost()
366+ if host == nil {
367+ logger.Debug().Msg("cef: editable focus sync skipped — host nil")
368+ return
369+ }
370+ syncWindowlessBrowserFocus(host)
371+ logger.Debug().Msg("cef: editable focus synchronized")
372+ }))
373+ if result := purecef.PostTask(purecef.ThreadIDTidUi, task); result != 1 {
374+ logger.Warn().Int32("result", result).Msg("cef: failed to post editable focus sync to UI thread")
375+ }
376+}
A internal/infrastructure/cef/renderer_bridge_test.go +189 -0
@@ -0,0 +1,189 @@
1+package cef
2+
3+import (
4+ "go/ast"
5+ "go/parser"
6+ "go/token"
7+ "os"
8+ "strings"
9+ "testing"
10+
11+ purecef "github.com/bnema/purego-cef/cef"
12+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
13+ "github.com/stretchr/testify/mock"
14+ "github.com/stretchr/testify/require"
15+)
16+
17+type testBridgeProcessMessage struct {
18+ name string
19+ args *testBridgeListValue
20+}
21+
22+type testBridgeListValue struct {
23+ values map[int]string
24+}
25+
26+func newTestBridgeProcessMessage(name string, editable bool) *testBridgeProcessMessage {
27+ msg := &testBridgeProcessMessage{name: name, args: &testBridgeListValue{values: map[int]string{}}}
28+ msg.args.SetString(0, rendererBridgeActionEditableFocusChanged)
29+ if editable {
30+ msg.args.SetString(1, "1")
31+ } else {
32+ msg.args.SetString(1, "0")
33+ }
34+ return msg
35+}
36+
37+func (m *testBridgeProcessMessage) IsValid() bool { return true }
38+func (m *testBridgeProcessMessage) IsReadOnly() bool { return false }
39+func (m *testBridgeProcessMessage) Copy() purecef.ProcessMessage { return m }
40+func (m *testBridgeProcessMessage) GetName() string { return m.name }
41+func (m *testBridgeProcessMessage) GetArgumentList() purecef.ListValue { return m.args }
42+func (m *testBridgeProcessMessage) GetSharedMemoryRegion() purecef.SharedMemoryRegion { return nil }
43+
44+func (v *testBridgeListValue) IsValid() bool { return true }
45+func (v *testBridgeListValue) IsOwned() bool { return true }
46+func (v *testBridgeListValue) IsReadOnly() bool { return false }
47+func (v *testBridgeListValue) IsSame(that purecef.ListValue) bool { return v == that }
48+func (v *testBridgeListValue) IsEqual(that purecef.ListValue) bool {
49+ return v == that
50+}
51+func (v *testBridgeListValue) Copy() purecef.ListValue { return v }
52+func (v *testBridgeListValue) SetSize(_ int) int32 { return 0 }
53+func (v *testBridgeListValue) GetSize() int { return len(v.values) }
54+func (v *testBridgeListValue) Clear() int32 { v.values = map[int]string{}; return 0 }
55+func (v *testBridgeListValue) Remove(index int) int32 { delete(v.values, index); return 0 }
56+func (v *testBridgeListValue) GetType(_ int) purecef.ValueType { return 0 }
57+func (v *testBridgeListValue) GetValue(_ int) purecef.Value { return nil }
58+func (v *testBridgeListValue) GetBool(_ int) int32 { return 0 }
59+func (v *testBridgeListValue) GetInt(_ int) int32 { return 0 }
60+func (v *testBridgeListValue) GetDouble(_ int) float64 { return 0 }
61+func (v *testBridgeListValue) GetString(index int) string {
62+ if v.values == nil {
63+ return ""
64+ }
65+ return v.values[index]
66+}
67+func (v *testBridgeListValue) GetBinary(_ int) purecef.BinaryValue { return nil }
68+func (v *testBridgeListValue) GetDictionary(_ int) purecef.DictionaryValue { return nil }
69+func (v *testBridgeListValue) GetList(_ int) purecef.ListValue { return nil }
70+func (v *testBridgeListValue) SetValue(_ int, _ purecef.Value) int32 { return 0 }
71+func (v *testBridgeListValue) SetNull(_ int) int32 { return 0 }
72+func (v *testBridgeListValue) SetBool(_ int, _ int32) int32 { return 0 }
73+func (v *testBridgeListValue) SetInt(_ int, _ int32) int32 { return 0 }
74+func (v *testBridgeListValue) SetDouble(_ int, _ float64) int32 { return 0 }
75+func (v *testBridgeListValue) SetString(index int, value string) int32 {
76+ if v.values == nil {
77+ v.values = map[int]string{}
78+ }
79+ v.values[index] = value
80+ return 0
81+}
82+func (v *testBridgeListValue) SetBinary(_ int, _ purecef.BinaryValue) int32 { return 0 }
83+func (v *testBridgeListValue) SetDictionary(_ int, _ purecef.DictionaryValue) int32 { return 0 }
84+func (v *testBridgeListValue) SetList(_ int, _ purecef.ListValue) int32 { return 0 }
85+
86+func TestRendererBridgeExtensionJS_DoesNotExposeWritableGlobalDispatch(t *testing.T) {
87+ require.NotContains(t, rendererBridgeExtensionJS, "window.__dumberNativeBridge")
88+ require.NotContains(t, rendererBridgeExtensionJS, "DumberBridgeDispatch =")
89+ require.NotContains(t, rendererBridgeExtensionJS, "window.DumberBridgeDispatch")
90+ require.NotContains(t, rendererBridgeExtensionJS, "window.__dumberBridgeAction")
91+ require.NotContains(t, rendererBridgeExtensionJS, "window.__dumberBridgePayload")
92+}
93+
94+func TestRendererBridgeExtensionJS_UsesNativeDispatchInExtensionScope(t *testing.T) {
95+ require.Contains(t, rendererBridgeExtensionJS, "native function Dispatch(action, payload);")
96+ require.Contains(t, rendererBridgeExtensionJS, "return Dispatch(action, payload == null ? '' : String(payload));")
97+ require.Contains(t, rendererBridgeExtensionJS, "send('bridge_ready',")
98+}
99+
100+func TestRendererBridgeExtensionJS_EncodesTrustedSuccessSemantics(t *testing.T) {
101+ require.Contains(t, rendererBridgeExtensionJS, "if (!e.isTrusted) return;")
102+ require.Contains(t, rendererBridgeExtensionJS, "setTimeout(function() {")
103+ require.Contains(t, rendererBridgeExtensionJS, "if (!text && !e.defaultPrevented) text = getSelectedText();")
104+ require.NotContains(t, rendererBridgeExtensionJS, "document.execCommand = function")
105+ require.Contains(t, rendererBridgeExtensionJS, "return result;")
106+}
107+
108+func TestRendererBridgePackageDoesNotNeedGoLinkname(t *testing.T) {
109+ files := parseCEFPackageFiles(t)
110+ for _, file := range files {
111+ for _, group := range file.Comments {
112+ for _, comment := range group.List {
113+ require.NotContains(t, comment.Text, "go:linkname")
114+ }
115+ }
116+ }
117+}
118+
119+func TestCEFPackageDoesNotKeepDeprecatedClipboardBridgeConstants(t *testing.T) {
120+ files := parseCEFPackageFiles(t)
121+ for _, banned := range []string{"autoCopySelectionBridgeJS", "clipboardCopyBridgeJS", "editableFocusBridgeJS"} {
122+ require.False(t, packageDeclaresIdentifier(files, banned), "unexpected deprecated identifier %q", banned)
123+ }
124+}
125+
126+func TestDecodeRendererBridgeExplicitTextCopyPayload(t *testing.T) {
127+ req, err := decodeRendererBridgeExplicitTextCopyPayload([]byte(`{"text":"copied text","action":"cut"}`))
128+ require.NoError(t, err)
129+ require.Equal(t, "copied text", req.Text)
130+ require.Equal(t, "cut", req.Action)
131+}
132+
133+func parseCEFPackageFiles(t *testing.T) []*ast.File {
134+ t.Helper()
135+ entries, err := os.ReadDir(".")
136+ require.NoError(t, err)
137+
138+ fset := token.NewFileSet()
139+ files := make([]*ast.File, 0, len(entries))
140+ for _, entry := range entries {
141+ name := entry.Name()
142+ if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") {
143+ continue
144+ }
145+ file, err := parser.ParseFile(fset, name, nil, parser.ParseComments)
146+ require.NoError(t, err)
147+ if file.Name != nil && file.Name.Name == "cef" {
148+ files = append(files, file)
149+ }
150+ }
151+ require.NotEmpty(t, files)
152+ return files
153+}
154+
155+func packageDeclaresIdentifier(files []*ast.File, name string) bool {
156+ for _, file := range files {
157+ found := false
158+ ast.Inspect(file, func(node ast.Node) bool {
159+ ident, ok := node.(*ast.Ident)
160+ if ok && ident.Name == name {
161+ found = true
162+ return false
163+ }
164+ return true
165+ })
166+ if found {
167+ return true
168+ }
169+ }
170+ return false
171+}
172+
173+func TestRendererBridgeProcessHandler_OnFocusedNodeChanged_ReportsEditableState(t *testing.T) {
174+ oldFactory := newRendererBridgeProcessMessage
175+ t.Cleanup(func() { newRendererBridgeProcessMessage = oldFactory })
176+ newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage {
177+ return newTestBridgeProcessMessage(name, true)
178+ }
179+
180+ frame := cefmocks.NewMockFrame(t)
181+ frame.EXPECT().SendProcessMessage(purecef.ProcessIDPidBrowser, mock.Anything).Run(func(_ purecef.ProcessID, message purecef.ProcessMessage) {
182+ require.Equal(t, rendererBridgeMessageName, message.GetName())
183+ args := message.GetArgumentList()
184+ require.Equal(t, "editable_focus_changed", args.GetString(0))
185+ require.Equal(t, "1", args.GetString(1))
186+ }).Once()
187+
188+ (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: true})
189+}
M internal/infrastructure/cef/scheme_handler.go +135 -42
@@ -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 {
@@ -59,6 +61,14 @@ type dumbSchemeHandler struct {
59 // onClipboardSet is called when JS sends copied text via /api/clipboard-set.61 // onClipboardSet is called when JS sends copied text via /api/clipboard-set.
60 // Set by the engine to write to the GDK system clipboard.62 // Set by the engine to write to the GDK system clipboard.
61 onClipboardSet func(text string)63 onClipboardSet func(text string)
64+
65+ // onEditableFocus is called when JS reports that an editable element gained
66+ // DOM focus. The engine uses this to reassert CEF browser focus in OSR mode.
67+ onEditableFocus func(browser purecef.Browser)
68+
69+ // bridgeNonceValidator checks whether a bridge nonce belongs to the active
70+ // browser/navigation context that issued the request.
71+ bridgeNonceValidator func(browser purecef.Browser, bridgeNonce string) bool
62 }72 }
63 73
64 // newDumbSchemeHandler creates a handler for internal CEF pages.74 // newDumbSchemeHandler creates a handler for internal CEF pages.
@@ -99,7 +109,7 @@ func (h *dumbSchemeHandler) setAssets(assets embed.FS) {
99 109
100 // Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread110 // Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread
101 // for requests to either dumb:// or the internal https origin.111 // for requests to either dumb:// or the internal https origin.
102-func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string, request purecef.Request) purecef.ResourceHandler {112+func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ string, request purecef.Request) purecef.ResourceHandler {
103 reqURL := request.GetURL()113 reqURL := request.GetURL()
104 method := request.GetMethod()114 method := request.GetMethod()
105 115
@@ -119,8 +129,17 @@ func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string,
119 return h.newRawResourceHandler(http.StatusOK, "text/html; charset=utf-8", []byte(buildCEFCrashPageHTML(originalURI)))129 return h.newRawResourceHandler(http.StatusOK, "text/html; charset=utf-8", []byte(buildCEFCrashPageHTML(originalURI)))
120 }130 }
121 131
122- // Keep dumb:// as the app-level abstraction, but redirect CEF loads to a132+ // Route API requests before redirecting conceptual dumb:// URLs to the
123- // normal HTTPS origin so Chromium applies the standard web security model.133+ // internal HTTPS origin. External pages use dumb://api/... for clipboard and
134+ // focus bridges specifically to bypass page CSP; redirecting those requests
135+ // to https://dumber.invalid makes site connect-src policies block them.
136+ if apiPath, ok := resolveAPIPath(u); ok {
137+ return h.handleAPI(browser, method, apiPath, request)
138+ }
139+
140+ // Keep dumb:// as the app-level abstraction, but redirect CEF page/asset
141+ // loads to a normal HTTPS origin so Chromium applies the standard web
142+ // security model.
124 if isConceptualInternalURL(reqURL) {143 if isConceptualInternalURL(reqURL) {
125 targetURL := toActualInternalURL(reqURL)144 targetURL := toActualInternalURL(reqURL)
126 if targetURL != "" && targetURL != reqURL {145 if targetURL != "" && targetURL != reqURL {
@@ -128,30 +147,27 @@ func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string,
128 }147 }
129 }148 }
130 149
131- // Route API requests.
132- path := u.Path
133-
134- if strings.HasPrefix(path, "/api/") {
135- return h.handleAPI(method, path, request)
136- }
137-
138 // Serve static assets.150 // Serve static assets.
139 return h.handleAsset(u)151 return h.handleAsset(u)
140 }152 }
141 153
142 // handleAPI routes API requests to the message router or built-in handlers.154 // handleAPI routes API requests to the message router or built-in handlers.
143-func (h *dumbSchemeHandler) handleAPI(method, path string, request purecef.Request) purecef.ResourceHandler {155+func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path string, request purecef.Request) purecef.ResourceHandler {
156+ if strings.EqualFold(method, http.MethodOptions) {
157+ return h.newAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil)
158+ }
159+
144 switch {160 switch {
145 case path == "/api/message" && strings.EqualFold(method, "POST"):161 case path == "/api/message" && strings.EqualFold(method, "POST"):
146 body := readBodyFromHeader(request)162 body := readBodyFromHeader(request)
147 if body == nil {163 if body == nil {
148- return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})164+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
149 }165 }
150 resp, err := h.messageRouter.HandleMessage(h.ctx, 0, body)166 resp, err := h.messageRouter.HandleMessage(h.ctx, 0, body)
151 if err != nil {167 if err != nil {
152- return h.newJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})168+ return h.newAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})
153 }169 }
154- return h.newRawResourceHandler(http.StatusOK, "application/json", resp)170+ return h.newAPIRawResourceHandler(http.StatusOK, "application/json", resp)
155 171
156 case path == "/api/config" && (method == "" || strings.EqualFold(method, "GET")):172 case path == "/api/config" && (method == "" || strings.EqualFold(method, "GET")):
157 return h.handleConfigAPI(h.currentConfigPayload)173 return h.handleConfigAPI(h.currentConfigPayload)
@@ -163,32 +179,35 @@ func (h *dumbSchemeHandler) handleAPI(method, path string, request purecef.Reque
163 return h.handleTranscodeAPI(request)179 return h.handleTranscodeAPI(request)
164 180
165 case path == "/api/clipboard-set" && strings.EqualFold(method, "POST"):181 case path == "/api/clipboard-set" && strings.EqualFold(method, "POST"):
166- return h.handleClipboardSet(request)182+ return h.handleClipboardSet(request, browser)
183+
184+ case path == "/api/focus-sync" && strings.EqualFold(method, "POST"):
185+ return h.handleFocusSync(request, browser)
167 186
168 default:187 default:
169- return h.newJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"})188+ return h.newAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"})
170 }189 }
171 }190 }
172 191
173 func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler {192 func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler {
174 if h.transcoder == nil {193 if h.transcoder == nil {
175- return h.newJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"})194+ return h.newAPIJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"})
176 }195 }
177 196
178 reqURL := request.GetURL()197 reqURL := request.GetURL()
179 parsed, err := url.Parse(reqURL)198 parsed, err := url.Parse(reqURL)
180 if err != nil {199 if err != nil {
181- return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid request URL"})200+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid request URL"})
182 }201 }
183 202
184 sourceURL := strings.TrimSpace(parsed.Query().Get("src"))203 sourceURL := strings.TrimSpace(parsed.Query().Get("src"))
185 if sourceURL == "" {204 if sourceURL == "" {
186- return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "missing src"})205+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "missing src"})
187 }206 }
188 207
189 sourceParsed, err := url.Parse(sourceURL)208 sourceParsed, err := url.Parse(sourceURL)
190 if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != "https") {209 if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != "https") {
191- return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"})210+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"})
192 }211 }
193 212
194 headers := make(map[string]string)213 headers := make(map[string]string)
@@ -210,7 +229,7 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.
210 Int("forwarded_header_count", len(headers)).229 Int("forwarded_header_count", len(headers)).
211 Msg("scheme handler returning transcoding stream")230 Msg("scheme handler returning transcoding stream")
212 231
213- return purecef.NewResourceHandler(&transcodingResourceHandler{232+ return cefNewResourceHandler(&transcodingResourceHandler{
214 transcoder: h.transcoder,233 transcoder: h.transcoder,
215 sourceURL: sourceURL,234 sourceURL: sourceURL,
216 headers: headers,235 headers: headers,
@@ -233,26 +252,37 @@ func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) {
233 func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purecef.ResourceHandler {252 func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purecef.ResourceHandler {
234 data, err := resolveConfigPayload(build)253 data, err := resolveConfigPayload(build)
235 if err != nil {254 if err != nil {
236- return h.newJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})255+ return h.newAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})
237 }256 }
238- return h.newRawResourceHandler(http.StatusOK, "application/json", data)257+ return h.newAPIRawResourceHandler(http.StatusOK, "application/json", data)
239 }258 }
240 259
241 // handleClipboardSet receives copied text from JS copy/cut events and writes260 // handleClipboardSet receives copied text from JS copy/cut events and writes
242 // it to the system clipboard via the engine callback.261 // it to the system clipboard via the engine callback.
243-const maxClipboardBytes = 10 << 20 // 10 MB262+const (
263+ maxClipboardBytes = 10 << 20 // 10 MB
264+ dumberBodyHeaderName = "X-Dumber-Body"
265+ dumberBridgeActionHeaderName = "X-Dumber-Bridge-Action"
266+ dumberBridgeNonceHeaderName = "X-Dumber-Bridge-Nonce"
267+ dumberBridgeActionFocusSync = "focus-sync"
268+)
244 269
245-func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.ResourceHandler {270+func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
246 h.logger.Debug().Msg("cef: /api/clipboard-set request received")271 h.logger.Debug().Msg("cef: /api/clipboard-set request received")
247 272
273+ if !h.hasTrustedBridgeNonce(request, browser) {
274+ h.logger.Warn().Msg("cef: clipboard-set — rejected request without valid bridge nonce")
275+ return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
276+ }
277+
248 body := readBodyFromHeader(request)278 body := readBodyFromHeader(request)
249 if body == nil {279 if body == nil {
250 h.logger.Debug().Msg("cef: clipboard-set — empty body (no X-Dumber-Body header)")280 h.logger.Debug().Msg("cef: clipboard-set — empty body (no X-Dumber-Body header)")
251- return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})281+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
252 }282 }
253 if len(body) > maxClipboardBytes {283 if len(body) > maxClipboardBytes {
254 h.logger.Warn().Int("body_len", len(body)).Msg("cef: clipboard-set — payload too large")284 h.logger.Warn().Int("body_len", len(body)).Msg("cef: clipboard-set — payload too large")
255- return h.newJSONResourceHandler(http.StatusRequestEntityTooLarge, map[string]string{"error": "payload too large"})285+ return h.newAPIJSONResourceHandler(http.StatusRequestEntityTooLarge, map[string]string{"error": "payload too large"})
256 }286 }
257 287
258 var payload struct {288 var payload struct {
@@ -260,7 +290,7 @@ func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.
260 }290 }
261 if err := json.Unmarshal(body, &payload); err != nil || payload.Text == "" {291 if err := json.Unmarshal(body, &payload); err != nil || payload.Text == "" {
262 h.logger.Debug().Int("body_len", len(body)).Msg("cef: clipboard-set — invalid or empty payload")292 h.logger.Debug().Int("body_len", len(body)).Msg("cef: clipboard-set — invalid or empty payload")
263- return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid payload"})293+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid payload"})
264 }294 }
265 295
266 h.logger.Debug().Int("text_len", len(payload.Text)).Msg("cef: clipboard-set — forwarding to GDK clipboard")296 h.logger.Debug().Int("text_len", len(payload.Text)).Msg("cef: clipboard-set — forwarding to GDK clipboard")
@@ -271,17 +301,51 @@ func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef.
271 h.logger.Warn().Msg("cef: clipboard-set — onClipboardSet callback not wired")301 h.logger.Warn().Msg("cef: clipboard-set — onClipboardSet callback not wired")
272 }302 }
273 303
274- return h.newJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})304+ return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
305+}
306+
307+func (h *dumbSchemeHandler) handleFocusSync(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
308+ bridgeAction := ""
309+ if request != nil {
310+ bridgeAction = strings.TrimSpace(request.GetHeaderByName(dumberBridgeActionHeaderName))
311+ }
312+ if !strings.EqualFold(bridgeAction, dumberBridgeActionFocusSync) {
313+ h.logger.Warn().Msg("cef: focus-sync — rejected request without trusted bridge action header")
314+ return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
315+ }
316+ if browser == nil {
317+ h.logger.Debug().Msg("cef: focus-sync — browser unavailable")
318+ return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})
319+ }
320+ if !h.hasTrustedBridgeNonce(request, browser) {
321+ h.logger.Warn().Msg("cef: focus-sync — rejected request without valid bridge nonce")
322+ return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
323+ }
324+
325+ if h.onEditableFocus != nil {
326+ h.onEditableFocus(browser)
327+ } else {
328+ h.logger.Debug().Msg("cef: focus-sync — onEditableFocus callback not wired")
329+ }
330+
331+ return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
332+}
333+
334+func (h *dumbSchemeHandler) hasTrustedBridgeNonce(request purecef.Request, browser purecef.Browser) bool {
335+ if browser == nil || h == nil || h.bridgeNonceValidator == nil || request == nil {
336+ return false
337+ }
338+ bridgeNonce := strings.TrimSpace(request.GetHeaderByName(dumberBridgeNonceHeaderName))
339+ if bridgeNonce == "" {
340+ return false
341+ }
342+ return h.bridgeNonceValidator(browser, bridgeNonce)
275 }343 }
276 344
277 func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler {345 func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler {
278- return purecef.NewResourceHandler(&staticResourceHandler{346+ return newStaticResourceHandler(status, "text/plain; charset=utf-8", nil, map[string]string{
279- contentType: "text/plain; charset=utf-8",347+ "Location": location,
280- statusCode: status,348+ "Cache-Control": "no-store",
281- headers: map[string]string{
282- "Location": location,
283- "Cache-Control": "no-store",
284- },
285 })349 })
286 }350 }
287 351
@@ -439,26 +503,55 @@ type staticResourceHandler struct {
439 offset int503 offset int
440 }504 }
441 505
442-func (h *dumbSchemeHandler) newRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler {506+func apiResponseHeaders(extra map[string]string) map[string]string {
443- return purecef.NewResourceHandler(&staticResourceHandler{507+ headers := map[string]string{
508+ "Access-Control-Allow-Origin": "*",
509+ "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
510+ "Access-Control-Allow-Headers": strings.Join([]string{
511+ "Content-Type",
512+ "X-Dumber-Body",
513+ "X-Dumber-Bridge-Action",
514+ "X-Dumber-Bridge-Nonce",
515+ }, ", "),
516+ "Access-Control-Max-Age": "86400",
517+ "Cache-Control": "no-store",
518+ }
519+ for name, value := range extra {
520+ headers[name] = value
521+ }
522+ return headers
523+}
524+
525+func newStaticResourceHandler(status int, contentType string, data []byte, headers map[string]string) purecef.ResourceHandler {
526+ return cefNewResourceHandler(&staticResourceHandler{
444 data: data,527 data: data,
445 contentType: contentType,528 contentType: contentType,
446 statusCode: status,529 statusCode: status,
530+ headers: headers,
447 })531 })
448 }532 }
449 533
534+func (h *dumbSchemeHandler) newRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler {
535+ return newStaticResourceHandler(status, contentType, data, nil)
536+}
537+
538+func (h *dumbSchemeHandler) newAPIRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler {
539+ return newStaticResourceHandler(status, contentType, data, apiResponseHeaders(nil))
540+}
541+
450 func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler {542 func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler {
451 escaped := html.EscapeString(msg)543 escaped := html.EscapeString(msg)
452 body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped)544 body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped)
453 return h.newRawResourceHandler(status, "text/html; charset=utf-8", []byte(body))545 return h.newRawResourceHandler(status, "text/html; charset=utf-8", []byte(body))
454 }546 }
455 547
456-func (h *dumbSchemeHandler) newJSONResourceHandler(status int, v any) purecef.ResourceHandler {548+func (h *dumbSchemeHandler) newAPIJSONResourceHandler(status int, v any) purecef.ResourceHandler {
457 data, err := json.Marshal(v)549 data, err := json.Marshal(v)
458 if err != nil {550 if err != nil {
459- return h.newErrorResourceHandler(http.StatusInternalServerError, "JSON encoding failed")551+ fallback, _ := json.Marshal(map[string]string{"error": "JSON encoding failed"})
552+ return h.newAPIRawResourceHandler(http.StatusInternalServerError, "application/json", fallback)
460 }553 }
461- return h.newRawResourceHandler(status, "application/json", data)554+ return h.newAPIRawResourceHandler(status, "application/json", data)
462 }555 }
463 556
464 // Open handles the request immediately (synchronous).557 // Open handles the request immediately (synchronous).
@@ -537,7 +630,7 @@ func (rh *staticResourceHandler) Cancel() {}
537 // The JS bridge base64-encodes the JSON body into this header to avoid the630 // The JS bridge base64-encodes the JSON body into this header to avoid the
538 // purego-cef PostData element wrapping limitation (wrapPostDataElement is unexported).631 // purego-cef PostData element wrapping limitation (wrapPostDataElement is unexported).
539 func readBodyFromHeader(request purecef.Request) []byte {632 func readBodyFromHeader(request purecef.Request) []byte {
540- encoded := request.GetHeaderByName("X-Dumber-Body")633+ encoded := request.GetHeaderByName(dumberBodyHeaderName)
541 if encoded == "" {634 if encoded == "" {
542 return nil635 return nil
543 }636 }
M internal/infrastructure/cef/scheme_handler_test.go +216 -0
@@ -2,12 +2,29 @@ package cef
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/base64"
5 "errors"6 "errors"
7+ "net/http"
6 "testing"8 "testing"
9+ "unsafe"
7 10
11+ purecef "github.com/bnema/purego-cef/cef"
12+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
8 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
9 )14 )
10 15
16+func expectAPIResponseHeaders(response *cefmocks.MockResponse) {
17+ response.EXPECT().SetHeaderByName("Access-Control-Allow-Origin", "*", int32(1)).Once()
18+ response.EXPECT().SetHeaderByName("Access-Control-Allow-Methods", "GET, POST, OPTIONS", int32(1)).Once()
19+ response.EXPECT().SetHeaderByName(
20+ "Access-Control-Allow-Headers",
21+ "Content-Type, X-Dumber-Body, X-Dumber-Bridge-Action, X-Dumber-Bridge-Nonce",
22+ int32(1),
23+ ).Once()
24+ response.EXPECT().SetHeaderByName("Access-Control-Max-Age", "86400", int32(1)).Once()
25+ response.EXPECT().SetHeaderByName("Cache-Control", "no-store", int32(1)).Once()
26+}
27+
11 func TestResolveConfigPayload_UsesInjectedBuilder(t *testing.T) {28 func TestResolveConfigPayload_UsesInjectedBuilder(t *testing.T) {
12 data, err := resolveConfigPayload(func() ([]byte, error) {29 data, err := resolveConfigPayload(func() ([]byte, error) {
13 return []byte(`{"engine_type":"cef"}`), nil30 return []byte(`{"engine_type":"cef"}`), nil
@@ -43,3 +60,202 @@ func TestNewDumbSchemeHandler_NilDefaultConfigPayloadFails(t *testing.T) {
43 require.Nil(t, h)60 require.Nil(t, h)
44 require.Contains(t, err.Error(), "default config payload builder")61 require.Contains(t, err.Error(), "default config payload builder")
45 }62 }
63+
64+func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) {
65+ request := cefmocks.NewMockRequest(t)
66+ encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`))
67+ request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()
68+
69+ body := readBodyFromHeader(request)
70+
71+ require.JSONEq(t, `{"text":"copied from js"}`, string(body))
72+}
73+
74+func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) {
75+ oldNewResourceHandler := cefNewResourceHandler
76+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
77+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
78+
79+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
80+ require.NoError(t, err)
81+
82+ const bridgeNonce = "bridge-nonce"
83+ browser := cefmocks.NewMockBrowser(t)
84+ h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool {
85+ return got == browser && gotNonce == bridgeNonce
86+ }
87+
88+ var copied string
89+ h.onClipboardSet = func(text string) { copied = text }
90+
91+ request := cefmocks.NewMockRequest(t)
92+ encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`))
93+ request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once()
94+ request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()
95+
96+ response := cefmocks.NewMockResponse(t)
97+ response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
98+ response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()
99+ response.EXPECT().SetMimeType("application/json").Once()
100+ expectAPIResponseHeaders(response)
101+
102+ handler := h.handleAPI(browser, http.MethodPost, "/api/clipboard-set", request)
103+ require.NotNil(t, handler)
104+
105+ var responseLength int64
106+ handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0)
107+ require.Positive(t, responseLength)
108+ require.Equal(t, "copied from js", copied)
109+}
110+
111+func TestSchemeHandler_APIFocusSyncRejectsRequestsWithoutTrustedBridgeHeader(t *testing.T) {
112+ oldNewResourceHandler := cefNewResourceHandler
113+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
114+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
115+
116+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
117+ require.NoError(t, err)
118+
119+ request := cefmocks.NewMockRequest(t)
120+ request.EXPECT().GetHeaderByName(dumberBridgeActionHeaderName).Return("").Once()
121+
122+ response := cefmocks.NewMockResponse(t)
123+ response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once()
124+ response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once()
125+ response.EXPECT().SetMimeType("application/json").Once()
126+ expectAPIResponseHeaders(response)
127+
128+ handler := h.handleAPI(nil, http.MethodPost, "/api/focus-sync", request)
129+ require.NotNil(t, handler)
130+
131+ var responseLength int64
132+ handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0)
133+ require.Positive(t, responseLength)
134+}
135+
136+func TestSchemeHandler_APIFocusSyncInvokesEditableFocusCallback(t *testing.T) {
137+ oldNewResourceHandler := cefNewResourceHandler
138+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
139+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
140+
141+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
142+ require.NoError(t, err)
143+
144+ const bridgeNonce = "bridge-nonce"
145+ browser := cefmocks.NewMockBrowser(t)
146+ h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool {
147+ return got == browser && gotNonce == bridgeNonce
148+ }
149+
150+ var focused purecef.Browser
151+ h.onEditableFocus = func(got purecef.Browser) { focused = got }
152+
153+ request := cefmocks.NewMockRequest(t)
154+ request.EXPECT().GetHeaderByName(dumberBridgeActionHeaderName).Return(dumberBridgeActionFocusSync).Once()
155+ request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once()
156+
157+ response := cefmocks.NewMockResponse(t)
158+ response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
159+ response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()
160+ response.EXPECT().SetMimeType("application/json").Once()
161+ expectAPIResponseHeaders(response)
162+
163+ handler := h.handleAPI(browser, http.MethodPost, "/api/focus-sync", request)
164+ require.NotNil(t, handler)
165+
166+ var responseLength int64
167+ handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0)
168+ require.Positive(t, responseLength)
169+ require.Same(t, browser, focused)
170+}
171+
172+func TestSchemeHandler_Create_ConceptualAPIRequestBypassesRedirect(t *testing.T) {
173+ oldNewResourceHandler := cefNewResourceHandler
174+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
175+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
176+
177+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
178+ require.NoError(t, err)
179+
180+ const bridgeNonce = "bridge-nonce"
181+ browser := cefmocks.NewMockBrowser(t)
182+ h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool {
183+ return got == browser && gotNonce == bridgeNonce
184+ }
185+
186+ var copied string
187+ h.onClipboardSet = func(text string) { copied = text }
188+
189+ request := cefmocks.NewMockRequest(t)
190+ request.EXPECT().GetURL().Return("dumb://api/clipboard-set").Once()
191+ request.EXPECT().GetMethod().Return(http.MethodPost).Once()
192+ request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once()
193+ encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from create"}`))
194+ request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once()
195+
196+ response := cefmocks.NewMockResponse(t)
197+ response.EXPECT().SetStatus(int32(http.StatusOK)).Once()
198+ response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once()
199+ response.EXPECT().SetMimeType("application/json").Once()
200+ expectAPIResponseHeaders(response)
201+
202+ handler := h.Create(browser, nil, "", request)
203+ require.NotNil(t, handler)
204+
205+ var responseLength int64
206+ handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0)
207+ require.Positive(t, responseLength)
208+ require.Equal(t, "copied from create", copied)
209+}
210+
211+func TestSchemeHandler_APIClipboardSetRejectsInvalidBridgeNonce(t *testing.T) {
212+ oldNewResourceHandler := cefNewResourceHandler
213+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
214+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
215+
216+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
217+ require.NoError(t, err)
218+
219+ browser := cefmocks.NewMockBrowser(t)
220+ h.bridgeNonceValidator = func(_ purecef.Browser, _ string) bool { return false }
221+
222+ request := cefmocks.NewMockRequest(t)
223+ request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return("invalid").Once()
224+
225+ response := cefmocks.NewMockResponse(t)
226+ response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once()
227+ response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once()
228+ response.EXPECT().SetMimeType("application/json").Once()
229+ expectAPIResponseHeaders(response)
230+
231+ handler := h.handleAPI(browser, http.MethodPost, "/api/clipboard-set", request)
232+ require.NotNil(t, handler)
233+
234+ var responseLength int64
235+ handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0)
236+ require.Positive(t, responseLength)
237+}
238+
239+func TestSchemeHandler_APIOptionsReturnsCORSPreflightHeaders(t *testing.T) {
240+ oldNewResourceHandler := cefNewResourceHandler
241+ cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl }
242+ defer func() { cefNewResourceHandler = oldNewResourceHandler }()
243+
244+ h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil })
245+ require.NoError(t, err)
246+
247+ request := cefmocks.NewMockRequest(t)
248+ response := cefmocks.NewMockResponse(t)
249+ response.EXPECT().SetStatus(int32(http.StatusNoContent)).Once()
250+ response.EXPECT().SetStatusText(http.StatusText(http.StatusNoContent)).Once()
251+ response.EXPECT().SetMimeType("text/plain").Once()
252+ response.EXPECT().SetCharset("utf-8").Once()
253+ expectAPIResponseHeaders(response)
254+
255+ handler := h.handleAPI(nil, http.MethodOptions, "/api/clipboard-set", request)
256+ require.NotNil(t, handler)
257+
258+ var responseLength int64
259+ handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0)
260+ require.Zero(t, responseLength)
261+}
M internal/infrastructure/cef/session_spawn_environment.go +9 -3
@@ -9,14 +9,20 @@ import (
9 9
10 // SessionSpawnEnvironment exposes the engine-specific launch environment needed10 // SessionSpawnEnvironment exposes the engine-specific launch environment needed
11 // when restoring a session into a freshly spawned browser process.11 // when restoring a session into a freshly spawned browser process.
12-type SessionSpawnEnvironment struct{}12+type SessionSpawnEnvironment struct {
13+ cefUserDataDir string
14+}
15+
16+func NewSessionSpawnEnvironment(cefUserDataDir string) SessionSpawnEnvironment {
17+ return SessionSpawnEnvironment{cefUserDataDir: cefUserDataDir}
18+}
13 19
14 func (SessionSpawnEnvironment) RootCacheEnvVar() string {20 func (SessionSpawnEnvironment) RootCacheEnvVar() string {
15 return CEFRootCachePathEnvVar21 return CEFRootCachePathEnvVar
16 }22 }
17 23
18-func (SessionSpawnEnvironment) SessionRootCachePath(sessionID entity.SessionID) string {24+func (e SessionSpawnEnvironment) SessionRootCachePath(sessionID entity.SessionID) string {
19- return filepath.Join(DefaultCEFUserDataDir(), "sessions", string(sessionID))25+ return filepath.Join(e.cefUserDataDir, "sessions", string(sessionID))
20 }26 }
21 27
22 var _ port.SessionSpawnEnvironment = SessionSpawnEnvironment{}28 var _ port.SessionSpawnEnvironment = SessionSpawnEnvironment{}
M internal/infrastructure/cef/webview.go +416 -13
@@ -2,12 +2,17 @@ package cef
2 2
3 import (3 import (
4 "context"4 "context"
5+ "crypto/rand"
5 "encoding/base64"6 "encoding/base64"
7+ "encoding/hex"
6 "errors"8 "errors"
7 "fmt"9 "fmt"
8 "math"10 "math"
11+ "strconv"
12+ "strings"
9 "sync"13 "sync"
10 "sync/atomic"14 "sync/atomic"
15+ "time"
11 16
12 purecef "github.com/bnema/purego-cef/cef"17 purecef "github.com/bnema/purego-cef/cef"
13 "github.com/bnema/puregotk/v4/glib"18 "github.com/bnema/puregotk/v4/glib"
@@ -30,6 +35,32 @@ var errDestroyed = errors.New("cef: webview is destroyed")
30 // errNoBrowser is returned when the browser has not been created yet.35 // errNoBrowser is returned when the browser has not been created yet.
31 var errNoBrowser = errors.New("cef: browser not yet created")36 var errNoBrowser = errors.New("cef: browser not yet created")
32 37
38+const (
39+ clipboardSelectionDebounceDelay = 300 * time.Millisecond
40+ pendingNavigationRetryDelay = 50 * time.Millisecond
41+ pendingNavigationMaxRetries = 80
42+)
43+
44+type stoppableTimer interface {
45+ Stop() bool
46+}
47+
48+var cefLoadWatchdogDelays = []time.Duration{
49+ 250 * time.Millisecond,
50+ 1 * time.Second,
51+ 2 * time.Second,
52+ 5 * time.Second,
53+ 15 * time.Second,
54+ 30 * time.Second,
55+}
56+
57+var (
58+ cefNewTask = purecef.NewTask
59+ cefPostTask = purecef.PostTask
60+ cefPostDelayedTask = purecef.PostDelayedTask
61+ cefScheduleAfter = func(delay time.Duration, fn func()) { time.AfterFunc(delay, fn) }
62+)
63+
33 // WebView implements port.WebView using a CEF off-screen browser rendered64 // WebView implements port.WebView using a CEF off-screen browser rendered
34 // through a renderPipeline and driven by an inputBridge.65 // through a renderPipeline and driven by an inputBridge.
35 type WebView struct {66 type WebView struct {
@@ -65,16 +96,30 @@ type WebView struct {
65 callbacks *port.WebViewCallbacks96 callbacks *port.WebViewCallbacks
66 97
67 // State cache (mutex-protected).98 // State cache (mutex-protected).
68- uri string99+ uri string
69- title string100+ title string
70- progress float64101+ progress float64
71- canGoBack bool102+ canGoBack bool
72- canGoFwd bool103+ canGoFwd bool
73- isLoading bool104+ isLoading bool
105+ selectedText string
106+ focusedEditable bool
107+ bridgeNonce string
108+ selectionDebounceTimer stoppableTimer
109+ selectionDebounceSeq uint64
110+ selectionDebounceDelay *time.Duration
111+ selectionDebounceSchedule func(time.Duration, func()) stoppableTimer
74 112
75 // Last known hover URI for middle-click → new tab.113 // Last known hover URI for middle-click → new tab.
76 lastHoverURI string114 lastHoverURI string
77 115
116+ // Load diagnostics state (mutex-protected).
117+ loadDiagSeq uint64
118+ loadDiagStartedAt time.Time
119+ loadDiagLastProgressAt time.Time
120+ loadDiagLastAddressAt time.Time
121+ loadDiagLastLoadStateAt time.Time
122+
78 // Atomic state.123 // Atomic state.
79 destroyed atomic.Bool124 destroyed atomic.Bool
80 fullscreen atomic.Bool125 fullscreen atomic.Bool
@@ -127,16 +172,14 @@ func (wv *WebView) LoadURI(_ context.Context, uri string) error {
127 actualURI := toActualInternalURL(uri)172 actualURI := toActualInternalURL(uri)
128 wv.mu.Lock()173 wv.mu.Lock()
129 browser := wv.browser174 browser := wv.browser
175+ // Always remember the latest requested URI so a browser/main-frame race
176+ // cannot strand startup on about:blank.
177+ wv.pendingURI = actualURI
178+ wv.mu.Unlock()
130 if browser == nil {179 if browser == nil {
131- // Browser not yet created — queue the URI for OnAfterCreated.
132- wv.pendingURI = actualURI
133- wv.mu.Unlock()
134 return nil180 return nil
135 }181 }
136- wv.mu.Unlock()182+ wv.schedulePendingNavigationReplay(0)
137- if frame := browser.GetMainFrame(); frame != nil {
138- frame.LoadURL(actualURI)
139- }
140 return nil183 return nil
141 }184 }
142 185
@@ -446,6 +489,22 @@ func (wv *WebView) RunJavaScript(_ context.Context, script string) {
446 // colorScale converts a 0.0–1.0 color component to an 8-bit integer.489 // colorScale converts a 0.0–1.0 color component to an 8-bit integer.
447 const colorScale = 255490 const colorScale = 255
448 491
492+func newBridgeNonce() string {
493+ buf := make([]byte, 16)
494+ if _, err := rand.Read(buf); err != nil {
495+ return strconv.FormatInt(time.Now().UnixNano(), 16)
496+ }
497+ return hex.EncodeToString(buf)
498+}
499+
500+func (wv *WebView) rotateBridgeNonce() string {
501+ nonce := newBridgeNonce()
502+ wv.mu.Lock()
503+ wv.bridgeNonce = nonce
504+ wv.mu.Unlock()
505+ return nonce
506+}
507+
449 // SetBackgroundColor sets the background via JS injection (CEF has no runtime API).508 // SetBackgroundColor sets the background via JS injection (CEF has no runtime API).
450 func (wv *WebView) SetBackgroundColor(r, g, b, a float64) {509 func (wv *WebView) SetBackgroundColor(r, g, b, a float64) {
451 script := fmt.Sprintf(510 script := fmt.Sprintf(
@@ -473,6 +532,7 @@ func (wv *WebView) Destroy() {
473 if !wv.destroyed.CompareAndSwap(false, true) {532 if !wv.destroyed.CompareAndSwap(false, true) {
474 return533 return
475 }534 }
535+ wv.cancelSelectionDebounce()
476 if wv.resizeReconciler != nil {536 if wv.resizeReconciler != nil {
477 wv.resizeReconciler.stop()537 wv.resizeReconciler.stop()
478 }538 }
@@ -507,11 +567,23 @@ func (wv *WebView) NativeWidget() uintptr {
507 567
508 func (wv *WebView) updateURI(uri string) {568 func (wv *WebView) updateURI(uri string) {
509 uri = toConceptualInternalURL(uri)569 uri = toConceptualInternalURL(uri)
570+ now := time.Now()
510 wv.mu.Lock()571 wv.mu.Lock()
511 wv.uri = uri572 wv.uri = uri
573+ wv.loadDiagLastAddressAt = now
512 cb := wv.callbacks574 cb := wv.callbacks
575+ pendingMatched := pendingURIEquivalent(wv.pendingURI, uri)
576+ if pendingMatched {
577+ wv.pendingURI = ""
578+ }
513 wv.mu.Unlock()579 wv.mu.Unlock()
514 580
581+ if pendingMatched && wv.ctx != nil {
582+ logging.FromContext(wv.ctx).Debug().
583+ Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
584+ Msg("cef: pending navigation acknowledged by address change")
585+ }
586+
515 if cb != nil && cb.OnURIChanged != nil {587 if cb != nil && cb.OnURIChanged != nil {
516 wv.runOnGTK(func() {588 wv.runOnGTK(func() {
517 cb.OnURIChanged(uri)589 cb.OnURIChanged(uri)
@@ -533,8 +605,10 @@ func (wv *WebView) updateTitle(title string) {
533 }605 }
534 606
535 func (wv *WebView) updateProgress(progress float64) {607 func (wv *WebView) updateProgress(progress float64) {
608+ now := time.Now()
536 wv.mu.Lock()609 wv.mu.Lock()
537 wv.progress = progress610 wv.progress = progress
611+ wv.loadDiagLastProgressAt = now
538 cb := wv.callbacks612 cb := wv.callbacks
539 wv.mu.Unlock()613 wv.mu.Unlock()
540 614
@@ -546,11 +620,26 @@ func (wv *WebView) updateProgress(progress float64) {
546 }620 }
547 621
548 func (wv *WebView) updateLoadState(loading, back, fwd bool) {622 func (wv *WebView) updateLoadState(loading, back, fwd bool) {
623+ now := time.Now()
549 wv.mu.Lock()624 wv.mu.Lock()
625+ wasLoading := wv.isLoading
550 wv.isLoading = loading626 wv.isLoading = loading
551 wv.canGoBack = back627 wv.canGoBack = back
552 wv.canGoFwd = fwd628 wv.canGoFwd = fwd
629+ wv.loadDiagLastLoadStateAt = now
630+ var loadDiagSeq uint64
631+ if loading && !wasLoading {
632+ wv.loadDiagSeq++
633+ wv.loadDiagStartedAt = now
634+ wv.loadDiagLastProgressAt = time.Time{}
635+ wv.loadDiagLastAddressAt = time.Time{}
636+ loadDiagSeq = wv.loadDiagSeq
637+ }
553 wv.mu.Unlock()638 wv.mu.Unlock()
639+
640+ if loadDiagSeq != 0 {
641+ wv.scheduleLoadWatchdogs(loadDiagSeq)
642+ }
554 }643 }
555 644
556 func (wv *WebView) setAudioPlaying(playing bool) {645 func (wv *WebView) setAudioPlaying(playing bool) {
@@ -565,6 +654,320 @@ func (wv *WebView) setAudioPlaying(playing bool) {
565 }654 }
566 }655 }
567 656
657+func (wv *WebView) setSelectedText(text string) (string, bool) {
658+ wv.mu.Lock()
659+ previous := wv.selectedText
660+ changed := previous != text
661+ wv.selectedText = text
662+ wv.mu.Unlock()
663+ return previous, changed
664+}
665+
666+func (wv *WebView) setEditableFocus(editable bool) {
667+ if wv == nil {
668+ return
669+ }
670+ wv.mu.Lock()
671+ previous := wv.focusedEditable
672+ wv.focusedEditable = editable
673+ if editable {
674+ wv.selectionDebounceSeq++
675+ timer := wv.selectionDebounceTimer
676+ wv.selectionDebounceTimer = nil
677+ wv.mu.Unlock()
678+ if timer != nil {
679+ timer.Stop()
680+ }
681+ if previous != editable && wv.ctx != nil {
682+ logging.FromContext(wv.ctx).Debug().Bool("editable", editable).Msg("cef: editable focus changed")
683+ }
684+ return
685+ }
686+ wv.mu.Unlock()
687+ if previous != editable && wv.ctx != nil {
688+ logging.FromContext(wv.ctx).Debug().Bool("editable", editable).Msg("cef: editable focus changed")
689+ }
690+}
691+
692+func (wv *WebView) cancelSelectionDebounce() {
693+ if wv == nil {
694+ return
695+ }
696+ wv.mu.Lock()
697+ wv.selectionDebounceSeq++
698+ timer := wv.selectionDebounceTimer
699+ wv.selectionDebounceTimer = nil
700+ wv.mu.Unlock()
701+ if timer != nil {
702+ timer.Stop()
703+ }
704+}
705+
706+func (wv *WebView) selectionDebounceInterval() time.Duration {
707+ if wv == nil || wv.selectionDebounceDelay == nil {
708+ return clipboardSelectionDebounceDelay
709+ }
710+ return *wv.selectionDebounceDelay
711+}
712+
713+func (wv *WebView) selectionDebounceScheduler() func(time.Duration, func()) stoppableTimer {
714+ if wv != nil && wv.selectionDebounceSchedule != nil {
715+ return wv.selectionDebounceSchedule
716+ }
717+ return func(delay time.Duration, fn func()) stoppableTimer {
718+ return time.AfterFunc(delay, fn)
719+ }
720+}
721+
722+func (wv *WebView) scheduleSelectionUpdate(text string) {
723+ if wv == nil || wv.destroyed.Load() {
724+ return
725+ }
726+ wv.mu.Lock()
727+ if wv.destroyed.Load() || wv.engine == nil {
728+ wv.mu.Unlock()
729+ return
730+ }
731+ if wv.focusedEditable {
732+ wv.mu.Unlock()
733+ if wv.ctx != nil {
734+ logging.FromContext(wv.ctx).Debug().
735+ Int("text_len", len(text)).
736+ Msg("cef: selection auto-copy skipped while editable focused")
737+ }
738+ return
739+ }
740+ wv.selectionDebounceSeq++
741+ seq := wv.selectionDebounceSeq
742+ if timer := wv.selectionDebounceTimer; timer != nil {
743+ timer.Stop()
744+ }
745+ delay := wv.selectionDebounceInterval()
746+ if delay <= 0 {
747+ wv.selectionDebounceTimer = nil
748+ wv.mu.Unlock()
749+ wv.flushSelectionUpdate(seq, text)
750+ return
751+ }
752+ scheduler := wv.selectionDebounceScheduler()
753+ wv.selectionDebounceTimer = scheduler(delay, func() {
754+ wv.flushSelectionUpdate(seq, text)
755+ })
756+ wv.mu.Unlock()
757+}
758+
759+func (wv *WebView) flushSelectionUpdate(seq uint64, text string) {
760+ if wv == nil || wv.destroyed.Load() {
761+ return
762+ }
763+ wv.mu.Lock()
764+ if wv.destroyed.Load() || wv.focusedEditable || seq != wv.selectionDebounceSeq {
765+ wv.mu.Unlock()
766+ return
767+ }
768+ engine := wv.engine
769+ viewID := wv.id
770+ wv.mu.Unlock()
771+ if engine != nil {
772+ if wv.ctx != nil {
773+ logging.FromContext(wv.ctx).Debug().
774+ Int("text_len", len(text)).
775+ Msg("cef: debounced selection update flushed")
776+ }
777+ engine.handleClipboardSelectionUpdate(viewID, text)
778+ }
779+}
780+
781+func (wv *WebView) selectedTextSnapshot() string {
782+ wv.mu.RLock()
783+ defer wv.mu.RUnlock()
784+ return wv.selectedText
785+}
786+
787+func (wv *WebView) scheduleLoadWatchdogs(loadSeq uint64) {
788+ for _, delay := range cefLoadWatchdogDelays {
789+ d := delay
790+ time.AfterFunc(d, func() {
791+ wv.logLoadWatchdog(loadSeq, d)
792+ })
793+ }
794+}
795+
796+func (wv *WebView) logLoadWatchdog(loadSeq uint64, delay time.Duration) {
797+ if wv == nil || wv.destroyed.Load() || wv.ctx == nil {
798+ return
799+ }
800+
801+ wv.mu.RLock()
802+ currentSeq := wv.loadDiagSeq
803+ loading := wv.isLoading
804+ uri := wv.uri
805+ title := wv.title
806+ progress := wv.progress
807+ pendingURI := wv.pendingURI
808+ startedAt := wv.loadDiagStartedAt
809+ lastProgressAt := wv.loadDiagLastProgressAt
810+ lastAddressAt := wv.loadDiagLastAddressAt
811+ lastLoadStateAt := wv.loadDiagLastLoadStateAt
812+ wv.mu.RUnlock()
813+
814+ if loadSeq != currentSeq || !loading {
815+ return
816+ }
817+
818+ snap := renderPipelineSnapshot{}
819+ if wv.pipeline != nil {
820+ snap = wv.pipeline.diagnosticSnapshot()
821+ }
822+
823+ now := time.Now()
824+ logging.FromContext(wv.ctx).Debug().
825+ Uint64("load_seq", loadSeq).
826+ Int64("watch_delay_ms", delay.Milliseconds()).
827+ Int64("since_start_ms", sinceTimestampMs(startedAt, now)).
828+ Int64("since_progress_ms", sinceTimestampMs(lastProgressAt, now)).
829+ Int64("since_address_ms", sinceTimestampMs(lastAddressAt, now)).
830+ Int64("since_load_state_ms", sinceTimestampMs(lastLoadStateAt, now)).
831+ Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
832+ Str("title", title).
833+ Float64("progress", progress).
834+ Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)).
835+ Int32("surface_width", snap.Width).
836+ Int32("surface_height", snap.Height).
837+ Int32("surface_scale", snap.Scale).
838+ Bool("gl_ready", snap.GLReady).
839+ Bool("needs_upload", snap.NeedsUpload).
840+ Bool("size_changed", snap.SizeChanged).
841+ Bool("force_full_upload", snap.ForceFullUpload).
842+ Uint64("view_rect_seq", snap.ViewRectSeq).
843+ Uint64("screen_info_seq", snap.ScreenInfoSeq).
844+ Uint64("paint_seq", snap.PaintSeq).
845+ Uint64("resize_seq", snap.ResizeSeq).
846+ Uint64("last_queued_paint_seq", snap.LastQueuedPaintSeq).
847+ Uint64("gl_render_seq", snap.GLRenderSeq).
848+ Uint64("paint_total", snap.PaintCount).
849+ Uint64("queue_total", snap.QueueRenderCount).
850+ Uint64("render_total", snap.RenderCount).
851+ Uint64("upload_total", snap.UploadCount).
852+ Uint64("full_upload_total", snap.FullUploadCount).
853+ Msg("cef: loading watchdog")
854+}
855+
856+func (wv *WebView) pendingNavigationURI() string {
857+ if wv == nil {
858+ return ""
859+ }
860+ wv.mu.RLock()
861+ defer wv.mu.RUnlock()
862+ return wv.pendingURI
863+}
864+
865+func (wv *WebView) schedulePendingNavigationReplay(attempt int) {
866+ if wv == nil || wv.destroyed.Load() {
867+ return
868+ }
869+ task := cefNewTask(cefTaskFunc(func() {
870+ wv.replayPendingNavigation(attempt)
871+ }))
872+ if task == nil {
873+ return
874+ }
875+ var result int32
876+ if attempt <= 0 {
877+ result = cefPostTask(purecef.ThreadIDTidUi, task)
878+ } else {
879+ result = cefPostDelayedTask(purecef.ThreadIDTidUi, task, int64(pendingNavigationRetryDelay/time.Millisecond))
880+ }
881+ if result == 1 {
882+ return
883+ }
884+ if wv.ctx != nil {
885+ log := logging.FromContext(wv.ctx).Warn().
886+ Int("attempt", attempt).
887+ Int32("result", result)
888+ if attempt >= pendingNavigationMaxRetries {
889+ log.Msg("cef: failed to schedule pending navigation replay; retries exhausted")
890+ } else {
891+ log.Msg("cef: failed to schedule pending navigation replay; retrying")
892+ }
893+ }
894+ if attempt >= pendingNavigationMaxRetries {
895+ return
896+ }
897+ cefScheduleAfter(pendingNavigationRetryDelay, func() {
898+ wv.schedulePendingNavigationReplay(attempt + 1)
899+ })
900+}
901+
902+func (wv *WebView) replayPendingNavigation(attempt int) {
903+ if wv == nil || wv.destroyed.Load() {
904+ return
905+ }
906+ wv.mu.RLock()
907+ uri := wv.pendingURI
908+ browser := wv.browser
909+ wv.mu.RUnlock()
910+ if uri == "" || browser == nil {
911+ return
912+ }
913+ frame := browser.GetMainFrame()
914+ if frame == nil {
915+ if attempt >= pendingNavigationMaxRetries {
916+ if wv.ctx != nil {
917+ logging.FromContext(wv.ctx).Warn().
918+ Int("attempt", attempt).
919+ Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
920+ Msg("cef: pending navigation replay exhausted without main frame")
921+ }
922+ return
923+ }
924+ if wv.ctx != nil {
925+ logging.FromContext(wv.ctx).Debug().
926+ Int("attempt", attempt).
927+ Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
928+ Msg("cef: pending navigation replay waiting for main frame")
929+ }
930+ wv.schedulePendingNavigationReplay(attempt + 1)
931+ return
932+ }
933+ currentURL := frame.GetURL()
934+ if pendingURIEquivalent(currentURL, uri) {
935+ wv.mu.Lock()
936+ if pendingURIEquivalent(wv.pendingURI, uri) {
937+ wv.pendingURI = ""
938+ }
939+ wv.mu.Unlock()
940+ if wv.ctx != nil {
941+ logging.FromContext(wv.ctx).Debug().
942+ Int("attempt", attempt).
943+ Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
944+ Msg("cef: pending navigation already active")
945+ }
946+ return
947+ }
948+ frame.LoadURL(uri)
949+ if wv.ctx != nil {
950+ logging.FromContext(wv.ctx).Debug().
951+ Int("attempt", attempt).
952+ Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
953+ Msg("cef: replayed pending navigation")
954+ }
955+}
956+
957+func pendingURIEquivalent(a, b string) bool {
958+ if a == "" || b == "" {
959+ return false
960+ }
961+ return toConceptualInternalURL(strings.TrimSpace(a)) == toConceptualInternalURL(strings.TrimSpace(b))
962+}
963+
964+func sinceTimestampMs(ts, now time.Time) int64 {
965+ if ts.IsZero() {
966+ return -1
967+ }
968+ return now.Sub(ts).Milliseconds()
969+}
970+
568 func (wv *WebView) updateHoverURI(uri string) {971 func (wv *WebView) updateHoverURI(uri string) {
569 wv.mu.Lock()972 wv.mu.Lock()
570 wv.lastHoverURI = uri973 wv.lastHoverURI = uri
M internal/infrastructure/cef/webview_handlers.go +191 -26
@@ -15,6 +15,7 @@ import (
15 const (15 const (
16 consoleMarkerVideoDiag = "[VIDEO-DIAG]"16 consoleMarkerVideoDiag = "[VIDEO-DIAG]"
17 consoleMarkerRedditVideoPatch = "[REDDIT-VIDEO-PATCH]"17 consoleMarkerRedditVideoPatch = "[REDDIT-VIDEO-PATCH]"
18+ consoleMarkerAutoCopy = "[AUTO-COPY]"
18 )19 )
19 20
20 // handlerSet implements all CEF handler interfaces and dispatches events to the21 // handlerSet implements all CEF handler interfaces and dispatches events to the
@@ -67,8 +68,46 @@ func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return
67 func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h }68 func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h }
68 func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h }69 func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h }
69 70
70-func (h *handlerSet) OnProcessMessageReceived(_ purecef.Browser, _ purecef.Frame, _ purecef.ProcessID, _ purecef.ProcessMessage) int32 {71+func (h *handlerSet) OnProcessMessageReceived(
71- return 072+ browser purecef.Browser,
73+ _ purecef.Frame,
74+ _ purecef.ProcessID,
75+ message purecef.ProcessMessage,
76+) int32 {
77+ if h == nil || h.wv == nil || h.wv.engine == nil {
78+ return 0
79+ }
80+ action, payload, ok := decodeRendererBridgeProcessMessage(message)
81+ if !ok {
82+ return 0
83+ }
84+
85+ log := logging.FromContext(h.wv.ctx)
86+ log.Debug().
87+ Str("action", action).
88+ Int("payload_len", len(payload)).
89+ Msg("cef: renderer bridge message received")
90+
91+ switch action {
92+ case rendererBridgeActionExplicitTextCopy:
93+ req, err := decodeRendererBridgeExplicitTextCopyPayload([]byte(payload))
94+ if err != nil {
95+ log.Debug().Str("action", action).Msg("cef: invalid explicit copy payload")
96+ return 1
97+ }
98+ h.wv.engine.handleExplicitClipboardBridgeText(h.wv.id, req.Action, req.Text)
99+ case rendererBridgeActionEditableFocusChanged:
100+ h.wv.setEditableFocus(payload == "1" || strings.EqualFold(payload, "true"))
101+ case rendererBridgeActionFocusSync:
102+ h.wv.engine.handleEditableFocusBridge(browser)
103+ case rendererBridgeActionReady:
104+ log.Debug().
105+ Str("frame_url", logging.TruncateURL(payload, logging.PermissionLogURLMaxLen)).
106+ Msg("cef: renderer bridge ready")
107+ default:
108+ log.Debug().Str("action", action).Msg("cef: unknown renderer bridge action")
109+ }
110+ return 1
72 }111 }
73 112
74 // ===========================================================================113 // ===========================================================================
@@ -267,6 +306,7 @@ func (h *handlerSet) shouldAcceptMainViewPaint(
267 if sizeMatchesCurrentView {306 if sizeMatchesCurrentView {
268 _, _, shouldLog := h.wv.pipeline.markFirstPaintAfterResize()307 _, _, shouldLog := h.wv.pipeline.markFirstPaintAfterResize()
269 if shouldLog {308 if shouldLog {
309+ logging.Trace().Mark("cef_first_content_paint")
270 logging.FromContext(h.wv.ctx).Debug().310 logging.FromContext(h.wv.ctx).Debug().
271 Uint64("webview_id", uint64(h.wv.id)).311 Uint64("webview_id", uint64(h.wv.id)).
272 Uint64("paint_seq", paintSeq).312 Uint64("paint_seq", paintSeq).
@@ -295,7 +335,12 @@ func (h *handlerSet) shouldAcceptMainViewPaint(
295 return sizeMatchesCurrentView335 return sizeMatchesCurrentView
296 }336 }
297 337
298-func (h *handlerSet) OnAcceleratedPaint(_ purecef.Browser, _ purecef.PaintElementType, _ []purecef.Rect, _ *purecef.AcceleratedPaintInfo) {338+func (h *handlerSet) OnAcceleratedPaint(
339+ _ purecef.Browser,
340+ _ purecef.PaintElementType,
341+ _ []purecef.Rect,
342+ _ *purecef.AcceleratedPaintInfo,
343+) {
299 count := h.wv.pipeline.recordAcceleratedPaint()344 count := h.wv.pipeline.recordAcceleratedPaint()
300 if count <= 5 || count%100 == 0 {345 if count <= 5 || count%100 == 0 {
301 logging.FromContext(h.wv.ctx).Info().346 logging.FromContext(h.wv.ctx).Info().
@@ -309,7 +354,12 @@ func (h *handlerSet) GetTouchHandleSize(_ purecef.Browser, _ purecef.HorizontalA
309 354
310 func (h *handlerSet) OnTouchHandleStateChanged(_ purecef.Browser, _ *purecef.TouchHandleState) {}355 func (h *handlerSet) OnTouchHandleStateChanged(_ purecef.Browser, _ *purecef.TouchHandleState) {}
311 356
312-func (h *handlerSet) StartDragging(_ purecef.Browser, _ purecef.DragData, _ purecef.DragOperationsMask, _, _ int32) int32 {357+func (h *handlerSet) StartDragging(
358+ _ purecef.Browser,
359+ _ purecef.DragData,
360+ _ purecef.DragOperationsMask,
361+ _, _ int32,
362+) int32 {
313 return 0363 return 0
314 }364 }
315 365
@@ -320,7 +370,29 @@ func (h *handlerSet) OnScrollOffsetChanged(_ purecef.Browser, _, _ float64) {}
320 func (h *handlerSet) OnImeCompositionRangeChanged(_ purecef.Browser, _ *purecef.Range, _ []purecef.Rect) {370 func (h *handlerSet) OnImeCompositionRangeChanged(_ purecef.Browser, _ *purecef.Range, _ []purecef.Rect) {
321 }371 }
322 372
323-func (h *handlerSet) OnTextSelectionChanged(_ purecef.Browser, _ string, _ *purecef.Range) {}373+func (h *handlerSet) OnTextSelectionChanged(_ purecef.Browser, selectedText string, _ *purecef.Range) {
374+ if h == nil || h.wv == nil {
375+ return
376+ }
377+ previous, changed := h.wv.setSelectedText(selectedText)
378+ if !changed {
379+ return
380+ }
381+ if h.wv.ctx != nil && selectedText == "" {
382+ if previous != "" {
383+ logging.FromContext(h.wv.ctx).Debug().
384+ Int("prev_text_len", len(previous)).
385+ Msg("cef: text selection cleared")
386+ }
387+ } else if h.wv.ctx != nil {
388+ logging.FromContext(h.wv.ctx).Debug().
389+ Int("text_len", len(selectedText)).
390+ Msg("cef: text selection changed")
391+ }
392+ if h.wv.engine != nil {
393+ h.wv.scheduleSelectionUpdate(selectedText)
394+ }
395+}
324 396
325 func (h *handlerSet) OnVirtualKeyboardRequested(_ purecef.Browser, _ purecef.TextInputMode) {}397 func (h *handlerSet) OnVirtualKeyboardRequested(_ purecef.Browser, _ purecef.TextInputMode) {}
326 398
@@ -331,6 +403,11 @@ func (h *handlerSet) OnVirtualKeyboardRequested(_ purecef.Browser, _ purecef.Tex
331 // OnAddressChange updates the cached URI when the main frame navigates.403 // OnAddressChange updates the cached URI when the main frame navigates.
332 func (h *handlerSet) OnAddressChange(_ purecef.Browser, frame purecef.Frame, url string) {404 func (h *handlerSet) OnAddressChange(_ purecef.Browser, frame purecef.Frame, url string) {
333 if frame != nil && frame.IsMain() {405 if frame != nil && frame.IsMain() {
406+ if h.wv != nil && h.wv.ctx != nil {
407+ logging.FromContext(h.wv.ctx).Debug().
408+ Str("url", logging.TruncateURL(url, logging.PermissionLogURLMaxLen)).
409+ Msg("cef: OnAddressChange")
410+ }
334 h.wv.updateURI(url)411 h.wv.updateURI(url)
335 }412 }
336 }413 }
@@ -382,9 +459,16 @@ func (h *handlerSet) OnStatusMessage(_ purecef.Browser, value string) {
382 }459 }
383 }460 }
384 461
385-func (h *handlerSet) OnConsoleMessage(_ purecef.Browser, level purecef.LogSeverity, message, source string, line int32) int32 {462+func (h *handlerSet) OnConsoleMessage(
463+ _ purecef.Browser,
464+ level purecef.LogSeverity,
465+ message, source string,
466+ line int32,
467+) int32 {
386 if h.wv != nil && h.wv.ctx != nil &&468 if h.wv != nil && h.wv.ctx != nil &&
387- (strings.Contains(message, consoleMarkerVideoDiag) || strings.Contains(message, consoleMarkerRedditVideoPatch)) {469+ (strings.Contains(message, consoleMarkerVideoDiag) ||
470+ strings.Contains(message, consoleMarkerRedditVideoPatch) ||
471+ strings.Contains(message, consoleMarkerAutoCopy)) {
388 log := logging.FromContext(h.wv.ctx).With().472 log := logging.FromContext(h.wv.ctx).With().
389 Str("component", "cef-console").473 Str("component", "cef-console").
390 Str("source", source).474 Str("source", source).
@@ -406,10 +490,20 @@ func (h *handlerSet) OnAutoResize(_ purecef.Browser, _ *purecef.Size) int32 { re
406 490
407 // OnLoadingProgressChange updates the cached progress value.491 // OnLoadingProgressChange updates the cached progress value.
408 func (h *handlerSet) OnLoadingProgressChange(_ purecef.Browser, progress float64) {492 func (h *handlerSet) OnLoadingProgressChange(_ purecef.Browser, progress float64) {
493+ if h.wv != nil && h.wv.ctx != nil {
494+ logging.FromContext(h.wv.ctx).Trace().
495+ Float64("progress", progress).
496+ Msg("cef: OnLoadingProgressChange")
497+ }
409 h.wv.updateProgress(progress)498 h.wv.updateProgress(progress)
410 }499 }
411 500
412-func (h *handlerSet) OnCursorChange(_ purecef.Browser, _ uintptr, cursorType purecef.CursorType, _ *purecef.CursorInfo) int32 {501+func (h *handlerSet) OnCursorChange(
502+ _ purecef.Browser,
503+ _ uintptr,
504+ cursorType purecef.CursorType,
505+ _ *purecef.CursorInfo,
506+) int32 {
413 name := cefCursorToGDKName(cursorType)507 name := cefCursorToGDKName(cursorType)
414 h.wv.runOnGTK(func() {508 h.wv.runOnGTK(func() {
415 h.wv.pipeline.glArea.SetCursorFromName(&name)509 h.wv.pipeline.glArea.SetCursorFromName(&name)
@@ -431,6 +525,30 @@ func (h *handlerSet) GetRootWindowScreenRect(_ purecef.Browser, _ *purecef.Rect)
431 func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangoback, cangoforward int32) {525 func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangoback, cangoforward int32) {
432 loading := isloading != 0526 loading := isloading != 0
433 h.wv.updateLoadState(loading, cangoback != 0, cangoforward != 0)527 h.wv.updateLoadState(loading, cangoback != 0, cangoforward != 0)
528+ if h.wv != nil && h.wv.ctx != nil {
529+ h.wv.mu.RLock()
530+ uri := h.wv.uri
531+ progress := h.wv.progress
532+ pendingURI := h.wv.pendingURI
533+ h.wv.mu.RUnlock()
534+ logging.FromContext(h.wv.ctx).Debug().
535+ Bool("loading", loading).
536+ Bool("can_go_back", cangoback != 0).
537+ Bool("can_go_forward", cangoforward != 0).
538+ Float64("progress", progress).
539+ Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
540+ Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)).
541+ Msg("cef: OnLoadingStateChange")
542+ }
543+
544+ if !loading && h.wv.input != nil && h.wv.input.hasGTKFocus() {
545+ h.wv.mu.RLock()
546+ host := h.wv.host
547+ h.wv.mu.RUnlock()
548+ if host != nil {
549+ syncWindowlessBrowserFocus(host)
550+ }
551+ }
434 552
435 h.wv.mu.RLock()553 h.wv.mu.RLock()
436 cb := h.wv.callbacks554 cb := h.wv.callbacks
@@ -455,6 +573,11 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec
455 if frame == nil || !frame.IsMain() {573 if frame == nil || !frame.IsMain() {
456 return574 return
457 }575 }
576+ if h.wv != nil && h.wv.ctx != nil {
577+ logging.FromContext(h.wv.ctx).Debug().
578+ Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)).
579+ Msg("cef: OnLoadStart")
580+ }
458 h.wv.updateURI(frame.GetURL())581 h.wv.updateURI(frame.GetURL())
459 h.wv.mu.RLock()582 h.wv.mu.RLock()
460 cb := h.wv.callbacks583 cb := h.wv.callbacks
@@ -480,6 +603,18 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu
480 // Successful load — reset the consecutive crash counter.603 // Successful load — reset the consecutive crash counter.
481 h.wv.crashCount.Store(0)604 h.wv.crashCount.Store(0)
482 605
606+ // If a queued startup navigation is still pending after about:blank finished,
607+ // replay it now that the initial main-frame load completed.
608+ frameURL := frame.GetURL()
609+ if pendingURI := h.wv.pendingNavigationURI(); pendingURI != "" && !pendingURIEquivalent(frameURL, pendingURI) {
610+ if strings.EqualFold(strings.TrimSpace(frameURL), "about:blank") {
611+ log.Debug().
612+ Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)).
613+ Msg("cef: replaying pending navigation after about:blank load end")
614+ h.wv.schedulePendingNavigationReplay(0)
615+ }
616+ }
617+
483 // Inject scripts and styles after page load.618 // Inject scripts and styles after page load.
484 // Must run on GTK thread — OnLoadEnd fires on the CEF IO thread,619 // Must run on GTK thread — OnLoadEnd fires on the CEF IO thread,
485 // and JavaScript injection requires the main thread.620 // and JavaScript injection requires the main thread.
@@ -564,35 +699,47 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
564 h.wv.engine.registerWebView(h.wv)699 h.wv.engine.registerWebView(h.wv)
565 }700 }
566 host := browser.GetHost()701 host := browser.GetHost()
702+ if host == nil {
703+ log.Warn().Msg("cef: OnAfterCreated returned nil host")
704+ return
705+ }
567 706
707+ shouldSyncFocus := false
708+ hasPendingNavigation := false
568 h.wv.mu.Lock()709 h.wv.mu.Lock()
569 h.wv.browser = browser710 h.wv.browser = browser
570 h.wv.host = host711 h.wv.host = host
571- uri := h.wv.pendingURI
572- h.wv.pendingURI = ""
573 h.wv.input.setHost(host)712 h.wv.input.setHost(host)
574 if h.wv.findCtrl != nil {713 if h.wv.findCtrl != nil {
575 h.wv.findCtrl.setHost(host)714 h.wv.findCtrl.setHost(host)
576 }715 }
577 716
578 // Mark browser as visible — CEF OSR starts in hidden state and suppresses717 // Mark browser as visible — CEF OSR starts in hidden state and suppresses
579- // UI elements like the text caret until explicitly told the browser is shown.718+ // painting/caret updates until explicitly told the browser is shown.
580 host.WasHidden(0)719 host.WasHidden(0)
581 720
582- // If the GLArea already has GTK focus (cold start race), tell CEF now.721+ // If GTK focus entered before the browser existed, reassert browser focus
583- // The focus-enter event fired before the browser existed, so SetFocus722+ // now. Otherwise CEF may stay internally unfocused and suppress caret paint.
584- // was never called and the caret won't blink until the user clicks.723+ if h.wv.input != nil {
585- if h.wv.input != nil && h.wv.input.glArea != nil && h.wv.input.glArea.HasFocus() {724+ shouldSyncFocus = h.wv.input.hasGTKFocus()
586- host.SetFocus(1)725+ if !shouldSyncFocus && h.wv.input.glArea != nil {
587- }726+ shouldSyncFocus = h.wv.input.glArea.HasFocus()
588-
589- // Replay any navigation that was requested before the browser existed.
590- if uri != "" {
591- if frame := browser.GetMainFrame(); frame != nil {
592- frame.LoadURL(uri)
593 }727 }
594 }728 }
729+ hasPendingNavigation = strings.TrimSpace(h.wv.pendingURI) != ""
730+
595 h.wv.mu.Unlock()731 h.wv.mu.Unlock()
732+ if shouldSyncFocus {
733+ syncWindowlessBrowserFocus(host)
734+ } else {
735+ // Request an initial OSR frame even before the first real navigation
736+ // commits. This restores the about:blank warm-up paint that the stable
737+ // startup path relied on and prevents the render pipeline from staying idle.
738+ host.Invalidate(purecef.PaintElementTypePetView)
739+ }
740+ if hasPendingNavigation {
741+ h.wv.schedulePendingNavigationReplay(0)
742+ }
596 743
597 h.wv.scheduleStartBeginFrameLoop()744 h.wv.scheduleStartBeginFrameLoop()
598 }745 }
@@ -636,7 +783,13 @@ func (h *handlerSet) OnBeforeBrowse(_ purecef.Browser, _ purecef.Frame, _ purece
636 return false783 return false
637 }784 }
638 785
639-func (h *handlerSet) OnOpenUrlfromTab(_ purecef.Browser, _ purecef.Frame, _ string, _ purecef.WindowOpenDisposition, _ int32) int32 {786+func (h *handlerSet) OnOpenUrlfromTab(
787+ _ purecef.Browser,
788+ _ purecef.Frame,
789+ _ string,
790+ _ purecef.WindowOpenDisposition,
791+ _ int32,
792+) int32 {
640 return 0793 return 0
641 }794 }
642 795
@@ -644,7 +797,8 @@ func (h *handlerSet) GetResourceRequestHandler(
644 _ purecef.Browser, _ purecef.Frame, request purecef.Request,797 _ purecef.Browser, _ purecef.Frame, request purecef.Request,
645 _, _ int32, _ string, disableDefaultHandling *int32,798 _, _ int32, _ string, disableDefaultHandling *int32,
646 ) purecef.ResourceRequestHandler {799 ) purecef.ResourceRequestHandler {
647- if h.transcodingHandler != nil && request != nil && disableDefaultHandling != nil && transcoder.IsEagerTranscodeURL(request.GetURL()) {800+ if h.transcodingHandler != nil && request != nil && disableDefaultHandling != nil &&
801+ transcoder.IsEagerTranscodeURL(request.GetURL()) {
648 *disableDefaultHandling = 1802 *disableDefaultHandling = 1
649 if h.wv != nil && h.wv.ctx != nil {803 if h.wv != nil && h.wv.ctx != nil {
650 logging.FromContext(h.wv.ctx).Info().804 logging.FromContext(h.wv.ctx).Info().
@@ -665,7 +819,13 @@ func (h *handlerSet) GetAuthCredentials(
665 return 0819 return 0
666 }820 }
667 821
668-func (h *handlerSet) OnCertificateError(_ purecef.Browser, _ purecef.Errorcode, _ string, _ purecef.Sslinfo, _ purecef.Callback) int32 {822+func (h *handlerSet) OnCertificateError(
823+ _ purecef.Browser,
824+ _ purecef.Errorcode,
825+ _ string,
826+ _ purecef.Sslinfo,
827+ _ purecef.Callback,
828+) int32 {
669 return 0829 return 0
670 }830 }
671 831
@@ -894,7 +1054,12 @@ func (h *handlerSet) OnAudioStreamError(_ purecef.Browser, message string) {
894 // ===========================================================================1054 // ===========================================================================
895 1055
896 // OnFindResult dispatches CEF find results to the WebView's FindController.1056 // OnFindResult dispatches CEF find results to the WebView's FindController.
897-func (h *handlerSet) OnFindResult(_ purecef.Browser, identifier, count int32, _ *purecef.Rect, activematchordinal, finalupdate int32) {1057+func (h *handlerSet) OnFindResult(
1058+ _ purecef.Browser,
1059+ identifier, count int32,
1060+ _ *purecef.Rect,
1061+ activematchordinal, finalupdate int32,
1062+) {
898 if h.wv.findCtrl != nil {1063 if h.wv.findCtrl != nil {
899 h.wv.findCtrl.handleFindResult(identifier, count, activematchordinal, finalupdate)1064 h.wv.findCtrl.handleFindResult(identifier, count, activematchordinal, finalupdate)
900 }1065 }
M internal/infrastructure/cef/webview_handlers_test.go +240 -0
@@ -2,15 +2,58 @@ package cef
2 2
3 import (3 import (
4 "context"4 "context"
5+ "sync"
5 "testing"6 "testing"
7+ "time"
6 8
7 purecef "github.com/bnema/purego-cef/cef"9 purecef "github.com/bnema/purego-cef/cef"
10+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
8 "github.com/stretchr/testify/assert"11 "github.com/stretchr/testify/assert"
12+ "github.com/stretchr/testify/mock"
9 "github.com/stretchr/testify/require"13 "github.com/stretchr/testify/require"
10 14
15+ "github.com/bnema/dumber/internal/application/dto"
11 "github.com/bnema/dumber/internal/application/port"16 "github.com/bnema/dumber/internal/application/port"
17+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
12 )18 )
13 19
20+type clipboardOrchestratorRecorder struct {
21+ mu sync.Mutex
22+ selection dto.SelectionClipboardInput
23+ selectionCalls int
24+}
25+
26+type controlledSelectionTimer struct {
27+ stopped bool
28+}
29+
30+func (t *controlledSelectionTimer) Stop() bool {
31+ alreadyStopped := t.stopped
32+ t.stopped = true
33+ return !alreadyStopped
34+}
35+
36+type controlledSelectionScheduler struct {
37+ timers []*controlledSelectionTimer
38+ callbacks []func()
39+}
40+
41+func (s *controlledSelectionScheduler) schedule(_ time.Duration, fn func()) stoppableTimer {
42+ timer := &controlledSelectionTimer{}
43+ s.timers = append(s.timers, timer)
44+ s.callbacks = append(s.callbacks, fn)
45+ return timer
46+}
47+
48+func (s *controlledSelectionScheduler) fire(index int) {
49+ if index < 0 || index >= len(s.callbacks) {
50+ return
51+ }
52+ if !s.timers[index].stopped {
53+ s.callbacks[index]()
54+ }
55+}
56+
14 func newTestPipeline(w, h, s int32) *renderPipeline {57 func newTestPipeline(w, h, s int32) *renderPipeline {
15 rp := &renderPipeline{scale: s}58 rp := &renderPipeline{scale: s}
16 rp.widthAtomic.Store(w)59 rp.widthAtomic.Store(w)
@@ -89,6 +132,203 @@ func TestOnLoadEndDoesNotDispatchBrowserLevelCompletion(t *testing.T) {
89 assert.Empty(t, gotProgress)132 assert.Empty(t, gotProgress)
90 }133 }
91 134
135+func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *testing.T) {
136+ recorder := &clipboardOrchestratorRecorder{}
137+ orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
138+ orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input dto.SelectionClipboardInput) {
139+ recorder.mu.Lock()
140+ defer recorder.mu.Unlock()
141+ recorder.selection = input
142+ recorder.selectionCalls++
143+ }).Return(nil).Once()
144+ zeroDelay := time.Duration(0)
145+ wv := &WebView{
146+ ctx: context.Background(),
147+ id: 42,
148+ selectionDebounceDelay: &zeroDelay,
149+ engine: &Engine{
150+ clipboardTextOrchestrator: orchestrator,
151+ },
152+ }
153+ h := &handlerSet{wv: wv}
154+
155+ h.OnTextSelectionChanged(nil, "selected text", nil)
156+
157+ recorder.mu.Lock()
158+ defer recorder.mu.Unlock()
159+ require.Equal(t, 1, recorder.selectionCalls)
160+ require.Equal(t, "selected text", recorder.selection.Text)
161+ require.Equal(t, dto.ClipboardSourceCEF, recorder.selection.SourceEngine)
162+ require.Equal(t, uint64(42), recorder.selection.ViewID)
163+}
164+
165+func TestOnTextSelectionChanged_DebouncesAndCollapsesRapidUpdates(t *testing.T) {
166+ recorder := &clipboardOrchestratorRecorder{}
167+ scheduler := &controlledSelectionScheduler{}
168+ orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
169+ orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input dto.SelectionClipboardInput) {
170+ recorder.mu.Lock()
171+ defer recorder.mu.Unlock()
172+ recorder.selection = input
173+ recorder.selectionCalls++
174+ }).Return(nil).Once()
175+ wv := &WebView{
176+ ctx: context.Background(),
177+ id: 42,
178+ selectionDebounceSchedule: scheduler.schedule,
179+ engine: &Engine{
180+ clipboardTextOrchestrator: orchestrator,
181+ },
182+ }
183+ h := &handlerSet{wv: wv}
184+
185+ h.OnTextSelectionChanged(nil, "first selection", nil)
186+ h.OnTextSelectionChanged(nil, "second selection", nil)
187+
188+ recorder.mu.Lock()
189+ gotCalls := recorder.selectionCalls
190+ recorder.mu.Unlock()
191+ require.Zero(t, gotCalls)
192+ require.Len(t, scheduler.callbacks, 2)
193+
194+ scheduler.fire(0)
195+ recorder.mu.Lock()
196+ gotCalls = recorder.selectionCalls
197+ recorder.mu.Unlock()
198+ require.Zero(t, gotCalls)
199+
200+ scheduler.fire(1)
201+ recorder.mu.Lock()
202+ defer recorder.mu.Unlock()
203+ require.Equal(t, 1, recorder.selectionCalls)
204+ require.Equal(t, "second selection", recorder.selection.Text)
205+ require.Equal(t, "second selection", wv.selectedTextSnapshot())
206+}
207+
208+func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResumesWhenCleared(t *testing.T) {
209+ recorder := &clipboardOrchestratorRecorder{}
210+ scheduler := &controlledSelectionScheduler{}
211+ orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
212+ orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input dto.SelectionClipboardInput) {
213+ recorder.mu.Lock()
214+ defer recorder.mu.Unlock()
215+ recorder.selection = input
216+ recorder.selectionCalls++
217+ }).Return(nil).Once()
218+ wv := &WebView{
219+ ctx: context.Background(),
220+ id: 42,
221+ selectionDebounceSchedule: scheduler.schedule,
222+ engine: &Engine{
223+ clipboardTextOrchestrator: orchestrator,
224+ },
225+ }
226+ h := &handlerSet{wv: wv}
227+ frame := cefmocks.NewMockFrame(t)
228+ oldFactory := newRendererBridgeProcessMessage
229+ t.Cleanup(func() { newRendererBridgeProcessMessage = oldFactory })
230+
231+ newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage {
232+ return newTestBridgeProcessMessage(name, true)
233+ }
234+ frame.EXPECT().SendProcessMessage(purecef.ProcessIDPidBrowser, mock.Anything).Run(func(_ purecef.ProcessID, message purecef.ProcessMessage) {
235+ require.Equal(t, rendererBridgeMessageName, message.GetName())
236+ args := message.GetArgumentList()
237+ require.Equal(t, "editable_focus_changed", args.GetString(0))
238+ require.Equal(t, "1", args.GetString(1))
239+ h.OnProcessMessageReceived(nil, nil, 0, message)
240+ }).Once()
241+ (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: true})
242+
243+ h.OnTextSelectionChanged(nil, "editable selection", nil)
244+ require.Equal(t, "editable selection", wv.selectedTextSnapshot())
245+
246+ recorder.mu.Lock()
247+ gotCalls := recorder.selectionCalls
248+ recorder.mu.Unlock()
249+ require.Zero(t, gotCalls)
250+ require.Empty(t, scheduler.callbacks)
251+
252+ newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage {
253+ return newTestBridgeProcessMessage(name, false)
254+ }
255+ frame.EXPECT().SendProcessMessage(purecef.ProcessIDPidBrowser, mock.Anything).Run(func(_ purecef.ProcessID, message purecef.ProcessMessage) {
256+ require.Equal(t, rendererBridgeMessageName, message.GetName())
257+ args := message.GetArgumentList()
258+ require.Equal(t, "editable_focus_changed", args.GetString(0))
259+ require.Equal(t, "0", args.GetString(1))
260+ h.OnProcessMessageReceived(nil, nil, 0, message)
261+ }).Once()
262+ (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: false})
263+
264+ h.OnTextSelectionChanged(nil, "free selection", nil)
265+ require.Equal(t, "free selection", wv.selectedTextSnapshot())
266+ recorder.mu.Lock()
267+ gotCalls = recorder.selectionCalls
268+ recorder.mu.Unlock()
269+ require.Zero(t, gotCalls)
270+ require.Len(t, scheduler.callbacks, 1)
271+
272+ scheduler.fire(0)
273+ recorder.mu.Lock()
274+ defer recorder.mu.Unlock()
275+ require.Equal(t, 1, recorder.selectionCalls)
276+ require.Equal(t, "free selection", recorder.selection.Text)
277+}
278+
279+func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *testing.T) {
280+ scheduler := &controlledSelectionScheduler{}
281+ orchestrator := portmocks.NewMockClipboardTextOrchestrator(t)
282+ wv := &WebView{
283+ ctx: context.Background(),
284+ id: 42,
285+ selectionDebounceSchedule: scheduler.schedule,
286+ engine: &Engine{
287+ clipboardTextOrchestrator: orchestrator,
288+ },
289+ }
290+ h := &handlerSet{wv: wv}
291+
292+ h.OnTextSelectionChanged(nil, "selected text", nil)
293+ require.Len(t, scheduler.callbacks, 1)
294+ wv.Destroy()
295+ scheduler.fire(0)
296+
297+ orchestrator.AssertNotCalled(t, "HandleSelectionUpdate", mock.Anything, mock.Anything)
298+ require.Equal(t, "selected text", wv.selectedTextSnapshot())
299+}
300+
301+type stubEditableDomnode struct {
302+ editable bool
303+}
304+
305+func (n stubEditableDomnode) GetType() purecef.DomNodeType { return 0 }
306+func (n stubEditableDomnode) IsText() bool { return false }
307+func (n stubEditableDomnode) IsElement() bool { return true }
308+func (n stubEditableDomnode) IsEditable() bool { return n.editable }
309+func (n stubEditableDomnode) IsFormControlElement() bool { return false }
310+func (n stubEditableDomnode) GetFormControlElementType() purecef.DomFormControlType { return 0 }
311+func (n stubEditableDomnode) IsSame(that purecef.Domnode) bool { return n == that }
312+func (n stubEditableDomnode) GetName() string { return "" }
313+func (n stubEditableDomnode) GetValue() string { return "" }
314+func (n stubEditableDomnode) SetValue(string) int32 { return 0 }
315+func (n stubEditableDomnode) GetAsMarkup() string { return "" }
316+func (n stubEditableDomnode) GetDocument() purecef.Domdocument { return nil }
317+func (n stubEditableDomnode) GetParent() purecef.Domnode { return nil }
318+func (n stubEditableDomnode) GetPreviousSibling() purecef.Domnode { return nil }
319+func (n stubEditableDomnode) GetNextSibling() purecef.Domnode { return nil }
320+func (n stubEditableDomnode) HasChildren() bool { return false }
321+func (n stubEditableDomnode) GetFirstChild() purecef.Domnode { return nil }
322+func (n stubEditableDomnode) GetLastChild() purecef.Domnode { return nil }
323+func (n stubEditableDomnode) GetElementTagName() string { return "" }
324+func (n stubEditableDomnode) HasElementAttributes() bool { return false }
325+func (n stubEditableDomnode) HasElementAttribute(string) bool { return false }
326+func (n stubEditableDomnode) GetElementAttribute(string) string { return "" }
327+func (n stubEditableDomnode) GetElementAttributes(uintptr) {}
328+func (n stubEditableDomnode) SetElementAttribute(string, string) int32 { return 0 }
329+func (n stubEditableDomnode) GetElementInnerText() string { return "" }
330+func (n stubEditableDomnode) GetElementBounds() uintptr { return 0 }
331+
92 func TestGetViewRectUsesDIPCoordinates(t *testing.T) {332 func TestGetViewRectUsesDIPCoordinates(t *testing.T) {
93 rect := &purecef.Rect{}333 rect := &purecef.Rect{}
94 h := &handlerSet{334 h := &handlerSet{
A internal/infrastructure/cef/webview_navigation_test.go +164 -0
@@ -0,0 +1,164 @@
1+package cef
2+
3+import (
4+ "context"
5+ "testing"
6+ "time"
7+
8+ purecef "github.com/bnema/purego-cef/cef"
9+ cefmocks "github.com/bnema/purego-cef/cef/mocks"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+func TestWebViewReplayPendingNavigation_LoadsQueuedURIWhenMainFrameAvailable(t *testing.T) {
14+ browser := cefmocks.NewMockBrowser(t)
15+ frame := cefmocks.NewMockFrame(t)
16+ frame.EXPECT().GetURL().Return("").Once()
17+ frame.EXPECT().LoadURL("https://github.com/bnema").Once()
18+ browser.EXPECT().GetMainFrame().Return(frame).Once()
19+
20+ wv := &WebView{ctx: context.Background(), browser: browser, pendingURI: "https://github.com/bnema"}
21+ wv.replayPendingNavigation(0)
22+
23+ wv.mu.RLock()
24+ defer wv.mu.RUnlock()
25+ require.Equal(t, "https://github.com/bnema", wv.pendingURI)
26+}
27+
28+func TestWebViewReplayPendingNavigation_RetriesWhenMainFrameUnavailable(t *testing.T) {
29+ browser := cefmocks.NewMockBrowser(t)
30+ browser.EXPECT().GetMainFrame().Return(purecef.Frame(nil)).Once()
31+
32+ oldTask := cefNewTask
33+ oldDelayed := cefPostDelayedTask
34+ defer func() {
35+ cefNewTask = oldTask
36+ cefPostDelayedTask = oldDelayed
37+ }()
38+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
39+
40+ scheduled := false
41+ cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 {
42+ require.Equal(t, purecef.ThreadIDTidUi, threadID)
43+ require.NotNil(t, task)
44+ require.Equal(t, int64(pendingNavigationRetryDelay/time.Millisecond), delayMs)
45+ scheduled = true
46+ return 1
47+ }
48+
49+ wv := &WebView{ctx: context.Background(), browser: browser, pendingURI: "https://github.com/bnema"}
50+ wv.replayPendingNavigation(0)
51+
52+ require.True(t, scheduled)
53+ wv.mu.RLock()
54+ defer wv.mu.RUnlock()
55+ require.Equal(t, "https://github.com/bnema", wv.pendingURI)
56+}
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(), browser: browser, pendingURI: "https://github.com/bnema"}
92+ wv.schedulePendingNavigationReplay(0)
93+
94+ require.True(t, retried)
95+}
96+
97+func TestWebViewReplayPendingNavigation_UsesCurrentBrowserAtExecutionTime(t *testing.T) {
98+ staleBrowser := cefmocks.NewMockBrowser(t)
99+ activeBrowser := cefmocks.NewMockBrowser(t)
100+ frame := cefmocks.NewMockFrame(t)
101+ frame.EXPECT().GetURL().Return("").Once()
102+ frame.EXPECT().LoadURL("https://github.com/bnema").Once()
103+ activeBrowser.EXPECT().GetMainFrame().Return(frame).Once()
104+
105+ oldTask := cefNewTask
106+ oldPost := cefPostTask
107+ defer func() {
108+ cefNewTask = oldTask
109+ cefPostTask = oldPost
110+ }()
111+ var scheduled purecef.Task
112+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
113+ cefPostTask = func(threadID purecef.ThreadID, task purecef.Task) int32 {
114+ require.Equal(t, purecef.ThreadIDTidUi, threadID)
115+ scheduled = task
116+ return 1
117+ }
118+
119+ wv := &WebView{ctx: context.Background(), browser: staleBrowser, pendingURI: "https://github.com/bnema"}
120+ wv.schedulePendingNavigationReplay(0)
121+ wv.mu.Lock()
122+ wv.browser = activeBrowser
123+ wv.mu.Unlock()
124+
125+ require.NotNil(t, scheduled)
126+ scheduled.Execute()
127+}
128+
129+func TestWebViewLoadURI_QueuesPendingNavigationReplayForExistingBrowser(t *testing.T) {
130+ browser := cefmocks.NewMockBrowser(t)
131+ frame := cefmocks.NewMockFrame(t)
132+ browser.EXPECT().GetMainFrame().Return(frame).Once()
133+ frame.EXPECT().GetURL().Return("").Once()
134+ frame.EXPECT().LoadURL("github.com/bnema").Once()
135+
136+ oldTask := cefNewTask
137+ oldPost := cefPostTask
138+ defer func() {
139+ cefNewTask = oldTask
140+ cefPostTask = oldPost
141+ }()
142+ cefNewTask = func(task purecef.Task) purecef.Task { return task }
143+ cefPostTask = func(threadID purecef.ThreadID, task purecef.Task) int32 {
144+ require.Equal(t, purecef.ThreadIDTidUi, threadID)
145+ require.NotNil(t, task)
146+ task.Execute()
147+ return 1
148+ }
149+
150+ wv := &WebView{ctx: context.Background(), browser: browser}
151+ require.NoError(t, wv.LoadURI(context.Background(), "github.com/bnema"))
152+
153+ wv.mu.RLock()
154+ defer wv.mu.RUnlock()
155+ require.Equal(t, "github.com/bnema", wv.pendingURI)
156+}
157+
158+func TestWebViewUpdateURI_ClearsMatchingPendingNavigation(t *testing.T) {
159+ wv := &WebView{ctx: context.Background(), pendingURI: "https://github.com/bnema"}
160+
161+ wv.updateURI("https://github.com/bnema")
162+
163+ require.Empty(t, wv.pendingNavigationURI())
164+}
M internal/infrastructure/config/engine.go +1 -1
@@ -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
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+}
M internal/infrastructure/desktop/browser_launch_relay.go +9 -22
@@ -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 (
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.go +46 -28
@@ -4,23 +4,20 @@ import (
4 "context"4 "context"
5 "encoding/json"5 "encoding/json"
6 6
7+ "github.com/bnema/dumber/internal/application/dto"
7 "github.com/bnema/dumber/internal/application/port"8 "github.com/bnema/dumber/internal/application/port"
8 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
9 )10 )
10 11
11 // ClipboardHandler handles clipboard-related messages from webviews.12 // ClipboardHandler handles clipboard-related messages from webviews.
12 type ClipboardHandler struct {13 type ClipboardHandler struct {
13- clipboard port.Clipboard14+ orchestrator port.ClipboardTextOrchestrator
14- autoCopyConfig port.AutoCopyConfig
15- onCopied func(textLen int) // Called after successful auto-copy (for toast notification)
16 }15 }
17 16
18 // NewClipboardHandler creates a new ClipboardHandler.17 // NewClipboardHandler creates a new ClipboardHandler.
19-func NewClipboardHandler(clipboard port.Clipboard, autoCopyConfig port.AutoCopyConfig, onCopied func(textLen int)) *ClipboardHandler {18+func NewClipboardHandler(orchestrator port.ClipboardTextOrchestrator) *ClipboardHandler {
20 return &ClipboardHandler{19 return &ClipboardHandler{
21- clipboard: clipboard,20+ orchestrator: orchestrator,
22- autoCopyConfig: autoCopyConfig,
23- onCopied: onCopied,
24 }21 }
25 }22 }
26 23
@@ -29,38 +26,58 @@ type autoCopyRequest struct {
29 Text string `json:"text"`26 Text string `json:"text"`
30 }27 }
31 28
29+type explicitCopyRequest struct {
30+ Text string `json:"text"`
31+ Action string `json:"action"`
32+}
33+
32 // HandleAutoCopySelection handles the auto_copy_selection message from JS.34 // HandleAutoCopySelection handles the auto_copy_selection message from JS.
33 // It copies the selected text to the clipboard if the feature is enabled.35 // It copies the selected text to the clipboard if the feature is enabled.
34 func (h *ClipboardHandler) HandleAutoCopySelection() port.WebUIMessageHandler {36 func (h *ClipboardHandler) HandleAutoCopySelection() port.WebUIMessageHandler {
35- return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) {37+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, webviewID port.WebViewID, payload json.RawMessage) (any, error) {
36 log := logging.FromContext(ctx)38 log := logging.FromContext(ctx)
37 39
38- // Check if feature is enabled
39- if h.autoCopyConfig == nil || !h.autoCopyConfig.IsAutoCopyEnabled() {
40- return nil, nil
41- }
42-
43 var req autoCopyRequest40 var req autoCopyRequest
44 if err := json.Unmarshal(payload, &req); err != nil {41 if err := json.Unmarshal(payload, &req); err != nil {
45 log.Debug().Err(err).Msg("failed to unmarshal auto-copy payload")42 log.Debug().Err(err).Msg("failed to unmarshal auto-copy payload")
46 return nil, nil // Silently ignore malformed requests43 return nil, nil // Silently ignore malformed requests
47 }44 }
48-45+ if h.orchestrator == nil {
49- // Skip empty or very short selections (< 2 chars)
50- if len(req.Text) < 2 {
51 return nil, nil46 return nil, nil
52 }47 }
53-48+ if err := h.orchestrator.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{
54- if err := h.clipboard.WriteText(ctx, req.Text); err != nil {49+ Text: req.Text,
55- log.Debug().Err(err).Msg("failed to write selection to clipboard")50+ SourceEngine: dto.ClipboardSourceWebKit,
56- return nil, nil // Don't propagate error to JS51+ ViewID: uint64(webviewID),
52+ }); err != nil {
53+ log.Debug().Err(err).Msg("clipboard selection handling failed")
57 }54 }
58 55
59- log.Debug().Int("length", len(req.Text)).Msg("auto-copied selection to clipboard")56+ return nil, nil
57+ })
58+}
60 59
61- // Notify UI for toast feedback (if callback is set)60+// HandleExplicitCopy handles the explicit_text_copy message from JS.
62- if h.onCopied != nil {61+func (h *ClipboardHandler) HandleExplicitCopy() port.WebUIMessageHandler {
63- h.onCopied(len(req.Text))62+ return port.WebUIMessageHandlerFunc(func(ctx context.Context, webviewID port.WebViewID, payload json.RawMessage) (any, error) {
63+ log := logging.FromContext(ctx)
64+
65+ var req explicitCopyRequest
66+ if err := json.Unmarshal(payload, &req); err != nil {
67+ log.Debug().Err(err).Msg("failed to unmarshal explicit copy payload")
68+ return nil, nil
69+ }
70+ if h.orchestrator == nil {
71+ return nil, nil
72+ }
73+ if err := h.orchestrator.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{
74+ Text: req.Text,
75+ Action: req.Action,
76+ SourceEngine: dto.ClipboardSourceWebKit,
77+ ViewID: uint64(webviewID),
78+ NativeHandled: true,
79+ }); err != nil {
80+ log.Debug().Err(err).Msg("clipboard explicit handling failed")
64 }81 }
65 82
66 return nil, nil83 return nil, nil
@@ -71,16 +88,17 @@ func (h *ClipboardHandler) HandleAutoCopySelection() port.WebUIMessageHandler {
71 func RegisterClipboardHandlers(88 func RegisterClipboardHandlers(
72 ctx context.Context,89 ctx context.Context,
73 router port.WebUIHandlerRouter,90 router port.WebUIHandlerRouter,
74- clipboard port.Clipboard,91+ orchestrator port.ClipboardTextOrchestrator,
75- autoCopyConfig port.AutoCopyConfig,
76- onCopied func(textLen int),
77 ) error {92 ) error {
78- handler := NewClipboardHandler(clipboard, autoCopyConfig, onCopied)93+ handler := NewClipboardHandler(orchestrator)
79 94
80 // Register auto_copy_selection handler95 // Register auto_copy_selection handler
81 if err := router.RegisterHandler("auto_copy_selection", handler.HandleAutoCopySelection()); err != nil {96 if err := router.RegisterHandler("auto_copy_selection", handler.HandleAutoCopySelection()); err != nil {
82 return err97 return err
83 }98 }
99+ if err := router.RegisterHandler("explicit_text_copy", handler.HandleExplicitCopy()); err != nil {
100+ return err
101+ }
84 102
85 log := logging.FromContext(ctx)103 log := logging.FromContext(ctx)
86 log.Info().Msg("registered clipboard handlers")104 log.Info().Msg("registered clipboard handlers")
A internal/infrastructure/handlers/clipboard_test.go +63 -0
@@ -0,0 +1,63 @@
1+package handlers
2+
3+import (
4+ "context"
5+ "encoding/json"
6+ "sync"
7+ "testing"
8+
9+ "github.com/bnema/dumber/internal/application/dto"
10+ "github.com/stretchr/testify/require"
11+)
12+
13+type recordingClipboardOrchestrator struct {
14+ mu sync.Mutex
15+ selection dto.SelectionClipboardInput
16+ explicit dto.ExplicitClipboardInput
17+}
18+
19+func (r *recordingClipboardOrchestrator) HandleSelectionUpdate(_ context.Context, input dto.SelectionClipboardInput) error {
20+ r.mu.Lock()
21+ defer r.mu.Unlock()
22+ r.selection = input
23+ return nil
24+}
25+
26+func (r *recordingClipboardOrchestrator) HandleExplicitCopy(_ context.Context, input dto.ExplicitClipboardInput) error {
27+ r.mu.Lock()
28+ defer r.mu.Unlock()
29+ r.explicit = input
30+ return nil
31+}
32+
33+func TestClipboardHandler_HandleAutoCopySelection_DelegatesToOrchestrator(t *testing.T) {
34+ ctx := context.Background()
35+ orchestrator := &recordingClipboardOrchestrator{}
36+ handler := NewClipboardHandler(orchestrator)
37+
38+ _, err := handler.HandleAutoCopySelection().Handle(ctx, 42, json.RawMessage(`{"text":"selected text"}`))
39+ require.NoError(t, err)
40+
41+ orchestrator.mu.Lock()
42+ defer orchestrator.mu.Unlock()
43+ require.Equal(t, "selected text", orchestrator.selection.Text)
44+ require.Equal(t, dto.ClipboardSourceWebKit, orchestrator.selection.SourceEngine)
45+ require.Equal(t, uint64(42), orchestrator.selection.ViewID)
46+}
47+
48+func TestClipboardHandler_HandleExplicitCopy_DelegatesToOrchestrator(t *testing.T) {
49+ ctx := context.Background()
50+ orchestrator := &recordingClipboardOrchestrator{}
51+ handler := NewClipboardHandler(orchestrator)
52+
53+ _, err := handler.HandleExplicitCopy().Handle(ctx, 42, json.RawMessage(`{"text":"copied text","action":"copy"}`))
54+ require.NoError(t, err)
55+
56+ orchestrator.mu.Lock()
57+ defer orchestrator.mu.Unlock()
58+ require.Equal(t, "copied text", orchestrator.explicit.Text)
59+ require.Equal(t, "copy", orchestrator.explicit.Action)
60+ require.Equal(t, dto.ClipboardSourceWebKit, orchestrator.explicit.SourceEngine)
61+ require.Equal(t, uint64(42), orchestrator.explicit.ViewID)
62+ require.True(t, orchestrator.explicit.NativeHandled)
63+}
M internal/infrastructure/handlers/register.go +2 -2
@@ -52,8 +52,8 @@ func RegisterAll(ctx context.Context, router port.WebUIHandlerRouter, deps port.
52 }52 }
53 53
54 // Clipboard handlers (for auto-copy on selection feature)54 // Clipboard handlers (for auto-copy on selection feature)
55- if deps.Clipboard != nil && deps.AutoCopyConfig != nil {55+ if deps.ClipboardTextOrchestrator != nil {
56- if err := RegisterClipboardHandlers(ctx, router, deps.Clipboard, deps.AutoCopyConfig, deps.OnClipboardCopied); err != nil {56+ if err := RegisterClipboardHandlers(ctx, router, deps.ClipboardTextOrchestrator); err != nil {
57 return err57 return err
58 }58 }
59 }59 }
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+}
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+}
M internal/infrastructure/webkit/context_menu.go +55 -1
@@ -13,6 +13,7 @@ import (
13 "net/url"13 "net/url"
14 "strings"14 "strings"
15 "time"15 "time"
16+ "unicode/utf8"
16 17
17 "github.com/bnema/dumber/internal/application/port"18 "github.com/bnema/dumber/internal/application/port"
18 "github.com/bnema/dumber/internal/domain/entity"19 "github.com/bnema/dumber/internal/domain/entity"
@@ -365,6 +366,7 @@ type contextMenuPipeline struct {
365 builder port.ContextMenuBuilder366 builder port.ContextMenuBuilder
366 executorFactory port.ContextMenuActionExecutorFactory367 executorFactory port.ContextMenuActionExecutorFactory
367 clipboard port.Clipboard368 clipboard port.Clipboard
369+ onCopied func(textLen int)
368 resolver port.ImageDataResolver370 resolver port.ImageDataResolver
369 saver port.ResolvedImageSaver371 saver port.ResolvedImageSaver
370 renderer *Renderer372 renderer *Renderer
@@ -374,14 +376,66 @@ func (p *contextMenuPipeline) newExecutor(wv *WebView) port.ContextMenuActionExe
374 if p == nil || p.executorFactory == nil {376 if p == nil || p.executorFactory == nil {
375 return nil377 return nil
376 }378 }
379+ clipboard := p.clipboard
380+ if clipboard != nil && p.onCopied != nil {
381+ clipboard = &clipboardCopyNotifier{clipboard: clipboard, onCopied: p.onCopied}
382+ }
377 return p.executorFactory.NewContextMenuActionExecutor(383 return p.executorFactory.NewContextMenuActionExecutor(
378- p.clipboard,384+ clipboard,
379 p.resolver,385 p.resolver,
380 p.saver,386 p.saver,
381 &webkitMenuDelegator{wv: wv},387 &webkitMenuDelegator{wv: wv},
382 )388 )
383 }389 }
384 390
391+var _ port.Clipboard = (*clipboardCopyNotifier)(nil)
392+
393+type clipboardCopyNotifier struct {
394+ clipboard port.Clipboard
395+ onCopied func(textLen int)
396+}
397+
398+func (c *clipboardCopyNotifier) WriteText(ctx context.Context, text string) error {
399+ if c == nil || c.clipboard == nil {
400+ return fmt.Errorf("clipboard not available")
401+ }
402+ if err := c.clipboard.WriteText(ctx, text); err != nil {
403+ return err
404+ }
405+ if c.onCopied != nil {
406+ c.onCopied(utf8.RuneCountInString(text))
407+ }
408+ return nil
409+}
410+
411+func (c *clipboardCopyNotifier) WriteImage(ctx context.Context, image entity.ImageData) error {
412+ if c == nil || c.clipboard == nil {
413+ return fmt.Errorf("clipboard not available")
414+ }
415+ return c.clipboard.WriteImage(ctx, image)
416+}
417+
418+func (c *clipboardCopyNotifier) ReadText(ctx context.Context) (string, error) {
419+ if c == nil || c.clipboard == nil {
420+ return "", fmt.Errorf("clipboard not available")
421+ }
422+ return c.clipboard.ReadText(ctx)
423+}
424+
425+func (c *clipboardCopyNotifier) Clear(ctx context.Context) error {
426+ if c == nil || c.clipboard == nil {
427+ return fmt.Errorf("clipboard not available")
428+ }
429+ return c.clipboard.Clear(ctx)
430+}
431+
432+func (c *clipboardCopyNotifier) HasText(ctx context.Context) (bool, error) {
433+ if c == nil || c.clipboard == nil {
434+ return false, fmt.Errorf("clipboard not available")
435+ }
436+ return c.clipboard.HasText(ctx)
437+}
438+
385 // connectContextMenuSignal wires the WebKit "context-menu" signal to the439 // connectContextMenuSignal wires the WebKit "context-menu" signal to the
386 // shared context menu pipeline. When the user right-clicks, WebKit's native440 // shared context menu pipeline. When the user right-clicks, WebKit's native
387 // menu is suppressed and replaced by the shared GTK popover menu.441 // menu is suppressed and replaced by the shared GTK popover menu.
M internal/infrastructure/webkit/context_menu_test.go +51 -0
@@ -9,9 +9,13 @@ import (
9 "net/http"9 "net/http"
10 "net/http/httptest"10 "net/http/httptest"
11 "testing"11 "testing"
12+ "unicode/utf8"
12 13
13 "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"
16+ "github.com/bnema/dumber/internal/application/usecase"
14 "github.com/stretchr/testify/assert"17 "github.com/stretchr/testify/assert"
18+ "github.com/stretchr/testify/mock"
15 "github.com/stretchr/testify/require"19 "github.com/stretchr/testify/require"
16 )20 )
17 21
@@ -313,6 +317,53 @@ func TestWebkitMenuDelegator_OpenLinkNewTabRequiresHandlerSuccess(t *testing.T)
313 assert.Contains(t, err.Error(), "action not handled")317 assert.Contains(t, err.Error(), "action not handled")
314 }318 }
315 319
320+func TestContextMenuPipeline_NewExecutor_NotifiesOnCopiedText(t *testing.T) {
321+ var copiedLen int
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()
337+ pipeline := &contextMenuPipeline{
338+ clipboard: baseClipboard,
339+ onCopied: func(textLen int) { copiedLen = textLen },
340+ executorFactory: factory,
341+ }
342+
343+ executor := pipeline.newExecutor(&WebView{})
344+ require.NotNil(t, executor)
345+
346+ t.Run("copy link", func(t *testing.T) {
347+ copiedLen = 0
348+ require.NotNil(t, capturedClipboard)
349+ baseClipboard.EXPECT().WriteText(ctx, "https://example.com/new").Return(nil).Once()
350+
351+ err := executor.ExecuteMenuAction(ctx, port.MenuActionCopyLink, port.MenuContext{LinkURI: "https://example.com/new"})
352+ require.NoError(t, err)
353+ require.Equal(t, utf8.RuneCountInString("https://example.com/new"), copiedLen)
354+ })
355+
356+ t.Run("copy selection", func(t *testing.T) {
357+ copiedLen = 0
358+ require.NotNil(t, capturedClipboard)
359+ baseClipboard.EXPECT().WriteText(ctx, "selected text").Return(nil).Once()
360+
361+ err := executor.ExecuteMenuAction(ctx, port.MenuActionCopySelection, port.MenuContext{HasSelection: true, SelectionText: "selected text"})
362+ require.NoError(t, err)
363+ require.Equal(t, utf8.RuneCountInString("selected text"), copiedLen)
364+ })
365+}
366+
316 // buildMenuContextFromStubHitTest is the test-only equivalent of buildMenuContextFromHitTest367 // buildMenuContextFromStubHitTest is the test-only equivalent of buildMenuContextFromHitTest
317 // that works with our stub type instead of requiring real WebKit objects.368 // that works with our stub type instead of requiring real WebKit objects.
318 func buildMenuContextFromStubHitTest(hit stubHitTest, canGoBack, canGoForward bool, x, y int) port.MenuContext {369 func buildMenuContextFromStubHitTest(hit stubHitTest, canGoBack, canGoForward bool, x, y int) port.MenuContext {
M internal/infrastructure/webkit/engine.go +3 -0
@@ -26,6 +26,7 @@ type Engine struct {
26 logger zerolog.Logger26 logger zerolog.Logger
27 ctxMenuBuilder port.ContextMenuBuilder27 ctxMenuBuilder port.ContextMenuBuilder
28 ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory28 ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory
29+ onClipboardCopied func(textLen int)
29 downloadPath string30 downloadPath string
30 downloadPreparer port.DownloadPreparer31 downloadPreparer port.DownloadPreparer
31 clipboard port.Clipboard // captured during RegisterHandlers for context menu wiring32 clipboard port.Clipboard // captured during RegisterHandlers for context menu wiring
@@ -99,6 +100,7 @@ func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependen
99 }100 }
100 // Capture clipboard for context menu pipeline wiring in ConfigureDownloads.101 // Capture clipboard for context menu pipeline wiring in ConfigureDownloads.
101 e.clipboard = deps.Clipboard102 e.clipboard = deps.Clipboard
103+ e.onClipboardCopied = deps.OnClipboardCopied
102 return handlers.RegisterAll(ctx, e.messageRouter, deps)104 return handlers.RegisterAll(ctx, e.messageRouter, deps)
103 }105 }
104 106
@@ -179,6 +181,7 @@ func (e *Engine) installContextMenuPipeline() {
179 builder: e.ctxMenuBuilder,181 builder: e.ctxMenuBuilder,
180 executorFactory: e.ctxMenuExecutorFactory,182 executorFactory: e.ctxMenuExecutorFactory,
181 clipboard: e.clipboard,183 clipboard: e.clipboard,
184+ onCopied: e.onClipboardCopied,
182 resolver: resolver,185 resolver: resolver,
183 saver: saver,186 saver: saver,
184 renderer: renderer,187 renderer: renderer,
M internal/infrastructure/webkit/engine_init.go +16 -4
@@ -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+}
M internal/infrastructure/webkit/injector.go +125 -6
@@ -59,10 +59,117 @@ const autoCopySelectionScript = `(function() {
59 });59 });
60 })();`60 })();`
61 61
62+// explicitCopyScript captures explicit copy/cut operations and forwards them to Go.
63+const explicitCopyScript = `(function() {
64+ var pendingCommand = '';
65+
66+ function isSafeSelectableInput(activeEl) {
67+ if (!activeEl || activeEl.tagName !== 'INPUT') {
68+ return false;
69+ }
70+ switch (String(activeEl.type || '').toLowerCase()) {
71+ case 'password':
72+ case 'button':
73+ case 'checkbox':
74+ case 'color':
75+ case 'file':
76+ case 'hidden':
77+ case 'image':
78+ case 'radio':
79+ case 'range':
80+ case 'reset':
81+ case 'submit':
82+ return false;
83+ default:
84+ return true;
85+ }
86+ }
87+
88+ function selectedText() {
89+ var activeEl = document.activeElement;
90+ if (activeEl && (isSafeSelectableInput(activeEl) || activeEl.tagName === 'TEXTAREA')) {
91+ if (typeof activeEl.selectionStart === 'number' && typeof activeEl.selectionEnd === 'number') {
92+ var start = activeEl.selectionStart;
93+ var end = activeEl.selectionEnd;
94+ if (end > start) {
95+ return activeEl.value.slice(start, end);
96+ }
97+ }
98+ }
99+ var selection = window.getSelection ? window.getSelection() : null;
100+ return selection ? selection.toString() : '';
101+ }
102+
103+ function postExplicitCopy(action, text) {
104+ if (!text) {
105+ return;
106+ }
107+ if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber) {
108+ window.webkit.messageHandlers.dumber.postMessage({
109+ type: 'explicit_text_copy',
110+ payload: { text: text, action: action }
111+ });
112+ }
113+ }
114+
115+ function consumePendingAction(fallback) {
116+ var action = pendingCommand || fallback;
117+ pendingCommand = '';
118+ return action;
119+ }
120+
121+ document.addEventListener('copy', function(event) {
122+ if (event && event.isTrusted === false) {
123+ return;
124+ }
125+ postExplicitCopy(consumePendingAction('copy'), selectedText());
126+ }, true);
127+
128+ document.addEventListener('cut', function(event) {
129+ if (event && event.isTrusted === false) {
130+ return;
131+ }
132+ postExplicitCopy(consumePendingAction('cut'), selectedText());
133+ }, true);
134+
135+ if (document.execCommand) {
136+ var originalExecCommand = document.execCommand.bind(document);
137+ document.execCommand = function(commandId, showUI, value) {
138+ var command = String(commandId || '').toLowerCase();
139+ var tracked = command === 'copy' || command === 'cut';
140+ if (tracked) {
141+ pendingCommand = command;
142+ }
143+ try {
144+ return originalExecCommand(commandId, showUI, value);
145+ } finally {
146+ if (tracked && pendingCommand === command) {
147+ pendingCommand = '';
148+ }
149+ }
150+ };
151+ }
152+
153+ if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') {
154+ var originalWriteText = navigator.clipboard.writeText.bind(navigator.clipboard);
155+ navigator.clipboard.writeText = function(text) {
156+ return Promise.resolve(originalWriteText(text)).then(function(result) {
157+ // writeText only has copy semantics, so postExplicitCopy always uses "copy" here.
158+ postExplicitCopy('copy', typeof text === 'string' ? text : String(text || ''));
159+ return result;
160+ });
161+ };
162+ }
163+})();`
164+
62 // accentDetectionScript is built at init from entity.AccentMap so the JS165 // accentDetectionScript is built at init from entity.AccentMap so the JS
63 // filter stays in sync with the Go-side accent table.166 // filter stays in sync with the Go-side accent table.
64 var accentDetectionScript string167 var accentDetectionScript string
65 168
169+func buildExplicitCopyScript() string {
170+ return explicitCopyScript
171+}
172+
66 func init() {173 func init() {
67 // Build JS Set literal from AccentMap keys: "new Set(['a','c','e',...])"174 // Build JS Set literal from AccentMap keys: "new Set(['a','c','e',...])"
68 keys := make([]string, 0, len(entity.AccentMap))175 keys := make([]string, 0, len(entity.AccentMap))
@@ -229,7 +336,19 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
229 "webrtc-compat-shim",336 "webrtc-compat-shim",
230 )337 )
231 338
232- // 2. Inject WebView ID for debugging (internal pages only)339+ // 2. Inject explicit copy capture for all pages.
340+ addScript(
341+ webkit.NewUserScript(
342+ buildExplicitCopyScript(),
343+ webkit.UserContentInjectAllFramesValue,
344+ webkit.UserScriptInjectAtDocumentStartValue,
345+ nil,
346+ nil,
347+ ),
348+ "explicit-copy-capture",
349+ )
350+
351+ // 3. Inject WebView ID for debugging (internal pages only)
233 if webviewID != 0 {352 if webviewID != 0 {
234 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))353 idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID))
235 addScript(354 addScript(
@@ -247,7 +366,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
247 log.Warn().Msg("webview ID is 0, skipping ID injection")366 log.Warn().Msg("webview ID is 0, skipping ID injection")
248 }367 }
249 368
250- // 3. Inject dark mode handler for internal pages only369+ // 4. Inject dark mode handler for internal pages only
251 // This sets .dark/.light class on <html> and patches matchMedia for WebUI370 // This sets .dark/.light class on <html> and patches matchMedia for WebUI
252 internalDarkModeScript := webutil.DarkModeScript(prefersDark, "__dumber_gtk_prefers_dark")371 internalDarkModeScript := webutil.DarkModeScript(prefersDark, "__dumber_gtk_prefers_dark")
253 addScript(372 addScript(
@@ -261,7 +380,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
261 "internal-dark-mode-handler",380 "internal-dark-mode-handler",
262 )381 )
263 382
264- // 4. Inject theme CSS for internal pages (dumb://* only)383+ // 5. Inject theme CSS for internal pages (dumb://* only)
265 if ci.themeCSSVars != "" {384 if ci.themeCSSVars != "" {
266 escapedCSS := webutil.EscapeForJSString(ci.themeCSSVars)385 escapedCSS := webutil.EscapeForJSString(ci.themeCSSVars)
267 themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS)386 themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS)
@@ -278,7 +397,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
278 log.Debug().Msg("theme CSS vars injection configured for internal pages")397 log.Debug().Msg("theme CSS vars injection configured for internal pages")
279 }398 }
280 399
281- // 5. Inject find highlight CSS for all pages400+ // 6. Inject find highlight CSS for all pages
282 if ci.findCSS != "" {401 if ci.findCSS != "" {
283 stylesheet := webkit.NewUserStyleSheet(402 stylesheet := webkit.NewUserStyleSheet(
284 ci.findCSS,403 ci.findCSS,
@@ -295,7 +414,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
295 }414 }
296 }415 }
297 416
298- // 6. Inject auto-copy selection script for all pages (if enabled)417+ // 7. Inject auto-copy selection script for all pages (if enabled)
299 autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter()418 autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter()
300 if autoCopyEnabled {419 if autoCopyEnabled {
301 addScript(420 addScript(
@@ -311,7 +430,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo
311 log.Debug().Msg("auto-copy selection script injected")430 log.Debug().Msg("auto-copy selection script injected")
312 }431 }
313 432
314- // 7. Inject accent key detection for all pages and all frames (unconditional).433+ // 8. Inject accent key detection for all pages and all frames (unconditional).
315 // JS only reports keydown/keyup events; Go handles timing and picker display.434 // JS only reports keydown/keyup events; Go handles timing and picker display.
316 addScript(435 addScript(
317 webkit.NewUserScript(436 webkit.NewUserScript(
M internal/infrastructure/webkit/injector_accent_test.go +25 -0
@@ -30,3 +30,28 @@ func TestAccentDetectionScriptTracksLastFocusedEditableElement(t *testing.T) {
30 "accent detection script must register a focusin listener",30 "accent detection script must register a focusin listener",
31 )31 )
32 }32 }
33+
34+func TestExplicitCopyScriptCapturesClipboardOperations(t *testing.T) {
35+ script := buildExplicitCopyScript()
36+
37+ assert.Contains(t, script, "explicit_text_copy")
38+ assert.Contains(t, script, "document.addEventListener('copy'")
39+ assert.Contains(t, script, "document.addEventListener('cut'")
40+ assert.Contains(t, script, "document.execCommand")
41+ assert.Contains(t, script, "navigator.clipboard.writeText")
42+ assert.Contains(t, script, "try {")
43+ assert.Contains(t, script, "finally {")
44+ assert.Contains(t, script, "pendingCommand === command")
45+}
46+
47+func TestExplicitCopyScriptReadsInputAndTextareaSelectionFirst(t *testing.T) {
48+ script := buildExplicitCopyScript()
49+
50+ assert.Contains(t, script, "document.activeElement")
51+ assert.Contains(t, script, "selectionStart")
52+ assert.Contains(t, script, "selectionEnd")
53+ assert.Contains(t, script, "INPUT")
54+ assert.Contains(t, script, "TEXTAREA")
55+ assert.Contains(t, script, "type || '').toLowerCase()")
56+ assert.Contains(t, script, "case 'password':")
57+}
M internal/infrastructure/xdg/adapter.go +14 -6
@@ -9,11 +9,14 @@ import (
9 )9 )
10 10
11 // Adapter implements port.XDGPaths using config.GetXDGDirs().11 // Adapter implements port.XDGPaths using config.GetXDGDirs().
12-type Adapter struct{}12+type Adapter struct {
13+ isDev bool
14+ runtimeDir string
15+}
13 16
14 // New creates a new XDG paths adapter.17 // New creates a new XDG paths adapter.
15-func New() *Adapter {18+func New(isDev bool, runtimeDir string) *Adapter {
16- return &Adapter{}19+ return &Adapter{isDev: isDev, runtimeDir: runtimeDir}
17 }20 }
18 21
19 func (a *Adapter) ConfigDir() (string, error) {22 func (a *Adapter) ConfigDir() (string, error) {
@@ -29,11 +32,16 @@ func (a *Adapter) StateDir() (string, error) {
29 }32 }
30 33
31 func (a *Adapter) RuntimeDir() (string, error) {34 func (a *Adapter) RuntimeDir() (string, error) {
32- if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {35+ if a == nil || !a.isDev {
33- return dir, nil36+ if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" {
37+ return dir, nil
38+ }
39+ }
40+ if a != nil && a.runtimeDir != "" {
41+ return a.runtimeDir, nil
34 }42 }
35 43
36- stateDir, err := a.StateDir()44+ stateDir, err := config.GetStateDir()
37 if err != nil {45 if err != nil {
38 return "", err46 return "", err
39 }47 }
M internal/infrastructure/xdg/adapter_test.go +17 -7
@@ -10,7 +10,7 @@ import (
10 )10 )
11 11
12 func TestAdapter_DownloadDir(t *testing.T) {12 func TestAdapter_DownloadDir(t *testing.T) {
13- adapter := New()13+ adapter := New(false, "")
14 14
15 t.Run("returns XDG_DOWNLOAD_DIR when set", func(t *testing.T) {15 t.Run("returns XDG_DOWNLOAD_DIR when set", func(t *testing.T) {
16 t.Setenv("XDG_DOWNLOAD_DIR", "/custom/downloads")16 t.Setenv("XDG_DOWNLOAD_DIR", "/custom/downloads")
@@ -37,9 +37,8 @@ func TestAdapter_DownloadDir(t *testing.T) {
37 }37 }
38 38
39 func TestAdapter_RuntimeDir(t *testing.T) {39 func TestAdapter_RuntimeDir(t *testing.T) {
40- adapter := New()40+ t.Run("returns XDG_RUNTIME_DIR when set outside dev", func(t *testing.T) {
41-41+ adapter := New(false, "")
42- t.Run("returns XDG_RUNTIME_DIR when set", func(t *testing.T) {
43 t.Setenv("XDG_RUNTIME_DIR", "/custom/runtime")42 t.Setenv("XDG_RUNTIME_DIR", "/custom/runtime")
44 43
45 dir, err := adapter.RuntimeDir()44 dir, err := adapter.RuntimeDir()
@@ -48,19 +47,30 @@ func TestAdapter_RuntimeDir(t *testing.T) {
48 assert.Equal(t, "/custom/runtime", dir)47 assert.Equal(t, "/custom/runtime", dir)
49 })48 })
50 49
51- t.Run("falls back to state runtime dir when XDG_RUNTIME_DIR not set", func(t *testing.T) {50+ t.Run("falls back to injected prod runtime dir when XDG_RUNTIME_DIR not set", func(t *testing.T) {
51+ adapter := New(false, "/tmp/dumber-state/dumber/runtime")
52 t.Setenv("XDG_RUNTIME_DIR", "")52 t.Setenv("XDG_RUNTIME_DIR", "")
53- t.Setenv("XDG_STATE_HOME", "/tmp/dumber-state")
54 53
55 dir, err := adapter.RuntimeDir()54 dir, err := adapter.RuntimeDir()
56 55
57 require.NoError(t, err)56 require.NoError(t, err)
58 assert.Equal(t, "/tmp/dumber-state/dumber/runtime", dir)57 assert.Equal(t, "/tmp/dumber-state/dumber/runtime", dir)
59 })58 })
59+
60+ t.Run("uses injected sandbox runtime dir in dev even when XDG_RUNTIME_DIR is set", func(t *testing.T) {
61+ wd := t.TempDir()
62+ adapter := New(true, filepath.Join(wd, ".dev", "dumber", "runtime"))
63+ t.Setenv("XDG_RUNTIME_DIR", "/shared/runtime")
64+
65+ dir, err := adapter.RuntimeDir()
66+
67+ require.NoError(t, err)
68+ assert.Equal(t, filepath.Join(wd, ".dev", "dumber", "runtime"), dir)
69+ })
60 }70 }
61 71
62 func TestNew(t *testing.T) {72 func TestNew(t *testing.T) {
63- adapter := New()73+ adapter := New(false, "")
64 74
65 assert.NotNil(t, adapter)75 assert.NotNil(t, adapter)
66 }76 }
M internal/ui/app.go +27 -10
@@ -37,7 +37,8 @@ import (
37 37
38 const (38 const (
39 // AppID is the application identifier for GTK.39 // AppID is the application identifier for GTK.
40- AppID = "com.github.bnema.dumber"40+ AppID = "com.github.bnema.dumber"
41+ appTitle = "Dumber"
41 // crashReportToastDurationMs keeps startup crash-report toast visible longer.42 // crashReportToastDurationMs keeps startup crash-report toast visible longer.
42 crashReportToastDurationMs = 500043 crashReportToastDurationMs = 5000
43 floatingPaneFallbackWidth = 120044 floatingPaneFallbackWidth = 1200
@@ -181,17 +182,33 @@ func New(deps *Dependencies) (*App, error) {
181 engine: deps.Engine,182 engine: deps.Engine,
182 cancel: cancel,183 cancel: cancel,
183 }184 }
184- // Register message handlers through the engine.185+ var autoCopyConfig port.AutoCopyConfig
185- if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{186+ var clipboardOrchestrator port.ClipboardTextOrchestrator
186- HistoryUC: deps.HistoryUC,187+ if deps.Clipboard != nil {
187- FavoritesUC: deps.FavoritesUC,188+ autoCopyConfig = &autoCopyConfigFn{fn: func() bool {
188- Clipboard: deps.Clipboard,
189- AutoCopyConfig: &autoCopyConfigFn{fn: func() bool {
190 if deps.Config == nil {189 if deps.Config == nil {
191 return false190 return false
192 }191 }
193 return deps.Config.Clipboard.AutoCopyOnSelection192 return deps.Config.Clipboard.AutoCopyOnSelection
194- }},193+ }}
194+ clipboardOrchestrator = usecase.NewClipboardTextOrchestrator(deps.Clipboard, autoCopyConfig, func(textLen int) {
195+ cb := glib.SourceFunc(func(_ uintptr) bool {
196+ app.showToastOnLastFocusedBrowserWindow(ctx, "Copied to clipboard", component.ToastInfo,
197+ component.WithDuration(component.ToastBriefDurationMs),
198+ component.WithPosition(component.ToastPositionBottomRight),
199+ )
200+ return false
201+ })
202+ glib.IdleAdd(&cb, 0)
203+ })
204+ }
205+ // Register message handlers through the engine.
206+ if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{
207+ HistoryUC: deps.HistoryUC,
208+ FavoritesUC: deps.FavoritesUC,
209+ Clipboard: deps.Clipboard,
210+ AutoCopyConfig: autoCopyConfig,
211+ ClipboardTextOrchestrator: clipboardOrchestrator,
195 OnClipboardCopied: func(textLen int) {212 OnClipboardCopied: func(textLen int) {
196 cb := glib.SourceFunc(func(_ uintptr) bool {213 cb := glib.SourceFunc(func(_ uintptr) bool {
197 app.showToastOnLastFocusedBrowserWindow(ctx, "Copied to clipboard", component.ToastInfo,214 app.showToastOnLastFocusedBrowserWindow(ctx, "Copied to clipboard", component.ToastInfo,
@@ -2339,9 +2356,9 @@ func (a *App) updateWindowTitle(pageTitle string, target *browserWindow) {
2339 return2356 return
2340 }2357 }
2341 2358
2342- title := "Dumber"2359+ title := appTitle
2343 if pageTitle != "" {2360 if pageTitle != "" {
2344- title = pageTitle + " - Dumber"2361+ title = pageTitle + " - " + appTitle
2345 }2362 }
2346 target.mainWindow.SetTitle(title)2363 target.mainWindow.SetTitle(title)
2347 }2364 }
A internal/ui/app_clipboard_test.go +70 -0
@@ -0,0 +1,70 @@
1+package ui
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/application/port"
8+ portmocks "github.com/bnema/dumber/internal/application/port/mocks"
9+ "github.com/bnema/dumber/internal/domain/entity"
10+ "github.com/bnema/dumber/internal/infrastructure/config"
11+ "github.com/stretchr/testify/mock"
12+ "github.com/stretchr/testify/require"
13+)
14+
15+func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T) {
16+ ctx := context.Background()
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()
25+
26+ deps := &Dependencies{
27+ Ctx: ctx,
28+ Config: &config.Config{},
29+ Engine: engine,
30+ HandlerDeps: port.HandlerDeps{
31+ SaveConfig: func(context.Context, port.WebUIConfig) error { return nil },
32+ SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil },
33+ },
34+ Clipboard: nil,
35+ }
36+
37+ app, err := New(deps)
38+ require.NoError(t, err)
39+ require.NotNil(t, app)
40+ require.Nil(t, registerDeps.ClipboardTextOrchestrator)
41+}
42+
43+func TestNew_WiresClipboardOrchestratorWhenClipboardPresent(t *testing.T) {
44+ ctx := context.Background()
45+ engine := portmocks.NewMockEngine(t)
46+ clipboard := portmocks.NewMockClipboard(t)
47+ var registerDeps port.HandlerDependencies
48+ engine.EXPECT().RegisterHandlers(mock.Anything, mock.Anything).
49+ Run(func(_ context.Context, deps port.HandlerDependencies) {
50+ registerDeps = deps
51+ }).
52+ Return(nil).
53+ Once()
54+
55+ deps := &Dependencies{
56+ Ctx: ctx,
57+ Config: &config.Config{},
58+ Engine: engine,
59+ HandlerDeps: port.HandlerDeps{
60+ SaveConfig: func(context.Context, port.WebUIConfig) error { return nil },
61+ SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil },
62+ },
63+ Clipboard: clipboard,
64+ }
65+
66+ app, err := New(deps)
67+ require.NoError(t, err)
68+ require.NotNil(t, app)
69+ require.NotNil(t, registerDeps.ClipboardTextOrchestrator)
70+}
M internal/ui/component/pane_view.go +2 -2
@@ -390,7 +390,7 @@ func (pv *PaneView) SetLoadProgress(progress float64) {
390 ctx = context.Background()390 ctx = context.Background()
391 }391 }
392 logging.FromContext(ctx).392 logging.FromContext(ctx).
393- Debug().393+ Trace().
394 Str("pane_id", string(pv.paneID)).394 Str("pane_id", string(pv.paneID)).
395 Float64("progress", progress).395 Float64("progress", progress).
396 Msg("setting load progress")396 Msg("setting load progress")
@@ -419,7 +419,7 @@ func (pv *PaneView) SetLoading(loading bool) {
419 if loading {419 if loading {
420 pb.Show()420 pb.Show()
421 } else {421 } else {
422- pb.Hide()422+ pb.HideWithReason("loading_false")
423 }423 }
424 }424 }
425 425
M internal/ui/component/progress_bar.go +116 -16
@@ -4,6 +4,7 @@ package component
4 import (4 import (
5 "context"5 "context"
6 "sync"6 "sync"
7+ "time"
7 8
8 "github.com/bnema/dumber/internal/logging"9 "github.com/bnema/dumber/internal/logging"
9 "github.com/bnema/puregotk/v4/glib"10 "github.com/bnema/puregotk/v4/glib"
@@ -29,11 +30,14 @@ type ProgressBar struct {
29 ctx context.Context30 ctx context.Context
30 progressBar layout.ProgressBarWidget31 progressBar layout.ProgressBarWidget
31 32
32- visible bool33+ visible bool
33- currentValue float64 // Current displayed value34+ currentValue float64 // Current displayed value
34- targetValue float64 // Target value to animate towards35+ targetValue float64 // Target value to animate towards
35- animationTimer uint // Timer source ID for animation36+ animationTimer uint // Timer source ID for animation
36- timeoutTimer uint // Timer source ID for auto-hide timeout37+ timeoutTimer uint // Timer source ID for auto-hide timeout
38+ lastShowAt time.Time
39+ lastProgressAt time.Time
40+ progressEventSeq uint64
37 41
38 mu sync.Mutex42 mu sync.Mutex
39 }43 }
@@ -85,12 +89,17 @@ func (pb *ProgressBar) SetProgress(progress float64) {
85 if ctx == nil {89 if ctx == nil {
86 ctx = context.Background()90 ctx = context.Background()
87 }91 }
92+ now := time.Now()
88 logging.FromContext(ctx).93 logging.FromContext(ctx).
89- Debug().94+ Trace().
90 Float64("incoming_progress", incomingProgress).95 Float64("incoming_progress", incomingProgress).
91 Float64("current_value", pb.currentValue).96 Float64("current_value", pb.currentValue).
92 Float64("target_value", pb.targetValue).97 Float64("target_value", pb.targetValue).
93 Bool("visible", pb.visible).98 Bool("visible", pb.visible).
99+ Bool("animation_running", pb.animationTimer != 0).
100+ Bool("timeout_running", pb.timeoutTimer != 0).
101+ Int64("since_show_ms", sinceTimeMs(pb.lastShowAt, now)).
102+ Int64("since_last_progress_ms", sinceTimeMs(pb.lastProgressAt, now)).
94 Msg("setting progress")103 Msg("setting progress")
95 104
96 // Clamp progress to valid range105 // Clamp progress to valid range
@@ -101,6 +110,11 @@ func (pb *ProgressBar) SetProgress(progress float64) {
101 }110 }
102 111
103 pb.targetValue = progress112 pb.targetValue = progress
113+ pb.lastProgressAt = now
114+ pb.progressEventSeq++
115+ if pb.visible {
116+ pb.resetTimeout()
117+ }
104 118
105 // For large jumps (>0.3) or completion, update immediately119 // For large jumps (>0.3) or completion, update immediately
106 diff := progress - pb.currentValue120 diff := progress - pb.currentValue
@@ -146,6 +160,16 @@ func (pb *ProgressBar) startAnimation() {
146 })160 })
147 161
148 pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0)162 pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0)
163+ ctx := pb.ctx
164+ if ctx == nil {
165+ ctx = context.Background()
166+ }
167+ logging.FromContext(ctx).
168+ Trace().
169+ Uint64("progress_event_seq", pb.progressEventSeq).
170+ Float64("current_value", pb.currentValue).
171+ Float64("target_value", pb.targetValue).
172+ Msg("progress bar animation started")
149 }173 }
150 174
151 // initialProgressFraction is the fraction set when the progress bar first175 // initialProgressFraction is the fraction set when the progress bar first
@@ -172,6 +196,7 @@ func (pb *ProgressBar) Show() {
172 196
173 if !pb.visible {197 if !pb.visible {
174 pb.visible = true198 pb.visible = true
199+ pb.lastShowAt = time.Now()
175 // Set an initial non-zero fraction so the bar is visually noticeable200 // Set an initial non-zero fraction so the bar is visually noticeable
176 // immediately. Without this, the bar is technically visible but201 // immediately. Without this, the bar is technically visible but
177 // renders as empty (0% fill) until the first progress callback,202 // renders as empty (0% fill) until the first progress callback,
@@ -182,10 +207,10 @@ func (pb *ProgressBar) Show() {
182 pb.progressBar.SetVisible(true)207 pb.progressBar.SetVisible(true)
183 }208 }
184 209
185- // Reset timeout timer on every Show call210+ // Reset timeout timer on every Show call.
186 pb.resetTimeout()211 pb.resetTimeout()
187 212
188- logging.FromContext(pb.ctx).213+ logging.FromContext(ctx).
189 Debug().214 Debug().
190 Bool("visible", pb.visible).215 Bool("visible", pb.visible).
191 Float64("current_value", pb.currentValue).216 Float64("current_value", pb.currentValue).
@@ -196,46 +221,96 @@ func (pb *ProgressBar) Show() {
196 // resetTimeout cancels any existing timeout and starts a new one.221 // resetTimeout cancels any existing timeout and starts a new one.
197 // Must be called with lock held.222 // Must be called with lock held.
198 func (pb *ProgressBar) resetTimeout() {223 func (pb *ProgressBar) resetTimeout() {
199- // Cancel existing timeout224+ pb.resetTimeoutAfter(time.Duration(progressTimeoutMs) * time.Millisecond)
225+}
226+
227+// resetTimeoutAfter cancels any existing timeout and starts a new one after
228+// the requested delay. Must be called with lock held.
229+func (pb *ProgressBar) resetTimeoutAfter(delay time.Duration) {
230+ if delay <= 0 {
231+ delay = time.Millisecond
232+ }
233+
234+ // Cancel existing timeout.
200 if pb.timeoutTimer != 0 {235 if pb.timeoutTimer != 0 {
201 glib.SourceRemove(pb.timeoutTimer)236 glib.SourceRemove(pb.timeoutTimer)
202 pb.timeoutTimer = 0237 pb.timeoutTimer = 0
203 }238 }
204 239
205- // Start new timeout240+ timeoutMs := int(delay / time.Millisecond)
241+ if delay%time.Millisecond != 0 {
242+ timeoutMs++
243+ }
244+ if timeoutMs < 1 {
245+ timeoutMs = 1
246+ }
247+
206 cb := glib.SourceFunc(func(_ uintptr) bool {248 cb := glib.SourceFunc(func(_ uintptr) bool {
207 pb.mu.Lock()249 pb.mu.Lock()
208 defer pb.mu.Unlock()250 defer pb.mu.Unlock()
209 251
210- // Clear timer ID (timer is being removed)252+ now := time.Now()
253+ remaining := pb.timeoutRemainingLocked(now)
254+ ctx := pb.ctx
255+ if ctx == nil {
256+ ctx = context.Background()
257+ }
258+ logging.FromContext(ctx).
259+ Debug().
260+ Float64("current_value", pb.currentValue).
261+ Float64("target_value", pb.targetValue).
262+ Int64("since_show_ms", sinceTimeMs(pb.lastShowAt, now)).
263+ Int64("since_last_progress_ms", sinceTimeMs(pb.lastProgressAt, now)).
264+ Int64("timeout_remaining_ms", remaining.Milliseconds()).
265+ Msg("progress bar timeout fired")
266+
267+ // Clear timer ID (timer is being removed).
211 pb.timeoutTimer = 0268 pb.timeoutTimer = 0
212 269
213- // Hide the progress bar inline to avoid race condition270+ if remaining > 0 {
214- pb.hideInternal()271+ pb.resetTimeoutAfter(remaining)
272+ return false
273+ }
274+
275+ // Hide the progress bar inline to avoid race condition.
276+ pb.hideInternal("timeout")
215 return false // Don't repeat277 return false // Don't repeat
216 })278 })
217 279
218- pb.timeoutTimer = glib.TimeoutAdd(progressTimeoutMs, &cb, 0)280+ pb.timeoutTimer = glib.TimeoutAdd(uint(timeoutMs), &cb, 0)
219 }281 }
220 282
221 // Hide makes the progress bar invisible and resets state.283 // Hide makes the progress bar invisible and resets state.
222 func (pb *ProgressBar) Hide() {284 func (pb *ProgressBar) Hide() {
285+ pb.HideWithReason("manual")
286+}
287+
288+// HideWithReason makes the progress bar invisible and records why it was hidden.
289+func (pb *ProgressBar) HideWithReason(reason string) {
223 pb.mu.Lock()290 pb.mu.Lock()
224 defer pb.mu.Unlock()291 defer pb.mu.Unlock()
225 292
226- pb.hideInternal()293+ pb.hideInternal(reason)
227 }294 }
228 295
229 // hideInternal performs the actual hide operation.296 // hideInternal performs the actual hide operation.
230 // Must be called with lock held.297 // Must be called with lock held.
231-func (pb *ProgressBar) hideInternal() {298+func (pb *ProgressBar) hideInternal(reason string) {
232 ctx := pb.ctx299 ctx := pb.ctx
233 if ctx == nil {300 if ctx == nil {
234 ctx = context.Background()301 ctx = context.Background()
235 }302 }
303+ now := time.Now()
236 logging.FromContext(ctx).304 logging.FromContext(ctx).
237 Debug().305 Debug().
238 Bool("visible", pb.visible).306 Bool("visible", pb.visible).
307+ Str("reason", reason).
308+ Float64("current_value", pb.currentValue).
309+ Float64("target_value", pb.targetValue).
310+ Bool("animation_running", pb.animationTimer != 0).
311+ Bool("timeout_running", pb.timeoutTimer != 0).
312+ Int64("since_show_ms", sinceTimeMs(pb.lastShowAt, now)).
313+ Int64("since_last_progress_ms", sinceTimeMs(pb.lastProgressAt, now)).
239 Msg("progress bar hide before")314 Msg("progress bar hide before")
240 315
241 if pb.visible {316 if pb.visible {
@@ -257,10 +332,35 @@ func (pb *ProgressBar) hideInternal() {
257 // Reset values332 // Reset values
258 pb.currentValue = 0333 pb.currentValue = 0
259 pb.targetValue = 0334 pb.targetValue = 0
335+ pb.lastShowAt = time.Time{}
336+ pb.lastProgressAt = time.Time{}
260 pb.progressBar.SetFraction(0)337 pb.progressBar.SetFraction(0)
261 }338 }
262 }339 }
263 340
341+func (pb *ProgressBar) timeoutRemainingLocked(now time.Time) time.Duration {
342+ lastActivityAt := pb.lastShowAt
343+ if pb.lastProgressAt.After(lastActivityAt) {
344+ lastActivityAt = pb.lastProgressAt
345+ }
346+ if lastActivityAt.IsZero() {
347+ return 0
348+ }
349+
350+ remaining := time.Duration(progressTimeoutMs)*time.Millisecond - now.Sub(lastActivityAt)
351+ if remaining < 0 {
352+ return 0
353+ }
354+ return remaining
355+}
356+
357+func sinceTimeMs(start, now time.Time) int64 {
358+ if start.IsZero() {
359+ return -1
360+ }
361+ return now.Sub(start).Milliseconds()
362+}
363+
264 // IsVisible returns whether the progress bar is currently visible.364 // IsVisible returns whether the progress bar is currently visible.
265 func (pb *ProgressBar) IsVisible() bool {365 func (pb *ProgressBar) IsVisible() bool {
266 pb.mu.Lock()366 pb.mu.Lock()
A internal/ui/component/progress_bar_test.go +62 -0
@@ -0,0 +1,62 @@
1+package component
2+
3+import (
4+ "testing"
5+ "time"
6+
7+ "github.com/stretchr/testify/require"
8+)
9+
10+func TestProgressBarTimeoutRemainingLocked_UsesLatestProgressEvent(t *testing.T) {
11+ showAt := time.Unix(0, 0)
12+ progressAt := showAt.Add(20 * time.Second)
13+ pb := &ProgressBar{
14+ lastShowAt: showAt,
15+ lastProgressAt: progressAt,
16+ }
17+
18+ remaining := pb.timeoutRemainingLocked(showAt.Add(25 * time.Second))
19+
20+ require.Equal(t, 25*time.Second, remaining)
21+}
22+
23+func TestProgressBarTimeoutRemainingLocked_UsesShowTimeBeforeFirstProgress(t *testing.T) {
24+ showAt := time.Unix(0, 0)
25+ pb := &ProgressBar{lastShowAt: showAt}
26+
27+ remaining := pb.timeoutRemainingLocked(showAt.Add(31 * time.Second))
28+
29+ require.Zero(t, remaining)
30+}
31+
32+func TestProgressBarTimeoutRemainingLocked_ZeroTimestamps(t *testing.T) {
33+ pb := &ProgressBar{}
34+
35+ remaining := pb.timeoutRemainingLocked(time.Unix(10, 0))
36+
37+ require.Zero(t, remaining)
38+}
39+
40+func TestProgressBarTimeoutRemainingLocked_EqualTimestamps(t *testing.T) {
41+ showAt := time.Unix(0, 0)
42+ pb := &ProgressBar{
43+ lastShowAt: showAt,
44+ lastProgressAt: showAt,
45+ }
46+
47+ remaining := pb.timeoutRemainingLocked(showAt.Add(10 * time.Second))
48+
49+ require.Equal(t, 20*time.Second, remaining)
50+}
51+
52+func TestProgressBarTimeoutRemainingLocked_NegativeClampsToZero(t *testing.T) {
53+ showAt := time.Unix(0, 0)
54+ pb := &ProgressBar{
55+ lastShowAt: showAt,
56+ lastProgressAt: showAt.Add(5 * time.Second),
57+ }
58+
59+ remaining := pb.timeoutRemainingLocked(showAt.Add(36 * time.Second))
60+
61+ require.Zero(t, remaining)
62+}

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