Merge pull request #224 from bnema/fix/cef-clipboard-input-focus
fix: stabilize CEF clipboard, focus sync, and startup paints
71 files · +5380 -567
M .mockery.yaml +1 -0
| @@ -49,6 +49,7 @@ packages: | |||
| 49 | 49 | AccentKeyHandler: {} | |
| 50 | 50 | AutoCopyConfig: {} | |
| 51 | 51 | Clipboard: {} | |
| 52 | + | ClipboardTextOrchestrator: {} | |
| 52 | 53 | ContextMenuBuilder: {} | |
| 53 | 54 | ContextMenuActionExecutor: {} | |
| 54 | 55 | ContextMenuActionExecutorFactory: {} | |
M cmd/cef-helper/main.go +5 -4
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "os" | |
| 14 | 14 | "runtime" | |
| 15 | 15 | ||
| 16 | + | infracef "github.com/bnema/dumber/internal/infrastructure/cef" | |
| 16 | 17 | cef "github.com/bnema/purego-cef/cef" | |
| 17 | 18 | ) | |
| 18 | 19 | ||
| @@ -22,10 +23,10 @@ func main() { | |||
| 22 | 23 | ||
| 23 | 24 | // cef_execute_process will handle the subprocess lifecycle and call | |
| 24 | 25 | // os.Exit internally if this is a valid CEF subprocess invocation. | |
| 25 | - | // If it returns (code < 0), this is not a subprocess — shouldn't | |
| 26 | - | // happen in normal operation since CEF only launches this binary | |
| 27 | - | // with --type= flags. | |
| 28 | - | cef.MaybeExitSubprocess() | |
| 26 | + | // We pass the lightweight subprocess app, which intentionally returns nil | |
| 27 | + | // from GetRenderProcessHandler(), so renderer-side bridges remain disabled | |
| 28 | + | // in helper subprocesses while the OSR startup regression is under repair. | |
| 29 | + | cef.MaybeExitSubprocessWithApp(infracef.NewSubprocessApp()) | |
| 29 | 30 | ||
| 30 | 31 | // If we get here, something went wrong. | |
| 31 | 32 | os.Exit(1) | |
M cmd/dumber/main.go +64 -36
| @@ -26,6 +26,7 @@ import ( | |||
| 26 | 26 | "github.com/bnema/dumber/internal/infrastructure/filesystem" | |
| 27 | 27 | "github.com/bnema/dumber/internal/infrastructure/idle" | |
| 28 | 28 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 29 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 29 | 30 | "github.com/bnema/dumber/internal/infrastructure/snapshot" | |
| 30 | 31 | "github.com/bnema/dumber/internal/infrastructure/textinput" | |
| 31 | 32 | "github.com/bnema/dumber/internal/infrastructure/updater" | |
| @@ -54,7 +55,7 @@ var initialURL string | |||
| 54 | 55 | var restoreSessionID string | |
| 55 | 56 | ||
| 56 | 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 | 60 | type launchMode string | |
| 60 | 61 | ||
| @@ -97,6 +98,24 @@ func tryForwardBrowseURLToRunningInstance(ctx context.Context, relay port.Browse | |||
| 97 | 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 | 119 | func main() { | |
| 101 | 120 | enableCrashForensics() | |
| 102 | 121 | ||
| @@ -114,6 +133,8 @@ func main() { | |||
| 114 | 133 | mode, browseURL := launchModeFromArgs(os.Args) | |
| 115 | 134 | // Run GUI mode for browse command | |
| 116 | 135 | if mode == launchModeBrowse { | |
| 136 | + | cfg := initConfig() | |
| 137 | + | configureBrowserLaunchRelay(cfg) | |
| 117 | 138 | if forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), browserLaunchRelay, browseURL); err != nil { | |
| 118 | 139 | fmt.Fprintf( | |
| 119 | 140 | os.Stderr, | |
| @@ -128,7 +149,7 @@ func main() { | |||
| 128 | 149 | initialURL = browseURL | |
| 129 | 150 | restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION") | |
| 130 | 151 | os.Args = os.Args[:1] | |
| 131 | - | os.Exit(runGUI()) | |
| 152 | + | os.Exit(runGUI(cfg)) | |
| 132 | 153 | return | |
| 133 | 154 | } | |
| 134 | 155 | ||
| @@ -149,14 +170,17 @@ func main() { | |||
| 149 | 170 | cmd.Execute() | |
| 150 | 171 | } | |
| 151 | 172 | ||
| 152 | - | func runGUI() int { | |
| 173 | + | func runGUI(cfg *config.Config) int { | |
| 153 | 174 | bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr) | |
| 154 | 175 | ||
| 155 | 176 | runtime.LockOSThread() | |
| 156 | 177 | component.SetSkeletonVersion(version) | |
| 157 | 178 | timer := bootstrap.NewStartupTimer() | |
| 158 | 179 | ||
| 159 | - | cfg := initConfig() | |
| 180 | + | if cfg == nil { | |
| 181 | + | cfg = initConfig() | |
| 182 | + | configureBrowserLaunchRelay(cfg) | |
| 183 | + | } | |
| 160 | 184 | timer.Mark("config") | |
| 161 | 185 | ||
| 162 | 186 | ctx := initStartupContextWithTrace(cfg) | |
| @@ -232,6 +256,7 @@ func runStandaloneOmnibox() int { | |||
| 232 | 256 | component.SetSkeletonVersion(version) | |
| 233 | 257 | ||
| 234 | 258 | cfg := initConfig() | |
| 259 | + | configureBrowserLaunchRelay(cfg) | |
| 235 | 260 | ctx := initStartupContextWithTrace(cfg) | |
| 236 | 261 | ||
| 237 | 262 | initResult, err := runParallelInitPhase(ctx, cfg) | |
| @@ -260,6 +285,7 @@ func runStandaloneOmnibox() int { | |||
| 260 | 285 | uiDeps, err := buildUIDependencies( | |
| 261 | 286 | ctx, | |
| 262 | 287 | cfg, | |
| 288 | + | initResult.RuntimeProfile, | |
| 263 | 289 | initResult.ThemeManager, | |
| 264 | 290 | initResult.ColorResolver, | |
| 265 | 291 | initResult.AdwaitaDetector, | |
| @@ -391,7 +417,7 @@ func buildAndConfigureApp( | |||
| 391 | 417 | browserSession *bootstrap.BrowserSession, | |
| 392 | 418 | ) (*ui.App, error) { | |
| 393 | 419 | uiDeps, err := buildUIDependencies( | |
| 394 | - | ctx, cfg, initResult.ThemeManager, | |
| 420 | + | ctx, cfg, initResult.RuntimeProfile, initResult.ThemeManager, | |
| 395 | 421 | initResult.ColorResolver, initResult.AdwaitaDetector, | |
| 396 | 422 | engine, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.CrashReports(), | |
| 397 | 423 | ) | |
| @@ -427,12 +453,11 @@ func initStackAndRepos( | |||
| 427 | 453 | if needsEagerDB { | |
| 428 | 454 | // Parallel phase 2: Database + engine initialize concurrently | |
| 429 | 455 | dbEngine, err := bootstrap.RunParallelDBEngine(bootstrap.ParallelDBEngineInput{ | |
| 430 | - | Ctx: ctx, | |
| 431 | - | Config: cfg, | |
| 432 | - | DataDir: initResult.DataDir, | |
| 433 | - | CacheDir: initResult.CacheDir, | |
| 434 | - | ThemeManager: initResult.ThemeManager, | |
| 435 | - | ColorResolver: initResult.ColorResolver, | |
| 456 | + | Ctx: ctx, | |
| 457 | + | Config: cfg, | |
| 458 | + | RuntimeProfile: initResult.RuntimeProfile, | |
| 459 | + | ThemeManager: initResult.ThemeManager, | |
| 460 | + | ColorResolver: initResult.ColorResolver, | |
| 436 | 461 | }) | |
| 437 | 462 | if err != nil { | |
| 438 | 463 | return nil, nil, nil, err | |
| @@ -442,13 +467,12 @@ func initStackAndRepos( | |||
| 442 | 467 | ||
| 443 | 468 | log := logging.FromContext(ctx) | |
| 444 | 469 | engine, err := bootstrap.BuildEngine(bootstrap.EngineInput{ | |
| 445 | - | Ctx: ctx, | |
| 446 | - | Config: cfg, | |
| 447 | - | DataDir: initResult.DataDir, | |
| 448 | - | CacheDir: initResult.CacheDir, | |
| 449 | - | ThemeManager: initResult.ThemeManager, | |
| 450 | - | ColorResolver: initResult.ColorResolver, | |
| 451 | - | Logger: *log, | |
| 470 | + | Ctx: ctx, | |
| 471 | + | Config: cfg, | |
| 472 | + | RuntimeProfile: initResult.RuntimeProfile, | |
| 473 | + | ThemeManager: initResult.ThemeManager, | |
| 474 | + | ColorResolver: initResult.ColorResolver, | |
| 475 | + | Logger: *log, | |
| 452 | 476 | }) | |
| 453 | 477 | if err != nil { | |
| 454 | 478 | return nil, nil, nil, err | |
| @@ -717,6 +741,7 @@ func isCEFSubprocess() bool { | |||
| 717 | 741 | func buildUIDependencies( | |
| 718 | 742 | ctx context.Context, | |
| 719 | 743 | cfg *config.Config, | |
| 744 | + | runtimeProfile runtimeprofile.Profile, | |
| 720 | 745 | themeManager *theme.Manager, | |
| 721 | 746 | colorResolver port.ColorSchemeResolver, | |
| 722 | 747 | adwaitaDetector port.ToolkitAvailabilityNotifier, | |
| @@ -749,23 +774,26 @@ func buildUIDependencies( | |||
| 749 | 774 | Theme: themeManager, | |
| 750 | 775 | ColorResolver: colorResolver, | |
| 751 | 776 | AdwaitaDetector: adwaitaDetector, | |
| 752 | - | XDG: xdg.New(), | |
| 753 | - | Engine: engine, | |
| 754 | - | FilterManager: filterManager, | |
| 755 | - | HistoryRepo: repos.history, | |
| 756 | - | FavoriteRepo: repos.favorite, | |
| 757 | - | ZoomRepo: repos.zoom, | |
| 758 | - | PermissionRepo: repos.permission, | |
| 759 | - | TabsUC: uc.tabs, | |
| 760 | - | PanesUC: uc.panes, | |
| 761 | - | HistoryUC: uc.history, | |
| 762 | - | FavoritesUC: uc.favorites, | |
| 763 | - | ZoomUC: uc.zoom, | |
| 764 | - | PermissionUC: uc.permission, | |
| 765 | - | NavigateUC: uc.navigate, | |
| 766 | - | CopyURLUC: uc.copyURL, | |
| 767 | - | Clipboard: uc.clipboard, | |
| 768 | - | FaviconService: uc.favicon, | |
| 777 | + | XDG: xdg.New( | |
| 778 | + | runtimeProfile.Mode == runtimeprofile.ModeDev, | |
| 779 | + | bootstrap.ResolveXDGRuntimeDir(runtimeProfile), | |
| 780 | + | ), | |
| 781 | + | Engine: engine, | |
| 782 | + | FilterManager: filterManager, | |
| 783 | + | HistoryRepo: repos.history, | |
| 784 | + | FavoriteRepo: repos.favorite, | |
| 785 | + | ZoomRepo: repos.zoom, | |
| 786 | + | PermissionRepo: repos.permission, | |
| 787 | + | TabsUC: uc.tabs, | |
| 788 | + | PanesUC: uc.panes, | |
| 789 | + | HistoryUC: uc.history, | |
| 790 | + | FavoritesUC: uc.favorites, | |
| 791 | + | ZoomUC: uc.zoom, | |
| 792 | + | PermissionUC: uc.permission, | |
| 793 | + | NavigateUC: uc.navigate, | |
| 794 | + | CopyURLUC: uc.copyURL, | |
| 795 | + | Clipboard: uc.clipboard, | |
| 796 | + | FaviconService: uc.favicon, | |
| 769 | 797 | FaviconAdapterConfig: adapter.FaviconAdapterConfig{ | |
| 770 | 798 | IsInternalURL: favicon.IsInternalURL, | |
| 771 | 799 | InternalDomain: favicon.InternalDomain, | |
| @@ -782,7 +810,7 @@ func buildUIDependencies( | |||
| 782 | 810 | }, | |
| 783 | 811 | CheckUpdateUC: uc.checkUpdate, | |
| 784 | 812 | ApplyUpdateUC: uc.applyUpdate, | |
| 785 | - | SessionSpawner: bootstrap.NewSessionSpawner(ctx, cfg), | |
| 813 | + | SessionSpawner: bootstrap.NewSessionSpawner(ctx, runtimeProfile), | |
| 786 | 814 | FileSystem: filesystem.New(), | |
| 787 | 815 | AccentFocusProvider: focusProvider, | |
| 788 | 816 | NewGTKEntryTarget: func(entry *gtk.SearchEntry) port.TextInputTarget { | |
M go.mod +1 -1
| @@ -4,7 +4,7 @@ go 1.26.1 | |||
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | 6 | github.com/bnema/purego v0.11.0-bnema.2 | |
| 7 | - | github.com/bnema/purego-cef v0.8.2 | |
| 7 | + | github.com/bnema/purego-cef v0.9.0 | |
| 8 | 8 | github.com/bnema/purego-ffmpeg v0.3.1 | |
| 9 | 9 | github.com/bnema/purego-pipewire v0.1.2 | |
| 10 | 10 | github.com/bnema/purego-sqlite v0.1.2 | |
M go.sum +2 -2
| @@ -6,8 +6,8 @@ github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3v | |||
| 6 | 6 | github.com/aymanbagabas/go-udiff v0.3.1/go.mod h1:G0fsKmG+P6ylD0r6N/KgQD/nWzgfnl8ZBcNLgcbrw8E= | |
| 7 | 7 | github.com/bnema/purego v0.11.0-bnema.2 h1:7pzcdOJ8bGtLrCz6V582oyleP6css1ICQoGgj+/Ve4o= | |
| 8 | 8 | github.com/bnema/purego v0.11.0-bnema.2/go.mod h1:5BnOIUj+0fvk/vwv45iottFoGfkDT7cnLXnpM2s6Hng= | |
| 9 | - | github.com/bnema/purego-cef v0.8.2 h1:MOyKiR0wwq2koI8We77rx+Fw/u/LsSU/XqvckGJKVYc= | |
| 10 | - | github.com/bnema/purego-cef v0.8.2/go.mod h1:AIW4lrXr4f2vesO4WnMDN49yAyfivYHV4x6hxpu5SmM= | |
| 9 | + | github.com/bnema/purego-cef v0.9.0 h1:pFTxOa0nvoDkXsqIzUWCjzPTCaQwPnJaXqZk0kMt1ts= | |
| 10 | + | github.com/bnema/purego-cef v0.9.0/go.mod h1:AIW4lrXr4f2vesO4WnMDN49yAyfivYHV4x6hxpu5SmM= | |
| 11 | 11 | github.com/bnema/purego-ffmpeg v0.3.1 h1:BWqRQvRXH3Lu7atem71Ai//fOYdNwuFofVF7uz/0XF0= | |
| 12 | 12 | github.com/bnema/purego-ffmpeg v0.3.1/go.mod h1:0/PCk5gYU0Dl1taBgiJd8ttR7A3SFSJXeNJBljeqFGc= | |
| 13 | 13 | github.com/bnema/purego-pipewire v0.1.2 h1:U057iOlpQatwlis6nfrZQJVrT3aGDFdagRLUz+ll7Ik= | |
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 | 20 | // MenuContext captures the state needed to build and execute a context menu. | |
| 21 | 21 | type MenuContext struct { | |
| 22 | - | PageURI string | |
| 23 | - | LinkURI string | |
| 24 | - | ImageURI string | |
| 25 | - | HasSelection bool | |
| 26 | - | IsEditable bool | |
| 27 | - | CanGoBack bool | |
| 28 | - | CanGoForward bool | |
| 29 | - | X int | |
| 30 | - | Y int | |
| 22 | + | PageURI string | |
| 23 | + | LinkURI string | |
| 24 | + | ImageURI string | |
| 25 | + | SelectionText string | |
| 26 | + | HasSelection bool | |
| 27 | + | IsEditable bool | |
| 28 | + | CanGoBack bool | |
| 29 | + | CanGoForward bool | |
| 30 | + | X int | |
| 31 | + | Y int | |
| 31 | 32 | } | |
| 32 | 33 | ||
| 33 | 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 | 194 | // HandlerDependencies holds use cases needed by WebUI message handlers. | |
| 195 | 195 | type HandlerDependencies struct { | |
| 196 | - | HistoryUC HomepageHistory | |
| 197 | - | FavoritesUC HomepageFavorites | |
| 198 | - | Clipboard Clipboard | |
| 199 | - | AutoCopyConfig AutoCopyConfig | |
| 200 | - | OnClipboardCopied func(textLen int) | |
| 196 | + | HistoryUC HomepageHistory | |
| 197 | + | FavoritesUC HomepageFavorites | |
| 198 | + | Clipboard Clipboard | |
| 199 | + | AutoCopyConfig AutoCopyConfig | |
| 200 | + | ClipboardTextOrchestrator ClipboardTextOrchestrator | |
| 201 | + | OnClipboardCopied func(textLen int) | |
| 201 | 202 | HandlerDeps | |
| 202 | 203 | } | |
| 203 | 204 | ||
M internal/application/port/mocks/mocks.go +142 -0
| @@ -11,6 +11,7 @@ import ( | |||
| 11 | 11 | "io" | |
| 12 | 12 | "time" | |
| 13 | 13 | ||
| 14 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 14 | 15 | "github.com/bnema/dumber/internal/application/port" | |
| 15 | 16 | "github.com/bnema/dumber/internal/domain/entity" | |
| 16 | 17 | "github.com/rs/zerolog" | |
| @@ -730,6 +731,147 @@ func (_c *MockClipboard_WriteText_Call) RunAndReturn(run func(ctx context.Contex | |||
| 730 | 731 | 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 | 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 | 876 | // The first argument is typically a *testing.T value. | |
| 735 | 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 | 105 | return fmt.Errorf("save image: %w", err) | |
| 106 | 106 | } | |
| 107 | 107 | return nil | |
| 108 | - | default: | |
| 109 | - | // All other normalized actions are delegated to the engine/UI layer. | |
| 110 | - | if uc.delegator == nil { | |
| 111 | - | return fmt.Errorf("delegate action %s: menu action delegator not available", action) | |
| 108 | + | case port.MenuActionCopySelection: | |
| 109 | + | // When selection text is present and clipboard wiring is available, copy it here. | |
| 110 | + | // Otherwise intentionally fall through to DelegateMenuAction so native browser copy | |
| 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 | 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 | 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 | 260 | func TestExecuteContextMenuActionUseCase_CopyLinkWritesText(t *testing.T) { | |
| 221 | 261 | clipboard := &fakeClipboard{} | |
| 222 | 262 | resolver := &fakeImageResolver{} | |
M internal/bootstrap/engine.go +27 -13
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 14 | 14 | "github.com/bnema/dumber/internal/infrastructure/env" | |
| 15 | 15 | "github.com/bnema/dumber/internal/infrastructure/handlers" | |
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 16 | 17 | "github.com/bnema/dumber/internal/infrastructure/transcoder" | |
| 17 | 18 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| 18 | 19 | "github.com/bnema/dumber/internal/ui/theme" | |
| @@ -21,13 +22,12 @@ import ( | |||
| 21 | 22 | ||
| 22 | 23 | // EngineInput holds the input for BuildEngine. | |
| 23 | 24 | type EngineInput struct { | |
| 24 | - | Ctx context.Context | |
| 25 | - | Config *config.Config | |
| 26 | - | DataDir string | |
| 27 | - | CacheDir string | |
| 28 | - | ThemeManager *theme.Manager | |
| 29 | - | ColorResolver port.ColorSchemeResolver | |
| 30 | - | Logger zerolog.Logger | |
| 25 | + | Ctx context.Context | |
| 26 | + | Config *config.Config | |
| 27 | + | RuntimeProfile runtimeprofile.Profile | |
| 28 | + | ThemeManager *theme.Manager | |
| 29 | + | ColorResolver port.ColorSchemeResolver | |
| 30 | + | Logger zerolog.Logger | |
| 31 | 31 | } | |
| 32 | 32 | ||
| 33 | 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 | 38 | engineType := cfg.Engine.ResolveEngineType() | |
| 39 | 39 | switch engineType { | |
| 40 | 40 | case config.EngineTypeWebKit: | |
| 41 | + | profile, err := requireRuntimeProfile(input.RuntimeProfile, engineType) | |
| 42 | + | if err != nil { | |
| 43 | + | return nil, err | |
| 44 | + | } | |
| 41 | 45 | opts := port.EngineOptions{ | |
| 42 | - | DataDir: input.DataDir, | |
| 43 | - | CacheDir: input.CacheDir, | |
| 44 | 46 | CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy), | |
| 45 | 47 | } | |
| 46 | 48 | wkCfg := webkit.EngineConfigFromConfig(cfg.Engine.WebKit) | |
| 47 | 49 | ||
| 48 | 50 | return webkit.NewEngine( | |
| 49 | - | input.Ctx, cfg, opts, wkCfg, | |
| 51 | + | input.Ctx, cfg, opts, profile, wkCfg, | |
| 50 | 52 | input.ThemeManager, input.ColorResolver, | |
| 51 | 53 | contextMenuBuilder, contextMenuExecutorFactory, | |
| 52 | 54 | input.Logger, | |
| 53 | 55 | ) | |
| 54 | 56 | case config.EngineTypeCEF: | |
| 57 | + | profile, err := requireRuntimeProfile(input.RuntimeProfile, engineType) | |
| 58 | + | if err != nil { | |
| 59 | + | return nil, err | |
| 60 | + | } | |
| 55 | 61 | opts := port.EngineOptions{ | |
| 56 | - | DataDir: input.DataDir, | |
| 57 | - | CacheDir: input.CacheDir, | |
| 58 | 62 | CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy), | |
| 59 | 63 | } | |
| 60 | 64 | cefCfg := cef.RuntimeConfig{ | |
| @@ -102,8 +106,18 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||
| 102 | 106 | }, | |
| 103 | 107 | } | |
| 104 | 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 | 110 | default: | |
| 107 | 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 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "path/filepath" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ||
| 7 | 8 | "github.com/bnema/dumber/internal/infrastructure/cef" | |
| 8 | 9 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 9 | 11 | "github.com/stretchr/testify/require" | |
| 10 | 12 | ) | |
| 11 | 13 | ||
| @@ -21,7 +23,7 @@ func TestBuildEngine_CEF_ReturnsErrorWhenRuntimeUnavailable(t *testing.T) { | |||
| 21 | 23 | cfg := config.DefaultConfig() | |
| 22 | 24 | cfg.Engine.Type = "cef" | |
| 23 | 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 | 27 | require.Error(t, err) | |
| 26 | 28 | require.Contains(t, err.Error(), "cef.InitWithApp") | |
| 27 | 29 | } | |
| @@ -30,7 +32,7 @@ func TestBuildEngine_CEF_ReturnsErrorForUnsupportedCookiePolicy(t *testing.T) { | |||
| 30 | 32 | cfg := config.DefaultConfig() | |
| 31 | 33 | cfg.Engine.Type = "cef" | |
| 32 | 34 | 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 | 36 | require.ErrorIs(t, err, cef.ErrCookiePolicyUnsupported) | |
| 35 | 37 | } | |
| 36 | 38 | ||
| @@ -39,6 +41,34 @@ func TestBuildEngine_CEF_AllowsNoThirdPartyCookiePolicy(t *testing.T) { | |||
| 39 | 41 | cfg.Engine.Type = "cef" | |
| 40 | 42 | cfg.Engine.CookiePolicy = config.CookiePolicyNoThirdParty | |
| 41 | 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 | 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 | 6 | "database/sql" | |
| 7 | 7 | "fmt" | |
| 8 | 8 | "os" | |
| 9 | - | "path/filepath" | |
| 10 | 9 | "sync" | |
| 11 | 10 | "time" | |
| 12 | 11 | ||
| @@ -18,6 +17,7 @@ import ( | |||
| 18 | 17 | "github.com/bnema/dumber/internal/infrastructure/env" | |
| 19 | 18 | "github.com/bnema/dumber/internal/infrastructure/media" | |
| 20 | 19 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | |
| 20 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 21 | 21 | "github.com/bnema/dumber/internal/logging" | |
| 22 | 22 | "github.com/bnema/dumber/internal/ui/theme" | |
| 23 | 23 | ) | |
| @@ -30,8 +30,7 @@ type DatabaseResult struct { | |||
| 30 | 30 | ||
| 31 | 31 | // ParallelInitResult holds the results of parallel initialization phase. | |
| 32 | 32 | type ParallelInitResult struct { | |
| 33 | - | DataDir string | |
| 34 | - | CacheDir string | |
| 33 | + | RuntimeProfile runtimeprofile.Profile | |
| 35 | 34 | ThemeManager *theme.Manager | |
| 36 | 35 | ColorResolver port.ColorSchemeResolver | |
| 37 | 36 | AdwaitaDetector *colorscheme.AdwaitaDetector | |
| @@ -91,11 +90,15 @@ func (e *RuntimeRequirementsError) LogDetails(ctx context.Context) { | |||
| 91 | 90 | // Returns the first fatal error encountered, or nil with the results. | |
| 92 | 91 | func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |
| 93 | 92 | var ( | |
| 94 | - | dataDir, cacheDir string | |
| 95 | - | dirsErr error | |
| 96 | - | wg sync.WaitGroup | |
| 93 | + | dirsErr error | |
| 94 | + | 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 | 102 | start := time.Now() | |
| 100 | 103 | ||
| 101 | 104 | // Create color scheme resolver with initial detectors. | |
| @@ -116,7 +119,7 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |||
| 116 | 119 | // Resolve directories | |
| 117 | 120 | go func() { | |
| 118 | 121 | defer wg.Done() | |
| 119 | - | dataDir, cacheDir, dirsErr = resolveWebKitDirs() | |
| 122 | + | dirsErr = resolveWebKitDirs(profile) | |
| 120 | 123 | }() | |
| 121 | 124 | ||
| 122 | 125 | // Theme manager (CPU-bound, no I/O) | |
| @@ -138,8 +141,7 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |||
| 138 | 141 | } | |
| 139 | 142 | ||
| 140 | 143 | return &ParallelInitResult{ | |
| 141 | - | DataDir: dataDir, | |
| 142 | - | CacheDir: cacheDir, | |
| 144 | + | RuntimeProfile: profile, | |
| 143 | 145 | ThemeManager: themeManager, | |
| 144 | 146 | ColorResolver: resolver, | |
| 145 | 147 | AdwaitaDetector: adwaitaDetector, | |
| @@ -218,21 +220,17 @@ func CheckMediaRequirements(ctx context.Context, cfg *config.Config) error { | |||
| 218 | 220 | } | |
| 219 | 221 | ||
| 220 | 222 | // resolveWebKitDirs resolves and creates the data and cache directories for WebKit. | |
| 221 | - | func resolveWebKitDirs() (dataDir, cacheDir string, err error) { | |
| 222 | - | const cacheDirPerm = 0o755 | |
| 223 | - | dataDir, err = config.GetDataDir() | |
| 224 | - | if err != nil { | |
| 225 | - | return "", "", fmt.Errorf("resolve data directory: %w", err) | |
| 223 | + | func resolveWebKitDirs(profile runtimeprofile.Profile) error { | |
| 224 | + | const dirPerm = 0o755 | |
| 225 | + | dataDir := profile.WebKitDataDir() | |
| 226 | + | cacheDir := profile.WebKitCacheDir() | |
| 227 | + | if mkErr := os.MkdirAll(dataDir, dirPerm); mkErr != nil { | |
| 228 | + | return fmt.Errorf("create data directory %s: %w", dataDir, mkErr) | |
| 226 | 229 | } | |
| 227 | - | stateDir, err := config.GetStateDir() | |
| 228 | - | if err != nil { | |
| 229 | - | return "", "", fmt.Errorf("resolve state directory: %w", err) | |
| 230 | - | } | |
| 231 | - | cacheDir = filepath.Join(stateDir, "webkit-cache") | |
| 232 | - | if mkErr := os.MkdirAll(cacheDir, cacheDirPerm); mkErr != nil { | |
| 233 | - | return "", "", fmt.Errorf("create cache directory %s: %w", cacheDir, mkErr) | |
| 230 | + | if mkErr := os.MkdirAll(cacheDir, dirPerm); mkErr != nil { | |
| 231 | + | return fmt.Errorf("create cache directory %s: %w", cacheDir, mkErr) | |
| 234 | 232 | } | |
| 235 | - | return dataDir, cacheDir, nil | |
| 233 | + | return nil | |
| 236 | 234 | } | |
| 237 | 235 | ||
| 238 | 236 | // OpenDatabase opens and initializes the SQLite database using XDG paths. | |
| @@ -277,12 +275,11 @@ type ParallelDBEngineResult struct { | |||
| 277 | 275 | ||
| 278 | 276 | // ParallelDBEngineInput holds inputs for parallel DB and engine initialization. | |
| 279 | 277 | type ParallelDBEngineInput struct { | |
| 280 | - | Ctx context.Context | |
| 281 | - | Config *config.Config | |
| 282 | - | DataDir string // For engine context | |
| 283 | - | CacheDir string // For engine cache | |
| 284 | - | ThemeManager *theme.Manager | |
| 285 | - | ColorResolver port.ColorSchemeResolver | |
| 278 | + | Ctx context.Context | |
| 279 | + | Config *config.Config | |
| 280 | + | RuntimeProfile runtimeprofile.Profile | |
| 281 | + | ThemeManager *theme.Manager | |
| 282 | + | ColorResolver port.ColorSchemeResolver | |
| 286 | 283 | } | |
| 287 | 284 | ||
| 288 | 285 | // Note: Database path is resolved via config.GetDatabaseFile() internally. | |
| @@ -317,13 +314,12 @@ func RunParallelDBEngine(input ParallelDBEngineInput) (*ParallelDBEngineResult, | |||
| 317 | 314 | ||
| 318 | 315 | // Engine on main thread (GTK requirement) | |
| 319 | 316 | engine, err := BuildEngine(EngineInput{ | |
| 320 | - | Ctx: input.Ctx, | |
| 321 | - | Config: input.Config, | |
| 322 | - | DataDir: input.DataDir, | |
| 323 | - | CacheDir: input.CacheDir, | |
| 324 | - | ThemeManager: input.ThemeManager, | |
| 325 | - | ColorResolver: input.ColorResolver, | |
| 326 | - | Logger: *log, | |
| 317 | + | Ctx: input.Ctx, | |
| 318 | + | Config: input.Config, | |
| 319 | + | RuntimeProfile: input.RuntimeProfile, | |
| 320 | + | ThemeManager: input.ThemeManager, | |
| 321 | + | ColorResolver: input.ColorResolver, | |
| 322 | + | Logger: *log, | |
| 327 | 323 | }) | |
| 328 | 324 | if err != nil { | |
| 329 | 325 | 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 | 7 | "github.com/bnema/dumber/internal/infrastructure/cef" | |
| 8 | 8 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 9 | 9 | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| 12 | 13 | // NewSessionSpawner wires the session spawner with any engine-specific launch | |
| 13 | 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 | 16 | var spawnEnv port.SessionSpawnEnvironment | |
| 16 | - | if cfg != nil && cfg.Engine.ResolveEngineType() == config.EngineTypeCEF { | |
| 17 | - | spawnEnv = cef.SessionSpawnEnvironment{} | |
| 17 | + | if profile.Engine == config.EngineTypeCEF { | |
| 18 | + | spawnEnv = cef.NewSessionSpawnEnvironment(profile.CEFUserDataDir()) | |
| 18 | 19 | } | |
| 19 | 20 | return desktop.NewSessionSpawner(ctx, spawnEnv) | |
| 20 | 21 | } | |
M internal/cli/app.go +5 -1
| @@ -51,6 +51,10 @@ func NewApp() (*App, error) { | |||
| 51 | 51 | var err error | |
| 52 | 52 | // Load config | |
| 53 | 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 | 59 | // Create theme from config | |
| 56 | 60 | theme := styles.NewTheme(cfg) | |
| @@ -133,7 +137,7 @@ func NewApp() (*App, error) { | |||
| 133 | 137 | RestoreUC: restoreUC, | |
| 134 | 138 | DeleteSessionUC: deleteSessionUC, | |
| 135 | 139 | FaviconService: faviconService, | |
| 136 | - | SessionSpawner: bootstrap.NewSessionSpawner(ctx, cfg), | |
| 140 | + | SessionSpawner: bootstrap.NewSessionSpawner(ctx, profile), | |
| 137 | 141 | ctx: ctx, | |
| 138 | 142 | logCleanup: logCleanup, | |
| 139 | 143 | }, nil | |
M internal/cli/cmd/gen_docs.go +1 -1
| @@ -57,7 +57,7 @@ func runGenDocs(_ *cobra.Command, _ []string) error { | |||
| 57 | 57 | if outputDir == "" { | |
| 58 | 58 | switch genDocsFormat { | |
| 59 | 59 | case "man": | |
| 60 | - | xdg := xdgadapter.New() | |
| 60 | + | xdg := xdgadapter.New(false, "") | |
| 61 | 61 | manDir, err := xdg.ManDir() | |
| 62 | 62 | if err != nil { | |
| 63 | 63 | return fmt.Errorf("resolve man directory: %w", err) | |
M internal/cli/cmd/purge.go +11 -2
| @@ -7,11 +7,13 @@ import ( | |||
| 7 | 7 | "github.com/spf13/cobra" | |
| 8 | 8 | ||
| 9 | 9 | "github.com/bnema/dumber/internal/application/usecase" | |
| 10 | + | "github.com/bnema/dumber/internal/bootstrap" | |
| 10 | 11 | "github.com/bnema/dumber/internal/cli" | |
| 11 | 12 | "github.com/bnema/dumber/internal/cli/model" | |
| 12 | 13 | "github.com/bnema/dumber/internal/cli/styles" | |
| 13 | 14 | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 14 | 15 | "github.com/bnema/dumber/internal/infrastructure/filesystem" | |
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 15 | 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 | 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 | 60 | desktopAdapter := desktop.New() | |
| 52 | 61 | purgeUC := usecase.NewPurgeDataUseCase(fsAdapter, xdgAdapter, desktopAdapter) | |
| 53 | 62 | ||
| @@ -63,7 +72,7 @@ func runPurge(_ *cobra.Command, _ []string) error { | |||
| 63 | 72 | ||
| 64 | 73 | m := model.NewPurgeModel(app.Ctx(), app.Theme, purgeUC, cfg) | |
| 65 | 74 | p := tea.NewProgram(m, tea.WithAltScreen()) | |
| 66 | - | _, err := p.Run() | |
| 75 | + | _, err = p.Run() | |
| 67 | 76 | 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 | 56 | return app | |
| 57 | 57 | } | |
| 58 | 58 | ||
| 59 | - | // NewSubprocessApp returns a lightweight App for helper processes so CEF sees | |
| 60 | - | // the same custom scheme registration in renderer/GPU/utility processes. | |
| 59 | + | // NewSubprocessApp returns a lightweight raw App implementation for helper | |
| 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 | 63 | func NewSubprocessApp() purecef.App { | |
| 62 | - | return purecef.NewApp(&subprocessApp{}) | |
| 64 | + | return &subprocessApp{} | |
| 63 | 65 | } | |
| 64 | 66 | ||
| 65 | 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 | 385 | }, 1000); | |
| 386 | 386 | })();` | |
| 387 | 387 | ||
| 388 | - | // clipboardCopyBridgeJS intercepts copy/cut events and sends the selected text | |
| 389 | - | // back to Go via the message bridge so it can be written to the GDK clipboard. | |
| 390 | - | // Without this, Ctrl+C in CEF OSR mode writes to CEF's internal clipboard but | |
| 391 | - | // not the Wayland/X11 system clipboard. | |
| 392 | - | const clipboardCopyBridgeJS = `(function(){ | |
| 393 | - | if (window.__dumberClipboardBridge) return; | |
| 394 | - | window.__dumberClipboardBridge = true; | |
| 388 | + | // clipboardSelectionFetchBridgeJS keeps the lightweight fetch bridge enabled | |
| 389 | + | // while the native renderer bridge remains disabled due to an OSR startup | |
| 390 | + | // regression. It mirrors explicit clipboard text from DOM selections, | |
| 391 | + | // safe input/textarea selections, and async Clipboard API writes, and also | |
| 392 | + | // reasserts browser focus when an editable element gains DOM focus. | |
| 393 | + | const clipboardSelectionFetchBridgeJS = `(function(){ | |
| 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 | 465 | function sendToClipboard(text) { | |
| 396 | 466 | if (!text) return; | |
| 397 | 467 | var body = JSON.stringify({text: text}); | |
| 398 | - | var encoded = typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : ''; | |
| 468 | + | var encoded = encodeBody(body); | |
| 399 | 469 | if (!encoded) return; | |
| 400 | 470 | fetch('dumb:///api/clipboard-set', { | |
| 401 | 471 | method: 'POST', | |
| 402 | - | headers: {'X-Dumber-Body': encoded} | |
| 472 | + | headers: { | |
| 473 | + | 'X-Dumber-Body': encoded, | |
| 474 | + | 'X-Dumber-Bridge-Nonce': bridgeNonce | |
| 475 | + | } | |
| 403 | 476 | }).catch(function(){}); | |
| 404 | 477 | } | |
| 405 | - | document.addEventListener('copy', function() { | |
| 406 | - | var sel = window.getSelection(); | |
| 407 | - | if (sel) sendToClipboard(sel.toString()); | |
| 478 | + | ||
| 479 | + | function sendFocusSync() { | |
| 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() { | |
| 410 | - | var sel = window.getSelection(); | |
| 411 | - | if (sel) sendToClipboard(sel.toString()); | |
| 512 | + | document.addEventListener('cut', function(event) { | |
| 513 | + | if (event && event.isTrusted === false) return; | |
| 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 | 633 | // contentInjector implements port.ContentInjector for the CEF engine. | |
| 416 | 634 | // It stores CSS strings and injects them into webviews via ExecuteJavaScript. | |
| 417 | 635 | // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd | |
| @@ -532,8 +750,9 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) { | |||
| 532 | 750 | ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS) | |
| 533 | 751 | wv.RunJavaScript(context.Background(), scrollbarAutoHideJS) | |
| 534 | 752 | ||
| 535 | - | // All pages get clipboard copy bridge (CEF OSR can't write to Wayland clipboard). | |
| 536 | - | wv.RunJavaScript(context.Background(), clipboardCopyBridgeJS) | |
| 753 | + | // Clipboard copy/cut and editable focus sync still need a JS bridge in OSR | |
| 754 | + | // mode while the native renderer bridge remains disabled. | |
| 755 | + | wv.RunJavaScript(context.Background(), buildClipboardSelectionFetchBridgeJS(wv.rotateBridgeNonce())) | |
| 537 | 756 | ||
| 538 | 757 | // Video playback diagnostic — logs video element state changes. | |
| 539 | 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 | 82 | x, | |
| 83 | 83 | y, | |
| 84 | 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 | 91 | func() { | |
| 88 | 92 | callback.Cancel() | |
| @@ -162,6 +166,7 @@ func dispatchContextMenuSelection( | |||
| 162 | 166 | ctx context.Context, | |
| 163 | 167 | executor port.ContextMenuActionExecutor, | |
| 164 | 168 | callback purecef.RunContextMenuCallback, | |
| 169 | + | copiedCallback func(text string), | |
| 165 | 170 | commandIDByAction map[port.MenuAction]int32, | |
| 166 | 171 | item port.MenuItem, | |
| 167 | 172 | menuContext port.MenuContext, | |
| @@ -171,14 +176,29 @@ func dispatchContextMenuSelection( | |||
| 171 | 176 | return | |
| 172 | 177 | } | |
| 173 | 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 | 189 | if shouldExecuteDirectCEFAction(item.Action) && executor != nil { | |
| 175 | 190 | if err := executor.ExecuteMenuAction(ctx, item.Action, menuContext); err != nil { | |
| 176 | 191 | log.Warn().Err(err).Str("action", string(item.Action)).Msg("cef: context menu action failed") | |
| 177 | 192 | callback.Cancel() | |
| 178 | 193 | return | |
| 179 | 194 | } | |
| 195 | + | if copiedCallback != nil { | |
| 196 | + | if copiedText := contextMenuCopiedText(item.Action, menuContext); copiedText != "" { | |
| 197 | + | copiedCallback(copiedText) | |
| 198 | + | } | |
| 199 | + | } | |
| 180 | 200 | log.Debug().Str("action", string(item.Action)).Msg("cef: context menu action executed directly") | |
| 181 | - | callback.Cont(0, 0) | |
| 201 | + | callback.Cancel() | |
| 182 | 202 | return | |
| 183 | 203 | } | |
| 184 | 204 | if cmdID, ok := commandIDByAction[item.Action]; ok { | |
| @@ -190,6 +210,17 @@ func dispatchContextMenuSelection( | |||
| 190 | 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 | 224 | func shouldExecuteDirectCEFAction(action port.MenuAction) bool { | |
| 194 | 225 | switch action { | |
| 195 | 226 | case port.MenuActionBack, | |
| @@ -296,7 +327,8 @@ func buildMenuContext(wv *WebView, params purecef.ContextMenuParams) port.MenuCo | |||
| 296 | 327 | if params.HasImageContents() { | |
| 297 | 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 | 332 | menuContext.IsEditable = params.IsEditable() | |
| 301 | 333 | menuContext.X = int(params.GetXcoord()) | |
| 302 | 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 | 64 | require.Equal(t, "https://example.com/page", ctx.PageURI) | |
| 65 | 65 | require.Equal(t, "https://example.com/link", ctx.LinkURI) | |
| 66 | 66 | require.Equal(t, "https://example.com/image.png", ctx.ImageURI) | |
| 67 | + | require.Equal(t, "selected text", ctx.SelectionText) | |
| 67 | 68 | require.True(t, ctx.HasSelection) | |
| 68 | 69 | require.True(t, ctx.IsEditable) | |
| 69 | 70 | require.True(t, ctx.CanGoBack) | |
| @@ -104,7 +105,7 @@ func (c *stubRunContextMenuCallback) Cancel() { | |||
| 104 | 105 | func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) { | |
| 105 | 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 | 109 | port.MenuActionReload: 102, | |
| 109 | 110 | }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{}) | |
| 110 | 111 | ||
| @@ -115,7 +116,7 @@ func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) { | |||
| 115 | 116 | func TestContextMenuSelectionContinuesWhenCEFCommandPresent(t *testing.T) { | |
| 116 | 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 | 120 | port.MenuActionInspectElement: 204, | |
| 120 | 121 | }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{}) | |
| 121 | 122 | ||
| @@ -133,6 +134,7 @@ func TestContextMenuSelectionExecutesDirectActionWhenExecutorAvailable(t *testin | |||
| 133 | 134 | context.Background(), | |
| 134 | 135 | executor, | |
| 135 | 136 | callback, | |
| 137 | + | nil, | |
| 136 | 138 | map[port.MenuAction]int32{port.MenuActionReload: 102}, | |
| 137 | 139 | port.MenuItem{Action: port.MenuActionReload, Label: "Reload"}, | |
| 138 | 140 | menuContext, | |
| @@ -141,9 +143,9 @@ func TestContextMenuSelectionExecutesDirectActionWhenExecutorAvailable(t *testin | |||
| 141 | 143 | require.Equal(t, 1, executor.executeCalls) | |
| 142 | 144 | require.Equal(t, port.MenuActionReload, executor.action) | |
| 143 | 145 | require.Equal(t, menuContext, executor.menuContext) | |
| 144 | - | require.Equal(t, 1, callback.contCalls) | |
| 146 | + | require.Zero(t, callback.contCalls) | |
| 145 | 147 | require.Zero(t, callback.commandID) | |
| 146 | - | require.Zero(t, callback.cancelCalls) | |
| 148 | + | require.Equal(t, 1, callback.cancelCalls) | |
| 147 | 149 | } | |
| 148 | 150 | ||
| 149 | 151 | func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *testing.T) { | |
| @@ -155,6 +157,7 @@ func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *t | |||
| 155 | 157 | context.Background(), | |
| 156 | 158 | executor, | |
| 157 | 159 | callback, | |
| 160 | + | nil, | |
| 158 | 161 | map[port.MenuAction]int32{}, | |
| 159 | 162 | port.MenuItem{Action: port.MenuActionCopyImage, Label: "Copy Image"}, | |
| 160 | 163 | menuContext, | |
| @@ -163,9 +166,54 @@ func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *t | |||
| 163 | 166 | require.Equal(t, 1, executor.executeCalls) | |
| 164 | 167 | require.Equal(t, port.MenuActionCopyImage, executor.action) | |
| 165 | 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 | 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 | 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 | 219 | func TestContextMenuAnchorPositionScalesCEFCoordinates(t *testing.T) { | |
M internal/infrastructure/cef/engine.go +89 -1
| @@ -2,12 +2,15 @@ package cef | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "crypto/subtle" | |
| 5 | 6 | "sync" | |
| 6 | 7 | "sync/atomic" | |
| 7 | 8 | "time" | |
| 9 | + | "unicode/utf8" | |
| 8 | 10 | ||
| 9 | 11 | purecef "github.com/bnema/purego-cef/cef" | |
| 10 | 12 | ||
| 13 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 11 | 14 | "github.com/bnema/dumber/internal/application/port" | |
| 12 | 15 | "github.com/bnema/dumber/internal/logging" | |
| 13 | 16 | ) | |
| @@ -35,6 +38,8 @@ type Engine struct { | |||
| 35 | 38 | ctxMenuBuilder port.ContextMenuBuilder | |
| 36 | 39 | ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory | |
| 37 | 40 | clipboard port.Clipboard | |
| 41 | + | clipboardTextOrchestrator port.ClipboardTextOrchestrator | |
| 42 | + | onClipboardCopied func(textLen int) | |
| 38 | 43 | resolver port.ImageDataResolver | |
| 39 | 44 | mediaClassifier MediaClassifier | |
| 40 | 45 | alreadyRunningAppRelaunchMu sync.RWMutex | |
| @@ -236,6 +241,8 @@ func (e *Engine) closeActiveWebViews() { | |||
| 236 | 241 | // each handler is registered by message type with the router, which dispatches | |
| 237 | 242 | // incoming /api/message POSTs to the correct handler based on Message.Type. | |
| 238 | 243 | func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependencies) error { | |
| 244 | + | e.clipboardTextOrchestrator = deps.ClipboardTextOrchestrator | |
| 245 | + | e.onClipboardCopied = deps.OnClipboardCopied | |
| 239 | 246 | if e.messageRouter == nil || e.registerHandlers == nil { | |
| 240 | 247 | return nil | |
| 241 | 248 | } | |
| @@ -250,7 +257,12 @@ func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.Accent | |||
| 250 | 257 | } | |
| 251 | 258 | ||
| 252 | 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 | 266 | return ErrDownloadsUnsupported | |
| 255 | 267 | } | |
| 256 | 268 | ||
| @@ -276,6 +288,53 @@ func (e *Engine) UpdateSettings(_ context.Context, _ port.EngineSettingsUpdate) | |||
| 276 | 288 | 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 | 338 | // SetHandlerContext sets the base context for message handler dispatch. | |
| 280 | 339 | func (e *Engine) SetHandlerContext(ctx context.Context) { | |
| 281 | 340 | e.ctx = ctx | |
| @@ -359,6 +418,35 @@ func (e *Engine) currentContext() context.Context { | |||
| 359 | 418 | 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 | 450 | func (e *Engine) logTranscoderStartupState() { | |
| 363 | 451 | if e == nil || !e.transcoderStateLogged.CompareAndSwap(false, true) { | |
| 364 | 452 | return | |
M internal/infrastructure/cef/engine_init.go +35 -58
| @@ -17,6 +17,7 @@ import ( | |||
| 17 | 17 | "github.com/bnema/dumber/assets" | |
| 18 | 18 | "github.com/bnema/dumber/internal/application/port" | |
| 19 | 19 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 20 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 20 | 21 | transcoderpkg "github.com/bnema/dumber/internal/infrastructure/transcoder" | |
| 21 | 22 | "github.com/bnema/dumber/internal/logging" | |
| 22 | 23 | ) | |
| @@ -28,6 +29,7 @@ const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH" | |||
| 28 | 29 | func NewEngine( | |
| 29 | 30 | ctx context.Context, | |
| 30 | 31 | opts port.EngineOptions, | |
| 32 | + | profile runtimeprofile.Profile, | |
| 31 | 33 | cfg RuntimeConfig, | |
| 32 | 34 | transcodingCfg TranscodingRuntimeConfig, | |
| 33 | 35 | audioFactory port.AudioOutputFactory, | |
| @@ -35,11 +37,11 @@ func NewEngine( | |||
| 35 | 37 | ) (*Engine, error) { | |
| 36 | 38 | logger := logging.FromContext(ctx) | |
| 37 | 39 | deps.MediaClassifier = deps.MediaClassifier.normalize() | |
| 38 | - | stateRoot := resolvedStateRoot(opts) | |
| 40 | + | stateRoot := resolvedStateRoot(profile, opts) | |
| 39 | 41 | cleanStaleSingletonLocks(logger, stateRoot) | |
| 40 | 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 | 45 | if err != nil { | |
| 44 | 46 | 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 | 95 | if root := os.Getenv(CEFRootCachePathEnvVar); root != "" { | |
| 94 | 96 | return root | |
| 95 | 97 | } | |
| 96 | - | ||
| 97 | - | switch { | |
| 98 | - | case opts.DataDir != "": | |
| 99 | - | return opts.DataDir | |
| 100 | - | case opts.CacheDir != "": | |
| 98 | + | if opts.CacheDir != "" { | |
| 101 | 99 | 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 | 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 | 114 | switch opts.CookiePolicy { | |
| 110 | 115 | case "", port.CookiePolicyAlways: | |
| 111 | 116 | case port.CookiePolicyNoThirdParty: | |
| @@ -121,16 +126,16 @@ func prepareCEFSettings(opts port.EngineOptions, cfg RuntimeConfig, logger *zero | |||
| 121 | 126 | settings := purecef.DefaultSettings() | |
| 122 | 127 | settings.MultiThreadedMessageLoop = true | |
| 123 | 128 | settings.ExternalMessagePump = false | |
| 124 | - | settings.RootCachePath = resolvedStateRoot(opts) | |
| 129 | + | settings.RootCachePath = resolvedStateRoot(profile, opts) | |
| 125 | 130 | if cfg.CEFDir != "" { | |
| 126 | 131 | 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 | 134 | logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file") | |
| 130 | 135 | } else { | |
| 131 | 136 | 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 | 139 | logger.Warn().Err(err).Msg("cef: failed to prepare init trace file") | |
| 135 | 140 | } else if bootstrapLogFile != "" { | |
| 136 | 141 | settings.InitTraceFile = bootstrapLogFile | |
| @@ -151,24 +156,6 @@ func prepareCEFSettings(opts port.EngineOptions, cfg RuntimeConfig, logger *zero | |||
| 151 | 156 | 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 | 159 | // initializeCEF calls cef_initialize with the App to register custom schemes | |
| 173 | 160 | // and browser-process callbacks (OnBeforeCommandLineProcessing, etc.). | |
| 174 | 161 | func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logger) error { | |
| @@ -253,9 +240,9 @@ func wireEngine( | |||
| 253 | 240 | } | |
| 254 | 241 | schemeHandler.setAssets(assets.WebUIAssets) | |
| 255 | 242 | ||
| 256 | - | // Bridge clipboard writes from CEF JS → GDK system clipboard. | |
| 257 | - | // The callback is invoked on the CEF IO thread, so we schedule the | |
| 258 | - | // GDK write on the GTK main loop via glib.IdleAddOnce. | |
| 243 | + | // Bridge clipboard writes from page JS → GDK system clipboard. | |
| 244 | + | // The callback is invoked on the CEF IO thread, so schedule the actual GDK | |
| 245 | + | // write on the GTK main loop. | |
| 259 | 246 | schemeHandler.onClipboardSet = func(text string) { | |
| 260 | 247 | fn := glib.SourceOnceFunc(func(_ uintptr) { | |
| 261 | 248 | if display := gdk.DisplayGetDefault(); display != nil { | |
| @@ -272,6 +259,9 @@ func wireEngine( | |||
| 272 | 259 | glib.IdleAddOnce(&fn, 0) | |
| 273 | 260 | } | |
| 274 | 261 | ||
| 262 | + | schemeHandler.onEditableFocus = eng.handleEditableFocusBridge | |
| 263 | + | schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest | |
| 264 | + | ||
| 275 | 265 | schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler) | |
| 276 | 266 | ||
| 277 | 267 | result := purecef.RegisterSchemeHandlerFactory("dumb", "", schemeFactory) | |
| @@ -433,33 +423,24 @@ func findHelperBinary() string { | |||
| 433 | 423 | } | |
| 434 | 424 | ||
| 435 | 425 | const ( | |
| 436 | - | dirPerm = 0o755 | |
| 437 | - | filePerm = 0o644 | |
| 426 | + | dirPerm = 0o700 | |
| 427 | + | filePerm = 0o600 | |
| 438 | 428 | ) | |
| 439 | 429 | ||
| 440 | - | func prepareCEFLogFile(logFile string) (string, error) { | |
| 441 | - | if logFile != "" { | |
| 442 | - | runtimeLogFile := filepath.Clean(logFile) | |
| 443 | - | if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil { | |
| 444 | - | return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err) | |
| 445 | - | } | |
| 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) | |
| 430 | + | func prepareCEFLogFile(profile runtimeprofile.Profile, logFile string) (string, error) { | |
| 431 | + | runtimeLogFile := logFile | |
| 432 | + | if runtimeLogFile != "" { | |
| 433 | + | runtimeLogFile = filepath.Clean(runtimeLogFile) | |
| 434 | + | } else { | |
| 435 | + | runtimeLogFile = profile.CEFLogFile() | |
| 454 | 436 | } | |
| 455 | - | runtimeLogFile := filepath.Join(cwd, ".dev", "dumber", "logs", "cef_runtime.log") | |
| 456 | 437 | if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil { | |
| 457 | 438 | return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err) | |
| 458 | 439 | } | |
| 459 | 440 | 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 | 444 | if !puregoCEFInitTraceEnabled() { | |
| 464 | 445 | return "", nil | |
| 465 | 446 | } | |
| @@ -468,11 +449,7 @@ func prepareCEFInitTraceFile(logFile string) (string, error) { | |||
| 468 | 449 | if runtimeLogFile != "" { | |
| 469 | 450 | runtimeLogFile = filepath.Clean(runtimeLogFile) | |
| 470 | 451 | } else { | |
| 471 | - | cwd, cwdErr := os.Getwd() | |
| 472 | - | if cwdErr != nil { | |
| 473 | - | return "", fmt.Errorf("getwd: %w", cwdErr) | |
| 474 | - | } | |
| 475 | - | runtimeLogFile = filepath.Join(cwd, ".dev", "dumber", "logs", "cef_runtime.log") | |
| 452 | + | runtimeLogFile = profile.CEFLogFile() | |
| 476 | 453 | } | |
| 477 | 454 | bootstrapLogFile := strings.TrimSuffix(runtimeLogFile, filepath.Ext(runtimeLogFile)) + ".bootstrap.log" | |
| 478 | 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 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | 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 | 11 | "github.com/rs/zerolog" | |
| 12 | 12 | "github.com/stretchr/testify/require" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | - | func TestResolvedStateRoot_PrefersDataDir(t *testing.T) { | |
| 16 | - | root := resolvedStateRoot(port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}) | |
| 17 | - | require.Equal(t, "/tmp/data", root) | |
| 18 | - | } | |
| 19 | - | ||
| 20 | - | func TestResolvedStateRoot_FallsBackToCacheDir(t *testing.T) { | |
| 21 | - | root := resolvedStateRoot(port.EngineOptions{CacheDir: "/tmp/cache"}) | |
| 15 | + | func TestResolvedStateRoot_PrefersCacheDir(t *testing.T) { | |
| 16 | + | profile := testCEFDevProfile(t) | |
| 17 | + | root := resolvedStateRoot(profile, port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}) | |
| 22 | 18 | require.Equal(t, "/tmp/cache", root) | |
| 23 | 19 | } | |
| 24 | 20 | ||
| 25 | 21 | func TestResolvedStateRoot_UsesEnvOverride(t *testing.T) { | |
| 22 | + | profile := testCEFDevProfile(t) | |
| 26 | 23 | t.Setenv(CEFRootCachePathEnvVar, "/tmp/override") | |
| 27 | 24 | ||
| 28 | - | root := resolvedStateRoot(port.EngineOptions{}) | |
| 25 | + | root := resolvedStateRoot(profile, port.EngineOptions{}) | |
| 29 | 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 | 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 | 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 | 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 | 47 | func TestPrepareCEFSettings_RejectsNonDefaultCookiePolicy(t *testing.T) { | |
| 43 | 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 | 50 | require.ErrorIs(t, err, ErrCookiePolicyUnsupported) | |
| 46 | 51 | } | |
| 47 | 52 | ||
| 48 | 53 | func TestPrepareCEFSettings_AllowsNoThirdPartyCookiePolicy(t *testing.T) { | |
| 49 | 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 | 56 | require.NoError(t, err) | |
| 52 | 57 | } | |
| 53 | 58 | ||
| 54 | 59 | func TestPrepareCEFInitTraceFile_DisabledByDefault(t *testing.T) { | |
| 55 | 60 | t.Setenv(puregoCEFInitTraceEnvVar, "") | |
| 56 | 61 | ||
| 57 | - | path, err := prepareCEFInitTraceFile("") | |
| 62 | + | path, err := prepareCEFInitTraceFile(testCEFDevProfile(t), "") | |
| 58 | 63 | require.NoError(t, err) | |
| 59 | 64 | require.Empty(t, path) | |
| 60 | 65 | } | |
| 61 | 66 | ||
| 62 | - | func TestPrepareCEFInitTraceFile_EnabledViaEnv(t *testing.T) { | |
| 63 | - | t.Setenv(puregoCEFInitTraceEnvVar, "1") | |
| 64 | - | cfg := config.CEFEngineConfig{ | |
| 65 | - | LogFile: filepath.Join(t.TempDir(), "cef_runtime.log"), | |
| 66 | - | } | |
| 67 | + | func TestPrepareCEFLogFile_CreatesPrivateDirectory(t *testing.T) { | |
| 68 | + | logFile := filepath.Join(t.TempDir(), "private", "cef_runtime.log") | |
| 67 | 69 | ||
| 68 | - | path, err := prepareCEFInitTraceFile(cfg.LogFile) | |
| 70 | + | path, err := prepareCEFLogFile(testCEFDevProfile(t), logFile) | |
| 69 | 71 | require.NoError(t, err) | |
| 70 | - | require.Equal(t, filepath.Join(filepath.Dir(cfg.LogFile), "cef_runtime.bootstrap.log"), path) | |
| 71 | - | require.FileExists(t, path) | |
| 72 | + | require.Equal(t, logFile, 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) { | |
| 75 | - | homeDir := filepath.Join(t.TempDir(), "home") | |
| 76 | - | dataHome := filepath.Join(homeDir, ".local", "share") | |
| 77 | - | t.Setenv("HOME", homeDir) | |
| 78 | - | t.Setenv("ENV", "") | |
| 79 | - | t.Setenv("XDG_DATA_HOME", dataHome) | |
| 79 | + | func TestPrepareCEFInitTraceFile_EnabledViaExplicitLogFile(t *testing.T) { | |
| 80 | + | t.Setenv(puregoCEFInitTraceEnvVar, "1") | |
| 81 | + | logFile := filepath.Join(t.TempDir(), "custom", "cef_runtime.log") | |
| 80 | 82 | ||
| 81 | - | logger := zerolog.Nop() | |
| 82 | - | settings, err := prepareCEFSettings(port.EngineOptions{}, RuntimeConfig{}, &logger) | |
| 83 | + | path, err := prepareCEFInitTraceFile(testCEFDevProfile(t), logFile) | |
| 83 | 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) | |
| 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() | |
| 88 | + | info, err := os.Stat(path) | |
| 92 | 89 | require.NoError(t, err) | |
| 93 | - | defer func() { | |
| 94 | - | _ = os.Chdir(originalDir) | |
| 95 | - | }() | |
| 90 | + | require.Equal(t, os.FileMode(filePerm), info.Mode().Perm()) | |
| 96 | 91 | ||
| 97 | - | err = os.Chdir(tempDir) | |
| 92 | + | dirInfo, err := os.Stat(filepath.Dir(path)) | |
| 98 | 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 mode | |
| 101 | - | t.Setenv("ENV", "dev") | |
| 97 | + | func TestPrepareCEFInitTraceFile_UsesResolvedProfileLogDirByDefault(t *testing.T) { | |
| 98 | + | t.Setenv(puregoCEFInitTraceEnvVar, "1") | |
| 99 | + | profile := testCEFDevProfile(t) | |
| 102 | 100 | ||
| 103 | - | logger := zerolog.Nop() | |
| 104 | - | settings, err := prepareCEFSettings(port.EngineOptions{}, RuntimeConfig{}, &logger) | |
| 101 | + | path, err := prepareCEFInitTraceFile(profile, "") | |
| 105 | 102 | require.NoError(t, err) | |
| 106 | - | ||
| 107 | - | // In dev mode, should resolve under .dev/dumber/cef_user_data | |
| 108 | - | require.Equal(t, filepath.Join(tempDir, ".dev", "dumber", "cef_user_data"), settings.RootCachePath) | |
| 103 | + | require.Equal(t, filepath.Join(profile.EnginePaths.LogDir, "cef_runtime.bootstrap.log"), path) | |
| 104 | + | require.FileExists(t, path) | |
| 109 | 105 | } | |
| 110 | 106 | ||
| 111 | 107 | // TestCreateAudioOutputFactory_CreatesUsableFactory verifies that the audio | |
| @@ -116,3 +112,38 @@ func TestCreateAudioOutputFactory_ReturnsFactory(t *testing.T) { | |||
| 116 | 112 | ||
| 117 | 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 | 5 | "errors" | |
| 6 | 6 | "testing" | |
| 7 | 7 | ||
| 8 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 8 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| 9 | 10 | "github.com/stretchr/testify/require" | |
| 10 | 11 | ) | |
| @@ -51,3 +52,54 @@ func TestEngineRegisterHandlers_PropagatesRegistrarError(t *testing.T) { | |||
| 51 | 52 | err := eng.RegisterHandlers(context.Background(), port.HandlerDependencies{}) | |
| 52 | 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 | 103 | wv.findCtrl = newFindController() | |
| 104 | 104 | ||
| 105 | 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 | 112 | input.attachTo(pipeline.glArea) | |
| 107 | 113 | wv.input = input | |
| 108 | 114 | ||
| @@ -172,14 +178,24 @@ func (f *WebViewFactory) configureInitialBrowserCreation( | |||
| 172 | 178 | log.Debug().Msg("cef: skipping browser creation for destroyed webview") | |
| 173 | 179 | 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 | 187 | result := purecef.BrowserHostCreateBrowser( | |
| 176 | 188 | pc.windowInfo, | |
| 177 | 189 | pc.client, | |
| 178 | - | "about:blank", | |
| 190 | + | initialURL, | |
| 179 | 191 | pc.settings, | |
| 180 | 192 | nil, // extraInfo | |
| 181 | 193 | nil, // requestContext | |
| 182 | 194 | ) | |
| 195 | + | log.Debug(). | |
| 196 | + | Str("initial_url", initialURL). | |
| 197 | + | Str("pending_url", pendingURL). | |
| 198 | + | Msg("cef: BrowserHostCreateBrowser initial URL") | |
| 183 | 199 | if f.engine != nil { | |
| 184 | 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 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "sync" | |
| 6 | + | "sync/atomic" | |
| 7 | + | "unicode/utf8" | |
| 6 | 8 | ||
| 7 | 9 | purecef "github.com/bnema/purego-cef/cef" | |
| 8 | 10 | "github.com/bnema/puregotk/v4/gdk" | |
| @@ -56,6 +58,14 @@ type inputBridge struct { | |||
| 56 | 58 | // ImeCommitText. Set once during attachTo, read-only afterwards. | |
| 57 | 59 | 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 | 69 | // IMContext for dead key / compose sequence support. Held as a field | |
| 60 | 70 | // to prevent garbage collection while the controller is alive. | |
| 61 | 71 | imContext *gtk.IMContextSimple | |
| @@ -67,12 +77,32 @@ type inputBridge struct { | |||
| 67 | 77 | // show the text caret. | |
| 68 | 78 | 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 | 85 | // onMiddleClick is called when button 2 (middle) is pressed on a link. | |
| 71 | 86 | // The callback receives the hovered URI. Set by the factory. | |
| 72 | 87 | onMiddleClick func(uri string) | |
| 73 | 88 | } | |
| 74 | 89 | ||
| 75 | 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 | 106 | func newInputBridge(ctx context.Context, scale int32) *inputBridge { | |
| 77 | 107 | return &inputBridge{ctx: ctx, scale: scale} | |
| 78 | 108 | } | |
| @@ -163,6 +193,7 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) { | |||
| 163 | 193 | // Focus — forward to CEF and IMContext. | |
| 164 | 194 | focus := gtk.NewEventControllerFocus() | |
| 165 | 195 | focusEnterCb := func(_ gtk.EventControllerFocus) { | |
| 196 | + | ib.gtkFocused.Store(true) | |
| 166 | 197 | if ib.imContext != nil { | |
| 167 | 198 | ib.imContext.FocusIn() | |
| 168 | 199 | } | |
| @@ -170,6 +201,7 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) { | |||
| 170 | 201 | } | |
| 171 | 202 | focus.ConnectEnter(&focusEnterCb) | |
| 172 | 203 | focusLeaveCb := func(_ gtk.EventControllerFocus) { | |
| 204 | + | ib.gtkFocused.Store(false) | |
| 173 | 205 | if ib.imContext != nil { | |
| 174 | 206 | ib.imContext.Reset() | |
| 175 | 207 | ib.imContext.FocusOut() | |
| @@ -200,6 +232,8 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) { | |||
| 200 | 232 | keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool { | |
| 201 | 233 | mods := uint(state) | |
| 202 | 234 | ||
| 235 | + | ib.maybeMirrorClipboardShortcut(keyval, mods) | |
| 236 | + | ||
| 203 | 237 | // Intercept Ctrl+V / Ctrl+Shift+V: CEF OSR cannot access the | |
| 204 | 238 | // Wayland/X11 clipboard, so we read from GDK and inject via | |
| 205 | 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 | 319 | 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 | 329 | evt := buildMouseEvent(x, y, mods, ib.scale) | |
| 289 | 330 | btn := translateMouseButton(button) | |
| 290 | 331 | host.SendMouseClickEvent(&evt, btn, 0, int32(clickCount)) | |
| @@ -321,10 +362,7 @@ func (ib *inputBridge) onFocusIn() { | |||
| 321 | 362 | ib.mu.Lock() | |
| 322 | 363 | host := ib.host | |
| 323 | 364 | ib.mu.Unlock() | |
| 324 | - | if host == nil { | |
| 325 | - | return | |
| 326 | - | } | |
| 327 | - | host.SetFocus(1) | |
| 365 | + | syncWindowlessBrowserFocus(host) | |
| 328 | 366 | } | |
| 329 | 367 | ||
| 330 | 368 | func (ib *inputBridge) onFocusOut() { | |
| @@ -337,6 +375,46 @@ func (ib *inputBridge) onFocusOut() { | |||
| 337 | 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 | 418 | func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) { | |
| 341 | 419 | ib.mu.Lock() | |
| 342 | 420 | host := ib.host | |
| @@ -558,8 +636,12 @@ const ( | |||
| 558 | 636 | gdkKeyPageDown = 0xff56 | |
| 559 | 637 | ||
| 560 | 638 | // Clipboard shortcut keyvals. | |
| 639 | + | gdkKeyLowercaseC = 0x063 | |
| 640 | + | gdkKeyUppercaseC = 0x043 | |
| 561 | 641 | gdkKeyLowercaseV = 0x076 | |
| 562 | 642 | gdkKeyUppercaseV = 0x056 | |
| 643 | + | gdkKeyLowercaseX = 0x078 | |
| 644 | + | gdkKeyUppercaseX = 0x058 | |
| 563 | 645 | ) | |
| 564 | 646 | ||
| 565 | 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 | 11 | actualInternalScheme = "https" | |
| 12 | 12 | actualInternalHost = "dumber.invalid" | |
| 13 | 13 | actualInternalOrigin = actualInternalScheme + "://" + actualInternalHost | |
| 14 | + | internalAPIPathPrefix = "/api/" | |
| 15 | + | internalAPIPathPrefixTrimmed = "api/" | |
| 14 | 16 | ) | |
| 15 | 17 | ||
| 16 | 18 | func isConceptualInternalURL(raw string) bool { | |
| @@ -49,7 +51,7 @@ func toActualInternalURL(raw string) string { | |||
| 49 | 51 | case trimmedPath == "": | |
| 50 | 52 | // Keep page roots at /home instead of /home/ so relative assets resolve | |
| 51 | 53 | // from the origin root. | |
| 52 | - | case strings.HasPrefix(trimmedPath, "api/"): | |
| 54 | + | case strings.HasPrefix(trimmedPath, internalAPIPathPrefixTrimmed): | |
| 53 | 55 | actualPath = "/" + trimmedPath | |
| 54 | 56 | case path.Ext(trimmedPath) != "": | |
| 55 | 57 | actualPath = "/" + trimmedPath | |
| @@ -67,6 +69,36 @@ func toActualInternalURL(raw string) string { | |||
| 67 | 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 | 102 | func toConceptualInternalURL(raw string) string { | |
| 71 | 103 | if raw == "" || !isActualInternalURL(raw) { | |
| 72 | 104 | return raw | |
M internal/infrastructure/cef/internal_urls_test.go +64 -1
| @@ -1,6 +1,9 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | - | import "testing" | |
| 3 | + | import ( | |
| 4 | + | "net/url" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 4 | 7 | ||
| 5 | 8 | func TestToActualInternalURL(t *testing.T) { | |
| 6 | 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 | 113 | func TestToConceptualInternalURL(t *testing.T) { | |
| 51 | 114 | t.Parallel() | |
| 52 | 115 | ||
M internal/infrastructure/cef/render_pipeline.go +55 -0
| @@ -114,6 +114,27 @@ type renderPipeline struct { | |||
| 114 | 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 | 138 | // Shader sources for the fullscreen textured quad. | |
| 118 | 139 | const vertexShaderSource = "" + | |
| 119 | 140 | "#version 330 core\n" + | |
| @@ -806,6 +827,40 @@ func (rp *renderPipeline) maybeLogDiagnostics() { | |||
| 806 | 827 | } | |
| 807 | 828 | ||
| 808 | 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 | 864 | func (rp *renderPipeline) buildShaderProgram() uint32 { | |
| 810 | 865 | 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 | 42 | errorPath: "error.html", | |
| 43 | 43 | } | |
| 44 | 44 | ||
| 45 | + | var cefNewResourceHandler = purecef.NewResourceHandler | |
| 46 | + | ||
| 45 | 47 | // dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual | |
| 46 | 48 | // internal https://dumber.invalid origin used by CEF. | |
| 47 | 49 | type dumbSchemeHandler struct { | |
| @@ -59,6 +61,14 @@ type dumbSchemeHandler struct { | |||
| 59 | 61 | // onClipboardSet is called when JS sends copied text via /api/clipboard-set. | |
| 60 | 62 | // Set by the engine to write to the GDK system clipboard. | |
| 61 | 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 | 74 | // newDumbSchemeHandler creates a handler for internal CEF pages. | |
| @@ -99,7 +109,7 @@ func (h *dumbSchemeHandler) setAssets(assets embed.FS) { | |||
| 99 | 109 | ||
| 100 | 110 | // Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread | |
| 101 | 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 | 113 | reqURL := request.GetURL() | |
| 104 | 114 | method := request.GetMethod() | |
| 105 | 115 | ||
| @@ -119,8 +129,17 @@ func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string, | |||
| 119 | 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 a | |
| 123 | - | // normal HTTPS origin so Chromium applies the standard web security model. | |
| 132 | + | // Route API requests before redirecting conceptual dumb:// URLs to the | |
| 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 | 143 | if isConceptualInternalURL(reqURL) { | |
| 125 | 144 | targetURL := toActualInternalURL(reqURL) | |
| 126 | 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 | 150 | // Serve static assets. | |
| 139 | 151 | return h.handleAsset(u) | |
| 140 | 152 | } | |
| 141 | 153 | ||
| 142 | 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 | 160 | switch { | |
| 145 | 161 | case path == "/api/message" && strings.EqualFold(method, "POST"): | |
| 146 | 162 | body := readBodyFromHeader(request) | |
| 147 | 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 | 166 | resp, err := h.messageRouter.HandleMessage(h.ctx, 0, body) | |
| 151 | 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 | 172 | case path == "/api/config" && (method == "" || strings.EqualFold(method, "GET")): | |
| 157 | 173 | return h.handleConfigAPI(h.currentConfigPayload) | |
| @@ -163,32 +179,35 @@ func (h *dumbSchemeHandler) handleAPI(method, path string, request purecef.Reque | |||
| 163 | 179 | return h.handleTranscodeAPI(request) | |
| 164 | 180 | ||
| 165 | 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 | 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 | 192 | func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler { | |
| 174 | 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 | 197 | reqURL := request.GetURL() | |
| 179 | 198 | parsed, err := url.Parse(reqURL) | |
| 180 | 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 | 203 | sourceURL := strings.TrimSpace(parsed.Query().Get("src")) | |
| 185 | 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 | 208 | sourceParsed, err := url.Parse(sourceURL) | |
| 190 | 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 | 213 | headers := make(map[string]string) | |
| @@ -210,7 +229,7 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef. | |||
| 210 | 229 | Int("forwarded_header_count", len(headers)). | |
| 211 | 230 | Msg("scheme handler returning transcoding stream") | |
| 212 | 231 | ||
| 213 | - | return purecef.NewResourceHandler(&transcodingResourceHandler{ | |
| 232 | + | return cefNewResourceHandler(&transcodingResourceHandler{ | |
| 214 | 233 | transcoder: h.transcoder, | |
| 215 | 234 | sourceURL: sourceURL, | |
| 216 | 235 | headers: headers, | |
| @@ -233,26 +252,37 @@ func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) { | |||
| 233 | 252 | func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purecef.ResourceHandler { | |
| 234 | 253 | data, err := resolveConfigPayload(build) | |
| 235 | 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 | 260 | // handleClipboardSet receives copied text from JS copy/cut events and writes | |
| 242 | 261 | // it to the system clipboard via the engine callback. | |
| 243 | - | const maxClipboardBytes = 10 << 20 // 10 MB | |
| 262 | + | 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 | 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 | 278 | body := readBodyFromHeader(request) | |
| 249 | 279 | if body == nil { | |
| 250 | 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 | 283 | if len(body) > maxClipboardBytes { | |
| 254 | 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 | 288 | var payload struct { | |
| @@ -260,7 +290,7 @@ func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef. | |||
| 260 | 290 | } | |
| 261 | 291 | if err := json.Unmarshal(body, &payload); err != nil || payload.Text == "" { | |
| 262 | 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 | 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 | 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 | 345 | func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler { | |
| 278 | - | return purecef.NewResourceHandler(&staticResourceHandler{ | |
| 279 | - | contentType: "text/plain; charset=utf-8", | |
| 280 | - | statusCode: status, | |
| 281 | - | headers: map[string]string{ | |
| 282 | - | "Location": location, | |
| 283 | - | "Cache-Control": "no-store", | |
| 284 | - | }, | |
| 346 | + | return newStaticResourceHandler(status, "text/plain; charset=utf-8", nil, map[string]string{ | |
| 347 | + | "Location": location, | |
| 348 | + | "Cache-Control": "no-store", | |
| 285 | 349 | }) | |
| 286 | 350 | } | |
| 287 | 351 | ||
| @@ -439,26 +503,55 @@ type staticResourceHandler struct { | |||
| 439 | 503 | offset int | |
| 440 | 504 | } | |
| 441 | 505 | ||
| 442 | - | func (h *dumbSchemeHandler) newRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler { | |
| 443 | - | return purecef.NewResourceHandler(&staticResourceHandler{ | |
| 506 | + | func apiResponseHeaders(extra map[string]string) map[string]string { | |
| 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 | 527 | data: data, | |
| 445 | 528 | contentType: contentType, | |
| 446 | 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 | 542 | func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler { | |
| 451 | 543 | escaped := html.EscapeString(msg) | |
| 452 | 544 | body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped) | |
| 453 | 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 | 549 | data, err := json.Marshal(v) | |
| 458 | 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 | 557 | // Open handles the request immediately (synchronous). | |
| @@ -537,7 +630,7 @@ func (rh *staticResourceHandler) Cancel() {} | |||
| 537 | 630 | // The JS bridge base64-encodes the JSON body into this header to avoid the | |
| 538 | 631 | // purego-cef PostData element wrapping limitation (wrapPostDataElement is unexported). | |
| 539 | 632 | func readBodyFromHeader(request purecef.Request) []byte { | |
| 540 | - | encoded := request.GetHeaderByName("X-Dumber-Body") | |
| 633 | + | encoded := request.GetHeaderByName(dumberBodyHeaderName) | |
| 541 | 634 | if encoded == "" { | |
| 542 | 635 | return nil | |
| 543 | 636 | } | |
M internal/infrastructure/cef/scheme_handler_test.go +216 -0
| @@ -2,12 +2,29 @@ package cef | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "encoding/base64" | |
| 5 | 6 | "errors" | |
| 7 | + | "net/http" | |
| 6 | 8 | "testing" | |
| 9 | + | "unsafe" | |
| 7 | 10 | ||
| 11 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 12 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |
| 8 | 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 | 28 | func TestResolveConfigPayload_UsesInjectedBuilder(t *testing.T) { | |
| 12 | 29 | data, err := resolveConfigPayload(func() ([]byte, error) { | |
| 13 | 30 | return []byte(`{"engine_type":"cef"}`), nil | |
| @@ -43,3 +60,202 @@ func TestNewDumbSchemeHandler_NilDefaultConfigPayloadFails(t *testing.T) { | |||
| 43 | 60 | require.Nil(t, h) | |
| 44 | 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 | 10 | // SessionSpawnEnvironment exposes the engine-specific launch environment needed | |
| 11 | 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 | 20 | func (SessionSpawnEnvironment) RootCacheEnvVar() string { | |
| 15 | 21 | return CEFRootCachePathEnvVar | |
| 16 | 22 | } | |
| 17 | 23 | ||
| 18 | - | func (SessionSpawnEnvironment) SessionRootCachePath(sessionID entity.SessionID) string { | |
| 19 | - | return filepath.Join(DefaultCEFUserDataDir(), "sessions", string(sessionID)) | |
| 24 | + | func (e SessionSpawnEnvironment) SessionRootCachePath(sessionID entity.SessionID) string { | |
| 25 | + | return filepath.Join(e.cefUserDataDir, "sessions", string(sessionID)) | |
| 20 | 26 | } | |
| 21 | 27 | ||
| 22 | 28 | var _ port.SessionSpawnEnvironment = SessionSpawnEnvironment{} | |
M internal/infrastructure/cef/webview.go +416 -13
| @@ -2,12 +2,17 @@ package cef | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "crypto/rand" | |
| 5 | 6 | "encoding/base64" | |
| 7 | + | "encoding/hex" | |
| 6 | 8 | "errors" | |
| 7 | 9 | "fmt" | |
| 8 | 10 | "math" | |
| 11 | + | "strconv" | |
| 12 | + | "strings" | |
| 9 | 13 | "sync" | |
| 10 | 14 | "sync/atomic" | |
| 15 | + | "time" | |
| 11 | 16 | ||
| 12 | 17 | purecef "github.com/bnema/purego-cef/cef" | |
| 13 | 18 | "github.com/bnema/puregotk/v4/glib" | |
| @@ -30,6 +35,32 @@ var errDestroyed = errors.New("cef: webview is destroyed") | |||
| 30 | 35 | // errNoBrowser is returned when the browser has not been created yet. | |
| 31 | 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 | 64 | // WebView implements port.WebView using a CEF off-screen browser rendered | |
| 34 | 65 | // through a renderPipeline and driven by an inputBridge. | |
| 35 | 66 | type WebView struct { | |
| @@ -65,16 +96,30 @@ type WebView struct { | |||
| 65 | 96 | callbacks *port.WebViewCallbacks | |
| 66 | 97 | ||
| 67 | 98 | // State cache (mutex-protected). | |
| 68 | - | uri string | |
| 69 | - | title string | |
| 70 | - | progress float64 | |
| 71 | - | canGoBack bool | |
| 72 | - | canGoFwd bool | |
| 73 | - | isLoading bool | |
| 99 | + | uri string | |
| 100 | + | title string | |
| 101 | + | progress float64 | |
| 102 | + | canGoBack bool | |
| 103 | + | canGoFwd bool | |
| 104 | + | 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 | 113 | // Last known hover URI for middle-click → new tab. | |
| 76 | 114 | 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 | 123 | // Atomic state. | |
| 79 | 124 | destroyed atomic.Bool | |
| 80 | 125 | fullscreen atomic.Bool | |
| @@ -127,16 +172,14 @@ func (wv *WebView) LoadURI(_ context.Context, uri string) error { | |||
| 127 | 172 | actualURI := toActualInternalURL(uri) | |
| 128 | 173 | wv.mu.Lock() | |
| 129 | 174 | 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 | 179 | if browser == nil { | |
| 131 | - | // Browser not yet created — queue the URI for OnAfterCreated. | |
| 132 | - | wv.pendingURI = actualURI | |
| 133 | - | wv.mu.Unlock() | |
| 134 | 180 | return nil | |
| 135 | 181 | } | |
| 136 | - | wv.mu.Unlock() | |
| 137 | - | if frame := browser.GetMainFrame(); frame != nil { | |
| 138 | - | frame.LoadURL(actualURI) | |
| 139 | - | } | |
| 182 | + | wv.schedulePendingNavigationReplay(0) | |
| 140 | 183 | return nil | |
| 141 | 184 | } | |
| 142 | 185 | ||
| @@ -446,6 +489,22 @@ func (wv *WebView) RunJavaScript(_ context.Context, script string) { | |||
| 446 | 489 | // colorScale converts a 0.0–1.0 color component to an 8-bit integer. | |
| 447 | 490 | 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 | 508 | // SetBackgroundColor sets the background via JS injection (CEF has no runtime API). | |
| 450 | 509 | func (wv *WebView) SetBackgroundColor(r, g, b, a float64) { | |
| 451 | 510 | script := fmt.Sprintf( | |
| @@ -473,6 +532,7 @@ func (wv *WebView) Destroy() { | |||
| 473 | 532 | if !wv.destroyed.CompareAndSwap(false, true) { | |
| 474 | 533 | return | |
| 475 | 534 | } | |
| 535 | + | wv.cancelSelectionDebounce() | |
| 476 | 536 | if wv.resizeReconciler != nil { | |
| 477 | 537 | wv.resizeReconciler.stop() | |
| 478 | 538 | } | |
| @@ -507,11 +567,23 @@ func (wv *WebView) NativeWidget() uintptr { | |||
| 507 | 567 | ||
| 508 | 568 | func (wv *WebView) updateURI(uri string) { | |
| 509 | 569 | uri = toConceptualInternalURL(uri) | |
| 570 | + | now := time.Now() | |
| 510 | 571 | wv.mu.Lock() | |
| 511 | 572 | wv.uri = uri | |
| 573 | + | wv.loadDiagLastAddressAt = now | |
| 512 | 574 | cb := wv.callbacks | |
| 575 | + | pendingMatched := pendingURIEquivalent(wv.pendingURI, uri) | |
| 576 | + | if pendingMatched { | |
| 577 | + | wv.pendingURI = "" | |
| 578 | + | } | |
| 513 | 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 | 587 | if cb != nil && cb.OnURIChanged != nil { | |
| 516 | 588 | wv.runOnGTK(func() { | |
| 517 | 589 | cb.OnURIChanged(uri) | |
| @@ -533,8 +605,10 @@ func (wv *WebView) updateTitle(title string) { | |||
| 533 | 605 | } | |
| 534 | 606 | ||
| 535 | 607 | func (wv *WebView) updateProgress(progress float64) { | |
| 608 | + | now := time.Now() | |
| 536 | 609 | wv.mu.Lock() | |
| 537 | 610 | wv.progress = progress | |
| 611 | + | wv.loadDiagLastProgressAt = now | |
| 538 | 612 | cb := wv.callbacks | |
| 539 | 613 | wv.mu.Unlock() | |
| 540 | 614 | ||
| @@ -546,11 +620,26 @@ func (wv *WebView) updateProgress(progress float64) { | |||
| 546 | 620 | } | |
| 547 | 621 | ||
| 548 | 622 | func (wv *WebView) updateLoadState(loading, back, fwd bool) { | |
| 623 | + | now := time.Now() | |
| 549 | 624 | wv.mu.Lock() | |
| 625 | + | wasLoading := wv.isLoading | |
| 550 | 626 | wv.isLoading = loading | |
| 551 | 627 | wv.canGoBack = back | |
| 552 | 628 | 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 | 638 | wv.mu.Unlock() | |
| 639 | + | ||
| 640 | + | if loadDiagSeq != 0 { | |
| 641 | + | wv.scheduleLoadWatchdogs(loadDiagSeq) | |
| 642 | + | } | |
| 554 | 643 | } | |
| 555 | 644 | ||
| 556 | 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 | 971 | func (wv *WebView) updateHoverURI(uri string) { | |
| 569 | 972 | wv.mu.Lock() | |
| 570 | 973 | wv.lastHoverURI = uri | |
M internal/infrastructure/cef/webview_handlers.go +191 -26
| @@ -15,6 +15,7 @@ import ( | |||
| 15 | 15 | const ( | |
| 16 | 16 | consoleMarkerVideoDiag = "[VIDEO-DIAG]" | |
| 17 | 17 | consoleMarkerRedditVideoPatch = "[REDDIT-VIDEO-PATCH]" | |
| 18 | + | consoleMarkerAutoCopy = "[AUTO-COPY]" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| 20 | 21 | // handlerSet implements all CEF handler interfaces and dispatches events to the | |
| @@ -67,8 +68,46 @@ func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return | |||
| 67 | 68 | func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h } | |
| 68 | 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 | - | return 0 | |
| 71 | + | func (h *handlerSet) OnProcessMessageReceived( | |
| 72 | + | 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 | 306 | if sizeMatchesCurrentView { | |
| 268 | 307 | _, _, shouldLog := h.wv.pipeline.markFirstPaintAfterResize() | |
| 269 | 308 | if shouldLog { | |
| 309 | + | logging.Trace().Mark("cef_first_content_paint") | |
| 270 | 310 | logging.FromContext(h.wv.ctx).Debug(). | |
| 271 | 311 | Uint64("webview_id", uint64(h.wv.id)). | |
| 272 | 312 | Uint64("paint_seq", paintSeq). | |
| @@ -295,7 +335,12 @@ func (h *handlerSet) shouldAcceptMainViewPaint( | |||
| 295 | 335 | 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 | 344 | count := h.wv.pipeline.recordAcceleratedPaint() | |
| 300 | 345 | if count <= 5 || count%100 == 0 { | |
| 301 | 346 | logging.FromContext(h.wv.ctx).Info(). | |
| @@ -309,7 +354,12 @@ func (h *handlerSet) GetTouchHandleSize(_ purecef.Browser, _ purecef.HorizontalA | |||
| 309 | 354 | ||
| 310 | 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 | 363 | return 0 | |
| 314 | 364 | } | |
| 315 | 365 | ||
| @@ -320,7 +370,29 @@ func (h *handlerSet) OnScrollOffsetChanged(_ purecef.Browser, _, _ float64) {} | |||
| 320 | 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 | 397 | func (h *handlerSet) OnVirtualKeyboardRequested(_ purecef.Browser, _ purecef.TextInputMode) {} | |
| 326 | 398 | ||
| @@ -331,6 +403,11 @@ func (h *handlerSet) OnVirtualKeyboardRequested(_ purecef.Browser, _ purecef.Tex | |||
| 331 | 403 | // OnAddressChange updates the cached URI when the main frame navigates. | |
| 332 | 404 | func (h *handlerSet) OnAddressChange(_ purecef.Browser, frame purecef.Frame, url string) { | |
| 333 | 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 | 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 | 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 | 472 | log := logging.FromContext(h.wv.ctx).With(). | |
| 389 | 473 | Str("component", "cef-console"). | |
| 390 | 474 | Str("source", source). | |
| @@ -406,10 +490,20 @@ func (h *handlerSet) OnAutoResize(_ purecef.Browser, _ *purecef.Size) int32 { re | |||
| 406 | 490 | ||
| 407 | 491 | // OnLoadingProgressChange updates the cached progress value. | |
| 408 | 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 | 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 | 507 | name := cefCursorToGDKName(cursorType) | |
| 414 | 508 | h.wv.runOnGTK(func() { | |
| 415 | 509 | h.wv.pipeline.glArea.SetCursorFromName(&name) | |
| @@ -431,6 +525,30 @@ func (h *handlerSet) GetRootWindowScreenRect(_ purecef.Browser, _ *purecef.Rect) | |||
| 431 | 525 | func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangoback, cangoforward int32) { | |
| 432 | 526 | loading := isloading != 0 | |
| 433 | 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 | 553 | h.wv.mu.RLock() | |
| 436 | 554 | cb := h.wv.callbacks | |
| @@ -455,6 +573,11 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec | |||
| 455 | 573 | if frame == nil || !frame.IsMain() { | |
| 456 | 574 | 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 | 581 | h.wv.updateURI(frame.GetURL()) | |
| 459 | 582 | h.wv.mu.RLock() | |
| 460 | 583 | cb := h.wv.callbacks | |
| @@ -480,6 +603,18 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu | |||
| 480 | 603 | // Successful load — reset the consecutive crash counter. | |
| 481 | 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 | 618 | // Inject scripts and styles after page load. | |
| 484 | 619 | // Must run on GTK thread — OnLoadEnd fires on the CEF IO thread, | |
| 485 | 620 | // and JavaScript injection requires the main thread. | |
| @@ -564,35 +699,47 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) { | |||
| 564 | 699 | h.wv.engine.registerWebView(h.wv) | |
| 565 | 700 | } | |
| 566 | 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 | 709 | h.wv.mu.Lock() | |
| 569 | 710 | h.wv.browser = browser | |
| 570 | 711 | h.wv.host = host | |
| 571 | - | uri := h.wv.pendingURI | |
| 572 | - | h.wv.pendingURI = "" | |
| 573 | 712 | h.wv.input.setHost(host) | |
| 574 | 713 | if h.wv.findCtrl != nil { | |
| 575 | 714 | h.wv.findCtrl.setHost(host) | |
| 576 | 715 | } | |
| 577 | 716 | ||
| 578 | 717 | // 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 | 719 | host.WasHidden(0) | |
| 581 | 720 | ||
| 582 | - | // If the GLArea already has GTK focus (cold start race), tell CEF now. | |
| 583 | - | // The focus-enter event fired before the browser existed, so SetFocus | |
| 584 | - | // was never called and the caret won't blink until the user clicks. | |
| 585 | - | if h.wv.input != nil && h.wv.input.glArea != nil && h.wv.input.glArea.HasFocus() { | |
| 586 | - | host.SetFocus(1) | |
| 587 | - | } | |
| 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) | |
| 721 | + | // If GTK focus entered before the browser existed, reassert browser focus | |
| 722 | + | // now. Otherwise CEF may stay internally unfocused and suppress caret paint. | |
| 723 | + | if h.wv.input != nil { | |
| 724 | + | shouldSyncFocus = h.wv.input.hasGTKFocus() | |
| 725 | + | if !shouldSyncFocus && h.wv.input.glArea != nil { | |
| 726 | + | shouldSyncFocus = h.wv.input.glArea.HasFocus() | |
| 593 | 727 | } | |
| 594 | 728 | } | |
| 729 | + | hasPendingNavigation = strings.TrimSpace(h.wv.pendingURI) != "" | |
| 730 | + | ||
| 595 | 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 | 744 | h.wv.scheduleStartBeginFrameLoop() | |
| 598 | 745 | } | |
| @@ -636,7 +783,13 @@ func (h *handlerSet) OnBeforeBrowse(_ purecef.Browser, _ purecef.Frame, _ purece | |||
| 636 | 783 | 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 | 793 | return 0 | |
| 641 | 794 | } | |
| 642 | 795 | ||
| @@ -644,7 +797,8 @@ func (h *handlerSet) GetResourceRequestHandler( | |||
| 644 | 797 | _ purecef.Browser, _ purecef.Frame, request purecef.Request, | |
| 645 | 798 | _, _ int32, _ string, disableDefaultHandling *int32, | |
| 646 | 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 | 802 | *disableDefaultHandling = 1 | |
| 649 | 803 | if h.wv != nil && h.wv.ctx != nil { | |
| 650 | 804 | logging.FromContext(h.wv.ctx).Info(). | |
| @@ -665,7 +819,13 @@ func (h *handlerSet) GetAuthCredentials( | |||
| 665 | 819 | 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 | 829 | return 0 | |
| 670 | 830 | } | |
| 671 | 831 | ||
| @@ -894,7 +1054,12 @@ func (h *handlerSet) OnAudioStreamError(_ purecef.Browser, message string) { | |||
| 894 | 1054 | // =========================================================================== | |
| 895 | 1055 | ||
| 896 | 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 | 1063 | if h.wv.findCtrl != nil { | |
| 899 | 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 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "sync" | |
| 5 | 6 | "testing" | |
| 7 | + | "time" | |
| 6 | 8 | ||
| 7 | 9 | purecef "github.com/bnema/purego-cef/cef" | |
| 10 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |
| 8 | 11 | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/mock" | |
| 9 | 13 | "github.com/stretchr/testify/require" | |
| 10 | 14 | ||
| 15 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 11 | 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 | 57 | func newTestPipeline(w, h, s int32) *renderPipeline { | |
| 15 | 58 | rp := &renderPipeline{scale: s} | |
| 16 | 59 | rp.widthAtomic.Store(w) | |
| @@ -89,6 +132,203 @@ func TestOnLoadEndDoesNotDispatchBrowserLevelCompletion(t *testing.T) { | |||
| 89 | 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 | 332 | func TestGetViewRectUsesDIPCoordinates(t *testing.T) { | |
| 93 | 333 | rect := &purecef.Rect{} | |
| 94 | 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 | 85 | // libcef.so and the Resources/locales subdirectories. | |
| 86 | 86 | CEFDir string `mapstructure:"cef_dir" toml:"cef_dir" yaml:"cef_dir"` | |
| 87 | 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 | 89 | LogFile string `mapstructure:"log_file" toml:"log_file" yaml:"log_file"` | |
| 90 | 90 | // LogSeverity controls CEF's internal log verbosity. | |
| 91 | 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 | 4 | import ( | |
| 5 | 5 | "os" | |
| 6 | 6 | "path/filepath" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 7 | 9 | ) | |
| 8 | 10 | ||
| 9 | 11 | // File permission constants | |
| @@ -25,25 +27,42 @@ type XDGDirs struct { | |||
| 25 | 27 | CacheHome string | |
| 26 | 28 | } | |
| 27 | 29 | ||
| 28 | - | // GetXDGDirs returns the XDG Base Directory paths for dumber. | |
| 29 | - | // It follows the XDG Base Directory specification: | |
| 30 | - | // - $XDG_CONFIG_HOME/dumber (default: ~/.config/dumber) | |
| 31 | - | // - $XDG_DATA_HOME/dumber (default: ~/.local/share/dumber) | |
| 32 | - | // - $XDG_STATE_HOME/dumber (default: ~/.local/state/dumber) | |
| 30 | + | // GetXDGDirs returns the dumber-specific XDG directories. | |
| 31 | + | // In prod they match the current XDG layout exactly. | |
| 32 | + | // In dev they resolve into the shared sandbox under .dev/dumber/{config,data,state,cache}. | |
| 33 | 33 | func GetXDGDirs() (*XDGDirs, error) { | |
| 34 | - | // Development mode: use .dev directory in current working directory | |
| 34 | + | profile, err := resolveSharedProfile() | |
| 35 | + | if err != nil { | |
| 36 | + | return nil, err | |
| 37 | + | } | |
| 38 | + | return &XDGDirs{ | |
| 39 | + | ConfigHome: profile.Shared.ConfigDir, | |
| 40 | + | DataHome: profile.Shared.DataDir, | |
| 41 | + | StateHome: profile.Shared.StateDir, | |
| 42 | + | CacheHome: profile.Shared.CacheDir, | |
| 43 | + | }, nil | |
| 44 | + | } | |
| 45 | + | ||
| 46 | + | func resolveSharedProfile() (runtimeprofile.Profile, error) { | |
| 47 | + | base, err := getBaseXDGDirs() | |
| 48 | + | if err != nil { | |
| 49 | + | return runtimeprofile.Profile{}, err | |
| 50 | + | } | |
| 51 | + | return runtimeprofile.Resolve(runtimeprofile.ResolveInput{ | |
| 52 | + | Env: os.Getenv, | |
| 53 | + | CWD: os.Getwd, | |
| 54 | + | Base: runtimeprofile.BasePaths{ | |
| 55 | + | ConfigHome: base.ConfigHome, | |
| 56 | + | DataHome: base.DataHome, | |
| 57 | + | StateHome: base.StateHome, | |
| 58 | + | CacheHome: base.CacheHome, | |
| 59 | + | }, | |
| 60 | + | }) | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | func getBaseXDGDirs() (*XDGDirs, error) { | |
| 35 | 64 | if os.Getenv("ENV") == "dev" { | |
| 36 | - | cwd, err := os.Getwd() | |
| 37 | - | if err != nil { | |
| 38 | - | return nil, err | |
| 39 | - | } | |
| 40 | - | devDir := filepath.Join(cwd, ".dev", appName) | |
| 41 | - | return &XDGDirs{ | |
| 42 | - | ConfigHome: devDir, | |
| 43 | - | DataHome: devDir, | |
| 44 | - | StateHome: devDir, | |
| 45 | - | CacheHome: devDir, | |
| 46 | - | }, nil | |
| 65 | + | return &XDGDirs{}, nil | |
| 47 | 66 | } | |
| 48 | 67 | ||
| 49 | 68 | homeDir, err := os.UserHomeDir() | |
| @@ -51,28 +70,24 @@ func GetXDGDirs() (*XDGDirs, error) { | |||
| 51 | 70 | return nil, err | |
| 52 | 71 | } | |
| 53 | 72 | ||
| 54 | - | // XDG_CONFIG_HOME | |
| 55 | 73 | configHome := os.Getenv("XDG_CONFIG_HOME") | |
| 56 | 74 | if configHome == "" { | |
| 57 | 75 | configHome = filepath.Join(homeDir, ".config") | |
| 58 | 76 | } | |
| 59 | 77 | configHome = filepath.Join(configHome, appName) | |
| 60 | 78 | ||
| 61 | - | // XDG_DATA_HOME | |
| 62 | 79 | dataHome := os.Getenv("XDG_DATA_HOME") | |
| 63 | 80 | if dataHome == "" { | |
| 64 | 81 | dataHome = filepath.Join(homeDir, ".local", "share") | |
| 65 | 82 | } | |
| 66 | 83 | dataHome = filepath.Join(dataHome, appName) | |
| 67 | 84 | ||
| 68 | - | // XDG_STATE_HOME | |
| 69 | 85 | stateHome := os.Getenv("XDG_STATE_HOME") | |
| 70 | 86 | if stateHome == "" { | |
| 71 | 87 | stateHome = filepath.Join(homeDir, ".local", "state") | |
| 72 | 88 | } | |
| 73 | 89 | stateHome = filepath.Join(stateHome, appName) | |
| 74 | 90 | ||
| 75 | - | // XDG_CACHE_HOME | |
| 76 | 91 | cacheHome := os.Getenv("XDG_CACHE_HOME") | |
| 77 | 92 | if cacheHome == "" { | |
| 78 | 93 | cacheHome = filepath.Join(homeDir, ".cache") | |
| @@ -114,14 +129,13 @@ func GetStateDir() (string, error) { | |||
| 114 | 129 | return dirs.StateHome, nil | |
| 115 | 130 | } | |
| 116 | 131 | ||
| 117 | - | // GetLogDir returns the XDG-compliant log directory for dumber. | |
| 118 | - | // Logs are stored in XDG_STATE_HOME as per specification. | |
| 132 | + | // GetLogDir returns the shared application log directory for dumber. | |
| 119 | 133 | func GetLogDir() (string, error) { | |
| 120 | - | stateDir, err := GetStateDir() | |
| 134 | + | profile, err := resolveSharedProfile() | |
| 121 | 135 | if err != nil { | |
| 122 | 136 | return "", err | |
| 123 | 137 | } | |
| 124 | - | return filepath.Join(stateDir, "logs"), nil | |
| 138 | + | return profile.Shared.LogDir, nil | |
| 125 | 139 | } | |
| 126 | 140 | ||
| 127 | 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 | 14 | "time" | |
| 15 | 15 | ||
| 16 | 16 | "github.com/bnema/dumber/internal/application/port" | |
| 17 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 17 | 18 | "github.com/bnema/dumber/internal/logging" | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| @@ -24,7 +25,7 @@ const browserLaunchIOTimeout = 50 * time.Millisecond | |||
| 24 | 25 | const browserLaunchDirPerm = 0o700 | |
| 25 | 26 | ||
| 26 | 27 | type browserLaunchRelay struct { | |
| 27 | - | xdg port.XDGPaths | |
| 28 | + | ipc runtimeprofile.IPCPaths | |
| 28 | 29 | } | |
| 29 | 30 | ||
| 30 | 31 | type browserLaunchRequest struct { | |
| @@ -42,8 +43,8 @@ type browserLaunchRelayListener struct { | |||
| 42 | 43 | err error | |
| 43 | 44 | } | |
| 44 | 45 | ||
| 45 | - | func NewBrowserLaunchRelay(xdg port.XDGPaths) port.BrowserLaunchRelay { | |
| 46 | - | return &browserLaunchRelay{xdg: xdg} | |
| 46 | + | func NewBrowserLaunchRelay(ipc runtimeprofile.IPCPaths) port.BrowserLaunchRelay { | |
| 47 | + | return &browserLaunchRelay{ipc: ipc} | |
| 47 | 48 | } | |
| 48 | 49 | ||
| 49 | 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 | 174 | func (r *browserLaunchRelay) socketPath() (string, error) { | |
| 174 | - | if r == nil || r.xdg == nil { | |
| 175 | - | return "", errors.New("browser launch relay missing XDG paths") | |
| 175 | + | if r == nil { | |
| 176 | + | return "", errors.New("browser launch relay missing IPC paths") | |
| 176 | 177 | } | |
| 177 | - | ||
| 178 | - | runtimeDir, err := r.xdg.RuntimeDir() | |
| 179 | - | if err != nil { | |
| 180 | - | return "", fmt.Errorf("get runtime dir: %w", err) | |
| 181 | - | } | |
| 182 | - | if runtimeDir != "" { | |
| 183 | - | return filepath.Join(runtimeDir, browserLaunchSocketName), nil | |
| 184 | - | } | |
| 185 | - | ||
| 186 | - | stateDir, err := r.xdg.StateDir() | |
| 187 | - | if err != nil { | |
| 188 | - | return "", fmt.Errorf("get state dir: %w", err) | |
| 178 | + | if r.ipc.BrowserLaunchSocket == "" { | |
| 179 | + | return "", errors.New("browser launch relay missing browser launch socket path") | |
| 189 | 180 | } | |
| 190 | - | if stateDir == "" { | |
| 191 | - | return "", errors.New("browser launch relay needs runtime or state dir") | |
| 192 | - | } | |
| 193 | - | ||
| 194 | - | return filepath.Join(stateDir, "runtime", browserLaunchSocketName), nil | |
| 181 | + | return r.ipc.BrowserLaunchSocket, nil | |
| 195 | 182 | } | |
| 196 | 183 | ||
| 197 | 184 | func (l *browserLaunchRelayListener) Close() error { | |
M internal/infrastructure/desktop/browser_launch_relay_test.go +85 -86
| @@ -9,29 +9,11 @@ import ( | |||
| 9 | 9 | "testing" | |
| 10 | 10 | "time" | |
| 11 | 11 | ||
| 12 | - | "github.com/bnema/dumber/internal/application/port" | |
| 12 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 13 | 13 | "github.com/stretchr/testify/assert" | |
| 14 | 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 | 17 | type browserWindowOpenerFunc func(context.Context, string) error | |
| 36 | 18 | ||
| 37 | 19 | func (f browserWindowOpenerFunc) OpenFreshWindow(ctx context.Context, url string) error { | |
| @@ -68,8 +50,24 @@ func shortTempDir(t *testing.T) string { | |||
| 68 | 50 | 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 | 69 | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_MissingListenerReturnsFalseNil(t *testing.T) { | |
| 72 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: shortTempDir(t)}) | |
| 70 | + | relay := NewBrowserLaunchRelay(testIPC(shortTempDir(t))) | |
| 73 | 71 | ||
| 74 | 72 | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com") | |
| 75 | 73 | ||
| @@ -77,39 +75,40 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_MissingListenerReturnsFalseNi | |||
| 77 | 75 | assert.False(t, delivered) | |
| 78 | 76 | } | |
| 79 | 77 | ||
| 80 | - | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) { | |
| 81 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | |
| 82 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | |
| 78 | + | func TestBrowserLaunchRelay_SameProfileAndEngine_UsesSameSocket(t *testing.T) { | |
| 79 | + | ipc := testNamespacedIPC(shortTempDir(t), "cef") | |
| 80 | + | listenerRelay := NewBrowserLaunchRelay(ipc) | |
| 81 | + | clientRelay := NewBrowserLaunchRelay(ipc) | |
| 83 | 82 | ||
| 84 | 83 | received := make(chan string, 1) | |
| 85 | 84 | ctx, cancel := context.WithCancel(context.Background()) | |
| 86 | 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 | 88 | received <- url | |
| 90 | 89 | return nil | |
| 91 | 90 | })) | |
| 92 | 91 | require.NoError(t, err) | |
| 93 | 92 | defer closer.Close() | |
| 94 | 93 | ||
| 95 | - | waitForSocket(t, filepath.Join(runtimeDir, "browser-launch.sock")) | |
| 96 | - | ||
| 97 | - | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/new") | |
| 94 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |
| 98 | 95 | ||
| 96 | + | delivered, err := clientRelay.DeliverOpenFreshWindow(context.Background(), "https://example.com/same-namespace") | |
| 99 | 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 { | |
| 103 | - | case got := <-received: | |
| 104 | - | assert.Equal(t, "https://example.com/new", got) | |
| 105 | - | case <-time.After(time.Second): | |
| 106 | - | t.Fatal("expected opener to receive the URL") | |
| 107 | - | } | |
| 102 | + | func TestBrowserLaunchRelay_DevCEFAndDevWebKit_UseDifferentSockets(t *testing.T) { | |
| 103 | + | root := t.TempDir() | |
| 104 | + | cefIPC := testNamespacedIPC(root, "cef") | |
| 105 | + | wkIPC := testNamespacedIPC(root, "webkit") | |
| 106 | + | require.NotEqual(t, cefIPC.BrowserLaunchSocket, wkIPC.BrowserLaunchSocket) | |
| 108 | 107 | } | |
| 109 | 108 | ||
| 110 | - | func TestBrowserLaunchRelay_UsesStateFallbackWhenRuntimeDirMissing(t *testing.T) { | |
| 111 | - | stateDir := shortTempDir(t) | |
| 112 | - | relay := NewBrowserLaunchRelay(testXDGPaths{stateDir: stateDir}) | |
| 109 | + | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) { | |
| 110 | + | ipc := testIPC(shortTempDir(t)) | |
| 111 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 113 | 112 | ||
| 114 | 113 | received := make(chan string, 1) | |
| 115 | 114 | ctx, cancel := context.WithCancel(context.Background()) | |
| @@ -122,25 +121,32 @@ func TestBrowserLaunchRelay_UsesStateFallbackWhenRuntimeDirMissing(t *testing.T) | |||
| 122 | 121 | require.NoError(t, err) | |
| 123 | 122 | defer closer.Close() | |
| 124 | 123 | ||
| 125 | - | socketPath := filepath.Join(stateDir, "runtime", "browser-launch.sock") | |
| 126 | - | waitForSocket(t, socketPath) | |
| 124 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |
| 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 | 128 | require.NoError(t, err) | |
| 131 | 129 | assert.True(t, delivered) | |
| 132 | 130 | ||
| 133 | 131 | select { | |
| 134 | 132 | case got := <-received: | |
| 135 | - | assert.Equal(t, "https://example.com/fallback", got) | |
| 133 | + | assert.Equal(t, "https://example.com/new", got) | |
| 136 | 134 | case <-time.After(time.Second): | |
| 137 | 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 | 147 | func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t *testing.T) { | |
| 142 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | |
| 143 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | |
| 148 | + | ipc := testIPC(shortTempDir(t)) | |
| 149 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 144 | 150 | ||
| 145 | 151 | firstReceived := make(chan string, 1) | |
| 146 | 152 | ctx, cancel := context.WithCancel(context.Background()) | |
| @@ -153,7 +159,7 @@ func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t | |||
| 153 | 159 | require.NoError(t, err) | |
| 154 | 160 | defer closer.Close() | |
| 155 | 161 | ||
| 156 | - | waitForSocket(t, filepath.Join(runtimeDir, "browser-launch.sock")) | |
| 162 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |
| 157 | 163 | ||
| 158 | 164 | _, err = relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | |
| 159 | 165 | require.Error(t, err) | |
| @@ -171,12 +177,11 @@ func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t | |||
| 171 | 177 | } | |
| 172 | 178 | ||
| 173 | 179 | func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) { | |
| 174 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | |
| 175 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | |
| 180 | + | ipc := testIPC(shortTempDir(t)) | |
| 181 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 176 | 182 | ||
| 177 | - | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | |
| 178 | - | require.NoError(t, os.MkdirAll(runtimeDir, 0o700)) | |
| 179 | - | require.NoError(t, os.WriteFile(socketPath, []byte("stale"), 0o600)) | |
| 183 | + | require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700)) | |
| 184 | + | require.NoError(t, os.WriteFile(ipc.BrowserLaunchSocket, []byte("stale"), 0o600)) | |
| 180 | 185 | ||
| 181 | 186 | received := make(chan string, 1) | |
| 182 | 187 | ctx, cancel := context.WithCancel(context.Background()) | |
| @@ -189,7 +194,7 @@ func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) { | |||
| 189 | 194 | require.NoError(t, err) | |
| 190 | 195 | defer closer.Close() | |
| 191 | 196 | ||
| 192 | - | waitForSocket(t, socketPath) | |
| 197 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |
| 193 | 198 | ||
| 194 | 199 | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/stale") | |
| 195 | 200 | require.NoError(t, err) | |
| @@ -204,8 +209,8 @@ func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) { | |||
| 204 | 209 | } | |
| 205 | 210 | ||
| 206 | 211 | func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) { | |
| 207 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | |
| 208 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | |
| 212 | + | ipc := testIPC(shortTempDir(t)) | |
| 213 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 209 | 214 | ||
| 210 | 215 | received := make(chan string, 1) | |
| 211 | 216 | ctx, cancel := context.WithCancel(context.Background()) | |
| @@ -218,10 +223,9 @@ func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) { | |||
| 218 | 223 | require.NoError(t, err) | |
| 219 | 224 | defer closer.Close() | |
| 220 | 225 | ||
| 221 | - | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | |
| 222 | - | waitForSocket(t, socketPath) | |
| 226 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |
| 223 | 227 | ||
| 224 | - | conn, err := net.Dial("unix", socketPath) | |
| 228 | + | conn, err := net.Dial("unix", ipc.BrowserLaunchSocket) | |
| 225 | 229 | require.NoError(t, err) | |
| 226 | 230 | ||
| 227 | 231 | _, err = conn.Write([]byte("not-json")) | |
| @@ -242,8 +246,8 @@ func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) { | |||
| 242 | 246 | } | |
| 243 | 247 | ||
| 244 | 248 | func TestBrowserLaunchRelay_CloseRemovesSocketPath(t *testing.T) { | |
| 245 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | |
| 246 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | |
| 249 | + | ipc := testIPC(shortTempDir(t)) | |
| 250 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 247 | 251 | ||
| 248 | 252 | ctx, cancel := context.WithCancel(context.Background()) | |
| 249 | 253 | defer cancel() | |
| @@ -251,26 +255,24 @@ func TestBrowserLaunchRelay_CloseRemovesSocketPath(t *testing.T) { | |||
| 251 | 255 | closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | |
| 252 | 256 | require.NoError(t, err) | |
| 253 | 257 | ||
| 254 | - | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | |
| 255 | - | waitForSocket(t, socketPath) | |
| 258 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |
| 256 | 259 | ||
| 257 | 260 | require.NoError(t, closer.Close()) | |
| 258 | - | waitForSocketGone(t, socketPath) | |
| 261 | + | waitForSocketGone(t, ipc.BrowserLaunchSocket) | |
| 259 | 262 | } | |
| 260 | 263 | ||
| 261 | 264 | func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) { | |
| 262 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | |
| 263 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | |
| 265 | + | ipc := testIPC(shortTempDir(t)) | |
| 266 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 264 | 267 | ||
| 265 | 268 | ctx, cancel := context.WithCancel(context.Background()) | |
| 266 | 269 | closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | |
| 267 | 270 | require.NoError(t, err) | |
| 268 | 271 | ||
| 269 | - | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | |
| 270 | - | waitForSocket(t, socketPath) | |
| 272 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |
| 271 | 273 | ||
| 272 | 274 | cancel() | |
| 273 | - | waitForSocketGone(t, socketPath) | |
| 275 | + | waitForSocketGone(t, ipc.BrowserLaunchSocket) | |
| 274 | 276 | require.NoError(t, closer.Close()) | |
| 275 | 277 | ||
| 276 | 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 | 283 | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t *testing.T) { | |
| 282 | - | runtimeDir := shortTempDir(t) | |
| 283 | - | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | |
| 284 | - | require.NoError(t, os.MkdirAll(runtimeDir, 0o700)) | |
| 284 | + | ipc := testIPC(shortTempDir(t)) | |
| 285 | + | require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700)) | |
| 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 | 288 | require.NoError(t, err) | |
| 288 | 289 | defer func() { | |
| 289 | 290 | _ = listener.Close() | |
| 290 | - | _ = os.Remove(socketPath) | |
| 291 | + | _ = os.Remove(ipc.BrowserLaunchSocket) | |
| 291 | 292 | }() | |
| 292 | 293 | ||
| 293 | 294 | ready := make(chan struct{}) | |
| @@ -302,7 +303,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t | |||
| 302 | 303 | }() | |
| 303 | 304 | <-ready | |
| 304 | 305 | ||
| 305 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | |
| 306 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 306 | 307 | ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) | |
| 307 | 308 | defer cancel() | |
| 308 | 309 | ||
| @@ -313,15 +314,14 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t | |||
| 313 | 314 | } | |
| 314 | 315 | ||
| 315 | 316 | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsTrueOnNoDeadlineTimeout(t *testing.T) { | |
| 316 | - | runtimeDir := shortTempDir(t) | |
| 317 | - | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | |
| 318 | - | require.NoError(t, os.MkdirAll(runtimeDir, 0o700)) | |
| 317 | + | ipc := testIPC(shortTempDir(t)) | |
| 318 | + | require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700)) | |
| 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 | 321 | require.NoError(t, err) | |
| 322 | 322 | defer func() { | |
| 323 | 323 | _ = listener.Close() | |
| 324 | - | _ = os.Remove(socketPath) | |
| 324 | + | _ = os.Remove(ipc.BrowserLaunchSocket) | |
| 325 | 325 | }() | |
| 326 | 326 | ||
| 327 | 327 | ready := make(chan struct{}) | |
| @@ -336,7 +336,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsTrueOnNoDeadlineTimeou | |||
| 336 | 336 | }() | |
| 337 | 337 | <-ready | |
| 338 | 338 | ||
| 339 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | |
| 339 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 340 | 340 | ||
| 341 | 341 | started := make(chan struct{}) | |
| 342 | 342 | ||
| @@ -362,8 +362,8 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsTrueOnNoDeadlineTimeou | |||
| 362 | 362 | } | |
| 363 | 363 | ||
| 364 | 364 | func TestBrowserLaunchRelay_AcknowledgesBeforeOpenerReturns(t *testing.T) { | |
| 365 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | |
| 366 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | |
| 365 | + | ipc := testIPC(shortTempDir(t)) | |
| 366 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 367 | 367 | ||
| 368 | 368 | started := make(chan struct{}) | |
| 369 | 369 | release := make(chan struct{}) | |
| @@ -378,9 +378,9 @@ func TestBrowserLaunchRelay_AcknowledgesBeforeOpenerReturns(t *testing.T) { | |||
| 378 | 378 | require.NoError(t, err) | |
| 379 | 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 | 384 | require.NoError(t, err) | |
| 385 | 385 | defer conn.Close() | |
| 386 | 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 | 401 | func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) { | |
| 402 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | |
| 403 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | |
| 402 | + | ipc := testIPC(shortTempDir(t)) | |
| 403 | + | relay := NewBrowserLaunchRelay(ipc) | |
| 404 | 404 | ||
| 405 | 405 | received := make(chan string, 1) | |
| 406 | 406 | ctx, cancel := context.WithCancel(context.Background()) | |
| @@ -413,10 +413,9 @@ func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) { | |||
| 413 | 413 | require.NoError(t, err) | |
| 414 | 414 | defer closer.Close() | |
| 415 | 415 | ||
| 416 | - | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | |
| 417 | - | waitForSocket(t, socketPath) | |
| 416 | + | waitForSocket(t, ipc.BrowserLaunchSocket) | |
| 418 | 417 | ||
| 419 | - | conn, err := net.Dial("unix", socketPath) | |
| 418 | + | conn, err := net.Dial("unix", ipc.BrowserLaunchSocket) | |
| 420 | 419 | require.NoError(t, err) | |
| 421 | 420 | defer conn.Close() | |
| 422 | 421 | time.Sleep(20 * time.Millisecond) | |
M internal/infrastructure/handlers/clipboard.go +46 -28
| @@ -4,23 +4,20 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "encoding/json" | |
| 6 | 6 | ||
| 7 | + | "github.com/bnema/dumber/internal/application/dto" | |
| 7 | 8 | "github.com/bnema/dumber/internal/application/port" | |
| 8 | 9 | "github.com/bnema/dumber/internal/logging" | |
| 9 | 10 | ) | |
| 10 | 11 | ||
| 11 | 12 | // ClipboardHandler handles clipboard-related messages from webviews. | |
| 12 | 13 | type ClipboardHandler struct { | |
| 13 | - | clipboard port.Clipboard | |
| 14 | - | autoCopyConfig port.AutoCopyConfig | |
| 15 | - | onCopied func(textLen int) // Called after successful auto-copy (for toast notification) | |
| 14 | + | orchestrator port.ClipboardTextOrchestrator | |
| 16 | 15 | } | |
| 17 | 16 | ||
| 18 | 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 | 19 | return &ClipboardHandler{ | |
| 21 | - | clipboard: clipboard, | |
| 22 | - | autoCopyConfig: autoCopyConfig, | |
| 23 | - | onCopied: onCopied, | |
| 20 | + | orchestrator: orchestrator, | |
| 24 | 21 | } | |
| 25 | 22 | } | |
| 26 | 23 | ||
| @@ -29,38 +26,58 @@ type autoCopyRequest struct { | |||
| 29 | 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 | 34 | // HandleAutoCopySelection handles the auto_copy_selection message from JS. | |
| 33 | 35 | // It copies the selected text to the clipboard if the feature is enabled. | |
| 34 | 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 | 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 | 40 | var req autoCopyRequest | |
| 44 | 41 | if err := json.Unmarshal(payload, &req); err != nil { | |
| 45 | 42 | log.Debug().Err(err).Msg("failed to unmarshal auto-copy payload") | |
| 46 | 43 | return nil, nil // Silently ignore malformed requests | |
| 47 | 44 | } | |
| 48 | - | ||
| 49 | - | // Skip empty or very short selections (< 2 chars) | |
| 50 | - | if len(req.Text) < 2 { | |
| 45 | + | if h.orchestrator == nil { | |
| 51 | 46 | return nil, nil | |
| 52 | 47 | } | |
| 53 | - | ||
| 54 | - | if err := h.clipboard.WriteText(ctx, req.Text); err != nil { | |
| 55 | - | log.Debug().Err(err).Msg("failed to write selection to clipboard") | |
| 56 | - | return nil, nil // Don't propagate error to JS | |
| 48 | + | if err := h.orchestrator.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{ | |
| 49 | + | Text: req.Text, | |
| 50 | + | SourceEngine: dto.ClipboardSourceWebKit, | |
| 51 | + | 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) | |
| 62 | - | if h.onCopied != nil { | |
| 63 | - | h.onCopied(len(req.Text)) | |
| 60 | + | // HandleExplicitCopy handles the explicit_text_copy message from JS. | |
| 61 | + | func (h *ClipboardHandler) HandleExplicitCopy() port.WebUIMessageHandler { | |
| 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 | 83 | return nil, nil | |
| @@ -71,16 +88,17 @@ func (h *ClipboardHandler) HandleAutoCopySelection() port.WebUIMessageHandler { | |||
| 71 | 88 | func RegisterClipboardHandlers( | |
| 72 | 89 | ctx context.Context, | |
| 73 | 90 | router port.WebUIHandlerRouter, | |
| 74 | - | clipboard port.Clipboard, | |
| 75 | - | autoCopyConfig port.AutoCopyConfig, | |
| 76 | - | onCopied func(textLen int), | |
| 91 | + | orchestrator port.ClipboardTextOrchestrator, | |
| 77 | 92 | ) error { | |
| 78 | - | handler := NewClipboardHandler(clipboard, autoCopyConfig, onCopied) | |
| 93 | + | handler := NewClipboardHandler(orchestrator) | |
| 79 | 94 | ||
| 80 | 95 | // Register auto_copy_selection handler | |
| 81 | 96 | if err := router.RegisterHandler("auto_copy_selection", handler.HandleAutoCopySelection()); err != nil { | |
| 82 | 97 | return err | |
| 83 | 98 | } | |
| 99 | + | if err := router.RegisterHandler("explicit_text_copy", handler.HandleExplicitCopy()); err != nil { | |
| 100 | + | return err | |
| 101 | + | } | |
| 84 | 102 | ||
| 85 | 103 | log := logging.FromContext(ctx) | |
| 86 | 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 | 54 | // Clipboard handlers (for auto-copy on selection feature) | |
| 55 | - | if deps.Clipboard != nil && deps.AutoCopyConfig != nil { | |
| 56 | - | if err := RegisterClipboardHandlers(ctx, router, deps.Clipboard, deps.AutoCopyConfig, deps.OnClipboardCopied); err != nil { | |
| 55 | + | if deps.ClipboardTextOrchestrator != nil { | |
| 56 | + | if err := RegisterClipboardHandlers(ctx, router, deps.ClipboardTextOrchestrator); err != nil { | |
| 57 | 57 | 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 | 13 | "net/url" | |
| 14 | 14 | "strings" | |
| 15 | 15 | "time" | |
| 16 | + | "unicode/utf8" | |
| 16 | 17 | ||
| 17 | 18 | "github.com/bnema/dumber/internal/application/port" | |
| 18 | 19 | "github.com/bnema/dumber/internal/domain/entity" | |
| @@ -365,6 +366,7 @@ type contextMenuPipeline struct { | |||
| 365 | 366 | builder port.ContextMenuBuilder | |
| 366 | 367 | executorFactory port.ContextMenuActionExecutorFactory | |
| 367 | 368 | clipboard port.Clipboard | |
| 369 | + | onCopied func(textLen int) | |
| 368 | 370 | resolver port.ImageDataResolver | |
| 369 | 371 | saver port.ResolvedImageSaver | |
| 370 | 372 | renderer *Renderer | |
| @@ -374,14 +376,66 @@ func (p *contextMenuPipeline) newExecutor(wv *WebView) port.ContextMenuActionExe | |||
| 374 | 376 | if p == nil || p.executorFactory == nil { | |
| 375 | 377 | 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 | 383 | return p.executorFactory.NewContextMenuActionExecutor( | |
| 378 | - | p.clipboard, | |
| 384 | + | clipboard, | |
| 379 | 385 | p.resolver, | |
| 380 | 386 | p.saver, | |
| 381 | 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 | 439 | // connectContextMenuSignal wires the WebKit "context-menu" signal to the | |
| 386 | 440 | // shared context menu pipeline. When the user right-clicks, WebKit's native | |
| 387 | 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 | 9 | "net/http" | |
| 10 | 10 | "net/http/httptest" | |
| 11 | 11 | "testing" | |
| 12 | + | "unicode/utf8" | |
| 12 | 13 | ||
| 13 | 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 | 17 | "github.com/stretchr/testify/assert" | |
| 18 | + | "github.com/stretchr/testify/mock" | |
| 15 | 19 | "github.com/stretchr/testify/require" | |
| 16 | 20 | ) | |
| 17 | 21 | ||
| @@ -313,6 +317,53 @@ func TestWebkitMenuDelegator_OpenLinkNewTabRequiresHandlerSuccess(t *testing.T) | |||
| 313 | 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 | 367 | // buildMenuContextFromStubHitTest is the test-only equivalent of buildMenuContextFromHitTest | |
| 317 | 368 | // that works with our stub type instead of requiring real WebKit objects. | |
| 318 | 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 | 26 | logger zerolog.Logger | |
| 27 | 27 | ctxMenuBuilder port.ContextMenuBuilder | |
| 28 | 28 | ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory | |
| 29 | + | onClipboardCopied func(textLen int) | |
| 29 | 30 | downloadPath string | |
| 30 | 31 | downloadPreparer port.DownloadPreparer | |
| 31 | 32 | 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 | 101 | // Capture clipboard for context menu pipeline wiring in ConfigureDownloads. | |
| 101 | 102 | e.clipboard = deps.Clipboard | |
| 103 | + | e.onClipboardCopied = deps.OnClipboardCopied | |
| 102 | 104 | return handlers.RegisterAll(ctx, e.messageRouter, deps) | |
| 103 | 105 | } | |
| 104 | 106 | ||
| @@ -179,6 +181,7 @@ func (e *Engine) installContextMenuPipeline() { | |||
| 179 | 181 | builder: e.ctxMenuBuilder, | |
| 180 | 182 | executorFactory: e.ctxMenuExecutorFactory, | |
| 181 | 183 | clipboard: e.clipboard, | |
| 184 | + | onCopied: e.onClipboardCopied, | |
| 182 | 185 | resolver: resolver, | |
| 183 | 186 | saver: saver, | |
| 184 | 187 | renderer: renderer, | |
M internal/infrastructure/webkit/engine_init.go +16 -4
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 11 | 11 | "github.com/bnema/dumber/internal/infrastructure/env" | |
| 12 | 12 | "github.com/bnema/dumber/internal/infrastructure/filtering" | |
| 13 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 13 | 14 | "github.com/bnema/dumber/internal/logging" | |
| 14 | 15 | "github.com/bnema/dumber/internal/ui/theme" | |
| 15 | 16 | "github.com/bnema/puregotk/v4/gdk" | |
| @@ -22,6 +23,7 @@ func NewEngine( | |||
| 22 | 23 | ctx context.Context, | |
| 23 | 24 | cfg *config.Config, | |
| 24 | 25 | opts port.EngineOptions, | |
| 26 | + | profile runtimeprofile.Profile, | |
| 25 | 27 | wkCfg WebKitEngineConfig, | |
| 26 | 28 | themeManager *theme.Manager, | |
| 27 | 29 | colorResolver port.ColorSchemeResolver, | |
| @@ -37,7 +39,7 @@ func NewEngine( | |||
| 37 | 39 | logging.Trace().Mark("render_env") | |
| 38 | 40 | ||
| 39 | 41 | // --- Build webKitContextOptions from opts + wkCfg + perfSettings --- | |
| 40 | - | wkOpts := engineBuildContextOptions(opts, wkCfg, &perfSettings) | |
| 42 | + | wkOpts := engineBuildContextOptions(opts, profile, wkCfg, &perfSettings) | |
| 41 | 43 | logger.Info(). | |
| 42 | 44 | Str("cookie_policy", string(wkOpts.CookiePolicy)). | |
| 43 | 45 | Bool("itp_enabled", wkOpts.ITPEnabled). | |
| @@ -50,7 +52,7 @@ func NewEngine( | |||
| 50 | 52 | logging.Trace().Mark("webkit_context") | |
| 51 | 53 | ||
| 52 | 54 | // --- Filter manager --- | |
| 53 | - | filterManager := engineInitFilterManager(ctx, cfg, opts.DataDir, logger) | |
| 55 | + | filterManager := engineInitFilterManager(ctx, cfg, profile.Shared.DataDir, logger) | |
| 54 | 56 | ||
| 55 | 57 | // --- Scheme handler --- | |
| 56 | 58 | schemeHandler := NewDumbSchemeHandler(ctx) | |
| @@ -203,14 +205,24 @@ func engineConfigureRenderingEnvironment( | |||
| 203 | 205 | // engineBuildContextOptions builds webKitContextOptions from EngineOptions, wkCfg and perfSettings. | |
| 204 | 206 | func engineBuildContextOptions( | |
| 205 | 207 | opts port.EngineOptions, | |
| 208 | + | profile runtimeprofile.Profile, | |
| 206 | 209 | wkCfg WebKitEngineConfig, | |
| 207 | 210 | perfSettings *config.ResolvedPerformanceSettings, | |
| 208 | 211 | ) webKitContextOptions { | |
| 209 | 212 | 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 | 223 | wkOpts := webKitContextOptions{ | |
| 212 | - | DataDir: opts.DataDir, | |
| 213 | - | CacheDir: opts.CacheDir, | |
| 224 | + | DataDir: dataDir, | |
| 225 | + | CacheDir: cacheDir, | |
| 214 | 226 | CookiePolicy: cp, | |
| 215 | 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 | 165 | // accentDetectionScript is built at init from entity.AccentMap so the JS | |
| 63 | 166 | // filter stays in sync with the Go-side accent table. | |
| 64 | 167 | var accentDetectionScript string | |
| 65 | 168 | ||
| 169 | + | func buildExplicitCopyScript() string { | |
| 170 | + | return explicitCopyScript | |
| 171 | + | } | |
| 172 | + | ||
| 66 | 173 | func init() { | |
| 67 | 174 | // Build JS Set literal from AccentMap keys: "new Set(['a','c','e',...])" | |
| 68 | 175 | keys := make([]string, 0, len(entity.AccentMap)) | |
| @@ -229,7 +336,19 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 229 | 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 | 352 | if webviewID != 0 { | |
| 234 | 353 | idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID)) | |
| 235 | 354 | addScript( | |
| @@ -247,7 +366,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 247 | 366 | log.Warn().Msg("webview ID is 0, skipping ID injection") | |
| 248 | 367 | } | |
| 249 | 368 | ||
| 250 | - | // 3. Inject dark mode handler for internal pages only | |
| 369 | + | // 4. Inject dark mode handler for internal pages only | |
| 251 | 370 | // This sets .dark/.light class on <html> and patches matchMedia for WebUI | |
| 252 | 371 | internalDarkModeScript := webutil.DarkModeScript(prefersDark, "__dumber_gtk_prefers_dark") | |
| 253 | 372 | addScript( | |
| @@ -261,7 +380,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 261 | 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 | 384 | if ci.themeCSSVars != "" { | |
| 266 | 385 | escapedCSS := webutil.EscapeForJSString(ci.themeCSSVars) | |
| 267 | 386 | themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS) | |
| @@ -278,7 +397,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 278 | 397 | log.Debug().Msg("theme CSS vars injection configured for internal pages") | |
| 279 | 398 | } | |
| 280 | 399 | ||
| 281 | - | // 5. Inject find highlight CSS for all pages | |
| 400 | + | // 6. Inject find highlight CSS for all pages | |
| 282 | 401 | if ci.findCSS != "" { | |
| 283 | 402 | stylesheet := webkit.NewUserStyleSheet( | |
| 284 | 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 | 418 | autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter() | |
| 300 | 419 | if autoCopyEnabled { | |
| 301 | 420 | addScript( | |
| @@ -311,7 +430,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||
| 311 | 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 | 434 | // JS only reports keydown/keyup events; Go handles timing and picker display. | |
| 316 | 435 | addScript( | |
| 317 | 436 | webkit.NewUserScript( | |
M internal/infrastructure/webkit/injector_accent_test.go +25 -0
| @@ -30,3 +30,28 @@ func TestAccentDetectionScriptTracksLastFocusedEditableElement(t *testing.T) { | |||
| 30 | 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 | 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 | 17 | // New creates a new XDG paths adapter. | |
| 15 | - | func New() *Adapter { | |
| 16 | - | return &Adapter{} | |
| 18 | + | func New(isDev bool, runtimeDir string) *Adapter { | |
| 19 | + | return &Adapter{isDev: isDev, runtimeDir: runtimeDir} | |
| 17 | 20 | } | |
| 18 | 21 | ||
| 19 | 22 | func (a *Adapter) ConfigDir() (string, error) { | |
| @@ -29,11 +32,16 @@ func (a *Adapter) StateDir() (string, error) { | |||
| 29 | 32 | } | |
| 30 | 33 | ||
| 31 | 34 | func (a *Adapter) RuntimeDir() (string, error) { | |
| 32 | - | if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" { | |
| 33 | - | return dir, nil | |
| 35 | + | if a == nil || !a.isDev { | |
| 36 | + | 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 | 45 | if err != nil { | |
| 38 | 46 | return "", err | |
| 39 | 47 | } | |
M internal/infrastructure/xdg/adapter_test.go +17 -7
| @@ -10,7 +10,7 @@ import ( | |||
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | 12 | func TestAdapter_DownloadDir(t *testing.T) { | |
| 13 | - | adapter := New() | |
| 13 | + | adapter := New(false, "") | |
| 14 | 14 | ||
| 15 | 15 | t.Run("returns XDG_DOWNLOAD_DIR when set", func(t *testing.T) { | |
| 16 | 16 | t.Setenv("XDG_DOWNLOAD_DIR", "/custom/downloads") | |
| @@ -37,9 +37,8 @@ func TestAdapter_DownloadDir(t *testing.T) { | |||
| 37 | 37 | } | |
| 38 | 38 | ||
| 39 | 39 | func TestAdapter_RuntimeDir(t *testing.T) { | |
| 40 | - | adapter := New() | |
| 41 | - | ||
| 42 | - | t.Run("returns XDG_RUNTIME_DIR when set", func(t *testing.T) { | |
| 40 | + | t.Run("returns XDG_RUNTIME_DIR when set outside dev", func(t *testing.T) { | |
| 41 | + | adapter := New(false, "") | |
| 43 | 42 | t.Setenv("XDG_RUNTIME_DIR", "/custom/runtime") | |
| 44 | 43 | ||
| 45 | 44 | dir, err := adapter.RuntimeDir() | |
| @@ -48,19 +47,30 @@ func TestAdapter_RuntimeDir(t *testing.T) { | |||
| 48 | 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 | 52 | t.Setenv("XDG_RUNTIME_DIR", "") | |
| 53 | - | t.Setenv("XDG_STATE_HOME", "/tmp/dumber-state") | |
| 54 | 53 | ||
| 55 | 54 | dir, err := adapter.RuntimeDir() | |
| 56 | 55 | ||
| 57 | 56 | require.NoError(t, err) | |
| 58 | 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 | 72 | func TestNew(t *testing.T) { | |
| 63 | - | adapter := New() | |
| 73 | + | adapter := New(false, "") | |
| 64 | 74 | ||
| 65 | 75 | assert.NotNil(t, adapter) | |
| 66 | 76 | } | |
M internal/ui/app.go +27 -10
| @@ -37,7 +37,8 @@ import ( | |||
| 37 | 37 | ||
| 38 | 38 | const ( | |
| 39 | 39 | // AppID is the application identifier for GTK. | |
| 40 | - | AppID = "com.github.bnema.dumber" | |
| 40 | + | AppID = "com.github.bnema.dumber" | |
| 41 | + | appTitle = "Dumber" | |
| 41 | 42 | // crashReportToastDurationMs keeps startup crash-report toast visible longer. | |
| 42 | 43 | crashReportToastDurationMs = 5000 | |
| 43 | 44 | floatingPaneFallbackWidth = 1200 | |
| @@ -181,17 +182,33 @@ func New(deps *Dependencies) (*App, error) { | |||
| 181 | 182 | engine: deps.Engine, | |
| 182 | 183 | cancel: cancel, | |
| 183 | 184 | } | |
| 184 | - | // Register message handlers through the engine. | |
| 185 | - | if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{ | |
| 186 | - | HistoryUC: deps.HistoryUC, | |
| 187 | - | FavoritesUC: deps.FavoritesUC, | |
| 188 | - | Clipboard: deps.Clipboard, | |
| 189 | - | AutoCopyConfig: &autoCopyConfigFn{fn: func() bool { | |
| 185 | + | var autoCopyConfig port.AutoCopyConfig | |
| 186 | + | var clipboardOrchestrator port.ClipboardTextOrchestrator | |
| 187 | + | if deps.Clipboard != nil { | |
| 188 | + | autoCopyConfig = &autoCopyConfigFn{fn: func() bool { | |
| 190 | 189 | if deps.Config == nil { | |
| 191 | 190 | return false | |
| 192 | 191 | } | |
| 193 | 192 | 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 | 212 | OnClipboardCopied: func(textLen int) { | |
| 196 | 213 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 197 | 214 | app.showToastOnLastFocusedBrowserWindow(ctx, "Copied to clipboard", component.ToastInfo, | |
| @@ -2339,9 +2356,9 @@ func (a *App) updateWindowTitle(pageTitle string, target *browserWindow) { | |||
| 2339 | 2356 | return | |
| 2340 | 2357 | } | |
| 2341 | 2358 | ||
| 2342 | - | title := "Dumber" | |
| 2359 | + | title := appTitle | |
| 2343 | 2360 | if pageTitle != "" { | |
| 2344 | - | title = pageTitle + " - Dumber" | |
| 2361 | + | title = pageTitle + " - " + appTitle | |
| 2345 | 2362 | } | |
| 2346 | 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 | 390 | ctx = context.Background() | |
| 391 | 391 | } | |
| 392 | 392 | logging.FromContext(ctx). | |
| 393 | - | Debug(). | |
| 393 | + | Trace(). | |
| 394 | 394 | Str("pane_id", string(pv.paneID)). | |
| 395 | 395 | Float64("progress", progress). | |
| 396 | 396 | Msg("setting load progress") | |
| @@ -419,7 +419,7 @@ func (pv *PaneView) SetLoading(loading bool) { | |||
| 419 | 419 | if loading { | |
| 420 | 420 | pb.Show() | |
| 421 | 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 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | 6 | "sync" | |
| 7 | + | "time" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/dumber/internal/logging" | |
| 9 | 10 | "github.com/bnema/puregotk/v4/glib" | |
| @@ -29,11 +30,14 @@ type ProgressBar struct { | |||
| 29 | 30 | ctx context.Context | |
| 30 | 31 | progressBar layout.ProgressBarWidget | |
| 31 | 32 | ||
| 32 | - | visible bool | |
| 33 | - | currentValue float64 // Current displayed value | |
| 34 | - | targetValue float64 // Target value to animate towards | |
| 35 | - | animationTimer uint // Timer source ID for animation | |
| 36 | - | timeoutTimer uint // Timer source ID for auto-hide timeout | |
| 33 | + | visible bool | |
| 34 | + | currentValue float64 // Current displayed value | |
| 35 | + | targetValue float64 // Target value to animate towards | |
| 36 | + | animationTimer uint // Timer source ID for animation | |
| 37 | + | timeoutTimer uint // Timer source ID for auto-hide timeout | |
| 38 | + | lastShowAt time.Time | |
| 39 | + | lastProgressAt time.Time | |
| 40 | + | progressEventSeq uint64 | |
| 37 | 41 | ||
| 38 | 42 | mu sync.Mutex | |
| 39 | 43 | } | |
| @@ -85,12 +89,17 @@ func (pb *ProgressBar) SetProgress(progress float64) { | |||
| 85 | 89 | if ctx == nil { | |
| 86 | 90 | ctx = context.Background() | |
| 87 | 91 | } | |
| 92 | + | now := time.Now() | |
| 88 | 93 | logging.FromContext(ctx). | |
| 89 | - | Debug(). | |
| 94 | + | Trace(). | |
| 90 | 95 | Float64("incoming_progress", incomingProgress). | |
| 91 | 96 | Float64("current_value", pb.currentValue). | |
| 92 | 97 | Float64("target_value", pb.targetValue). | |
| 93 | 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 | 103 | Msg("setting progress") | |
| 95 | 104 | ||
| 96 | 105 | // Clamp progress to valid range | |
| @@ -101,6 +110,11 @@ func (pb *ProgressBar) SetProgress(progress float64) { | |||
| 101 | 110 | } | |
| 102 | 111 | ||
| 103 | 112 | pb.targetValue = progress | |
| 113 | + | pb.lastProgressAt = now | |
| 114 | + | pb.progressEventSeq++ | |
| 115 | + | if pb.visible { | |
| 116 | + | pb.resetTimeout() | |
| 117 | + | } | |
| 104 | 118 | ||
| 105 | 119 | // For large jumps (>0.3) or completion, update immediately | |
| 106 | 120 | diff := progress - pb.currentValue | |
| @@ -146,6 +160,16 @@ func (pb *ProgressBar) startAnimation() { | |||
| 146 | 160 | }) | |
| 147 | 161 | ||
| 148 | 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 | 175 | // initialProgressFraction is the fraction set when the progress bar first | |
| @@ -172,6 +196,7 @@ func (pb *ProgressBar) Show() { | |||
| 172 | 196 | ||
| 173 | 197 | if !pb.visible { | |
| 174 | 198 | pb.visible = true | |
| 199 | + | pb.lastShowAt = time.Now() | |
| 175 | 200 | // Set an initial non-zero fraction so the bar is visually noticeable | |
| 176 | 201 | // immediately. Without this, the bar is technically visible but | |
| 177 | 202 | // renders as empty (0% fill) until the first progress callback, | |
| @@ -182,10 +207,10 @@ func (pb *ProgressBar) Show() { | |||
| 182 | 207 | pb.progressBar.SetVisible(true) | |
| 183 | 208 | } | |
| 184 | 209 | ||
| 185 | - | // Reset timeout timer on every Show call | |
| 210 | + | // Reset timeout timer on every Show call. | |
| 186 | 211 | pb.resetTimeout() | |
| 187 | 212 | ||
| 188 | - | logging.FromContext(pb.ctx). | |
| 213 | + | logging.FromContext(ctx). | |
| 189 | 214 | Debug(). | |
| 190 | 215 | Bool("visible", pb.visible). | |
| 191 | 216 | Float64("current_value", pb.currentValue). | |
| @@ -196,46 +221,96 @@ func (pb *ProgressBar) Show() { | |||
| 196 | 221 | // resetTimeout cancels any existing timeout and starts a new one. | |
| 197 | 222 | // Must be called with lock held. | |
| 198 | 223 | func (pb *ProgressBar) resetTimeout() { | |
| 199 | - | // Cancel existing timeout | |
| 224 | + | 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 | 235 | if pb.timeoutTimer != 0 { | |
| 201 | 236 | glib.SourceRemove(pb.timeoutTimer) | |
| 202 | 237 | pb.timeoutTimer = 0 | |
| 203 | 238 | } | |
| 204 | 239 | ||
| 205 | - | // Start new timeout | |
| 240 | + | timeoutMs := int(delay / time.Millisecond) | |
| 241 | + | if delay%time.Millisecond != 0 { | |
| 242 | + | timeoutMs++ | |
| 243 | + | } | |
| 244 | + | if timeoutMs < 1 { | |
| 245 | + | timeoutMs = 1 | |
| 246 | + | } | |
| 247 | + | ||
| 206 | 248 | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 207 | 249 | pb.mu.Lock() | |
| 208 | 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 | 268 | pb.timeoutTimer = 0 | |
| 212 | 269 | ||
| 213 | - | // Hide the progress bar inline to avoid race condition | |
| 214 | - | pb.hideInternal() | |
| 270 | + | if remaining > 0 { | |
| 271 | + | pb.resetTimeoutAfter(remaining) | |
| 272 | + | return false | |
| 273 | + | } | |
| 274 | + | ||
| 275 | + | // Hide the progress bar inline to avoid race condition. | |
| 276 | + | pb.hideInternal("timeout") | |
| 215 | 277 | 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 | 283 | // Hide makes the progress bar invisible and resets state. | |
| 222 | 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 | 290 | pb.mu.Lock() | |
| 224 | 291 | defer pb.mu.Unlock() | |
| 225 | 292 | ||
| 226 | - | pb.hideInternal() | |
| 293 | + | pb.hideInternal(reason) | |
| 227 | 294 | } | |
| 228 | 295 | ||
| 229 | 296 | // hideInternal performs the actual hide operation. | |
| 230 | 297 | // Must be called with lock held. | |
| 231 | - | func (pb *ProgressBar) hideInternal() { | |
| 298 | + | func (pb *ProgressBar) hideInternal(reason string) { | |
| 232 | 299 | ctx := pb.ctx | |
| 233 | 300 | if ctx == nil { | |
| 234 | 301 | ctx = context.Background() | |
| 235 | 302 | } | |
| 303 | + | now := time.Now() | |
| 236 | 304 | logging.FromContext(ctx). | |
| 237 | 305 | Debug(). | |
| 238 | 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 | 314 | Msg("progress bar hide before") | |
| 240 | 315 | ||
| 241 | 316 | if pb.visible { | |
| @@ -257,10 +332,35 @@ func (pb *ProgressBar) hideInternal() { | |||
| 257 | 332 | // Reset values | |
| 258 | 333 | pb.currentValue = 0 | |
| 259 | 334 | pb.targetValue = 0 | |
| 335 | + | pb.lastShowAt = time.Time{} | |
| 336 | + | pb.lastProgressAt = time.Time{} | |
| 260 | 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 | 364 | // IsVisible returns whether the progress bar is currently visible. | |
| 265 | 365 | func (pb *ProgressBar) IsVisible() bool { | |
| 266 | 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 | + | } | |