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 | AccentKeyHandler: {} | 49 | AccentKeyHandler: {} | ||
| 50 | AutoCopyConfig: {} | 50 | AutoCopyConfig: {} | ||
| 51 | Clipboard: {} | 51 | Clipboard: {} | ||
| 52 | + | ClipboardTextOrchestrator: {} | |||
| 52 | ContextMenuBuilder: {} | 53 | ContextMenuBuilder: {} | ||
| 53 | ContextMenuActionExecutor: {} | 54 | ContextMenuActionExecutor: {} | ||
| 54 | ContextMenuActionExecutorFactory: {} | 55 | ContextMenuActionExecutorFactory: {} | ||
M cmd/cef-helper/main.go +5 -4
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | "os" | 13 | "os" | ||
| 14 | "runtime" | 14 | "runtime" | ||
| 15 | 15 | ||||
| 16 | + | infracef "github.com/bnema/dumber/internal/infrastructure/cef" | |||
| 16 | cef "github.com/bnema/purego-cef/cef" | 17 | cef "github.com/bnema/purego-cef/cef" | ||
| 17 | ) | 18 | ) | ||
| 18 | 19 | ||||
| @@ -22,10 +23,10 @@ func main() { | |||||
| 22 | 23 | ||||
| 23 | // cef_execute_process will handle the subprocess lifecycle and call | 24 | // cef_execute_process will handle the subprocess lifecycle and call | ||
| 24 | // os.Exit internally if this is a valid CEF subprocess invocation. | 25 | // os.Exit internally if this is a valid CEF subprocess invocation. | ||
| 25 | - | // If it returns (code < 0), this is not a subprocess — shouldn't | 26 | + | // We pass the lightweight subprocess app, which intentionally returns nil |
| 26 | - | // happen in normal operation since CEF only launches this binary | 27 | + | // from GetRenderProcessHandler(), so renderer-side bridges remain disabled |
| 27 | - | // with --type= flags. | 28 | + | // in helper subprocesses while the OSR startup regression is under repair. |
| 28 | - | cef.MaybeExitSubprocess() | 29 | + | cef.MaybeExitSubprocessWithApp(infracef.NewSubprocessApp()) |
| 29 | 30 | ||||
| 30 | // If we get here, something went wrong. | 31 | // If we get here, something went wrong. | ||
| 31 | os.Exit(1) | 32 | os.Exit(1) | ||
M cmd/dumber/main.go +64 -36
| @@ -26,6 +26,7 @@ import ( | |||||
| 26 | "github.com/bnema/dumber/internal/infrastructure/filesystem" | 26 | "github.com/bnema/dumber/internal/infrastructure/filesystem" | ||
| 27 | "github.com/bnema/dumber/internal/infrastructure/idle" | 27 | "github.com/bnema/dumber/internal/infrastructure/idle" | ||
| 28 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | 28 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | ||
| 29 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 29 | "github.com/bnema/dumber/internal/infrastructure/snapshot" | 30 | "github.com/bnema/dumber/internal/infrastructure/snapshot" | ||
| 30 | "github.com/bnema/dumber/internal/infrastructure/textinput" | 31 | "github.com/bnema/dumber/internal/infrastructure/textinput" | ||
| 31 | "github.com/bnema/dumber/internal/infrastructure/updater" | 32 | "github.com/bnema/dumber/internal/infrastructure/updater" | ||
| @@ -54,7 +55,7 @@ var initialURL string | |||||
| 54 | var restoreSessionID string | 55 | var restoreSessionID string | ||
| 55 | 56 | ||||
| 56 | // browserLaunchRelay is shared for early browse handoff and in-app browser launches. | 57 | // browserLaunchRelay is shared for early browse handoff and in-app browser launches. | ||
| 57 | - | var browserLaunchRelay = desktop.NewBrowserLaunchRelay(xdg.New()) | 58 | + | var browserLaunchRelay port.BrowserLaunchRelay |
| 58 | 59 | ||||
| 59 | type launchMode string | 60 | type launchMode string | ||
| 60 | 61 | ||||
| @@ -97,6 +98,24 @@ func tryForwardBrowseURLToRunningInstance(ctx context.Context, relay port.Browse | |||||
| 97 | return relay.DeliverOpenFreshWindow(ctx, browseURL) | 98 | return relay.DeliverOpenFreshWindow(ctx, browseURL) | ||
| 98 | } | 99 | } | ||
| 99 | 100 | ||||
| 101 | + | func resolveBrowserLaunchRelay(cfg *config.Config) (port.BrowserLaunchRelay, error) { | |||
| 102 | + | profile, err := bootstrap.ResolveRuntimeProfile(cfg) | |||
| 103 | + | if err != nil { | |||
| 104 | + | return nil, err | |||
| 105 | + | } | |||
| 106 | + | return desktop.NewBrowserLaunchRelay(profile.IPC), nil | |||
| 107 | + | } | |||
| 108 | + | ||||
| 109 | + | func configureBrowserLaunchRelay(cfg *config.Config) { | |||
| 110 | + | relay, err := resolveBrowserLaunchRelay(cfg) | |||
| 111 | + | if err != nil { | |||
| 112 | + | fmt.Fprintf(os.Stderr, "warning: failed to resolve browser launch relay profile: %v\n", err) | |||
| 113 | + | browserLaunchRelay = nil | |||
| 114 | + | return | |||
| 115 | + | } | |||
| 116 | + | browserLaunchRelay = relay | |||
| 117 | + | } | |||
| 118 | + | ||||
| 100 | func main() { | 119 | func main() { | ||
| 101 | enableCrashForensics() | 120 | enableCrashForensics() | ||
| 102 | 121 | ||||
| @@ -114,6 +133,8 @@ func main() { | |||||
| 114 | mode, browseURL := launchModeFromArgs(os.Args) | 133 | mode, browseURL := launchModeFromArgs(os.Args) | ||
| 115 | // Run GUI mode for browse command | 134 | // Run GUI mode for browse command | ||
| 116 | if mode == launchModeBrowse { | 135 | if mode == launchModeBrowse { | ||
| 136 | + | cfg := initConfig() | |||
| 137 | + | configureBrowserLaunchRelay(cfg) | |||
| 117 | if forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), browserLaunchRelay, browseURL); err != nil { | 138 | if forwarded, err := tryForwardBrowseURLToRunningInstance(context.Background(), browserLaunchRelay, browseURL); err != nil { | ||
| 118 | fmt.Fprintf( | 139 | fmt.Fprintf( | ||
| 119 | os.Stderr, | 140 | os.Stderr, | ||
| @@ -128,7 +149,7 @@ func main() { | |||||
| 128 | initialURL = browseURL | 149 | initialURL = browseURL | ||
| 129 | restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION") | 150 | restoreSessionID = os.Getenv("DUMBER_RESTORE_SESSION") | ||
| 130 | os.Args = os.Args[:1] | 151 | os.Args = os.Args[:1] | ||
| 131 | - | os.Exit(runGUI()) | 152 | + | os.Exit(runGUI(cfg)) |
| 132 | return | 153 | return | ||
| 133 | } | 154 | } | ||
| 134 | 155 | ||||
| @@ -149,14 +170,17 @@ func main() { | |||||
| 149 | cmd.Execute() | 170 | cmd.Execute() | ||
| 150 | } | 171 | } | ||
| 151 | 172 | ||||
| 152 | - | func runGUI() int { | 173 | + | func runGUI(cfg *config.Config) int { |
| 153 | bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr) | 174 | bootstrap.ApplyGTKIMModuleFallbackDefault(os.Stderr) | ||
| 154 | 175 | ||||
| 155 | runtime.LockOSThread() | 176 | runtime.LockOSThread() | ||
| 156 | component.SetSkeletonVersion(version) | 177 | component.SetSkeletonVersion(version) | ||
| 157 | timer := bootstrap.NewStartupTimer() | 178 | timer := bootstrap.NewStartupTimer() | ||
| 158 | 179 | ||||
| 159 | - | cfg := initConfig() | 180 | + | if cfg == nil { |
| 181 | + | cfg = initConfig() | |||
| 182 | + | configureBrowserLaunchRelay(cfg) | |||
| 183 | + | } | |||
| 160 | timer.Mark("config") | 184 | timer.Mark("config") | ||
| 161 | 185 | ||||
| 162 | ctx := initStartupContextWithTrace(cfg) | 186 | ctx := initStartupContextWithTrace(cfg) | ||
| @@ -232,6 +256,7 @@ func runStandaloneOmnibox() int { | |||||
| 232 | component.SetSkeletonVersion(version) | 256 | component.SetSkeletonVersion(version) | ||
| 233 | 257 | ||||
| 234 | cfg := initConfig() | 258 | cfg := initConfig() | ||
| 259 | + | configureBrowserLaunchRelay(cfg) | |||
| 235 | ctx := initStartupContextWithTrace(cfg) | 260 | ctx := initStartupContextWithTrace(cfg) | ||
| 236 | 261 | ||||
| 237 | initResult, err := runParallelInitPhase(ctx, cfg) | 262 | initResult, err := runParallelInitPhase(ctx, cfg) | ||
| @@ -260,6 +285,7 @@ func runStandaloneOmnibox() int { | |||||
| 260 | uiDeps, err := buildUIDependencies( | 285 | uiDeps, err := buildUIDependencies( | ||
| 261 | ctx, | 286 | ctx, | ||
| 262 | cfg, | 287 | cfg, | ||
| 288 | + | initResult.RuntimeProfile, | |||
| 263 | initResult.ThemeManager, | 289 | initResult.ThemeManager, | ||
| 264 | initResult.ColorResolver, | 290 | initResult.ColorResolver, | ||
| 265 | initResult.AdwaitaDetector, | 291 | initResult.AdwaitaDetector, | ||
| @@ -391,7 +417,7 @@ func buildAndConfigureApp( | |||||
| 391 | browserSession *bootstrap.BrowserSession, | 417 | browserSession *bootstrap.BrowserSession, | ||
| 392 | ) (*ui.App, error) { | 418 | ) (*ui.App, error) { | ||
| 393 | uiDeps, err := buildUIDependencies( | 419 | uiDeps, err := buildUIDependencies( | ||
| 394 | - | ctx, cfg, initResult.ThemeManager, | 420 | + | ctx, cfg, initResult.RuntimeProfile, initResult.ThemeManager, |
| 395 | initResult.ColorResolver, initResult.AdwaitaDetector, | 421 | initResult.ColorResolver, initResult.AdwaitaDetector, | ||
| 396 | engine, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.CrashReports(), | 422 | engine, repos, useCases, idleInhibitor, browserSession.Session.ID, browserSession.CrashReports(), | ||
| 397 | ) | 423 | ) | ||
| @@ -427,12 +453,11 @@ func initStackAndRepos( | |||||
| 427 | if needsEagerDB { | 453 | if needsEagerDB { | ||
| 428 | // Parallel phase 2: Database + engine initialize concurrently | 454 | // Parallel phase 2: Database + engine initialize concurrently | ||
| 429 | dbEngine, err := bootstrap.RunParallelDBEngine(bootstrap.ParallelDBEngineInput{ | 455 | dbEngine, err := bootstrap.RunParallelDBEngine(bootstrap.ParallelDBEngineInput{ | ||
| 430 | - | Ctx: ctx, | 456 | + | Ctx: ctx, |
| 431 | - | Config: cfg, | 457 | + | Config: cfg, |
| 432 | - | DataDir: initResult.DataDir, | 458 | + | RuntimeProfile: initResult.RuntimeProfile, |
| 433 | - | CacheDir: initResult.CacheDir, | 459 | + | ThemeManager: initResult.ThemeManager, |
| 434 | - | ThemeManager: initResult.ThemeManager, | 460 | + | ColorResolver: initResult.ColorResolver, |
| 435 | - | ColorResolver: initResult.ColorResolver, | |||
| 436 | }) | 461 | }) | ||
| 437 | if err != nil { | 462 | if err != nil { | ||
| 438 | return nil, nil, nil, err | 463 | return nil, nil, nil, err | ||
| @@ -442,13 +467,12 @@ func initStackAndRepos( | |||||
| 442 | 467 | ||||
| 443 | log := logging.FromContext(ctx) | 468 | log := logging.FromContext(ctx) | ||
| 444 | engine, err := bootstrap.BuildEngine(bootstrap.EngineInput{ | 469 | engine, err := bootstrap.BuildEngine(bootstrap.EngineInput{ | ||
| 445 | - | Ctx: ctx, | 470 | + | Ctx: ctx, |
| 446 | - | Config: cfg, | 471 | + | Config: cfg, |
| 447 | - | DataDir: initResult.DataDir, | 472 | + | RuntimeProfile: initResult.RuntimeProfile, |
| 448 | - | CacheDir: initResult.CacheDir, | 473 | + | ThemeManager: initResult.ThemeManager, |
| 449 | - | ThemeManager: initResult.ThemeManager, | 474 | + | ColorResolver: initResult.ColorResolver, |
| 450 | - | ColorResolver: initResult.ColorResolver, | 475 | + | Logger: *log, |
| 451 | - | Logger: *log, | |||
| 452 | }) | 476 | }) | ||
| 453 | if err != nil { | 477 | if err != nil { | ||
| 454 | return nil, nil, nil, err | 478 | return nil, nil, nil, err | ||
| @@ -717,6 +741,7 @@ func isCEFSubprocess() bool { | |||||
| 717 | func buildUIDependencies( | 741 | func buildUIDependencies( | ||
| 718 | ctx context.Context, | 742 | ctx context.Context, | ||
| 719 | cfg *config.Config, | 743 | cfg *config.Config, | ||
| 744 | + | runtimeProfile runtimeprofile.Profile, | |||
| 720 | themeManager *theme.Manager, | 745 | themeManager *theme.Manager, | ||
| 721 | colorResolver port.ColorSchemeResolver, | 746 | colorResolver port.ColorSchemeResolver, | ||
| 722 | adwaitaDetector port.ToolkitAvailabilityNotifier, | 747 | adwaitaDetector port.ToolkitAvailabilityNotifier, | ||
| @@ -749,23 +774,26 @@ func buildUIDependencies( | |||||
| 749 | Theme: themeManager, | 774 | Theme: themeManager, | ||
| 750 | ColorResolver: colorResolver, | 775 | ColorResolver: colorResolver, | ||
| 751 | AdwaitaDetector: adwaitaDetector, | 776 | AdwaitaDetector: adwaitaDetector, | ||
| 752 | - | XDG: xdg.New(), | 777 | + | XDG: xdg.New( |
| 753 | - | Engine: engine, | 778 | + | runtimeProfile.Mode == runtimeprofile.ModeDev, |
| 754 | - | FilterManager: filterManager, | 779 | + | bootstrap.ResolveXDGRuntimeDir(runtimeProfile), |
| 755 | - | HistoryRepo: repos.history, | 780 | + | ), |
| 756 | - | FavoriteRepo: repos.favorite, | 781 | + | Engine: engine, |
| 757 | - | ZoomRepo: repos.zoom, | 782 | + | FilterManager: filterManager, |
| 758 | - | PermissionRepo: repos.permission, | 783 | + | HistoryRepo: repos.history, |
| 759 | - | TabsUC: uc.tabs, | 784 | + | FavoriteRepo: repos.favorite, |
| 760 | - | PanesUC: uc.panes, | 785 | + | ZoomRepo: repos.zoom, |
| 761 | - | HistoryUC: uc.history, | 786 | + | PermissionRepo: repos.permission, |
| 762 | - | FavoritesUC: uc.favorites, | 787 | + | TabsUC: uc.tabs, |
| 763 | - | ZoomUC: uc.zoom, | 788 | + | PanesUC: uc.panes, |
| 764 | - | PermissionUC: uc.permission, | 789 | + | HistoryUC: uc.history, |
| 765 | - | NavigateUC: uc.navigate, | 790 | + | FavoritesUC: uc.favorites, |
| 766 | - | CopyURLUC: uc.copyURL, | 791 | + | ZoomUC: uc.zoom, |
| 767 | - | Clipboard: uc.clipboard, | 792 | + | PermissionUC: uc.permission, |
| 768 | - | FaviconService: uc.favicon, | 793 | + | NavigateUC: uc.navigate, |
| 794 | + | CopyURLUC: uc.copyURL, | |||
| 795 | + | Clipboard: uc.clipboard, | |||
| 796 | + | FaviconService: uc.favicon, | |||
| 769 | FaviconAdapterConfig: adapter.FaviconAdapterConfig{ | 797 | FaviconAdapterConfig: adapter.FaviconAdapterConfig{ | ||
| 770 | IsInternalURL: favicon.IsInternalURL, | 798 | IsInternalURL: favicon.IsInternalURL, | ||
| 771 | InternalDomain: favicon.InternalDomain, | 799 | InternalDomain: favicon.InternalDomain, | ||
| @@ -782,7 +810,7 @@ func buildUIDependencies( | |||||
| 782 | }, | 810 | }, | ||
| 783 | CheckUpdateUC: uc.checkUpdate, | 811 | CheckUpdateUC: uc.checkUpdate, | ||
| 784 | ApplyUpdateUC: uc.applyUpdate, | 812 | ApplyUpdateUC: uc.applyUpdate, | ||
| 785 | - | SessionSpawner: bootstrap.NewSessionSpawner(ctx, cfg), | 813 | + | SessionSpawner: bootstrap.NewSessionSpawner(ctx, runtimeProfile), |
| 786 | FileSystem: filesystem.New(), | 814 | FileSystem: filesystem.New(), | ||
| 787 | AccentFocusProvider: focusProvider, | 815 | AccentFocusProvider: focusProvider, | ||
| 788 | NewGTKEntryTarget: func(entry *gtk.SearchEntry) port.TextInputTarget { | 816 | NewGTKEntryTarget: func(entry *gtk.SearchEntry) port.TextInputTarget { | ||
M go.mod +1 -1
| @@ -4,7 +4,7 @@ go 1.26.1 | |||||
| 4 | 4 | ||||
| 5 | require ( | 5 | require ( | ||
| 6 | github.com/bnema/purego v0.11.0-bnema.2 | 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 | github.com/bnema/purego-ffmpeg v0.3.1 | 8 | github.com/bnema/purego-ffmpeg v0.3.1 | ||
| 9 | github.com/bnema/purego-pipewire v0.1.2 | 9 | github.com/bnema/purego-pipewire v0.1.2 | ||
| 10 | github.com/bnema/purego-sqlite v0.1.2 | 10 | github.com/bnema/purego-sqlite v0.1.2 | ||
M go.sum +2 -2 generated file: collapsed
A internal/application/dto/clipboard.go +27 -0
| @@ -0,0 +1,27 @@ | |||||
| 1 | + | package dto | |||
| 2 | + | ||||
| 3 | + | // ClipboardSource identifies the originating engine for clipboard events. | |||
| 4 | + | type ClipboardSource string | |||
| 5 | + | ||||
| 6 | + | const ( | |||
| 7 | + | // ClipboardSourceCEF marks events from the CEF engine. | |||
| 8 | + | ClipboardSourceCEF ClipboardSource = "cef" | |||
| 9 | + | // ClipboardSourceWebKit marks events from the WebKit engine. | |||
| 10 | + | ClipboardSourceWebKit ClipboardSource = "webkit" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | // SelectionClipboardInput carries auto-copy selection updates. | |||
| 14 | + | type SelectionClipboardInput struct { | |||
| 15 | + | Text string | |||
| 16 | + | SourceEngine ClipboardSource | |||
| 17 | + | ViewID uint64 | |||
| 18 | + | } | |||
| 19 | + | ||||
| 20 | + | // ExplicitClipboardInput carries explicit copy requests. | |||
| 21 | + | type ExplicitClipboardInput struct { | |||
| 22 | + | Text string | |||
| 23 | + | SourceEngine ClipboardSource | |||
| 24 | + | ViewID uint64 | |||
| 25 | + | Action string | |||
| 26 | + | NativeHandled bool | |||
| 27 | + | } | |||
A internal/application/port/clipboard_events.go +13 -0
| @@ -0,0 +1,13 @@ | |||||
| 1 | + | package port | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | ||||
| 6 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | // ClipboardTextOrchestrator coordinates shared clipboard behavior. | |||
| 10 | + | type ClipboardTextOrchestrator interface { | |||
| 11 | + | HandleSelectionUpdate(ctx context.Context, input dto.SelectionClipboardInput) error | |||
| 12 | + | HandleExplicitCopy(ctx context.Context, input dto.ExplicitClipboardInput) error | |||
| 13 | + | } | |||
M internal/application/port/context_menu.go +10 -9
| @@ -19,15 +19,16 @@ const ( | |||||
| 19 | 19 | ||||
| 20 | // MenuContext captures the state needed to build and execute a context menu. | 20 | // MenuContext captures the state needed to build and execute a context menu. | ||
| 21 | type MenuContext struct { | 21 | type MenuContext struct { | ||
| 22 | - | PageURI string | 22 | + | PageURI string |
| 23 | - | LinkURI string | 23 | + | LinkURI string |
| 24 | - | ImageURI string | 24 | + | ImageURI string |
| 25 | - | HasSelection bool | 25 | + | SelectionText string |
| 26 | - | IsEditable bool | 26 | + | HasSelection bool |
| 27 | - | CanGoBack bool | 27 | + | IsEditable bool |
| 28 | - | CanGoForward bool | 28 | + | CanGoBack bool |
| 29 | - | X int | 29 | + | CanGoForward bool |
| 30 | - | Y int | 30 | + | X int |
| 31 | + | Y int | |||
| 31 | } | 32 | } | ||
| 32 | 33 | ||||
| 33 | // MenuItem is a normalized context menu entry. | 34 | // MenuItem is a normalized context menu entry. | ||
M internal/application/port/engine.go +6 -5
| @@ -193,11 +193,12 @@ type WebUIMessage struct { | |||||
| 193 | 193 | ||||
| 194 | // HandlerDependencies holds use cases needed by WebUI message handlers. | 194 | // HandlerDependencies holds use cases needed by WebUI message handlers. | ||
| 195 | type HandlerDependencies struct { | 195 | type HandlerDependencies struct { | ||
| 196 | - | HistoryUC HomepageHistory | 196 | + | HistoryUC HomepageHistory |
| 197 | - | FavoritesUC HomepageFavorites | 197 | + | FavoritesUC HomepageFavorites |
| 198 | - | Clipboard Clipboard | 198 | + | Clipboard Clipboard |
| 199 | - | AutoCopyConfig AutoCopyConfig | 199 | + | AutoCopyConfig AutoCopyConfig |
| 200 | - | OnClipboardCopied func(textLen int) | 200 | + | ClipboardTextOrchestrator ClipboardTextOrchestrator |
| 201 | + | OnClipboardCopied func(textLen int) | |||
| 201 | HandlerDeps | 202 | HandlerDeps | ||
| 202 | } | 203 | } | ||
| 203 | 204 | ||||
M internal/application/port/mocks/mocks.go +142 -0
| @@ -11,6 +11,7 @@ import ( | |||||
| 11 | "io" | 11 | "io" | ||
| 12 | "time" | 12 | "time" | ||
| 13 | 13 | ||||
| 14 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 14 | "github.com/bnema/dumber/internal/application/port" | 15 | "github.com/bnema/dumber/internal/application/port" | ||
| 15 | "github.com/bnema/dumber/internal/domain/entity" | 16 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 16 | "github.com/rs/zerolog" | 17 | "github.com/rs/zerolog" | ||
| @@ -730,6 +731,147 @@ func (_c *MockClipboard_WriteText_Call) RunAndReturn(run func(ctx context.Contex | |||||
| 730 | return _c | 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 | // NewMockColorSchemeDetector creates a new instance of MockColorSchemeDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | 875 | // NewMockColorSchemeDetector creates a new instance of MockColorSchemeDetector. It also registers a testing interface on the mock and a cleanup function to assert the mocks expectations. | ||
| 734 | // The first argument is typically a *testing.T value. | 876 | // The first argument is typically a *testing.T value. | ||
| 735 | func NewMockColorSchemeDetector(t interface { | 877 | func NewMockColorSchemeDetector(t interface { | ||
A internal/application/usecase/clipboard_text_orchestrator.go +190 -0
| @@ -0,0 +1,190 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "fmt" | |||
| 6 | + | "strings" | |||
| 7 | + | "sync" | |||
| 8 | + | "time" | |||
| 9 | + | "unicode/utf8" | |||
| 10 | + | ||||
| 11 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 12 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 13 | + | "github.com/bnema/dumber/internal/logging" | |||
| 14 | + | ) | |||
| 15 | + | ||||
| 16 | + | const explicitClipboardDedupWindow = 250 * time.Millisecond | |||
| 17 | + | ||||
| 18 | + | // ClipboardTextOrchestratorUseCase applies shared clipboard business rules. | |||
| 19 | + | type ClipboardTextOrchestratorUseCase struct { | |||
| 20 | + | clipboard port.Clipboard | |||
| 21 | + | autoCopyConfig port.AutoCopyConfig | |||
| 22 | + | toast func(textLen int) | |||
| 23 | + | mu sync.Mutex | |||
| 24 | + | lastSelection map[clipboardScope]string | |||
| 25 | + | lastExplicit map[clipboardScope]explicitCopyState | |||
| 26 | + | now func() time.Time | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | type clipboardScope struct { | |||
| 30 | + | source dto.ClipboardSource | |||
| 31 | + | viewID uint64 | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | type explicitCopyState struct { | |||
| 35 | + | text string | |||
| 36 | + | action string | |||
| 37 | + | at time.Time | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | var _ port.ClipboardTextOrchestrator = (*ClipboardTextOrchestratorUseCase)(nil) | |||
| 41 | + | ||||
| 42 | + | // NewClipboardTextOrchestrator creates a shared clipboard orchestrator. | |||
| 43 | + | func NewClipboardTextOrchestrator( | |||
| 44 | + | clipboard port.Clipboard, | |||
| 45 | + | autoCopyConfig port.AutoCopyConfig, | |||
| 46 | + | toast func(textLen int), | |||
| 47 | + | ) *ClipboardTextOrchestratorUseCase { | |||
| 48 | + | return &ClipboardTextOrchestratorUseCase{ | |||
| 49 | + | clipboard: clipboard, | |||
| 50 | + | autoCopyConfig: autoCopyConfig, | |||
| 51 | + | toast: toast, | |||
| 52 | + | lastSelection: make(map[clipboardScope]string), | |||
| 53 | + | lastExplicit: make(map[clipboardScope]explicitCopyState), | |||
| 54 | + | now: time.Now, | |||
| 55 | + | } | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | // HandleSelectionUpdate applies auto-copy selection rules. | |||
| 59 | + | func (uc *ClipboardTextOrchestratorUseCase) HandleSelectionUpdate( | |||
| 60 | + | ctx context.Context, | |||
| 61 | + | input dto.SelectionClipboardInput, | |||
| 62 | + | ) error { | |||
| 63 | + | if uc == nil || uc.autoCopyConfig == nil || !uc.autoCopyConfig.IsAutoCopyEnabled() { | |||
| 64 | + | if uc != nil { | |||
| 65 | + | uc.mu.Lock() | |||
| 66 | + | delete(uc.lastSelection, selectionScope(input)) | |||
| 67 | + | uc.mu.Unlock() | |||
| 68 | + | } | |||
| 69 | + | return nil | |||
| 70 | + | } | |||
| 71 | + | ||||
| 72 | + | if input.Text == "" { | |||
| 73 | + | uc.mu.Lock() | |||
| 74 | + | delete(uc.lastSelection, selectionScope(input)) | |||
| 75 | + | uc.mu.Unlock() | |||
| 76 | + | return nil | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | if utf8.RuneCountInString(strings.TrimSpace(input.Text)) < 2 { | |||
| 80 | + | return nil | |||
| 81 | + | } | |||
| 82 | + | textLen := utf8.RuneCountInString(input.Text) | |||
| 83 | + | scope := selectionScope(input) | |||
| 84 | + | currentTime := uc.currentTime() | |||
| 85 | + | ||||
| 86 | + | uc.mu.Lock() | |||
| 87 | + | if input.Text == uc.lastSelection[scope] { | |||
| 88 | + | uc.mu.Unlock() | |||
| 89 | + | return nil | |||
| 90 | + | } | |||
| 91 | + | if uc.clipboard == nil { | |||
| 92 | + | uc.mu.Unlock() | |||
| 93 | + | return fmt.Errorf("clipboard not available") | |||
| 94 | + | } | |||
| 95 | + | selectionState := input.Text | |||
| 96 | + | explicitInput := dto.ExplicitClipboardInput{SourceEngine: input.SourceEngine, ViewID: input.ViewID, Action: "copy"} | |||
| 97 | + | explicitState := explicitCopyState{text: input.Text, action: explicitInput.Action, at: currentTime} | |||
| 98 | + | uc.lastSelection[scope] = selectionState | |||
| 99 | + | uc.lastExplicit[explicitScope(explicitInput)] = explicitState | |||
| 100 | + | uc.mu.Unlock() | |||
| 101 | + | ||||
| 102 | + | if err := uc.clipboard.WriteText(ctx, input.Text); err != nil { | |||
| 103 | + | uc.mu.Lock() | |||
| 104 | + | if uc.lastSelection[scope] == selectionState { | |||
| 105 | + | delete(uc.lastSelection, scope) | |||
| 106 | + | } | |||
| 107 | + | if state, ok := uc.lastExplicit[explicitScope(explicitInput)]; ok && state == explicitState { | |||
| 108 | + | delete(uc.lastExplicit, explicitScope(explicitInput)) | |||
| 109 | + | } | |||
| 110 | + | uc.mu.Unlock() | |||
| 111 | + | return fmt.Errorf("clipboard write failed: %w", err) | |||
| 112 | + | } | |||
| 113 | + | ||||
| 114 | + | if uc.toast != nil { | |||
| 115 | + | uc.toast(textLen) | |||
| 116 | + | } | |||
| 117 | + | logging.FromContext(ctx).Debug(). | |||
| 118 | + | Int("text_len", textLen). | |||
| 119 | + | Str("source_engine", string(input.SourceEngine)). | |||
| 120 | + | Msg("clipboard selection copied") | |||
| 121 | + | return nil | |||
| 122 | + | } | |||
| 123 | + | ||||
| 124 | + | // HandleExplicitCopy applies explicit copy rules. | |||
| 125 | + | func (uc *ClipboardTextOrchestratorUseCase) HandleExplicitCopy( | |||
| 126 | + | ctx context.Context, | |||
| 127 | + | input dto.ExplicitClipboardInput, | |||
| 128 | + | ) error { | |||
| 129 | + | if uc == nil || input.Text == "" { | |||
| 130 | + | return nil | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | currentTime := uc.currentTime() | |||
| 134 | + | textLen := utf8.RuneCountInString(input.Text) | |||
| 135 | + | scope := explicitScope(input) | |||
| 136 | + | ||||
| 137 | + | state := explicitCopyState{text: input.Text, action: input.Action, at: currentTime} | |||
| 138 | + | ||||
| 139 | + | uc.mu.Lock() | |||
| 140 | + | if previous, ok := uc.lastExplicit[scope]; ok && | |||
| 141 | + | input.Text == previous.text && | |||
| 142 | + | input.Action == previous.action && | |||
| 143 | + | currentTime.Sub(previous.at) < explicitClipboardDedupWindow { | |||
| 144 | + | uc.mu.Unlock() | |||
| 145 | + | return nil | |||
| 146 | + | } | |||
| 147 | + | if !input.NativeHandled && uc.clipboard == nil { | |||
| 148 | + | uc.mu.Unlock() | |||
| 149 | + | return fmt.Errorf("clipboard not available") | |||
| 150 | + | } | |||
| 151 | + | uc.lastExplicit[scope] = state | |||
| 152 | + | uc.mu.Unlock() | |||
| 153 | + | ||||
| 154 | + | if !input.NativeHandled { | |||
| 155 | + | if err := uc.clipboard.WriteText(ctx, input.Text); err != nil { | |||
| 156 | + | uc.mu.Lock() | |||
| 157 | + | if current, ok := uc.lastExplicit[scope]; ok && current == state { | |||
| 158 | + | delete(uc.lastExplicit, scope) | |||
| 159 | + | } | |||
| 160 | + | uc.mu.Unlock() | |||
| 161 | + | return fmt.Errorf("clipboard write failed: %w", err) | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | if uc.toast != nil { | |||
| 166 | + | uc.toast(textLen) | |||
| 167 | + | } | |||
| 168 | + | logging.FromContext(ctx).Debug(). | |||
| 169 | + | Int("text_len", textLen). | |||
| 170 | + | Str("source_engine", string(input.SourceEngine)). | |||
| 171 | + | Str("action", input.Action). | |||
| 172 | + | Bool("native_handled", input.NativeHandled). | |||
| 173 | + | Msg("clipboard explicit copied") | |||
| 174 | + | return nil | |||
| 175 | + | } | |||
| 176 | + | ||||
| 177 | + | func selectionScope(input dto.SelectionClipboardInput) clipboardScope { | |||
| 178 | + | return clipboardScope{source: input.SourceEngine, viewID: input.ViewID} | |||
| 179 | + | } | |||
| 180 | + | ||||
| 181 | + | func explicitScope(input dto.ExplicitClipboardInput) clipboardScope { | |||
| 182 | + | return clipboardScope{source: input.SourceEngine, viewID: input.ViewID} | |||
| 183 | + | } | |||
| 184 | + | ||||
| 185 | + | func (uc *ClipboardTextOrchestratorUseCase) currentTime() time.Time { | |||
| 186 | + | if uc == nil || uc.now == nil { | |||
| 187 | + | return time.Now() | |||
| 188 | + | } | |||
| 189 | + | return uc.now() | |||
| 190 | + | } | |||
A internal/application/usecase/clipboard_text_orchestrator_test.go +473 -0
| @@ -0,0 +1,473 @@ | |||||
| 1 | + | package usecase | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "errors" | |||
| 6 | + | "sync" | |||
| 7 | + | "testing" | |||
| 8 | + | "time" | |||
| 9 | + | ||||
| 10 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 11 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 12 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | type blockingClipboard struct { | |||
| 16 | + | started chan struct{} | |||
| 17 | + | release chan struct{} | |||
| 18 | + | mu sync.Mutex | |||
| 19 | + | writes []string | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | type countingClipboard struct { | |||
| 23 | + | mu sync.Mutex | |||
| 24 | + | writes int | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | type staticAutoCopyConfig struct { | |||
| 28 | + | enabled bool | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func (s *staticAutoCopyConfig) IsAutoCopyEnabled() bool { return s.enabled } | |||
| 32 | + | ||||
| 33 | + | func newBlockingClipboard() *blockingClipboard { | |||
| 34 | + | return &blockingClipboard{ | |||
| 35 | + | started: make(chan struct{}, 2), | |||
| 36 | + | release: make(chan struct{}), | |||
| 37 | + | } | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func (b *blockingClipboard) WriteText(_ context.Context, text string) error { | |||
| 41 | + | b.mu.Lock() | |||
| 42 | + | b.writes = append(b.writes, text) | |||
| 43 | + | b.mu.Unlock() | |||
| 44 | + | b.started <- struct{}{} | |||
| 45 | + | <-b.release | |||
| 46 | + | return nil | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func (*blockingClipboard) WriteImage(context.Context, entity.ImageData) error { return nil } | |||
| 50 | + | func (*blockingClipboard) ReadText(context.Context) (string, error) { return "", nil } | |||
| 51 | + | func (*blockingClipboard) Clear(context.Context) error { return nil } | |||
| 52 | + | func (*blockingClipboard) HasText(context.Context) (bool, error) { return false, nil } | |||
| 53 | + | ||||
| 54 | + | func (b *blockingClipboard) writeCount() int { | |||
| 55 | + | b.mu.Lock() | |||
| 56 | + | defer b.mu.Unlock() | |||
| 57 | + | return len(b.writes) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func (c *countingClipboard) WriteText(_ context.Context, _ string) error { | |||
| 61 | + | c.mu.Lock() | |||
| 62 | + | c.writes++ | |||
| 63 | + | c.mu.Unlock() | |||
| 64 | + | return nil | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func (*countingClipboard) WriteImage(context.Context, entity.ImageData) error { return nil } | |||
| 68 | + | func (*countingClipboard) ReadText(context.Context) (string, error) { return "", nil } | |||
| 69 | + | func (*countingClipboard) Clear(context.Context) error { return nil } | |||
| 70 | + | func (*countingClipboard) HasText(context.Context) (bool, error) { return false, nil } | |||
| 71 | + | ||||
| 72 | + | func (c *countingClipboard) writeCount() int { | |||
| 73 | + | c.mu.Lock() | |||
| 74 | + | defer c.mu.Unlock() | |||
| 75 | + | return c.writes | |||
| 76 | + | } | |||
| 77 | + | ||||
| 78 | + | func TestClipboardTextOrchestrator_HandleSelectionUpdate_AutoCopyDisabledDoesNothing(t *testing.T) { | |||
| 79 | + | ctx := context.Background() | |||
| 80 | + | clipboard := portmocks.NewMockClipboard(t) | |||
| 81 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: false} | |||
| 82 | + | var toastCalls []int | |||
| 83 | + | ||||
| 84 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |||
| 85 | + | toastCalls = append(toastCalls, textLen) | |||
| 86 | + | }) | |||
| 87 | + | ||||
| 88 | + | if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "hello", SourceEngine: dto.ClipboardSourceCEF}); err != nil { | |||
| 89 | + | t.Fatalf("HandleSelectionUpdate() error = %v, want nil", err) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | if len(toastCalls) != 0 { | |||
| 93 | + | t.Fatalf("toast calls = %v, want none", toastCalls) | |||
| 94 | + | } | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestClipboardTextOrchestrator_HandleSelectionUpdate_WritesOncePerUniqueSelectionAndResetsAfterEmptySelection(t *testing.T) { | |||
| 98 | + | ctx := context.Background() | |||
| 99 | + | clipboard := portmocks.NewMockClipboard(t) | |||
| 100 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |||
| 101 | + | var toastCalls []int | |||
| 102 | + | ||||
| 103 | + | clipboard.EXPECT().WriteText(ctx, "éé").Return(nil).Times(2) | |||
| 104 | + | ||||
| 105 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |||
| 106 | + | toastCalls = append(toastCalls, textLen) | |||
| 107 | + | }) | |||
| 108 | + | ||||
| 109 | + | if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "é", SourceEngine: dto.ClipboardSourceCEF}); err != nil { | |||
| 110 | + | t.Fatalf("HandleSelectionUpdate(short) error = %v, want nil", err) | |||
| 111 | + | } | |||
| 112 | + | if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF}); err != nil { | |||
| 113 | + | t.Fatalf("HandleSelectionUpdate(first) error = %v, want nil", err) | |||
| 114 | + | } | |||
| 115 | + | if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF}); err != nil { | |||
| 116 | + | t.Fatalf("HandleSelectionUpdate(duplicate) error = %v, want nil", err) | |||
| 117 | + | } | |||
| 118 | + | if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "", SourceEngine: dto.ClipboardSourceCEF}); err != nil { | |||
| 119 | + | t.Fatalf("HandleSelectionUpdate(reset) error = %v, want nil", err) | |||
| 120 | + | } | |||
| 121 | + | if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF}); err != nil { | |||
| 122 | + | t.Fatalf("HandleSelectionUpdate(after reset) error = %v, want nil", err) | |||
| 123 | + | } | |||
| 124 | + | ||||
| 125 | + | if len(toastCalls) != 2 || toastCalls[0] != 2 || toastCalls[1] != 2 { | |||
| 126 | + | t.Fatalf("toast calls = %v, want [2 2]", toastCalls) | |||
| 127 | + | } | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | func TestClipboardTextOrchestrator_HandleSelectionUpdate_DoesNotDedupAcrossEngines(t *testing.T) { | |||
| 131 | + | ctx := context.Background() | |||
| 132 | + | clipboard := portmocks.NewMockClipboard(t) | |||
| 133 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |||
| 134 | + | ||||
| 135 | + | clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2) | |||
| 136 | + | ||||
| 137 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | |||
| 138 | + | ||||
| 139 | + | if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceWebKit}); err != nil { | |||
| 140 | + | t.Fatalf("HandleSelectionUpdate(webkit) error = %v, want nil", err) | |||
| 141 | + | } | |||
| 142 | + | if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF}); err != nil { | |||
| 143 | + | t.Fatalf("HandleSelectionUpdate(cef) error = %v, want nil", err) | |||
| 144 | + | } | |||
| 145 | + | } | |||
| 146 | + | ||||
| 147 | + | func TestClipboardTextOrchestrator_HandleSelectionUpdate_DoesNotDedupAcrossViews(t *testing.T) { | |||
| 148 | + | ctx := context.Background() | |||
| 149 | + | clipboard := portmocks.NewMockClipboard(t) | |||
| 150 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |||
| 151 | + | ||||
| 152 | + | clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2) | |||
| 153 | + | ||||
| 154 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | |||
| 155 | + | ||||
| 156 | + | if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil { | |||
| 157 | + | t.Fatalf("HandleSelectionUpdate(view 1) error = %v, want nil", err) | |||
| 158 | + | } | |||
| 159 | + | if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 2}); err != nil { | |||
| 160 | + | t.Fatalf("HandleSelectionUpdate(view 2) error = %v, want nil", err) | |||
| 161 | + | } | |||
| 162 | + | } | |||
| 163 | + | ||||
| 164 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_DeduplicatesIdenticalPayloadsWithinWindow(t *testing.T) { | |||
| 165 | + | ctx := context.Background() | |||
| 166 | + | clipboard := portmocks.NewMockClipboard(t) | |||
| 167 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |||
| 168 | + | var toastCalls []int | |||
| 169 | + | ||||
| 170 | + | clipboard.EXPECT().WriteText(ctx, "éé").Return(nil).Times(2) | |||
| 171 | + | ||||
| 172 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |||
| 173 | + | toastCalls = append(toastCalls, textLen) | |||
| 174 | + | }) | |||
| 175 | + | ||||
| 176 | + | now := time.Unix(0, 0) | |||
| 177 | + | uc.now = func() time.Time { return now } | |||
| 178 | + | ||||
| 179 | + | if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"}); err != nil { | |||
| 180 | + | t.Fatalf("HandleExplicitCopy(first) error = %v, want nil", err) | |||
| 181 | + | } | |||
| 182 | + | now = now.Add(100 * time.Millisecond) | |||
| 183 | + | if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"}); err != nil { | |||
| 184 | + | t.Fatalf("HandleExplicitCopy(duplicate) error = %v, want nil", err) | |||
| 185 | + | } | |||
| 186 | + | now = now.Add(151 * time.Millisecond) | |||
| 187 | + | if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"}); err != nil { | |||
| 188 | + | t.Fatalf("HandleExplicitCopy(after window) error = %v, want nil", err) | |||
| 189 | + | } | |||
| 190 | + | ||||
| 191 | + | if len(toastCalls) != 2 || toastCalls[0] != 2 || toastCalls[1] != 2 { | |||
| 192 | + | t.Fatalf("toast calls = %v, want [2 2]", toastCalls) | |||
| 193 | + | } | |||
| 194 | + | } | |||
| 195 | + | ||||
| 196 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_NativeHandledSkipsBackendWrite(t *testing.T) { | |||
| 197 | + | ctx := context.Background() | |||
| 198 | + | clipboard := &countingClipboard{} | |||
| 199 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |||
| 200 | + | var toastCalls []int | |||
| 201 | + | ||||
| 202 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |||
| 203 | + | toastCalls = append(toastCalls, textLen) | |||
| 204 | + | }) | |||
| 205 | + | ||||
| 206 | + | if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy", NativeHandled: true}); err != nil { | |||
| 207 | + | t.Fatalf("HandleExplicitCopy() error = %v, want nil", err) | |||
| 208 | + | } | |||
| 209 | + | ||||
| 210 | + | if got := clipboard.writeCount(); got != 0 { | |||
| 211 | + | t.Fatalf("clipboard writes = %d, want 0", got) | |||
| 212 | + | } | |||
| 213 | + | if len(toastCalls) != 1 || toastCalls[0] != 6 { | |||
| 214 | + | t.Fatalf("toast calls = %v, want [6]", toastCalls) | |||
| 215 | + | } | |||
| 216 | + | if got := len(uc.lastExplicit); got != 1 { | |||
| 217 | + | t.Fatalf("lastExplicit entries = %d, want 1", got) | |||
| 218 | + | } | |||
| 219 | + | } | |||
| 220 | + | ||||
| 221 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_DoesNotDedupDifferentActionsWithinWindow(t *testing.T) { | |||
| 222 | + | ctx := context.Background() | |||
| 223 | + | clipboard := portmocks.NewMockClipboard(t) | |||
| 224 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |||
| 225 | + | ||||
| 226 | + | clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2) | |||
| 227 | + | ||||
| 228 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | |||
| 229 | + | now := time.Unix(0, 0) | |||
| 230 | + | uc.now = func() time.Time { return now } | |||
| 231 | + | ||||
| 232 | + | if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1, Action: "copy"}); err != nil { | |||
| 233 | + | t.Fatalf("HandleExplicitCopy(copy) error = %v, want nil", err) | |||
| 234 | + | } | |||
| 235 | + | now = now.Add(100 * time.Millisecond) | |||
| 236 | + | if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1, Action: "cut"}); err != nil { | |||
| 237 | + | t.Fatalf("HandleExplicitCopy(cut) error = %v, want nil", err) | |||
| 238 | + | } | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_DeduplicatesRecentAutoCopySelection(t *testing.T) { | |||
| 242 | + | ctx := context.Background() | |||
| 243 | + | clipboard := portmocks.NewMockClipboard(t) | |||
| 244 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |||
| 245 | + | var toastCalls []int | |||
| 246 | + | ||||
| 247 | + | clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Once() | |||
| 248 | + | ||||
| 249 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |||
| 250 | + | toastCalls = append(toastCalls, textLen) | |||
| 251 | + | }) | |||
| 252 | + | ||||
| 253 | + | now := time.Unix(0, 0) | |||
| 254 | + | uc.now = func() time.Time { return now } | |||
| 255 | + | ||||
| 256 | + | if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil { | |||
| 257 | + | t.Fatalf("HandleSelectionUpdate() error = %v, want nil", err) | |||
| 258 | + | } | |||
| 259 | + | now = now.Add(100 * time.Millisecond) | |||
| 260 | + | if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1, Action: "copy"}); err != nil { | |||
| 261 | + | t.Fatalf("HandleExplicitCopy() error = %v, want nil", err) | |||
| 262 | + | } | |||
| 263 | + | ||||
| 264 | + | if len(toastCalls) != 1 || toastCalls[0] != 6 { | |||
| 265 | + | t.Fatalf("toast calls = %v, want [6]", toastCalls) | |||
| 266 | + | } | |||
| 267 | + | if got := len(uc.lastExplicit); got != 1 { | |||
| 268 | + | t.Fatalf("lastExplicit entries = %d, want 1", got) | |||
| 269 | + | } | |||
| 270 | + | } | |||
| 271 | + | ||||
| 272 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_DoesNotDedupAcrossEngines(t *testing.T) { | |||
| 273 | + | ctx := context.Background() | |||
| 274 | + | clipboard := portmocks.NewMockClipboard(t) | |||
| 275 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |||
| 276 | + | ||||
| 277 | + | clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2) | |||
| 278 | + | ||||
| 279 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | |||
| 280 | + | uc.now = func() time.Time { return time.Unix(0, 0) } | |||
| 281 | + | ||||
| 282 | + | if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"}); err != nil { | |||
| 283 | + | t.Fatalf("HandleExplicitCopy(webkit) error = %v, want nil", err) | |||
| 284 | + | } | |||
| 285 | + | if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, Action: "copy"}); err != nil { | |||
| 286 | + | t.Fatalf("HandleExplicitCopy(cef) error = %v, want nil", err) | |||
| 287 | + | } | |||
| 288 | + | } | |||
| 289 | + | ||||
| 290 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_DoesNotDedupAcrossViews(t *testing.T) { | |||
| 291 | + | ctx := context.Background() | |||
| 292 | + | clipboard := portmocks.NewMockClipboard(t) | |||
| 293 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |||
| 294 | + | ||||
| 295 | + | clipboard.EXPECT().WriteText(ctx, "shared").Return(nil).Times(2) | |||
| 296 | + | ||||
| 297 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | |||
| 298 | + | uc.now = func() time.Time { return time.Unix(0, 0) } | |||
| 299 | + | ||||
| 300 | + | if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1, Action: "copy"}); err != nil { | |||
| 301 | + | t.Fatalf("HandleExplicitCopy(view 1) error = %v, want nil", err) | |||
| 302 | + | } | |||
| 303 | + | if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "shared", SourceEngine: dto.ClipboardSourceCEF, ViewID: 2, Action: "copy"}); err != nil { | |||
| 304 | + | t.Fatalf("HandleExplicitCopy(view 2) error = %v, want nil", err) | |||
| 305 | + | } | |||
| 306 | + | } | |||
| 307 | + | ||||
| 308 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_KeepsBoundedStatePerScope(t *testing.T) { | |||
| 309 | + | ctx := context.Background() | |||
| 310 | + | clipboard := portmocks.NewMockClipboard(t) | |||
| 311 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |||
| 312 | + | ||||
| 313 | + | clipboard.EXPECT().WriteText(ctx, "one").Return(nil).Once() | |||
| 314 | + | clipboard.EXPECT().WriteText(ctx, "two").Return(nil).Once() | |||
| 315 | + | clipboard.EXPECT().WriteText(ctx, "three").Return(nil).Once() | |||
| 316 | + | ||||
| 317 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | |||
| 318 | + | ||||
| 319 | + | for i, text := range []string{"one", "two", "three"} { | |||
| 320 | + | advancedTime := time.Unix(0, 0).Add(time.Duration(i) * time.Second) | |||
| 321 | + | uc.now = func() time.Time { return advancedTime } | |||
| 322 | + | if err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: text, SourceEngine: dto.ClipboardSourceCEF, ViewID: 7, Action: "copy"}); err != nil { | |||
| 323 | + | t.Fatalf("HandleExplicitCopy(%q) error = %v, want nil", text, err) | |||
| 324 | + | } | |||
| 325 | + | } | |||
| 326 | + | ||||
| 327 | + | if got := len(uc.lastExplicit); got != 1 { | |||
| 328 | + | t.Fatalf("lastExplicit entries = %d, want 1", got) | |||
| 329 | + | } | |||
| 330 | + | } | |||
| 331 | + | ||||
| 332 | + | func TestClipboardTextOrchestrator_HandleSelectionUpdate_ReenableClearsDedupState(t *testing.T) { | |||
| 333 | + | ctx := context.Background() | |||
| 334 | + | clipboard := portmocks.NewMockClipboard(t) | |||
| 335 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |||
| 336 | + | ||||
| 337 | + | clipboard.EXPECT().WriteText(ctx, "toggle").Return(nil).Times(2) | |||
| 338 | + | ||||
| 339 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, nil) | |||
| 340 | + | ||||
| 341 | + | if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "toggle", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil { | |||
| 342 | + | t.Fatalf("HandleSelectionUpdate(enabled) error = %v, want nil", err) | |||
| 343 | + | } | |||
| 344 | + | autoCopyConfig.enabled = false | |||
| 345 | + | if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "toggle", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil { | |||
| 346 | + | t.Fatalf("HandleSelectionUpdate(disabled) error = %v, want nil", err) | |||
| 347 | + | } | |||
| 348 | + | autoCopyConfig.enabled = true | |||
| 349 | + | if err := uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "toggle", SourceEngine: dto.ClipboardSourceCEF, ViewID: 1}); err != nil { | |||
| 350 | + | t.Fatalf("HandleSelectionUpdate(reenabled) error = %v, want nil", err) | |||
| 351 | + | } | |||
| 352 | + | } | |||
| 353 | + | ||||
| 354 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_ClipboardFailureDoesNotToast(t *testing.T) { | |||
| 355 | + | ctx := context.Background() | |||
| 356 | + | clipboard := portmocks.NewMockClipboard(t) | |||
| 357 | + | clipboard.EXPECT().WriteText(ctx, "éé").Return(errors.New("clipboard failed")).Once() | |||
| 358 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |||
| 359 | + | ||||
| 360 | + | var toastCalls []int | |||
| 361 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |||
| 362 | + | toastCalls = append(toastCalls, textLen) | |||
| 363 | + | }) | |||
| 364 | + | ||||
| 365 | + | err := uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF, Action: "copy"}) | |||
| 366 | + | if err == nil { | |||
| 367 | + | t.Fatal("HandleExplicitCopy() error = nil, want non-nil") | |||
| 368 | + | } | |||
| 369 | + | if len(toastCalls) != 0 { | |||
| 370 | + | t.Fatalf("toast calls = %v, want none", toastCalls) | |||
| 371 | + | } | |||
| 372 | + | } | |||
| 373 | + | ||||
| 374 | + | func TestClipboardTextOrchestrator_HandleSelectionUpdate_DedupIsAtomic(t *testing.T) { | |||
| 375 | + | ctx := context.Background() | |||
| 376 | + | clipboard := newBlockingClipboard() | |||
| 377 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |||
| 378 | + | var ( | |||
| 379 | + | toastCalls []int | |||
| 380 | + | toastMu sync.Mutex | |||
| 381 | + | ) | |||
| 382 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |||
| 383 | + | toastMu.Lock() | |||
| 384 | + | defer toastMu.Unlock() | |||
| 385 | + | toastCalls = append(toastCalls, textLen) | |||
| 386 | + | }) | |||
| 387 | + | ||||
| 388 | + | done1 := make(chan error, 1) | |||
| 389 | + | go func() { | |||
| 390 | + | done1 <- uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF}) | |||
| 391 | + | }() | |||
| 392 | + | <-clipboard.started | |||
| 393 | + | ||||
| 394 | + | done2 := make(chan error, 1) | |||
| 395 | + | go func() { | |||
| 396 | + | done2 <- uc.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceCEF}) | |||
| 397 | + | }() | |||
| 398 | + | ||||
| 399 | + | select { | |||
| 400 | + | case <-clipboard.started: | |||
| 401 | + | t.Fatal("second selection write started before first completed") | |||
| 402 | + | default: | |||
| 403 | + | } | |||
| 404 | + | ||||
| 405 | + | close(clipboard.release) | |||
| 406 | + | ||||
| 407 | + | if err := <-done1; err != nil { | |||
| 408 | + | t.Fatalf("first HandleSelectionUpdate() error = %v, want nil", err) | |||
| 409 | + | } | |||
| 410 | + | if err := <-done2; err != nil { | |||
| 411 | + | t.Fatalf("second HandleSelectionUpdate() error = %v, want nil", err) | |||
| 412 | + | } | |||
| 413 | + | ||||
| 414 | + | if got := clipboard.writeCount(); got != 1 { | |||
| 415 | + | t.Fatalf("clipboard writes = %d, want 1", got) | |||
| 416 | + | } | |||
| 417 | + | toastMu.Lock() | |||
| 418 | + | defer toastMu.Unlock() | |||
| 419 | + | if len(toastCalls) != 1 || toastCalls[0] != 2 { | |||
| 420 | + | t.Fatalf("toast calls = %v, want [2]", toastCalls) | |||
| 421 | + | } | |||
| 422 | + | } | |||
| 423 | + | ||||
| 424 | + | func TestClipboardTextOrchestrator_HandleExplicitCopy_DedupIsAtomic(t *testing.T) { | |||
| 425 | + | ctx := context.Background() | |||
| 426 | + | clipboard := newBlockingClipboard() | |||
| 427 | + | autoCopyConfig := &staticAutoCopyConfig{enabled: true} | |||
| 428 | + | var ( | |||
| 429 | + | toastCalls []int | |||
| 430 | + | toastMu sync.Mutex | |||
| 431 | + | ) | |||
| 432 | + | uc := NewClipboardTextOrchestrator(clipboard, autoCopyConfig, func(textLen int) { | |||
| 433 | + | toastMu.Lock() | |||
| 434 | + | defer toastMu.Unlock() | |||
| 435 | + | toastCalls = append(toastCalls, textLen) | |||
| 436 | + | }) | |||
| 437 | + | uc.now = func() time.Time { return time.Unix(0, 0) } | |||
| 438 | + | ||||
| 439 | + | done1 := make(chan error, 1) | |||
| 440 | + | go func() { | |||
| 441 | + | done1 <- uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"}) | |||
| 442 | + | }() | |||
| 443 | + | <-clipboard.started | |||
| 444 | + | ||||
| 445 | + | done2 := make(chan error, 1) | |||
| 446 | + | go func() { | |||
| 447 | + | done2 <- uc.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{Text: "éé", SourceEngine: dto.ClipboardSourceWebKit, Action: "copy"}) | |||
| 448 | + | }() | |||
| 449 | + | ||||
| 450 | + | select { | |||
| 451 | + | case <-clipboard.started: | |||
| 452 | + | t.Fatal("second explicit write started before first completed") | |||
| 453 | + | default: | |||
| 454 | + | } | |||
| 455 | + | ||||
| 456 | + | close(clipboard.release) | |||
| 457 | + | ||||
| 458 | + | if err := <-done1; err != nil { | |||
| 459 | + | t.Fatalf("first HandleExplicitCopy() error = %v, want nil", err) | |||
| 460 | + | } | |||
| 461 | + | if err := <-done2; err != nil { | |||
| 462 | + | t.Fatalf("second HandleExplicitCopy() error = %v, want nil", err) | |||
| 463 | + | } | |||
| 464 | + | ||||
| 465 | + | if got := clipboard.writeCount(); got != 1 { | |||
| 466 | + | t.Fatalf("clipboard writes = %d, want 1", got) | |||
| 467 | + | } | |||
| 468 | + | toastMu.Lock() | |||
| 469 | + | defer toastMu.Unlock() | |||
| 470 | + | if len(toastCalls) != 1 || toastCalls[0] != 2 { | |||
| 471 | + | t.Fatalf("toast calls = %v, want [2]", toastCalls) | |||
| 472 | + | } | |||
| 473 | + | } | |||
M internal/application/usecase/execute_context_menu_action.go +18 -8
| @@ -105,16 +105,26 @@ func (uc *ExecuteContextMenuActionUseCase) executeMenuAction( | |||||
| 105 | return fmt.Errorf("save image: %w", err) | 105 | return fmt.Errorf("save image: %w", err) | ||
| 106 | } | 106 | } | ||
| 107 | return nil | 107 | return nil | ||
| 108 | - | default: | 108 | + | case port.MenuActionCopySelection: |
| 109 | - | // All other normalized actions are delegated to the engine/UI layer. | 109 | + | // When selection text is present and clipboard wiring is available, copy it here. |
| 110 | - | if uc.delegator == nil { | 110 | + | // Otherwise intentionally fall through to DelegateMenuAction so native browser copy |
| 111 | - | return fmt.Errorf("delegate action %s: menu action delegator not available", action) | 111 | + | // behavior can handle document selections that are only accessible inside the engine. |
| 112 | + | if menuContext.SelectionText != "" && uc.clipboard != nil { | |||
| 113 | + | if err := uc.clipboard.WriteText(ctx, menuContext.SelectionText); err != nil { | |||
| 114 | + | return fmt.Errorf("copy selection: %w", err) | |||
| 115 | + | } | |||
| 116 | + | return nil | |||
| 112 | } | 117 | } | ||
| 113 | - | if err := uc.delegator.DelegateMenuAction(ctx, action, menuContext); err != nil { | |||
| 114 | - | return fmt.Errorf("delegate action %s: %w", action, err) | |||
| 115 | - | } | |||
| 116 | - | return nil | |||
| 117 | } | 118 | } | ||
| 119 | + | ||||
| 120 | + | // All other normalized actions are delegated to the engine/UI layer. | |||
| 121 | + | if uc.delegator == nil { | |||
| 122 | + | return fmt.Errorf("delegate action %s: menu action delegator not available", action) | |||
| 123 | + | } | |||
| 124 | + | if err := uc.delegator.DelegateMenuAction(ctx, action, menuContext); err != nil { | |||
| 125 | + | return fmt.Errorf("delegate action %s: %w", action, err) | |||
| 126 | + | } | |||
| 127 | + | return nil | |||
| 118 | } | 128 | } | ||
| 119 | 129 | ||||
| 120 | func (uc *ExecuteContextMenuActionUseCase) resolveImageData(ctx context.Context, uri string) (entity.ImageData, error) { | 130 | func (uc *ExecuteContextMenuActionUseCase) resolveImageData(ctx context.Context, uri string) (entity.ImageData, error) { | ||
M internal/application/usecase/execute_context_menu_action_test.go +40 -0
| @@ -217,6 +217,46 @@ func TestExecuteContextMenuActionUseCase_WrapsDelegateErrors(t *testing.T) { | |||||
| 217 | require.Contains(t, err.Error(), "boom") | 217 | require.Contains(t, err.Error(), "boom") | ||
| 218 | } | 218 | } | ||
| 219 | 219 | ||||
| 220 | + | func TestExecuteContextMenuActionUseCase_CopySelectionWritesSelectedText(t *testing.T) { | |||
| 221 | + | clipboard := &fakeClipboard{} | |||
| 222 | + | resolver := &fakeImageResolver{} | |||
| 223 | + | saver := &fakeResolvedImageSaver{} | |||
| 224 | + | delegator := &fakeMenuActionDelegator{} | |||
| 225 | + | uc := NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |||
| 226 | + | ||||
| 227 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |||
| 228 | + | Action: port.MenuActionCopySelection, | |||
| 229 | + | Context: port.MenuContext{ | |||
| 230 | + | HasSelection: true, | |||
| 231 | + | SelectionText: "selected text", | |||
| 232 | + | }, | |||
| 233 | + | }) | |||
| 234 | + | ||||
| 235 | + | require.NoError(t, err) | |||
| 236 | + | require.Equal(t, 1, clipboard.writeTextCalls) | |||
| 237 | + | require.Equal(t, "selected text", clipboard.text) | |||
| 238 | + | require.Zero(t, delegator.delegateCalls) | |||
| 239 | + | } | |||
| 240 | + | ||||
| 241 | + | func TestExecuteContextMenuActionUseCase_CopySelectionFallsBackToDelegatorWhenClipboardMissing(t *testing.T) { | |||
| 242 | + | resolver := &fakeImageResolver{} | |||
| 243 | + | saver := &fakeResolvedImageSaver{} | |||
| 244 | + | delegator := &fakeMenuActionDelegator{} | |||
| 245 | + | uc := NewExecuteContextMenuActionUseCase(nil, resolver, saver, delegator) | |||
| 246 | + | ||||
| 247 | + | err := uc.Execute(context.Background(), ExecuteContextMenuActionInput{ | |||
| 248 | + | Action: port.MenuActionCopySelection, | |||
| 249 | + | Context: port.MenuContext{ | |||
| 250 | + | HasSelection: true, | |||
| 251 | + | SelectionText: "selected text", | |||
| 252 | + | }, | |||
| 253 | + | }) | |||
| 254 | + | ||||
| 255 | + | require.NoError(t, err) | |||
| 256 | + | require.Equal(t, 1, delegator.delegateCalls) | |||
| 257 | + | require.Equal(t, port.MenuActionCopySelection, delegator.action) | |||
| 258 | + | } | |||
| 259 | + | ||||
| 220 | func TestExecuteContextMenuActionUseCase_CopyLinkWritesText(t *testing.T) { | 260 | func TestExecuteContextMenuActionUseCase_CopyLinkWritesText(t *testing.T) { | ||
| 221 | clipboard := &fakeClipboard{} | 261 | clipboard := &fakeClipboard{} | ||
| 222 | resolver := &fakeImageResolver{} | 262 | resolver := &fakeImageResolver{} | ||
M internal/bootstrap/engine.go +27 -13
| @@ -13,6 +13,7 @@ import ( | |||||
| 13 | "github.com/bnema/dumber/internal/infrastructure/config" | 13 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 14 | "github.com/bnema/dumber/internal/infrastructure/env" | 14 | "github.com/bnema/dumber/internal/infrastructure/env" | ||
| 15 | "github.com/bnema/dumber/internal/infrastructure/handlers" | 15 | "github.com/bnema/dumber/internal/infrastructure/handlers" | ||
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 16 | "github.com/bnema/dumber/internal/infrastructure/transcoder" | 17 | "github.com/bnema/dumber/internal/infrastructure/transcoder" | ||
| 17 | "github.com/bnema/dumber/internal/infrastructure/webkit" | 18 | "github.com/bnema/dumber/internal/infrastructure/webkit" | ||
| 18 | "github.com/bnema/dumber/internal/ui/theme" | 19 | "github.com/bnema/dumber/internal/ui/theme" | ||
| @@ -21,13 +22,12 @@ import ( | |||||
| 21 | 22 | ||||
| 22 | // EngineInput holds the input for BuildEngine. | 23 | // EngineInput holds the input for BuildEngine. | ||
| 23 | type EngineInput struct { | 24 | type EngineInput struct { | ||
| 24 | - | Ctx context.Context | 25 | + | Ctx context.Context |
| 25 | - | Config *config.Config | 26 | + | Config *config.Config |
| 26 | - | DataDir string | 27 | + | RuntimeProfile runtimeprofile.Profile |
| 27 | - | CacheDir string | 28 | + | ThemeManager *theme.Manager |
| 28 | - | ThemeManager *theme.Manager | 29 | + | ColorResolver port.ColorSchemeResolver |
| 29 | - | ColorResolver port.ColorSchemeResolver | 30 | + | Logger zerolog.Logger |
| 30 | - | Logger zerolog.Logger | |||
| 31 | } | 31 | } | ||
| 32 | 32 | ||||
| 33 | // BuildEngine constructs a port.Engine for the engine type specified in cfg.Engine.Type. | 33 | // BuildEngine constructs a port.Engine for the engine type specified in cfg.Engine.Type. | ||
| @@ -38,23 +38,27 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||||
| 38 | engineType := cfg.Engine.ResolveEngineType() | 38 | engineType := cfg.Engine.ResolveEngineType() | ||
| 39 | switch engineType { | 39 | switch engineType { | ||
| 40 | case config.EngineTypeWebKit: | 40 | case config.EngineTypeWebKit: | ||
| 41 | + | profile, err := requireRuntimeProfile(input.RuntimeProfile, engineType) | |||
| 42 | + | if err != nil { | |||
| 43 | + | return nil, err | |||
| 44 | + | } | |||
| 41 | opts := port.EngineOptions{ | 45 | opts := port.EngineOptions{ | ||
| 42 | - | DataDir: input.DataDir, | |||
| 43 | - | CacheDir: input.CacheDir, | |||
| 44 | CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy), | 46 | CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy), | ||
| 45 | } | 47 | } | ||
| 46 | wkCfg := webkit.EngineConfigFromConfig(cfg.Engine.WebKit) | 48 | wkCfg := webkit.EngineConfigFromConfig(cfg.Engine.WebKit) | ||
| 47 | 49 | ||||
| 48 | return webkit.NewEngine( | 50 | return webkit.NewEngine( | ||
| 49 | - | input.Ctx, cfg, opts, wkCfg, | 51 | + | input.Ctx, cfg, opts, profile, wkCfg, |
| 50 | input.ThemeManager, input.ColorResolver, | 52 | input.ThemeManager, input.ColorResolver, | ||
| 51 | contextMenuBuilder, contextMenuExecutorFactory, | 53 | contextMenuBuilder, contextMenuExecutorFactory, | ||
| 52 | input.Logger, | 54 | input.Logger, | ||
| 53 | ) | 55 | ) | ||
| 54 | case config.EngineTypeCEF: | 56 | case config.EngineTypeCEF: | ||
| 57 | + | profile, err := requireRuntimeProfile(input.RuntimeProfile, engineType) | |||
| 58 | + | if err != nil { | |||
| 59 | + | return nil, err | |||
| 60 | + | } | |||
| 55 | opts := port.EngineOptions{ | 61 | opts := port.EngineOptions{ | ||
| 56 | - | DataDir: input.DataDir, | |||
| 57 | - | CacheDir: input.CacheDir, | |||
| 58 | CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy), | 62 | CookiePolicy: port.CookiePolicy(cfg.Engine.CookiePolicy), | ||
| 59 | } | 63 | } | ||
| 60 | cefCfg := cef.RuntimeConfig{ | 64 | cefCfg := cef.RuntimeConfig{ | ||
| @@ -102,8 +106,18 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||||
| 102 | }, | 106 | }, | ||
| 103 | } | 107 | } | ||
| 104 | audioFactory := audiofactory.NewAudioOutputFactory() | 108 | audioFactory := audiofactory.NewAudioOutputFactory() | ||
| 105 | - | return cef.NewEngine(input.Ctx, opts, cefCfg, transcodingCfg, audioFactory, deps) | 109 | + | return cef.NewEngine(input.Ctx, opts, profile, cefCfg, transcodingCfg, audioFactory, deps) |
| 106 | default: | 110 | default: | ||
| 107 | return nil, fmt.Errorf("unknown engine type: %q", engineType) | 111 | return nil, fmt.Errorf("unknown engine type: %q", engineType) | ||
| 108 | } | 112 | } | ||
| 109 | } | 113 | } | ||
| 114 | + | ||||
| 115 | + | func requireRuntimeProfile(profile runtimeprofile.Profile, engineType string) (runtimeprofile.Profile, error) { | |||
| 116 | + | if profile.Engine == "" { | |||
| 117 | + | return runtimeprofile.Profile{}, fmt.Errorf("missing runtime profile for %s engine", engineType) | |||
| 118 | + | } | |||
| 119 | + | if profile.Engine != engineType { | |||
| 120 | + | return runtimeprofile.Profile{}, fmt.Errorf("runtime profile engine %q does not match %q", profile.Engine, engineType) | |||
| 121 | + | } | |||
| 122 | + | return profile, nil | |||
| 123 | + | } | |||
M internal/bootstrap/engine_test.go +33 -3
| @@ -2,10 +2,12 @@ package bootstrap | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "path/filepath" | |||
| 5 | "testing" | 6 | "testing" | ||
| 6 | 7 | ||||
| 7 | "github.com/bnema/dumber/internal/infrastructure/cef" | 8 | "github.com/bnema/dumber/internal/infrastructure/cef" | ||
| 8 | "github.com/bnema/dumber/internal/infrastructure/config" | 9 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 9 | "github.com/stretchr/testify/require" | 11 | "github.com/stretchr/testify/require" | ||
| 10 | ) | 12 | ) | ||
| 11 | 13 | ||||
| @@ -21,7 +23,7 @@ func TestBuildEngine_CEF_ReturnsErrorWhenRuntimeUnavailable(t *testing.T) { | |||||
| 21 | cfg := config.DefaultConfig() | 23 | cfg := config.DefaultConfig() | ||
| 22 | cfg.Engine.Type = "cef" | 24 | cfg.Engine.Type = "cef" | ||
| 23 | cfg.Engine.CEF.CEFDir = t.TempDir() | 25 | cfg.Engine.CEF.CEFDir = t.TempDir() | ||
| 24 | - | _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background()}) | 26 | + | _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background(), RuntimeProfile: testRuntimeProfile(t)}) |
| 25 | require.Error(t, err) | 27 | require.Error(t, err) | ||
| 26 | require.Contains(t, err.Error(), "cef.InitWithApp") | 28 | require.Contains(t, err.Error(), "cef.InitWithApp") | ||
| 27 | } | 29 | } | ||
| @@ -30,7 +32,7 @@ func TestBuildEngine_CEF_ReturnsErrorForUnsupportedCookiePolicy(t *testing.T) { | |||||
| 30 | cfg := config.DefaultConfig() | 32 | cfg := config.DefaultConfig() | ||
| 31 | cfg.Engine.Type = "cef" | 33 | cfg.Engine.Type = "cef" | ||
| 32 | cfg.Engine.CookiePolicy = config.CookiePolicyNever | 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 | require.ErrorIs(t, err, cef.ErrCookiePolicyUnsupported) | 36 | require.ErrorIs(t, err, cef.ErrCookiePolicyUnsupported) | ||
| 35 | } | 37 | } | ||
| 36 | 38 | ||||
| @@ -39,6 +41,34 @@ func TestBuildEngine_CEF_AllowsNoThirdPartyCookiePolicy(t *testing.T) { | |||||
| 39 | cfg.Engine.Type = "cef" | 41 | cfg.Engine.Type = "cef" | ||
| 40 | cfg.Engine.CookiePolicy = config.CookiePolicyNoThirdParty | 42 | cfg.Engine.CookiePolicy = config.CookiePolicyNoThirdParty | ||
| 41 | cfg.Engine.CEF.CEFDir = t.TempDir() | 43 | cfg.Engine.CEF.CEFDir = t.TempDir() | ||
| 42 | - | _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background()}) | 44 | + | _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background(), RuntimeProfile: testRuntimeProfile(t)}) |
| 43 | require.NotErrorIs(t, err, cef.ErrCookiePolicyUnsupported) | 45 | require.NotErrorIs(t, err, cef.ErrCookiePolicyUnsupported) | ||
| 44 | } | 46 | } | ||
| 47 | + | ||||
| 48 | + | func TestBuildEngine_ReceivesResolvedProfile(t *testing.T) { | |||
| 49 | + | cfg := config.DefaultConfig() | |||
| 50 | + | cfg.Engine.Type = config.EngineTypeCEF | |||
| 51 | + | cfg.Engine.CEF.CEFDir = t.TempDir() | |||
| 52 | + | ||||
| 53 | + | _, err := BuildEngine(EngineInput{Config: cfg, Ctx: context.Background(), RuntimeProfile: testRuntimeProfile(t)}) | |||
| 54 | + | require.Error(t, err) | |||
| 55 | + | require.NotContains(t, err.Error(), "missing runtime profile") | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func testRuntimeProfile(t *testing.T) runtimeprofile.Profile { | |||
| 59 | + | t.Helper() | |||
| 60 | + | root := t.TempDir() | |||
| 61 | + | profile, err := runtimeprofile.Resolve(runtimeprofile.ResolveInput{ | |||
| 62 | + | Env: func(string) string { return "" }, | |||
| 63 | + | Engine: config.EngineTypeCEF, | |||
| 64 | + | CWD: func() (string, error) { return root, nil }, | |||
| 65 | + | Base: runtimeprofile.BasePaths{ | |||
| 66 | + | ConfigHome: filepath.Join(root, "config"), | |||
| 67 | + | DataHome: filepath.Join(root, "data"), | |||
| 68 | + | StateHome: filepath.Join(root, "state"), | |||
| 69 | + | CacheHome: filepath.Join(root, "cache"), | |||
| 70 | + | }, | |||
| 71 | + | }) | |||
| 72 | + | require.NoError(t, err) | |||
| 73 | + | return profile | |||
| 74 | + | } | |||
M internal/bootstrap/gui.go +31 -35
| @@ -6,7 +6,6 @@ import ( | |||||
| 6 | "database/sql" | 6 | "database/sql" | ||
| 7 | "fmt" | 7 | "fmt" | ||
| 8 | "os" | 8 | "os" | ||
| 9 | - | "path/filepath" | |||
| 10 | "sync" | 9 | "sync" | ||
| 11 | "time" | 10 | "time" | ||
| 12 | 11 | ||||
| @@ -18,6 +17,7 @@ import ( | |||||
| 18 | "github.com/bnema/dumber/internal/infrastructure/env" | 17 | "github.com/bnema/dumber/internal/infrastructure/env" | ||
| 19 | "github.com/bnema/dumber/internal/infrastructure/media" | 18 | "github.com/bnema/dumber/internal/infrastructure/media" | ||
| 20 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | 19 | "github.com/bnema/dumber/internal/infrastructure/persistence/sqlite" | ||
| 20 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 21 | "github.com/bnema/dumber/internal/logging" | 21 | "github.com/bnema/dumber/internal/logging" | ||
| 22 | "github.com/bnema/dumber/internal/ui/theme" | 22 | "github.com/bnema/dumber/internal/ui/theme" | ||
| 23 | ) | 23 | ) | ||
| @@ -30,8 +30,7 @@ type DatabaseResult struct { | |||||
| 30 | 30 | ||||
| 31 | // ParallelInitResult holds the results of parallel initialization phase. | 31 | // ParallelInitResult holds the results of parallel initialization phase. | ||
| 32 | type ParallelInitResult struct { | 32 | type ParallelInitResult struct { | ||
| 33 | - | DataDir string | 33 | + | RuntimeProfile runtimeprofile.Profile |
| 34 | - | CacheDir string | |||
| 35 | ThemeManager *theme.Manager | 34 | ThemeManager *theme.Manager | ||
| 36 | ColorResolver port.ColorSchemeResolver | 35 | ColorResolver port.ColorSchemeResolver | ||
| 37 | AdwaitaDetector *colorscheme.AdwaitaDetector | 36 | AdwaitaDetector *colorscheme.AdwaitaDetector | ||
| @@ -91,11 +90,15 @@ func (e *RuntimeRequirementsError) LogDetails(ctx context.Context) { | |||||
| 91 | // Returns the first fatal error encountered, or nil with the results. | 90 | // Returns the first fatal error encountered, or nil with the results. | ||
| 92 | func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | 91 | func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | ||
| 93 | var ( | 92 | var ( | ||
| 94 | - | dataDir, cacheDir string | 93 | + | dirsErr error |
| 95 | - | dirsErr error | 94 | + | wg sync.WaitGroup |
| 96 | - | wg sync.WaitGroup | |||
| 97 | ) | 95 | ) | ||
| 98 | 96 | ||||
| 97 | + | profile, err := ResolveRuntimeProfile(input.Config) | |||
| 98 | + | if err != nil { | |||
| 99 | + | return nil, fmt.Errorf("resolve runtime profile: %w", err) | |||
| 100 | + | } | |||
| 101 | + | ||||
| 99 | start := time.Now() | 102 | start := time.Now() | ||
| 100 | 103 | ||||
| 101 | // Create color scheme resolver with initial detectors. | 104 | // Create color scheme resolver with initial detectors. | ||
| @@ -116,7 +119,7 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |||||
| 116 | // Resolve directories | 119 | // Resolve directories | ||
| 117 | go func() { | 120 | go func() { | ||
| 118 | defer wg.Done() | 121 | defer wg.Done() | ||
| 119 | - | dataDir, cacheDir, dirsErr = resolveWebKitDirs() | 122 | + | dirsErr = resolveWebKitDirs(profile) |
| 120 | }() | 123 | }() | ||
| 121 | 124 | ||||
| 122 | // Theme manager (CPU-bound, no I/O) | 125 | // Theme manager (CPU-bound, no I/O) | ||
| @@ -138,8 +141,7 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |||||
| 138 | } | 141 | } | ||
| 139 | 142 | ||||
| 140 | return &ParallelInitResult{ | 143 | return &ParallelInitResult{ | ||
| 141 | - | DataDir: dataDir, | 144 | + | RuntimeProfile: profile, |
| 142 | - | CacheDir: cacheDir, | |||
| 143 | ThemeManager: themeManager, | 145 | ThemeManager: themeManager, | ||
| 144 | ColorResolver: resolver, | 146 | ColorResolver: resolver, | ||
| 145 | AdwaitaDetector: adwaitaDetector, | 147 | AdwaitaDetector: adwaitaDetector, | ||
| @@ -218,21 +220,17 @@ func CheckMediaRequirements(ctx context.Context, cfg *config.Config) error { | |||||
| 218 | } | 220 | } | ||
| 219 | 221 | ||||
| 220 | // resolveWebKitDirs resolves and creates the data and cache directories for WebKit. | 222 | // resolveWebKitDirs resolves and creates the data and cache directories for WebKit. | ||
| 221 | - | func resolveWebKitDirs() (dataDir, cacheDir string, err error) { | 223 | + | func resolveWebKitDirs(profile runtimeprofile.Profile) error { |
| 222 | - | const cacheDirPerm = 0o755 | 224 | + | const dirPerm = 0o755 |
| 223 | - | dataDir, err = config.GetDataDir() | 225 | + | dataDir := profile.WebKitDataDir() |
| 224 | - | if err != nil { | 226 | + | cacheDir := profile.WebKitCacheDir() |
| 225 | - | return "", "", fmt.Errorf("resolve data directory: %w", err) | 227 | + | if mkErr := os.MkdirAll(dataDir, dirPerm); mkErr != nil { |
| 228 | + | return fmt.Errorf("create data directory %s: %w", dataDir, mkErr) | |||
| 226 | } | 229 | } | ||
| 227 | - | stateDir, err := config.GetStateDir() | 230 | + | if mkErr := os.MkdirAll(cacheDir, dirPerm); mkErr != nil { |
| 228 | - | if err != nil { | 231 | + | return fmt.Errorf("create cache directory %s: %w", cacheDir, mkErr) |
| 229 | - | return "", "", fmt.Errorf("resolve state directory: %w", err) | |||
| 230 | - | } | |||
| 231 | - | cacheDir = filepath.Join(stateDir, "webkit-cache") | |||
| 232 | - | if mkErr := os.MkdirAll(cacheDir, cacheDirPerm); mkErr != nil { | |||
| 233 | - | return "", "", fmt.Errorf("create cache directory %s: %w", cacheDir, mkErr) | |||
| 234 | } | 232 | } | ||
| 235 | - | return dataDir, cacheDir, nil | 233 | + | return nil |
| 236 | } | 234 | } | ||
| 237 | 235 | ||||
| 238 | // OpenDatabase opens and initializes the SQLite database using XDG paths. | 236 | // OpenDatabase opens and initializes the SQLite database using XDG paths. | ||
| @@ -277,12 +275,11 @@ type ParallelDBEngineResult struct { | |||||
| 277 | 275 | ||||
| 278 | // ParallelDBEngineInput holds inputs for parallel DB and engine initialization. | 276 | // ParallelDBEngineInput holds inputs for parallel DB and engine initialization. | ||
| 279 | type ParallelDBEngineInput struct { | 277 | type ParallelDBEngineInput struct { | ||
| 280 | - | Ctx context.Context | 278 | + | Ctx context.Context |
| 281 | - | Config *config.Config | 279 | + | Config *config.Config |
| 282 | - | DataDir string // For engine context | 280 | + | RuntimeProfile runtimeprofile.Profile |
| 283 | - | CacheDir string // For engine cache | 281 | + | ThemeManager *theme.Manager |
| 284 | - | ThemeManager *theme.Manager | 282 | + | ColorResolver port.ColorSchemeResolver |
| 285 | - | ColorResolver port.ColorSchemeResolver | |||
| 286 | } | 283 | } | ||
| 287 | 284 | ||||
| 288 | // Note: Database path is resolved via config.GetDatabaseFile() internally. | 285 | // Note: Database path is resolved via config.GetDatabaseFile() internally. | ||
| @@ -317,13 +314,12 @@ func RunParallelDBEngine(input ParallelDBEngineInput) (*ParallelDBEngineResult, | |||||
| 317 | 314 | ||||
| 318 | // Engine on main thread (GTK requirement) | 315 | // Engine on main thread (GTK requirement) | ||
| 319 | engine, err := BuildEngine(EngineInput{ | 316 | engine, err := BuildEngine(EngineInput{ | ||
| 320 | - | Ctx: input.Ctx, | 317 | + | Ctx: input.Ctx, |
| 321 | - | Config: input.Config, | 318 | + | Config: input.Config, |
| 322 | - | DataDir: input.DataDir, | 319 | + | RuntimeProfile: input.RuntimeProfile, |
| 323 | - | CacheDir: input.CacheDir, | 320 | + | ThemeManager: input.ThemeManager, |
| 324 | - | ThemeManager: input.ThemeManager, | 321 | + | ColorResolver: input.ColorResolver, |
| 325 | - | ColorResolver: input.ColorResolver, | 322 | + | Logger: *log, |
| 326 | - | Logger: *log, | |||
| 327 | }) | 323 | }) | ||
| 328 | if err != nil { | 324 | if err != nil { | ||
| 329 | dbRes := <-dbCh | 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 | "github.com/bnema/dumber/internal/infrastructure/cef" | 7 | "github.com/bnema/dumber/internal/infrastructure/cef" | ||
| 8 | "github.com/bnema/dumber/internal/infrastructure/config" | 8 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 9 | "github.com/bnema/dumber/internal/infrastructure/desktop" | 9 | "github.com/bnema/dumber/internal/infrastructure/desktop" | ||
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 10 | ) | 11 | ) | ||
| 11 | 12 | ||||
| 12 | // NewSessionSpawner wires the session spawner with any engine-specific launch | 13 | // NewSessionSpawner wires the session spawner with any engine-specific launch | ||
| 13 | // environment required by the current browser backend. | 14 | // environment required by the current browser backend. | ||
| 14 | - | func NewSessionSpawner(ctx context.Context, cfg *config.Config) port.SessionSpawner { | 15 | + | func NewSessionSpawner(ctx context.Context, profile runtimeprofile.Profile) port.SessionSpawner { |
| 15 | var spawnEnv port.SessionSpawnEnvironment | 16 | var spawnEnv port.SessionSpawnEnvironment | ||
| 16 | - | if cfg != nil && cfg.Engine.ResolveEngineType() == config.EngineTypeCEF { | 17 | + | if profile.Engine == config.EngineTypeCEF { |
| 17 | - | spawnEnv = cef.SessionSpawnEnvironment{} | 18 | + | spawnEnv = cef.NewSessionSpawnEnvironment(profile.CEFUserDataDir()) |
| 18 | } | 19 | } | ||
| 19 | return desktop.NewSessionSpawner(ctx, spawnEnv) | 20 | return desktop.NewSessionSpawner(ctx, spawnEnv) | ||
| 20 | } | 21 | } | ||
M internal/cli/app.go +5 -1
| @@ -51,6 +51,10 @@ func NewApp() (*App, error) { | |||||
| 51 | var err error | 51 | var err error | ||
| 52 | // Load config | 52 | // Load config | ||
| 53 | cfg := loadConfig() | 53 | cfg := loadConfig() | ||
| 54 | + | profile, err := bootstrap.ResolveRuntimeProfile(cfg) | |||
| 55 | + | if err != nil { | |||
| 56 | + | return nil, fmt.Errorf("resolve runtime profile: %w", err) | |||
| 57 | + | } | |||
| 54 | 58 | ||||
| 55 | // Create theme from config | 59 | // Create theme from config | ||
| 56 | theme := styles.NewTheme(cfg) | 60 | theme := styles.NewTheme(cfg) | ||
| @@ -133,7 +137,7 @@ func NewApp() (*App, error) { | |||||
| 133 | RestoreUC: restoreUC, | 137 | RestoreUC: restoreUC, | ||
| 134 | DeleteSessionUC: deleteSessionUC, | 138 | DeleteSessionUC: deleteSessionUC, | ||
| 135 | FaviconService: faviconService, | 139 | FaviconService: faviconService, | ||
| 136 | - | SessionSpawner: bootstrap.NewSessionSpawner(ctx, cfg), | 140 | + | SessionSpawner: bootstrap.NewSessionSpawner(ctx, profile), |
| 137 | ctx: ctx, | 141 | ctx: ctx, | ||
| 138 | logCleanup: logCleanup, | 142 | logCleanup: logCleanup, | ||
| 139 | }, nil | 143 | }, nil | ||
M internal/cli/cmd/gen_docs.go +1 -1
| @@ -57,7 +57,7 @@ func runGenDocs(_ *cobra.Command, _ []string) error { | |||||
| 57 | if outputDir == "" { | 57 | if outputDir == "" { | ||
| 58 | switch genDocsFormat { | 58 | switch genDocsFormat { | ||
| 59 | case "man": | 59 | case "man": | ||
| 60 | - | xdg := xdgadapter.New() | 60 | + | xdg := xdgadapter.New(false, "") |
| 61 | manDir, err := xdg.ManDir() | 61 | manDir, err := xdg.ManDir() | ||
| 62 | if err != nil { | 62 | if err != nil { | ||
| 63 | return fmt.Errorf("resolve man directory: %w", err) | 63 | return fmt.Errorf("resolve man directory: %w", err) | ||
M internal/cli/cmd/purge.go +11 -2
| @@ -7,11 +7,13 @@ import ( | |||||
| 7 | "github.com/spf13/cobra" | 7 | "github.com/spf13/cobra" | ||
| 8 | 8 | ||||
| 9 | "github.com/bnema/dumber/internal/application/usecase" | 9 | "github.com/bnema/dumber/internal/application/usecase" | ||
| 10 | + | "github.com/bnema/dumber/internal/bootstrap" | |||
| 10 | "github.com/bnema/dumber/internal/cli" | 11 | "github.com/bnema/dumber/internal/cli" | ||
| 11 | "github.com/bnema/dumber/internal/cli/model" | 12 | "github.com/bnema/dumber/internal/cli/model" | ||
| 12 | "github.com/bnema/dumber/internal/cli/styles" | 13 | "github.com/bnema/dumber/internal/cli/styles" | ||
| 13 | "github.com/bnema/dumber/internal/infrastructure/desktop" | 14 | "github.com/bnema/dumber/internal/infrastructure/desktop" | ||
| 14 | "github.com/bnema/dumber/internal/infrastructure/filesystem" | 15 | "github.com/bnema/dumber/internal/infrastructure/filesystem" | ||
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 15 | xdgadapter "github.com/bnema/dumber/internal/infrastructure/xdg" | 17 | xdgadapter "github.com/bnema/dumber/internal/infrastructure/xdg" | ||
| 16 | ) | 18 | ) | ||
| 17 | 19 | ||||
| @@ -47,7 +49,14 @@ func runPurge(_ *cobra.Command, _ []string) error { | |||||
| 47 | } | 49 | } | ||
| 48 | 50 | ||||
| 49 | fsAdapter := filesystem.New() | 51 | fsAdapter := filesystem.New() | ||
| 50 | - | xdgAdapter := xdgadapter.New() | 52 | + | profile, err := bootstrap.ResolveRuntimeProfile(app.Config) |
| 53 | + | if err != nil { | |||
| 54 | + | return fmt.Errorf("resolve runtime profile: %w", err) | |||
| 55 | + | } | |||
| 56 | + | xdgAdapter := xdgadapter.New( | |||
| 57 | + | profile.Mode == runtimeprofile.ModeDev, | |||
| 58 | + | bootstrap.ResolveXDGRuntimeDir(profile), | |||
| 59 | + | ) | |||
| 51 | desktopAdapter := desktop.New() | 60 | desktopAdapter := desktop.New() | ||
| 52 | purgeUC := usecase.NewPurgeDataUseCase(fsAdapter, xdgAdapter, desktopAdapter) | 61 | purgeUC := usecase.NewPurgeDataUseCase(fsAdapter, xdgAdapter, desktopAdapter) | ||
| 53 | 62 | ||||
| @@ -63,7 +72,7 @@ func runPurge(_ *cobra.Command, _ []string) error { | |||||
| 63 | 72 | ||||
| 64 | m := model.NewPurgeModel(app.Ctx(), app.Theme, purgeUC, cfg) | 73 | m := model.NewPurgeModel(app.Ctx(), app.Theme, purgeUC, cfg) | ||
| 65 | p := tea.NewProgram(m, tea.WithAltScreen()) | 74 | p := tea.NewProgram(m, tea.WithAltScreen()) | ||
| 66 | - | _, err := p.Run() | 75 | + | _, err = p.Run() |
| 67 | return err | 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 | return app | 56 | return app | ||
| 57 | } | 57 | } | ||
| 58 | 58 | ||||
| 59 | - | // NewSubprocessApp returns a lightweight App for helper processes so CEF sees | 59 | + | // NewSubprocessApp returns a lightweight raw App implementation for helper |
| 60 | - | // the same custom scheme registration in renderer/GPU/utility processes. | 60 | + | // processes so cef_execute_process can wrap it itself. Keep helper processes |
| 61 | + | // free of custom render handlers for now: the renderer bridge introduced a | |||
| 62 | + | // startup regression where OSR pages stopped producing their first paint. | |||
| 61 | func NewSubprocessApp() purecef.App { | 63 | func NewSubprocessApp() purecef.App { | ||
| 62 | - | return purecef.NewApp(&subprocessApp{}) | 64 | + | return &subprocessApp{} |
| 63 | } | 65 | } | ||
| 64 | 66 | ||||
| 65 | // maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths. | 67 | // maxCmdLineLogLen limits logged command line length to avoid leaking sensitive paths. | ||
M internal/infrastructure/cef/content_injector.go +236 -17
| @@ -385,33 +385,251 @@ const redditDirectVideoJS = `(function(){ | |||||
| 385 | }, 1000); | 385 | }, 1000); | ||
| 386 | })();` | 386 | })();` | ||
| 387 | 387 | ||||
| 388 | - | // clipboardCopyBridgeJS intercepts copy/cut events and sends the selected text | 388 | + | // clipboardSelectionFetchBridgeJS keeps the lightweight fetch bridge enabled |
| 389 | - | // back to Go via the message bridge so it can be written to the GDK clipboard. | 389 | + | // while the native renderer bridge remains disabled due to an OSR startup |
| 390 | - | // Without this, Ctrl+C in CEF OSR mode writes to CEF's internal clipboard but | 390 | + | // regression. It mirrors explicit clipboard text from DOM selections, |
| 391 | - | // not the Wayland/X11 system clipboard. | 391 | + | // safe input/textarea selections, and async Clipboard API writes, and also |
| 392 | - | const clipboardCopyBridgeJS = `(function(){ | 392 | + | // reasserts browser focus when an editable element gains DOM focus. |
| 393 | - | if (window.__dumberClipboardBridge) return; | 393 | + | const clipboardSelectionFetchBridgeJS = `(function(){ |
| 394 | - | window.__dumberClipboardBridge = true; | 394 | + | var bridgeNonce = '__DUMBER_BRIDGE_NONCE__'; |
| 395 | + | if (window.__dumberClipboardBridge && typeof window.__dumberClipboardBridge.setBridgeNonce === 'function') { | |||
| 396 | + | window.__dumberClipboardBridge.setBridgeNonce(bridgeNonce); | |||
| 397 | + | return; | |||
| 398 | + | } | |||
| 399 | + | window.__dumberClipboardBridge = { | |||
| 400 | + | setBridgeNonce: function(nextBridgeNonce) { | |||
| 401 | + | bridgeNonce = nextBridgeNonce == null ? '' : String(nextBridgeNonce); | |||
| 402 | + | } | |||
| 403 | + | }; | |||
| 404 | + | ||||
| 405 | + | function encodeBody(body) { | |||
| 406 | + | return typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : ''; | |||
| 407 | + | } | |||
| 408 | + | ||||
| 409 | + | function inputType(node) { | |||
| 410 | + | return node && node.type ? String(node.type).toLowerCase() : 'text'; | |||
| 411 | + | } | |||
| 412 | + | ||||
| 413 | + | function isTextEditableInputType(type) { | |||
| 414 | + | switch ((type || 'text').toLowerCase()) { | |||
| 415 | + | case 'button': | |||
| 416 | + | case 'submit': | |||
| 417 | + | case 'reset': | |||
| 418 | + | case 'checkbox': | |||
| 419 | + | case 'radio': | |||
| 420 | + | case 'range': | |||
| 421 | + | case 'color': | |||
| 422 | + | case 'file': | |||
| 423 | + | case 'image': | |||
| 424 | + | case 'hidden': | |||
| 425 | + | return false; | |||
| 426 | + | default: | |||
| 427 | + | return true; | |||
| 428 | + | } | |||
| 429 | + | } | |||
| 430 | + | ||||
| 431 | + | function isSelectionReadableInputType(type) { | |||
| 432 | + | return isTextEditableInputType(type) && type !== 'password'; | |||
| 433 | + | } | |||
| 434 | + | ||||
| 435 | + | function getActiveElementSelection() { | |||
| 436 | + | var el = document.activeElement; | |||
| 437 | + | if (!el || el.nodeType !== 1 || typeof el.value !== 'string') return ''; | |||
| 438 | + | var tag = el.tagName; | |||
| 439 | + | if (el.disabled || el.readOnly) return ''; | |||
| 440 | + | if (tag === 'INPUT' && !isSelectionReadableInputType(inputType(el))) return ''; | |||
| 441 | + | if (tag !== 'INPUT' && tag !== 'TEXTAREA') return ''; | |||
| 442 | + | var start = typeof el.selectionStart === 'number' ? el.selectionStart : 0; | |||
| 443 | + | var end = typeof el.selectionEnd === 'number' ? el.selectionEnd : 0; | |||
| 444 | + | if (end > start) return el.value.slice(start, end); | |||
| 445 | + | return ''; | |||
| 446 | + | } | |||
| 447 | + | ||||
| 448 | + | function getSelectedText() { | |||
| 449 | + | var activeSelection = getActiveElementSelection(); | |||
| 450 | + | if (activeSelection) return activeSelection; | |||
| 451 | + | var sel = window.getSelection(); | |||
| 452 | + | return sel ? sel.toString() : ''; | |||
| 453 | + | } | |||
| 454 | + | ||||
| 455 | + | function isEditable(node) { | |||
| 456 | + | if (!node || node.nodeType !== 1) return false; | |||
| 457 | + | if (node.isContentEditable) return true; | |||
| 458 | + | var tag = node.tagName; | |||
| 459 | + | if (tag === 'TEXTAREA') return !node.disabled && !node.readOnly; | |||
| 460 | + | if (tag !== 'INPUT') return false; | |||
| 461 | + | if (node.disabled || node.readOnly) return false; | |||
| 462 | + | return isTextEditableInputType(inputType(node)); | |||
| 463 | + | } | |||
| 464 | + | ||||
| 395 | function sendToClipboard(text) { | 465 | function sendToClipboard(text) { | ||
| 396 | if (!text) return; | 466 | if (!text) return; | ||
| 397 | var body = JSON.stringify({text: text}); | 467 | var body = JSON.stringify({text: text}); | ||
| 398 | - | var encoded = typeof btoa === 'function' ? btoa(unescape(encodeURIComponent(body))) : ''; | 468 | + | var encoded = encodeBody(body); |
| 399 | if (!encoded) return; | 469 | if (!encoded) return; | ||
| 400 | fetch('dumb:///api/clipboard-set', { | 470 | fetch('dumb:///api/clipboard-set', { | ||
| 401 | method: 'POST', | 471 | method: 'POST', | ||
| 402 | - | headers: {'X-Dumber-Body': encoded} | 472 | + | headers: { |
| 473 | + | 'X-Dumber-Body': encoded, | |||
| 474 | + | 'X-Dumber-Bridge-Nonce': bridgeNonce | |||
| 475 | + | } | |||
| 403 | }).catch(function(){}); | 476 | }).catch(function(){}); | ||
| 404 | } | 477 | } | ||
| 405 | - | document.addEventListener('copy', function() { | 478 | + | |
| 406 | - | var sel = window.getSelection(); | 479 | + | function sendFocusSync() { |
| 407 | - | if (sel) sendToClipboard(sel.toString()); | 480 | + | fetch('dumb:///api/focus-sync', { |
| 481 | + | method: 'POST', | |||
| 482 | + | headers: { | |||
| 483 | + | 'X-Dumber-Bridge-Action': 'focus-sync', | |||
| 484 | + | 'X-Dumber-Bridge-Nonce': bridgeNonce | |||
| 485 | + | } | |||
| 486 | + | }).catch(function(){}); | |||
| 487 | + | } | |||
| 488 | + | ||||
| 489 | + | function mirrorClipboardItems(items) { | |||
| 490 | + | try { | |||
| 491 | + | if (!items || typeof items.length !== 'number') return; | |||
| 492 | + | Array.prototype.forEach.call(items, function(item) { | |||
| 493 | + | if (!item || !item.types || item.types.indexOf('text/plain') === -1 || typeof item.getType !== 'function') return; | |||
| 494 | + | Promise.resolve(item.getType('text/plain')) | |||
| 495 | + | .then(function(blob) { | |||
| 496 | + | if (!blob || typeof blob.text !== 'function') return ''; | |||
| 497 | + | return blob.text(); | |||
| 498 | + | }) | |||
| 499 | + | .then(function(text) { | |||
| 500 | + | if (text) sendToClipboard(text); | |||
| 501 | + | }) | |||
| 502 | + | .catch(function(){}); | |||
| 503 | + | }); | |||
| 504 | + | } catch (_) {} | |||
| 505 | + | } | |||
| 506 | + | ||||
| 507 | + | document.addEventListener('copy', function(event) { | |||
| 508 | + | if (event && event.isTrusted === false) return; | |||
| 509 | + | var text = getSelectedText(); | |||
| 510 | + | if (text) sendToClipboard(text); | |||
| 408 | }); | 511 | }); | ||
| 409 | - | document.addEventListener('cut', function() { | 512 | + | document.addEventListener('cut', function(event) { |
| 410 | - | var sel = window.getSelection(); | 513 | + | if (event && event.isTrusted === false) return; |
| 411 | - | if (sel) sendToClipboard(sel.toString()); | 514 | + | var text = getSelectedText(); |
| 515 | + | if (text) sendToClipboard(text); | |||
| 412 | }); | 516 | }); | ||
| 517 | + | document.addEventListener('focusin', function(event) { | |||
| 518 | + | if (event && event.isTrusted === false) return; | |||
| 519 | + | if (isEditable(event && event.target)) sendFocusSync(); | |||
| 520 | + | }, true); | |||
| 521 | + | ||||
| 522 | + | try { | |||
| 523 | + | var clipboardObj = navigator && navigator.clipboard ? navigator.clipboard : null; | |||
| 524 | + | var clipboardProto = (typeof Clipboard !== 'undefined' && Clipboard.prototype) || | |||
| 525 | + | (clipboardObj && typeof Object.getPrototypeOf === 'function' ? Object.getPrototypeOf(clipboardObj) : null); | |||
| 526 | + | ||||
| 527 | + | if (clipboardProto && typeof clipboardProto.writeText === 'function' && !clipboardProto.__dumberWriteTextPatched) { | |||
| 528 | + | clipboardProto.__dumberWriteTextPatched = true; | |||
| 529 | + | var originalProtoWriteText = clipboardProto.writeText; | |||
| 530 | + | var wrappedProtoWriteText = function(text) { | |||
| 531 | + | var normalized = text == null ? '' : String(text); | |||
| 532 | + | var result = originalProtoWriteText.call(this, normalized); | |||
| 533 | + | if (result && typeof result.then === 'function') { | |||
| 534 | + | return result.then(function(value) { | |||
| 535 | + | if (normalized) sendToClipboard(normalized); | |||
| 536 | + | return value; | |||
| 537 | + | }); | |||
| 538 | + | } | |||
| 539 | + | if (normalized) sendToClipboard(normalized); | |||
| 540 | + | return result; | |||
| 541 | + | }; | |||
| 542 | + | try { | |||
| 543 | + | Object.defineProperty(clipboardProto, 'writeText', { | |||
| 544 | + | configurable: true, | |||
| 545 | + | writable: true, | |||
| 546 | + | value: wrappedProtoWriteText | |||
| 547 | + | }); | |||
| 548 | + | } catch (_) { | |||
| 549 | + | clipboardProto.writeText = wrappedProtoWriteText; | |||
| 550 | + | } | |||
| 551 | + | } else if (clipboardObj && typeof clipboardObj.writeText === 'function' && !window.__dumberClipboardWriteTextPatched) { | |||
| 552 | + | window.__dumberClipboardWriteTextPatched = true; | |||
| 553 | + | var originalWriteText = clipboardObj.writeText.bind(clipboardObj); | |||
| 554 | + | var wrappedWriteText = function(text) { | |||
| 555 | + | var normalized = text == null ? '' : String(text); | |||
| 556 | + | var result = originalWriteText(normalized); | |||
| 557 | + | if (result && typeof result.then === 'function') { | |||
| 558 | + | return result.then(function(value) { | |||
| 559 | + | if (normalized) sendToClipboard(normalized); | |||
| 560 | + | return value; | |||
| 561 | + | }); | |||
| 562 | + | } | |||
| 563 | + | if (normalized) sendToClipboard(normalized); | |||
| 564 | + | return result; | |||
| 565 | + | }; | |||
| 566 | + | try { | |||
| 567 | + | Object.defineProperty(clipboardObj, 'writeText', { | |||
| 568 | + | configurable: true, | |||
| 569 | + | writable: true, | |||
| 570 | + | value: wrappedWriteText | |||
| 571 | + | }); | |||
| 572 | + | } catch (_) { | |||
| 573 | + | clipboardObj.writeText = wrappedWriteText; | |||
| 574 | + | } | |||
| 575 | + | } | |||
| 576 | + | ||||
| 577 | + | if (clipboardProto && typeof clipboardProto.write === 'function' && !clipboardProto.__dumberWritePatched) { | |||
| 578 | + | clipboardProto.__dumberWritePatched = true; | |||
| 579 | + | var originalProtoWrite = clipboardProto.write; | |||
| 580 | + | var wrappedProtoWrite = function(items) { | |||
| 581 | + | var result = originalProtoWrite.apply(this, arguments); | |||
| 582 | + | if (result && typeof result.then === 'function') { | |||
| 583 | + | return result.then(function(value) { | |||
| 584 | + | mirrorClipboardItems(items); | |||
| 585 | + | return value; | |||
| 586 | + | }); | |||
| 587 | + | } | |||
| 588 | + | mirrorClipboardItems(items); | |||
| 589 | + | return result; | |||
| 590 | + | }; | |||
| 591 | + | try { | |||
| 592 | + | Object.defineProperty(clipboardProto, 'write', { | |||
| 593 | + | configurable: true, | |||
| 594 | + | writable: true, | |||
| 595 | + | value: wrappedProtoWrite | |||
| 596 | + | }); | |||
| 597 | + | } catch (_) { | |||
| 598 | + | clipboardProto.write = wrappedProtoWrite; | |||
| 599 | + | } | |||
| 600 | + | } else if (clipboardObj && typeof clipboardObj.write === 'function' && !window.__dumberClipboardWritePatched) { | |||
| 601 | + | window.__dumberClipboardWritePatched = true; | |||
| 602 | + | var originalWrite = clipboardObj.write.bind(clipboardObj); | |||
| 603 | + | var wrappedWrite = function(items) { | |||
| 604 | + | var result = originalWrite(items); | |||
| 605 | + | if (result && typeof result.then === 'function') { | |||
| 606 | + | return result.then(function(value) { | |||
| 607 | + | mirrorClipboardItems(items); | |||
| 608 | + | return value; | |||
| 609 | + | }); | |||
| 610 | + | } | |||
| 611 | + | mirrorClipboardItems(items); | |||
| 612 | + | return result; | |||
| 613 | + | }; | |||
| 614 | + | try { | |||
| 615 | + | Object.defineProperty(clipboardObj, 'write', { | |||
| 616 | + | configurable: true, | |||
| 617 | + | writable: true, | |||
| 618 | + | value: wrappedWrite | |||
| 619 | + | }); | |||
| 620 | + | } catch (_) { | |||
| 621 | + | clipboardObj.write = wrappedWrite; | |||
| 622 | + | } | |||
| 623 | + | } | |||
| 624 | + | } catch (_) {} | |||
| 625 | + | ||||
| 626 | + | if (isEditable(document.activeElement)) sendFocusSync(); | |||
| 413 | })();` | 627 | })();` | ||
| 414 | 628 | ||||
| 629 | + | func buildClipboardSelectionFetchBridgeJS(bridgeNonce string) string { | |||
| 630 | + | return strings.ReplaceAll(clipboardSelectionFetchBridgeJS, "__DUMBER_BRIDGE_NONCE__", bridgeNonce) | |||
| 631 | + | } | |||
| 632 | + | ||||
| 415 | // contentInjector implements port.ContentInjector for the CEF engine. | 633 | // contentInjector implements port.ContentInjector for the CEF engine. | ||
| 416 | // It stores CSS strings and injects them into webviews via ExecuteJavaScript. | 634 | // It stores CSS strings and injects them into webviews via ExecuteJavaScript. | ||
| 417 | // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd | 635 | // Thread-safe: InjectThemeCSS may be called from the UI thread while OnLoadEnd | ||
| @@ -532,8 +750,9 @@ func (ci *contentInjector) onLoadEnd(wv *WebView) { | |||||
| 532 | ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS) | 750 | ci.injectCSS(wv, "dumber-scrollbar", scrollbarCSS) | ||
| 533 | wv.RunJavaScript(context.Background(), scrollbarAutoHideJS) | 751 | wv.RunJavaScript(context.Background(), scrollbarAutoHideJS) | ||
| 534 | 752 | ||||
| 535 | - | // All pages get clipboard copy bridge (CEF OSR can't write to Wayland clipboard). | 753 | + | // Clipboard copy/cut and editable focus sync still need a JS bridge in OSR |
| 536 | - | wv.RunJavaScript(context.Background(), clipboardCopyBridgeJS) | 754 | + | // mode while the native renderer bridge remains disabled. |
| 755 | + | wv.RunJavaScript(context.Background(), buildClipboardSelectionFetchBridgeJS(wv.rotateBridgeNonce())) | |||
| 537 | 756 | ||||
| 538 | // Video playback diagnostic — logs video element state changes. | 757 | // Video playback diagnostic — logs video element state changes. | ||
| 539 | // Gated behind DUMBER_VIDEO_DIAG=1 environment variable. | 758 | // Gated behind DUMBER_VIDEO_DIAG=1 environment variable. | ||
A internal/infrastructure/cef/content_injector_test.go +61 -0
| @@ -0,0 +1,61 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/require" | |||
| 7 | + | ) | |||
| 8 | + | ||||
| 9 | + | func testClipboardSelectionFetchBridgeJS() string { | |||
| 10 | + | return buildClipboardSelectionFetchBridgeJS("test-bridge-nonce") | |||
| 11 | + | } | |||
| 12 | + | ||||
| 13 | + | func TestClipboardSelectionFetchBridgeJS_CapturesInputAndTextareaSelections(t *testing.T) { | |||
| 14 | + | script := testClipboardSelectionFetchBridgeJS() | |||
| 15 | + | ||||
| 16 | + | require.Contains(t, script, "document.activeElement") | |||
| 17 | + | require.Contains(t, script, "selectionStart") | |||
| 18 | + | require.Contains(t, script, "selectionEnd") | |||
| 19 | + | require.Contains(t, script, "window.getSelection()") | |||
| 20 | + | require.Contains(t, script, "type !== 'password'") | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestClipboardSelectionFetchBridgeJS_PostsTrustedFocusSyncRequests(t *testing.T) { | |||
| 24 | + | script := testClipboardSelectionFetchBridgeJS() | |||
| 25 | + | ||||
| 26 | + | require.Contains(t, script, "dumb:///api/focus-sync") | |||
| 27 | + | require.Contains(t, script, "X-Dumber-Bridge-Action") | |||
| 28 | + | require.Contains(t, script, "X-Dumber-Bridge-Nonce") | |||
| 29 | + | require.Contains(t, script, "focus-sync") | |||
| 30 | + | require.Contains(t, script, "focusin") | |||
| 31 | + | require.Contains(t, script, "event.isTrusted === false") | |||
| 32 | + | require.Contains(t, script, "event && event.target") | |||
| 33 | + | require.Contains(t, script, "document.activeElement") | |||
| 34 | + | require.Contains(t, script, "isEditable(document.activeElement)") | |||
| 35 | + | require.Contains(t, script, "sendFocusSync()") | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestClipboardSelectionFetchBridgeJS_HandlesTrustedClipboardEvents(t *testing.T) { | |||
| 39 | + | script := testClipboardSelectionFetchBridgeJS() | |||
| 40 | + | ||||
| 41 | + | require.Contains(t, script, "dumb:///api/clipboard-set") | |||
| 42 | + | require.Contains(t, script, "X-Dumber-Body") | |||
| 43 | + | require.Contains(t, script, "X-Dumber-Bridge-Nonce") | |||
| 44 | + | require.Contains(t, script, "document.addEventListener('copy', function(event)") | |||
| 45 | + | require.Contains(t, script, "document.addEventListener('cut', function(event)") | |||
| 46 | + | require.Contains(t, script, "event.isTrusted === false") | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | func TestClipboardSelectionFetchBridgeJS_PatchesAsyncClipboardAPIs(t *testing.T) { | |||
| 50 | + | script := testClipboardSelectionFetchBridgeJS() | |||
| 51 | + | ||||
| 52 | + | require.Contains(t, script, "navigator && navigator.clipboard") | |||
| 53 | + | require.Contains(t, script, "clipboardProto.writeText") | |||
| 54 | + | require.Contains(t, script, "clipboardObj.writeText") | |||
| 55 | + | require.Contains(t, script, "clipboardProto.write") | |||
| 56 | + | require.Contains(t, script, "clipboardObj.write") | |||
| 57 | + | require.Contains(t, script, "sendToClipboard(normalized)") | |||
| 58 | + | require.Contains(t, script, "mirrorClipboardItems(items)") | |||
| 59 | + | require.NotContains(t, script, "__DUMBER_BRIDGE_NONCE__") | |||
| 60 | + | require.Contains(t, script, "test-bridge-nonce") | |||
| 61 | + | } | |||
M internal/infrastructure/cef/context_menu.go +35 -3
| @@ -82,7 +82,11 @@ func (h *handlerSet) RunContextMenu( | |||||
| 82 | x, | 82 | x, | ||
| 83 | y, | 83 | y, | ||
| 84 | func(item port.MenuItem) { | 84 | func(item port.MenuItem) { | ||
| 85 | - | dispatchContextMenuSelection(h.wv.ctx, executor, callback, commandIDByAction, item, menuContext) | 85 | + | var copiedCallback func(string) |
| 86 | + | if h.wv.engine != nil { | |||
| 87 | + | copiedCallback = h.wv.engine.notifyClipboardCopied | |||
| 88 | + | } | |||
| 89 | + | dispatchContextMenuSelection(h.wv.ctx, executor, callback, copiedCallback, commandIDByAction, item, menuContext) | |||
| 86 | }, | 90 | }, | ||
| 87 | func() { | 91 | func() { | ||
| 88 | callback.Cancel() | 92 | callback.Cancel() | ||
| @@ -162,6 +166,7 @@ func dispatchContextMenuSelection( | |||||
| 162 | ctx context.Context, | 166 | ctx context.Context, | ||
| 163 | executor port.ContextMenuActionExecutor, | 167 | executor port.ContextMenuActionExecutor, | ||
| 164 | callback purecef.RunContextMenuCallback, | 168 | callback purecef.RunContextMenuCallback, | ||
| 169 | + | copiedCallback func(text string), | |||
| 165 | commandIDByAction map[port.MenuAction]int32, | 170 | commandIDByAction map[port.MenuAction]int32, | ||
| 166 | item port.MenuItem, | 171 | item port.MenuItem, | ||
| 167 | menuContext port.MenuContext, | 172 | menuContext port.MenuContext, | ||
| @@ -171,14 +176,29 @@ func dispatchContextMenuSelection( | |||||
| 171 | return | 176 | return | ||
| 172 | } | 177 | } | ||
| 173 | log.Debug().Str("action", string(item.Action)).Str("label", item.Label).Msg("cef: context menu item selected") | 178 | log.Debug().Str("action", string(item.Action)).Str("label", item.Label).Msg("cef: context menu item selected") | ||
| 179 | + | if item.Action == port.MenuActionCopySelection && menuContext.SelectionText == "" { | |||
| 180 | + | if cmdID, ok := commandIDByAction[item.Action]; ok { | |||
| 181 | + | log.Debug().Str("action", string(item.Action)).Int32("command_id", cmdID).Msg("cef: continuing native context menu command") | |||
| 182 | + | callback.Cont(cmdID, 0) | |||
| 183 | + | return | |||
| 184 | + | } | |||
| 185 | + | log.Warn().Str("action", string(item.Action)).Msg("cef: no matching native context menu command") | |||
| 186 | + | callback.Cancel() | |||
| 187 | + | return | |||
| 188 | + | } | |||
| 174 | if shouldExecuteDirectCEFAction(item.Action) && executor != nil { | 189 | if shouldExecuteDirectCEFAction(item.Action) && executor != nil { | ||
| 175 | if err := executor.ExecuteMenuAction(ctx, item.Action, menuContext); err != nil { | 190 | if err := executor.ExecuteMenuAction(ctx, item.Action, menuContext); err != nil { | ||
| 176 | log.Warn().Err(err).Str("action", string(item.Action)).Msg("cef: context menu action failed") | 191 | log.Warn().Err(err).Str("action", string(item.Action)).Msg("cef: context menu action failed") | ||
| 177 | callback.Cancel() | 192 | callback.Cancel() | ||
| 178 | return | 193 | return | ||
| 179 | } | 194 | } | ||
| 195 | + | if copiedCallback != nil { | |||
| 196 | + | if copiedText := contextMenuCopiedText(item.Action, menuContext); copiedText != "" { | |||
| 197 | + | copiedCallback(copiedText) | |||
| 198 | + | } | |||
| 199 | + | } | |||
| 180 | log.Debug().Str("action", string(item.Action)).Msg("cef: context menu action executed directly") | 200 | log.Debug().Str("action", string(item.Action)).Msg("cef: context menu action executed directly") | ||
| 181 | - | callback.Cont(0, 0) | 201 | + | callback.Cancel() |
| 182 | return | 202 | return | ||
| 183 | } | 203 | } | ||
| 184 | if cmdID, ok := commandIDByAction[item.Action]; ok { | 204 | if cmdID, ok := commandIDByAction[item.Action]; ok { | ||
| @@ -190,6 +210,17 @@ func dispatchContextMenuSelection( | |||||
| 190 | callback.Cancel() | 210 | callback.Cancel() | ||
| 191 | } | 211 | } | ||
| 192 | 212 | ||||
| 213 | + | func contextMenuCopiedText(action port.MenuAction, menuContext port.MenuContext) string { | |||
| 214 | + | switch action { | |||
| 215 | + | case port.MenuActionCopySelection: | |||
| 216 | + | return menuContext.SelectionText | |||
| 217 | + | case port.MenuActionCopyLink: | |||
| 218 | + | return menuContext.LinkURI | |||
| 219 | + | default: | |||
| 220 | + | return "" | |||
| 221 | + | } | |||
| 222 | + | } | |||
| 223 | + | ||||
| 193 | func shouldExecuteDirectCEFAction(action port.MenuAction) bool { | 224 | func shouldExecuteDirectCEFAction(action port.MenuAction) bool { | ||
| 194 | switch action { | 225 | switch action { | ||
| 195 | case port.MenuActionBack, | 226 | case port.MenuActionBack, | ||
| @@ -296,7 +327,8 @@ func buildMenuContext(wv *WebView, params purecef.ContextMenuParams) port.MenuCo | |||||
| 296 | if params.HasImageContents() { | 327 | if params.HasImageContents() { | ||
| 297 | menuContext.ImageURI = params.GetSourceURL() | 328 | menuContext.ImageURI = params.GetSourceURL() | ||
| 298 | } | 329 | } | ||
| 299 | - | menuContext.HasSelection = strings.TrimSpace(params.GetSelectionText()) != "" | 330 | + | menuContext.SelectionText = params.GetSelectionText() |
| 331 | + | menuContext.HasSelection = strings.TrimSpace(menuContext.SelectionText) != "" | |||
| 300 | menuContext.IsEditable = params.IsEditable() | 332 | menuContext.IsEditable = params.IsEditable() | ||
| 301 | menuContext.X = int(params.GetXcoord()) | 333 | menuContext.X = int(params.GetXcoord()) | ||
| 302 | menuContext.Y = int(params.GetYcoord()) | 334 | menuContext.Y = int(params.GetYcoord()) | ||
M internal/infrastructure/cef/context_menu_adapter_test.go +53 -5
| @@ -64,6 +64,7 @@ func TestBuildMenuContextFromCEFParams(t *testing.T) { | |||||
| 64 | require.Equal(t, "https://example.com/page", ctx.PageURI) | 64 | require.Equal(t, "https://example.com/page", ctx.PageURI) | ||
| 65 | require.Equal(t, "https://example.com/link", ctx.LinkURI) | 65 | require.Equal(t, "https://example.com/link", ctx.LinkURI) | ||
| 66 | require.Equal(t, "https://example.com/image.png", ctx.ImageURI) | 66 | require.Equal(t, "https://example.com/image.png", ctx.ImageURI) | ||
| 67 | + | require.Equal(t, "selected text", ctx.SelectionText) | |||
| 67 | require.True(t, ctx.HasSelection) | 68 | require.True(t, ctx.HasSelection) | ||
| 68 | require.True(t, ctx.IsEditable) | 69 | require.True(t, ctx.IsEditable) | ||
| 69 | require.True(t, ctx.CanGoBack) | 70 | require.True(t, ctx.CanGoBack) | ||
| @@ -104,7 +105,7 @@ func (c *stubRunContextMenuCallback) Cancel() { | |||||
| 104 | func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) { | 105 | func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) { | ||
| 105 | callback := &stubRunContextMenuCallback{} | 106 | callback := &stubRunContextMenuCallback{} | ||
| 106 | 107 | ||||
| 107 | - | dispatchContextMenuSelection(context.Background(), nil, callback, map[port.MenuAction]int32{ | 108 | + | dispatchContextMenuSelection(context.Background(), nil, callback, nil, map[port.MenuAction]int32{ |
| 108 | port.MenuActionReload: 102, | 109 | port.MenuActionReload: 102, | ||
| 109 | }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{}) | 110 | }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{}) | ||
| 110 | 111 | ||||
| @@ -115,7 +116,7 @@ func TestContextMenuSelectionCancelsWhenCEFCommandMissing(t *testing.T) { | |||||
| 115 | func TestContextMenuSelectionContinuesWhenCEFCommandPresent(t *testing.T) { | 116 | func TestContextMenuSelectionContinuesWhenCEFCommandPresent(t *testing.T) { | ||
| 116 | callback := &stubRunContextMenuCallback{} | 117 | callback := &stubRunContextMenuCallback{} | ||
| 117 | 118 | ||||
| 118 | - | dispatchContextMenuSelection(context.Background(), nil, callback, map[port.MenuAction]int32{ | 119 | + | dispatchContextMenuSelection(context.Background(), nil, callback, nil, map[port.MenuAction]int32{ |
| 119 | port.MenuActionInspectElement: 204, | 120 | port.MenuActionInspectElement: 204, | ||
| 120 | }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{}) | 121 | }, port.MenuItem{Action: port.MenuActionInspectElement, Label: "Inspect Element"}, port.MenuContext{}) | ||
| 121 | 122 | ||||
| @@ -133,6 +134,7 @@ func TestContextMenuSelectionExecutesDirectActionWhenExecutorAvailable(t *testin | |||||
| 133 | context.Background(), | 134 | context.Background(), | ||
| 134 | executor, | 135 | executor, | ||
| 135 | callback, | 136 | callback, | ||
| 137 | + | nil, | |||
| 136 | map[port.MenuAction]int32{port.MenuActionReload: 102}, | 138 | map[port.MenuAction]int32{port.MenuActionReload: 102}, | ||
| 137 | port.MenuItem{Action: port.MenuActionReload, Label: "Reload"}, | 139 | port.MenuItem{Action: port.MenuActionReload, Label: "Reload"}, | ||
| 138 | menuContext, | 140 | menuContext, | ||
| @@ -141,9 +143,9 @@ func TestContextMenuSelectionExecutesDirectActionWhenExecutorAvailable(t *testin | |||||
| 141 | require.Equal(t, 1, executor.executeCalls) | 143 | require.Equal(t, 1, executor.executeCalls) | ||
| 142 | require.Equal(t, port.MenuActionReload, executor.action) | 144 | require.Equal(t, port.MenuActionReload, executor.action) | ||
| 143 | require.Equal(t, menuContext, executor.menuContext) | 145 | require.Equal(t, menuContext, executor.menuContext) | ||
| 144 | - | require.Equal(t, 1, callback.contCalls) | 146 | + | require.Zero(t, callback.contCalls) |
| 145 | require.Zero(t, callback.commandID) | 147 | require.Zero(t, callback.commandID) | ||
| 146 | - | require.Zero(t, callback.cancelCalls) | 148 | + | require.Equal(t, 1, callback.cancelCalls) |
| 147 | } | 149 | } | ||
| 148 | 150 | ||||
| 149 | func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *testing.T) { | 151 | func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *testing.T) { | ||
| @@ -155,6 +157,7 @@ func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *t | |||||
| 155 | context.Background(), | 157 | context.Background(), | ||
| 156 | executor, | 158 | executor, | ||
| 157 | callback, | 159 | callback, | ||
| 160 | + | nil, | |||
| 158 | map[port.MenuAction]int32{}, | 161 | map[port.MenuAction]int32{}, | ||
| 159 | port.MenuItem{Action: port.MenuActionCopyImage, Label: "Copy Image"}, | 162 | port.MenuItem{Action: port.MenuActionCopyImage, Label: "Copy Image"}, | ||
| 160 | menuContext, | 163 | menuContext, | ||
| @@ -163,9 +166,54 @@ func TestContextMenuSelectionExecutesCopyImageDirectlyWhenExecutorAvailable(t *t | |||||
| 163 | require.Equal(t, 1, executor.executeCalls) | 166 | require.Equal(t, 1, executor.executeCalls) | ||
| 164 | require.Equal(t, port.MenuActionCopyImage, executor.action) | 167 | require.Equal(t, port.MenuActionCopyImage, executor.action) | ||
| 165 | require.Equal(t, menuContext, executor.menuContext) | 168 | require.Equal(t, menuContext, executor.menuContext) | ||
| 166 | - | require.Equal(t, 1, callback.contCalls) | 169 | + | require.Zero(t, callback.contCalls) |
| 170 | + | require.Zero(t, callback.commandID) | |||
| 171 | + | require.Equal(t, 1, callback.cancelCalls) | |||
| 172 | + | } | |||
| 173 | + | ||||
| 174 | + | func TestContextMenuSelectionExecutesCopySelectionDirectlyWhenSelectionTextAvailable(t *testing.T) { | |||
| 175 | + | callback := &stubRunContextMenuCallback{} | |||
| 176 | + | executor := &stubContextMenuExecutor{} | |||
| 177 | + | var copiedLens []int | |||
| 178 | + | menuContext := port.MenuContext{SelectionText: "selected text", HasSelection: true} | |||
| 179 | + | ||||
| 180 | + | dispatchContextMenuSelection( | |||
| 181 | + | context.Background(), | |||
| 182 | + | executor, | |||
| 183 | + | callback, | |||
| 184 | + | func(text string) { copiedLens = append(copiedLens, len(text)) }, | |||
| 185 | + | map[port.MenuAction]int32{port.MenuActionCopySelection: 333}, | |||
| 186 | + | port.MenuItem{Action: port.MenuActionCopySelection, Label: "Copy"}, | |||
| 187 | + | menuContext, | |||
| 188 | + | ) | |||
| 189 | + | ||||
| 190 | + | require.Equal(t, 1, executor.executeCalls) | |||
| 191 | + | require.Equal(t, port.MenuActionCopySelection, executor.action) | |||
| 192 | + | require.Equal(t, menuContext, executor.menuContext) | |||
| 193 | + | require.Zero(t, callback.contCalls) | |||
| 167 | require.Zero(t, callback.commandID) | 194 | require.Zero(t, callback.commandID) | ||
| 195 | + | require.Equal(t, 1, callback.cancelCalls) | |||
| 196 | + | require.Equal(t, []int{len("selected text")}, copiedLens) | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | func TestContextMenuSelectionFallsBackToNativeCopySelectionWhenTextUnavailable(t *testing.T) { | |||
| 200 | + | callback := &stubRunContextMenuCallback{} | |||
| 201 | + | executor := &stubContextMenuExecutor{} | |||
| 202 | + | ||||
| 203 | + | dispatchContextMenuSelection( | |||
| 204 | + | context.Background(), | |||
| 205 | + | executor, | |||
| 206 | + | callback, | |||
| 207 | + | nil, | |||
| 208 | + | map[port.MenuAction]int32{port.MenuActionCopySelection: 333}, | |||
| 209 | + | port.MenuItem{Action: port.MenuActionCopySelection, Label: "Copy"}, | |||
| 210 | + | port.MenuContext{HasSelection: true}, | |||
| 211 | + | ) | |||
| 212 | + | ||||
| 213 | + | require.Equal(t, 1, callback.contCalls) | |||
| 168 | require.Zero(t, callback.cancelCalls) | 214 | require.Zero(t, callback.cancelCalls) | ||
| 215 | + | require.Equal(t, int32(333), callback.commandID) | |||
| 216 | + | require.Zero(t, executor.executeCalls) | |||
| 169 | } | 217 | } | ||
| 170 | 218 | ||||
| 171 | func TestContextMenuAnchorPositionScalesCEFCoordinates(t *testing.T) { | 219 | func TestContextMenuAnchorPositionScalesCEFCoordinates(t *testing.T) { | ||
M internal/infrastructure/cef/engine.go +89 -1
| @@ -2,12 +2,15 @@ package cef | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "crypto/subtle" | |||
| 5 | "sync" | 6 | "sync" | ||
| 6 | "sync/atomic" | 7 | "sync/atomic" | ||
| 7 | "time" | 8 | "time" | ||
| 9 | + | "unicode/utf8" | |||
| 8 | 10 | ||||
| 9 | purecef "github.com/bnema/purego-cef/cef" | 11 | purecef "github.com/bnema/purego-cef/cef" | ||
| 10 | 12 | ||||
| 13 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 11 | "github.com/bnema/dumber/internal/application/port" | 14 | "github.com/bnema/dumber/internal/application/port" | ||
| 12 | "github.com/bnema/dumber/internal/logging" | 15 | "github.com/bnema/dumber/internal/logging" | ||
| 13 | ) | 16 | ) | ||
| @@ -35,6 +38,8 @@ type Engine struct { | |||||
| 35 | ctxMenuBuilder port.ContextMenuBuilder | 38 | ctxMenuBuilder port.ContextMenuBuilder | ||
| 36 | ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory | 39 | ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory | ||
| 37 | clipboard port.Clipboard | 40 | clipboard port.Clipboard | ||
| 41 | + | clipboardTextOrchestrator port.ClipboardTextOrchestrator | |||
| 42 | + | onClipboardCopied func(textLen int) | |||
| 38 | resolver port.ImageDataResolver | 43 | resolver port.ImageDataResolver | ||
| 39 | mediaClassifier MediaClassifier | 44 | mediaClassifier MediaClassifier | ||
| 40 | alreadyRunningAppRelaunchMu sync.RWMutex | 45 | alreadyRunningAppRelaunchMu sync.RWMutex | ||
| @@ -236,6 +241,8 @@ func (e *Engine) closeActiveWebViews() { | |||||
| 236 | // each handler is registered by message type with the router, which dispatches | 241 | // each handler is registered by message type with the router, which dispatches | ||
| 237 | // incoming /api/message POSTs to the correct handler based on Message.Type. | 242 | // incoming /api/message POSTs to the correct handler based on Message.Type. | ||
| 238 | func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependencies) error { | 243 | func (e *Engine) RegisterHandlers(ctx context.Context, deps port.HandlerDependencies) error { | ||
| 244 | + | e.clipboardTextOrchestrator = deps.ClipboardTextOrchestrator | |||
| 245 | + | e.onClipboardCopied = deps.OnClipboardCopied | |||
| 239 | if e.messageRouter == nil || e.registerHandlers == nil { | 246 | if e.messageRouter == nil || e.registerHandlers == nil { | ||
| 240 | return nil | 247 | return nil | ||
| 241 | } | 248 | } | ||
| @@ -250,7 +257,12 @@ func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.Accent | |||||
| 250 | } | 257 | } | ||
| 251 | 258 | ||||
| 252 | // ConfigureDownloads sets up download handling (Phase 1 stub). | 259 | // ConfigureDownloads sets up download handling (Phase 1 stub). | ||
| 253 | - | func (e *Engine) ConfigureDownloads(_ context.Context, _ string, _ port.DownloadEventHandler, _ port.DownloadPreparer) error { | 260 | + | func (e *Engine) ConfigureDownloads( |
| 261 | + | _ context.Context, | |||
| 262 | + | _ string, | |||
| 263 | + | _ port.DownloadEventHandler, | |||
| 264 | + | _ port.DownloadPreparer, | |||
| 265 | + | ) error { | |||
| 254 | return ErrDownloadsUnsupported | 266 | return ErrDownloadsUnsupported | ||
| 255 | } | 267 | } | ||
| 256 | 268 | ||||
| @@ -276,6 +288,53 @@ func (e *Engine) UpdateSettings(_ context.Context, _ port.EngineSettingsUpdate) | |||||
| 276 | return nil | 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 | // SetHandlerContext sets the base context for message handler dispatch. | 338 | // SetHandlerContext sets the base context for message handler dispatch. | ||
| 280 | func (e *Engine) SetHandlerContext(ctx context.Context) { | 339 | func (e *Engine) SetHandlerContext(ctx context.Context) { | ||
| 281 | e.ctx = ctx | 340 | e.ctx = ctx | ||
| @@ -359,6 +418,35 @@ func (e *Engine) currentContext() context.Context { | |||||
| 359 | return e.ctx | 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 | func (e *Engine) logTranscoderStartupState() { | 450 | func (e *Engine) logTranscoderStartupState() { | ||
| 363 | if e == nil || !e.transcoderStateLogged.CompareAndSwap(false, true) { | 451 | if e == nil || !e.transcoderStateLogged.CompareAndSwap(false, true) { | ||
| 364 | return | 452 | return | ||
M internal/infrastructure/cef/engine_init.go +35 -58
| @@ -17,6 +17,7 @@ import ( | |||||
| 17 | "github.com/bnema/dumber/assets" | 17 | "github.com/bnema/dumber/assets" | ||
| 18 | "github.com/bnema/dumber/internal/application/port" | 18 | "github.com/bnema/dumber/internal/application/port" | ||
| 19 | "github.com/bnema/dumber/internal/infrastructure/config" | 19 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 20 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 20 | transcoderpkg "github.com/bnema/dumber/internal/infrastructure/transcoder" | 21 | transcoderpkg "github.com/bnema/dumber/internal/infrastructure/transcoder" | ||
| 21 | "github.com/bnema/dumber/internal/logging" | 22 | "github.com/bnema/dumber/internal/logging" | ||
| 22 | ) | 23 | ) | ||
| @@ -28,6 +29,7 @@ const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH" | |||||
| 28 | func NewEngine( | 29 | func NewEngine( | ||
| 29 | ctx context.Context, | 30 | ctx context.Context, | ||
| 30 | opts port.EngineOptions, | 31 | opts port.EngineOptions, | ||
| 32 | + | profile runtimeprofile.Profile, | |||
| 31 | cfg RuntimeConfig, | 33 | cfg RuntimeConfig, | ||
| 32 | transcodingCfg TranscodingRuntimeConfig, | 34 | transcodingCfg TranscodingRuntimeConfig, | ||
| 33 | audioFactory port.AudioOutputFactory, | 35 | audioFactory port.AudioOutputFactory, | ||
| @@ -35,11 +37,11 @@ func NewEngine( | |||||
| 35 | ) (*Engine, error) { | 37 | ) (*Engine, error) { | ||
| 36 | logger := logging.FromContext(ctx) | 38 | logger := logging.FromContext(ctx) | ||
| 37 | deps.MediaClassifier = deps.MediaClassifier.normalize() | 39 | deps.MediaClassifier = deps.MediaClassifier.normalize() | ||
| 38 | - | stateRoot := resolvedStateRoot(opts) | 40 | + | stateRoot := resolvedStateRoot(profile, opts) |
| 39 | cleanStaleSingletonLocks(logger, stateRoot) | 41 | cleanStaleSingletonLocks(logger, stateRoot) | ||
| 40 | windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate() | 42 | windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate() | ||
| 41 | 43 | ||||
| 42 | - | settings, err := prepareCEFSettings(opts, cfg, logger) | 44 | + | settings, err := prepareCEFSettings(opts, profile, cfg, logger) |
| 43 | if err != nil { | 45 | if err != nil { | ||
| 44 | return nil, err | 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 | if root := os.Getenv(CEFRootCachePathEnvVar); root != "" { | 95 | if root := os.Getenv(CEFRootCachePathEnvVar); root != "" { | ||
| 94 | return root | 96 | return root | ||
| 95 | } | 97 | } | ||
| 96 | - | 98 | + | if opts.CacheDir != "" { | |
| 97 | - | switch { | |||
| 98 | - | case opts.DataDir != "": | |||
| 99 | - | return opts.DataDir | |||
| 100 | - | case opts.CacheDir != "": | |||
| 101 | return opts.CacheDir | 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 | // prepareCEFSettings builds purecef.Settings from the engine config. | 107 | // prepareCEFSettings builds purecef.Settings from the engine config. | ||
| 108 | - | func prepareCEFSettings(opts port.EngineOptions, cfg RuntimeConfig, logger *zerolog.Logger) (purecef.Settings, error) { | 108 | + | func prepareCEFSettings( |
| 109 | + | opts port.EngineOptions, | |||
| 110 | + | profile runtimeprofile.Profile, | |||
| 111 | + | cfg RuntimeConfig, | |||
| 112 | + | logger *zerolog.Logger, | |||
| 113 | + | ) (purecef.Settings, error) { | |||
| 109 | switch opts.CookiePolicy { | 114 | switch opts.CookiePolicy { | ||
| 110 | case "", port.CookiePolicyAlways: | 115 | case "", port.CookiePolicyAlways: | ||
| 111 | case port.CookiePolicyNoThirdParty: | 116 | case port.CookiePolicyNoThirdParty: | ||
| @@ -121,16 +126,16 @@ func prepareCEFSettings(opts port.EngineOptions, cfg RuntimeConfig, logger *zero | |||||
| 121 | settings := purecef.DefaultSettings() | 126 | settings := purecef.DefaultSettings() | ||
| 122 | settings.MultiThreadedMessageLoop = true | 127 | settings.MultiThreadedMessageLoop = true | ||
| 123 | settings.ExternalMessagePump = false | 128 | settings.ExternalMessagePump = false | ||
| 124 | - | settings.RootCachePath = resolvedStateRoot(opts) | 129 | + | settings.RootCachePath = resolvedStateRoot(profile, opts) |
| 125 | if cfg.CEFDir != "" { | 130 | if cfg.CEFDir != "" { | ||
| 126 | settings.CEFDir = cfg.CEFDir | 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 | logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file") | 134 | logger.Warn().Err(err).Msg("cef: failed to prepare runtime log file") | ||
| 130 | } else { | 135 | } else { | ||
| 131 | settings.LogFile = runtimeLogFile | 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 | logger.Warn().Err(err).Msg("cef: failed to prepare init trace file") | 139 | logger.Warn().Err(err).Msg("cef: failed to prepare init trace file") | ||
| 135 | } else if bootstrapLogFile != "" { | 140 | } else if bootstrapLogFile != "" { | ||
| 136 | settings.InitTraceFile = bootstrapLogFile | 141 | settings.InitTraceFile = bootstrapLogFile | ||
| @@ -151,24 +156,6 @@ func prepareCEFSettings(opts port.EngineOptions, cfg RuntimeConfig, logger *zero | |||||
| 151 | return settings, nil | 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 | // initializeCEF calls cef_initialize with the App to register custom schemes | 159 | // initializeCEF calls cef_initialize with the App to register custom schemes | ||
| 173 | // and browser-process callbacks (OnBeforeCommandLineProcessing, etc.). | 160 | // and browser-process callbacks (OnBeforeCommandLineProcessing, etc.). | ||
| 174 | func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logger) error { | 161 | func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logger) error { | ||
| @@ -253,9 +240,9 @@ func wireEngine( | |||||
| 253 | } | 240 | } | ||
| 254 | schemeHandler.setAssets(assets.WebUIAssets) | 241 | schemeHandler.setAssets(assets.WebUIAssets) | ||
| 255 | 242 | ||||
| 256 | - | // Bridge clipboard writes from CEF JS → GDK system clipboard. | 243 | + | // Bridge clipboard writes from page JS → GDK system clipboard. |
| 257 | - | // The callback is invoked on the CEF IO thread, so we schedule the | 244 | + | // The callback is invoked on the CEF IO thread, so schedule the actual GDK |
| 258 | - | // GDK write on the GTK main loop via glib.IdleAddOnce. | 245 | + | // write on the GTK main loop. |
| 259 | schemeHandler.onClipboardSet = func(text string) { | 246 | schemeHandler.onClipboardSet = func(text string) { | ||
| 260 | fn := glib.SourceOnceFunc(func(_ uintptr) { | 247 | fn := glib.SourceOnceFunc(func(_ uintptr) { | ||
| 261 | if display := gdk.DisplayGetDefault(); display != nil { | 248 | if display := gdk.DisplayGetDefault(); display != nil { | ||
| @@ -272,6 +259,9 @@ func wireEngine( | |||||
| 272 | glib.IdleAddOnce(&fn, 0) | 259 | glib.IdleAddOnce(&fn, 0) | ||
| 273 | } | 260 | } | ||
| 274 | 261 | ||||
| 262 | + | schemeHandler.onEditableFocus = eng.handleEditableFocusBridge | |||
| 263 | + | schemeHandler.bridgeNonceValidator = eng.validateBridgeRequest | |||
| 264 | + | ||||
| 275 | schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler) | 265 | schemeFactory := purecef.NewSchemeHandlerFactory(schemeHandler) | ||
| 276 | 266 | ||||
| 277 | result := purecef.RegisterSchemeHandlerFactory("dumb", "", schemeFactory) | 267 | result := purecef.RegisterSchemeHandlerFactory("dumb", "", schemeFactory) | ||
| @@ -433,33 +423,24 @@ func findHelperBinary() string { | |||||
| 433 | } | 423 | } | ||
| 434 | 424 | ||||
| 435 | const ( | 425 | const ( | ||
| 436 | - | dirPerm = 0o755 | 426 | + | dirPerm = 0o700 |
| 437 | - | filePerm = 0o644 | 427 | + | filePerm = 0o600 |
| 438 | ) | 428 | ) | ||
| 439 | 429 | ||||
| 440 | - | func prepareCEFLogFile(logFile string) (string, error) { | 430 | + | func prepareCEFLogFile(profile runtimeprofile.Profile, logFile string) (string, error) { |
| 441 | - | if logFile != "" { | 431 | + | runtimeLogFile := logFile |
| 442 | - | runtimeLogFile := filepath.Clean(logFile) | 432 | + | if runtimeLogFile != "" { |
| 443 | - | if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil { | 433 | + | runtimeLogFile = filepath.Clean(runtimeLogFile) |
| 444 | - | return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err) | 434 | + | } else { |
| 445 | - | } | 435 | + | runtimeLogFile = profile.CEFLogFile() |
| 446 | - | return runtimeLogFile, nil | |||
| 447 | - | } | |||
| 448 | - | ||||
| 449 | - | // Dev-mode default: logs go into .dev/dumber/logs/ relative to CWD. | |||
| 450 | - | // Production deployments should set cfg.LogFile explicitly. | |||
| 451 | - | cwd, cwdErr := os.Getwd() | |||
| 452 | - | if cwdErr != nil { | |||
| 453 | - | return "", fmt.Errorf("getwd: %w", cwdErr) | |||
| 454 | } | 436 | } | ||
| 455 | - | runtimeLogFile := filepath.Join(cwd, ".dev", "dumber", "logs", "cef_runtime.log") | |||
| 456 | if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil { | 437 | if err := os.MkdirAll(filepath.Dir(runtimeLogFile), dirPerm); err != nil { | ||
| 457 | return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err) | 438 | return "", fmt.Errorf("mkdir %s: %w", filepath.Dir(runtimeLogFile), err) | ||
| 458 | } | 439 | } | ||
| 459 | return runtimeLogFile, nil | 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 | if !puregoCEFInitTraceEnabled() { | 444 | if !puregoCEFInitTraceEnabled() { | ||
| 464 | return "", nil | 445 | return "", nil | ||
| 465 | } | 446 | } | ||
| @@ -468,11 +449,7 @@ func prepareCEFInitTraceFile(logFile string) (string, error) { | |||||
| 468 | if runtimeLogFile != "" { | 449 | if runtimeLogFile != "" { | ||
| 469 | runtimeLogFile = filepath.Clean(runtimeLogFile) | 450 | runtimeLogFile = filepath.Clean(runtimeLogFile) | ||
| 470 | } else { | 451 | } else { | ||
| 471 | - | cwd, cwdErr := os.Getwd() | 452 | + | runtimeLogFile = profile.CEFLogFile() |
| 472 | - | if cwdErr != nil { | |||
| 473 | - | return "", fmt.Errorf("getwd: %w", cwdErr) | |||
| 474 | - | } | |||
| 475 | - | runtimeLogFile = filepath.Join(cwd, ".dev", "dumber", "logs", "cef_runtime.log") | |||
| 476 | } | 453 | } | ||
| 477 | bootstrapLogFile := strings.TrimSuffix(runtimeLogFile, filepath.Ext(runtimeLogFile)) + ".bootstrap.log" | 454 | bootstrapLogFile := strings.TrimSuffix(runtimeLogFile, filepath.Ext(runtimeLogFile)) + ".bootstrap.log" | ||
| 478 | if err := os.MkdirAll(filepath.Dir(bootstrapLogFile), dirPerm); err != nil { | 455 | if err := os.MkdirAll(filepath.Dir(bootstrapLogFile), dirPerm); err != nil { | ||
M internal/infrastructure/cef/engine_init_test.go +81 -50
| @@ -7,105 +7,101 @@ import ( | |||||
| 7 | 7 | ||||
| 8 | "github.com/bnema/dumber/internal/application/port" | 8 | "github.com/bnema/dumber/internal/application/port" | ||
| 9 | "github.com/bnema/dumber/internal/infrastructure/audio/factory" | 9 | "github.com/bnema/dumber/internal/infrastructure/audio/factory" | ||
| 10 | - | "github.com/bnema/dumber/internal/infrastructure/config" | 10 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" |
| 11 | "github.com/rs/zerolog" | 11 | "github.com/rs/zerolog" | ||
| 12 | "github.com/stretchr/testify/require" | 12 | "github.com/stretchr/testify/require" | ||
| 13 | ) | 13 | ) | ||
| 14 | 14 | ||||
| 15 | - | func TestResolvedStateRoot_PrefersDataDir(t *testing.T) { | 15 | + | func TestResolvedStateRoot_PrefersCacheDir(t *testing.T) { |
| 16 | - | root := resolvedStateRoot(port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}) | 16 | + | profile := testCEFDevProfile(t) |
| 17 | - | require.Equal(t, "/tmp/data", root) | 17 | + | root := resolvedStateRoot(profile, port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}) |
| 18 | - | } | |||
| 19 | - | ||||
| 20 | - | func TestResolvedStateRoot_FallsBackToCacheDir(t *testing.T) { | |||
| 21 | - | root := resolvedStateRoot(port.EngineOptions{CacheDir: "/tmp/cache"}) | |||
| 22 | require.Equal(t, "/tmp/cache", root) | 18 | require.Equal(t, "/tmp/cache", root) | ||
| 23 | } | 19 | } | ||
| 24 | 20 | ||||
| 25 | func TestResolvedStateRoot_UsesEnvOverride(t *testing.T) { | 21 | func TestResolvedStateRoot_UsesEnvOverride(t *testing.T) { | ||
| 22 | + | profile := testCEFDevProfile(t) | |||
| 26 | t.Setenv(CEFRootCachePathEnvVar, "/tmp/override") | 23 | t.Setenv(CEFRootCachePathEnvVar, "/tmp/override") | ||
| 27 | 24 | ||||
| 28 | - | root := resolvedStateRoot(port.EngineOptions{}) | 25 | + | root := resolvedStateRoot(profile, port.EngineOptions{}) |
| 29 | require.Equal(t, "/tmp/override", root) | 26 | require.Equal(t, "/tmp/override", root) | ||
| 30 | 27 | ||||
| 31 | - | root = resolvedStateRoot(port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}) | 28 | + | root = resolvedStateRoot(profile, port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}) |
| 32 | require.Equal(t, "/tmp/override", root) | 29 | require.Equal(t, "/tmp/override", root) | ||
| 33 | } | 30 | } | ||
| 34 | 31 | ||||
| 35 | - | func TestPrepareCEFSettings_UsesResolvedStateRoot(t *testing.T) { | 32 | + | func TestResolvedStateRoot_UsesProfileDefault(t *testing.T) { |
| 33 | + | profile := testCEFProdProfile(t) | |||
| 34 | + | root := resolvedStateRoot(profile, port.EngineOptions{}) | |||
| 35 | + | require.Equal(t, profile.CEFUserDataDir(), root) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) { | |||
| 36 | logger := zerolog.Nop() | 39 | logger := zerolog.Nop() | ||
| 37 | - | settings, err := prepareCEFSettings(port.EngineOptions{CacheDir: "/tmp/cache"}, RuntimeConfig{}, &logger) | 40 | + | profile := testCEFDevProfile(t) |
| 41 | + | settings, err := prepareCEFSettings(port.EngineOptions{}, profile, RuntimeConfig{}, &logger) | |||
| 38 | require.NoError(t, err) | 42 | require.NoError(t, err) | ||
| 39 | - | require.Equal(t, "/tmp/cache", settings.RootCachePath) | 43 | + | require.Equal(t, profile.CEFUserDataDir(), settings.RootCachePath) |
| 44 | + | require.Equal(t, profile.CEFLogFile(), settings.LogFile) | |||
| 40 | } | 45 | } | ||
| 41 | 46 | ||||
| 42 | func TestPrepareCEFSettings_RejectsNonDefaultCookiePolicy(t *testing.T) { | 47 | func TestPrepareCEFSettings_RejectsNonDefaultCookiePolicy(t *testing.T) { | ||
| 43 | logger := zerolog.Nop() | 48 | logger := zerolog.Nop() | ||
| 44 | - | _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNever}, RuntimeConfig{}, &logger) | 49 | + | _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNever}, testCEFDevProfile(t), RuntimeConfig{}, &logger) |
| 45 | require.ErrorIs(t, err, ErrCookiePolicyUnsupported) | 50 | require.ErrorIs(t, err, ErrCookiePolicyUnsupported) | ||
| 46 | } | 51 | } | ||
| 47 | 52 | ||||
| 48 | func TestPrepareCEFSettings_AllowsNoThirdPartyCookiePolicy(t *testing.T) { | 53 | func TestPrepareCEFSettings_AllowsNoThirdPartyCookiePolicy(t *testing.T) { | ||
| 49 | logger := zerolog.Nop() | 54 | logger := zerolog.Nop() | ||
| 50 | - | _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNoThirdParty}, RuntimeConfig{}, &logger) | 55 | + | _, err := prepareCEFSettings(port.EngineOptions{CookiePolicy: port.CookiePolicyNoThirdParty}, testCEFDevProfile(t), RuntimeConfig{}, &logger) |
| 51 | require.NoError(t, err) | 56 | require.NoError(t, err) | ||
| 52 | } | 57 | } | ||
| 53 | 58 | ||||
| 54 | func TestPrepareCEFInitTraceFile_DisabledByDefault(t *testing.T) { | 59 | func TestPrepareCEFInitTraceFile_DisabledByDefault(t *testing.T) { | ||
| 55 | t.Setenv(puregoCEFInitTraceEnvVar, "") | 60 | t.Setenv(puregoCEFInitTraceEnvVar, "") | ||
| 56 | 61 | ||||
| 57 | - | path, err := prepareCEFInitTraceFile("") | 62 | + | path, err := prepareCEFInitTraceFile(testCEFDevProfile(t), "") |
| 58 | require.NoError(t, err) | 63 | require.NoError(t, err) | ||
| 59 | require.Empty(t, path) | 64 | require.Empty(t, path) | ||
| 60 | } | 65 | } | ||
| 61 | 66 | ||||
| 62 | - | func TestPrepareCEFInitTraceFile_EnabledViaEnv(t *testing.T) { | 67 | + | func TestPrepareCEFLogFile_CreatesPrivateDirectory(t *testing.T) { |
| 63 | - | t.Setenv(puregoCEFInitTraceEnvVar, "1") | 68 | + | logFile := filepath.Join(t.TempDir(), "private", "cef_runtime.log") |
| 64 | - | cfg := config.CEFEngineConfig{ | |||
| 65 | - | LogFile: filepath.Join(t.TempDir(), "cef_runtime.log"), | |||
| 66 | - | } | |||
| 67 | 69 | ||||
| 68 | - | path, err := prepareCEFInitTraceFile(cfg.LogFile) | 70 | + | path, err := prepareCEFLogFile(testCEFDevProfile(t), logFile) |
| 69 | require.NoError(t, err) | 71 | require.NoError(t, err) | ||
| 70 | - | require.Equal(t, filepath.Join(filepath.Dir(cfg.LogFile), "cef_runtime.bootstrap.log"), path) | 72 | + | require.Equal(t, logFile, path) |
| 71 | - | require.FileExists(t, path) | 73 | + | |
| 74 | + | info, err := os.Stat(filepath.Dir(logFile)) | |||
| 75 | + | require.NoError(t, err) | |||
| 76 | + | require.Equal(t, os.FileMode(dirPerm), info.Mode().Perm()) | |||
| 72 | } | 77 | } | ||
| 73 | 78 | ||||
| 74 | - | func TestPrepareCEFSettings_RootCachePath_UsesXDGDataDir(t *testing.T) { | 79 | + | func TestPrepareCEFInitTraceFile_EnabledViaExplicitLogFile(t *testing.T) { |
| 75 | - | homeDir := filepath.Join(t.TempDir(), "home") | 80 | + | t.Setenv(puregoCEFInitTraceEnvVar, "1") |
| 76 | - | dataHome := filepath.Join(homeDir, ".local", "share") | 81 | + | logFile := filepath.Join(t.TempDir(), "custom", "cef_runtime.log") |
| 77 | - | t.Setenv("HOME", homeDir) | |||
| 78 | - | t.Setenv("ENV", "") | |||
| 79 | - | t.Setenv("XDG_DATA_HOME", dataHome) | |||
| 80 | 82 | ||||
| 81 | - | logger := zerolog.Nop() | 83 | + | path, err := prepareCEFInitTraceFile(testCEFDevProfile(t), logFile) |
| 82 | - | settings, err := prepareCEFSettings(port.EngineOptions{}, RuntimeConfig{}, &logger) | |||
| 83 | require.NoError(t, err) | 84 | require.NoError(t, err) | ||
| 85 | + | require.Equal(t, filepath.Join(filepath.Dir(logFile), "cef_runtime.bootstrap.log"), path) | |||
| 86 | + | require.FileExists(t, path) | |||
| 84 | 87 | ||||
| 85 | - | require.Equal(t, filepath.Join(dataHome, "dumber", "cef_user_data"), settings.RootCachePath) | 88 | + | info, err := os.Stat(path) |
| 86 | - | } | |||
| 87 | - | ||||
| 88 | - | func TestPrepareCEFSettings_RootCachePath_DevMode(t *testing.T) { | |||
| 89 | - | // Create a temp directory and change into it for the test | |||
| 90 | - | tempDir := t.TempDir() | |||
| 91 | - | originalDir, err := os.Getwd() | |||
| 92 | require.NoError(t, err) | 89 | require.NoError(t, err) | ||
| 93 | - | defer func() { | 90 | + | require.Equal(t, os.FileMode(filePerm), info.Mode().Perm()) |
| 94 | - | _ = os.Chdir(originalDir) | |||
| 95 | - | }() | |||
| 96 | 91 | ||||
| 97 | - | err = os.Chdir(tempDir) | 92 | + | dirInfo, err := os.Stat(filepath.Dir(path)) |
| 98 | require.NoError(t, err) | 93 | require.NoError(t, err) | ||
| 94 | + | require.Equal(t, os.FileMode(dirPerm), dirInfo.Mode().Perm()) | |||
| 95 | + | } | |||
| 99 | 96 | ||||
| 100 | - | // Set ENV=dev to trigger dev mode | 97 | + | func TestPrepareCEFInitTraceFile_UsesResolvedProfileLogDirByDefault(t *testing.T) { |
| 101 | - | t.Setenv("ENV", "dev") | 98 | + | t.Setenv(puregoCEFInitTraceEnvVar, "1") |
| 99 | + | profile := testCEFDevProfile(t) | |||
| 102 | 100 | ||||
| 103 | - | logger := zerolog.Nop() | 101 | + | path, err := prepareCEFInitTraceFile(profile, "") |
| 104 | - | settings, err := prepareCEFSettings(port.EngineOptions{}, RuntimeConfig{}, &logger) | |||
| 105 | require.NoError(t, err) | 102 | require.NoError(t, err) | ||
| 106 | - | 103 | + | require.Equal(t, filepath.Join(profile.EnginePaths.LogDir, "cef_runtime.bootstrap.log"), path) | |
| 107 | - | // In dev mode, should resolve under .dev/dumber/cef_user_data | 104 | + | require.FileExists(t, path) |
| 108 | - | require.Equal(t, filepath.Join(tempDir, ".dev", "dumber", "cef_user_data"), settings.RootCachePath) | |||
| 109 | } | 105 | } | ||
| 110 | 106 | ||||
| 111 | // TestCreateAudioOutputFactory_CreatesUsableFactory verifies that the audio | 107 | // TestCreateAudioOutputFactory_CreatesUsableFactory verifies that the audio | ||
| @@ -116,3 +112,38 @@ func TestCreateAudioOutputFactory_ReturnsFactory(t *testing.T) { | |||||
| 116 | 112 | ||||
| 117 | require.NotNil(t, audioFactory, "Audio factory should not be nil") | 113 | require.NotNil(t, audioFactory, "Audio factory should not be nil") | ||
| 118 | } | 114 | } | ||
| 115 | + | ||||
| 116 | + | func testCEFDevProfile(t *testing.T) runtimeprofile.Profile { | |||
| 117 | + | t.Helper() | |||
| 118 | + | cwd := t.TempDir() | |||
| 119 | + | profile, err := runtimeprofile.Resolve(runtimeprofile.ResolveInput{ | |||
| 120 | + | Env: func(key string) string { | |||
| 121 | + | if key == "ENV" { | |||
| 122 | + | return "dev" | |||
| 123 | + | } | |||
| 124 | + | return "" | |||
| 125 | + | }, | |||
| 126 | + | Engine: "cef", | |||
| 127 | + | CWD: func() (string, error) { return cwd, nil }, | |||
| 128 | + | }) | |||
| 129 | + | require.NoError(t, err) | |||
| 130 | + | return profile | |||
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func testCEFProdProfile(t *testing.T) runtimeprofile.Profile { | |||
| 134 | + | t.Helper() | |||
| 135 | + | root := t.TempDir() | |||
| 136 | + | profile, err := runtimeprofile.Resolve(runtimeprofile.ResolveInput{ | |||
| 137 | + | Env: func(string) string { return "" }, | |||
| 138 | + | Engine: "cef", | |||
| 139 | + | CWD: func() (string, error) { return root, nil }, | |||
| 140 | + | Base: runtimeprofile.BasePaths{ | |||
| 141 | + | ConfigHome: filepath.Join(root, "config"), | |||
| 142 | + | DataHome: filepath.Join(root, "data"), | |||
| 143 | + | StateHome: filepath.Join(root, "state"), | |||
| 144 | + | CacheHome: filepath.Join(root, "cache"), | |||
| 145 | + | }, | |||
| 146 | + | }) | |||
| 147 | + | require.NoError(t, err) | |||
| 148 | + | return profile | |||
| 149 | + | } | |||
M internal/infrastructure/cef/engine_runtime_test.go +52 -0
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "errors" | 5 | "errors" | ||
| 6 | "testing" | 6 | "testing" | ||
| 7 | 7 | ||||
| 8 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 8 | "github.com/bnema/dumber/internal/application/port" | 9 | "github.com/bnema/dumber/internal/application/port" | ||
| 9 | "github.com/stretchr/testify/require" | 10 | "github.com/stretchr/testify/require" | ||
| 10 | ) | 11 | ) | ||
| @@ -51,3 +52,54 @@ func TestEngineRegisterHandlers_PropagatesRegistrarError(t *testing.T) { | |||||
| 51 | err := eng.RegisterHandlers(context.Background(), port.HandlerDependencies{}) | 52 | err := eng.RegisterHandlers(context.Background(), port.HandlerDependencies{}) | ||
| 52 | require.ErrorIs(t, err, want) | 53 | require.ErrorIs(t, err, want) | ||
| 53 | } | 54 | } | ||
| 55 | + | ||||
| 56 | + | func TestEngineRegisterHandlers_StoresClipboardWiring(t *testing.T) { | |||
| 57 | + | orchestrator := &recordingClipboardTextOrchestrator{} | |||
| 58 | + | onCopied := func(int) {} | |||
| 59 | + | eng := &Engine{ | |||
| 60 | + | messageRouter: NewMessageRouter(context.Background()), | |||
| 61 | + | registerHandlers: func(context.Context, port.WebUIHandlerRouter, port.HandlerDependencies) error { | |||
| 62 | + | return nil | |||
| 63 | + | }, | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | require.NoError(t, eng.RegisterHandlers(context.Background(), port.HandlerDependencies{ | |||
| 67 | + | ClipboardTextOrchestrator: orchestrator, | |||
| 68 | + | OnClipboardCopied: onCopied, | |||
| 69 | + | })) | |||
| 70 | + | require.Same(t, orchestrator, eng.clipboardTextOrchestrator) | |||
| 71 | + | require.NotNil(t, eng.onClipboardCopied) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestEngineClipboardSelectionUpdate_ForwardsViewID(t *testing.T) { | |||
| 75 | + | orchestrator := &recordingClipboardTextOrchestrator{} | |||
| 76 | + | eng := &Engine{clipboardTextOrchestrator: orchestrator} | |||
| 77 | + | ||||
| 78 | + | eng.handleClipboardSelectionUpdate(99, "selected") | |||
| 79 | + | ||||
| 80 | + | require.Equal(t, dto.SelectionClipboardInput{Text: "selected", SourceEngine: dto.ClipboardSourceCEF, ViewID: 99}, orchestrator.selection) | |||
| 81 | + | } | |||
| 82 | + | ||||
| 83 | + | func TestEngineExplicitClipboardCopy_ForwardsViewID(t *testing.T) { | |||
| 84 | + | orchestrator := &recordingClipboardTextOrchestrator{} | |||
| 85 | + | eng := &Engine{clipboardTextOrchestrator: orchestrator} | |||
| 86 | + | ||||
| 87 | + | eng.handleExplicitClipboardBridgeText(77, "copy", "copied") | |||
| 88 | + | ||||
| 89 | + | require.Equal(t, dto.ExplicitClipboardInput{Text: "copied", SourceEngine: dto.ClipboardSourceCEF, ViewID: 77, Action: "copy", NativeHandled: false}, orchestrator.explicit) | |||
| 90 | + | } | |||
| 91 | + | ||||
| 92 | + | type recordingClipboardTextOrchestrator struct { | |||
| 93 | + | selection dto.SelectionClipboardInput | |||
| 94 | + | explicit dto.ExplicitClipboardInput | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func (r *recordingClipboardTextOrchestrator) HandleSelectionUpdate(_ context.Context, input dto.SelectionClipboardInput) error { | |||
| 98 | + | r.selection = input | |||
| 99 | + | return nil | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func (r *recordingClipboardTextOrchestrator) HandleExplicitCopy(_ context.Context, input dto.ExplicitClipboardInput) error { | |||
| 103 | + | r.explicit = input | |||
| 104 | + | return nil | |||
| 105 | + | } | |||
M internal/infrastructure/cef/factory.go +17 -1
| @@ -103,6 +103,12 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |||||
| 103 | wv.findCtrl = newFindController() | 103 | wv.findCtrl = newFindController() | ||
| 104 | 104 | ||||
| 105 | input := newInputBridge(ctx, f.scale) | 105 | input := newInputBridge(ctx, f.scale) | ||
| 106 | + | input.selectionText = wv.selectedTextSnapshot | |||
| 107 | + | input.explicitCopyText = func(action, text string) { | |||
| 108 | + | if f.engine != nil { | |||
| 109 | + | f.engine.handleExplicitClipboardBridgeText(wv.id, action, text) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 106 | input.attachTo(pipeline.glArea) | 112 | input.attachTo(pipeline.glArea) | ||
| 107 | wv.input = input | 113 | wv.input = input | ||
| 108 | 114 | ||||
| @@ -172,14 +178,24 @@ func (f *WebViewFactory) configureInitialBrowserCreation( | |||||
| 172 | log.Debug().Msg("cef: skipping browser creation for destroyed webview") | 178 | log.Debug().Msg("cef: skipping browser creation for destroyed webview") | ||
| 173 | return | 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 | result := purecef.BrowserHostCreateBrowser( | 187 | result := purecef.BrowserHostCreateBrowser( | ||
| 176 | pc.windowInfo, | 188 | pc.windowInfo, | ||
| 177 | pc.client, | 189 | pc.client, | ||
| 178 | - | "about:blank", | 190 | + | initialURL, |
| 179 | pc.settings, | 191 | pc.settings, | ||
| 180 | nil, // extraInfo | 192 | nil, // extraInfo | ||
| 181 | nil, // requestContext | 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 | if f.engine != nil { | 199 | if f.engine != nil { | ||
| 184 | f.engine.recordBrowserCreateRequest(w, h, result) | 200 | f.engine.recordBrowserCreateRequest(w, h, result) | ||
| 185 | } | 201 | } | ||
M internal/infrastructure/cef/input_bridge.go +86 -4
| @@ -3,6 +3,8 @@ package cef | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "sync" | 5 | "sync" | ||
| 6 | + | "sync/atomic" | |||
| 7 | + | "unicode/utf8" | |||
| 6 | 8 | ||||
| 7 | purecef "github.com/bnema/purego-cef/cef" | 9 | purecef "github.com/bnema/purego-cef/cef" | ||
| 8 | "github.com/bnema/puregotk/v4/gdk" | 10 | "github.com/bnema/puregotk/v4/gdk" | ||
| @@ -56,6 +58,14 @@ type inputBridge struct { | |||||
| 56 | // ImeCommitText. Set once during attachTo, read-only afterwards. | 58 | // ImeCommitText. Set once during attachTo, read-only afterwards. | ||
| 57 | clipboard *gdk.Clipboard | 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 | // IMContext for dead key / compose sequence support. Held as a field | 69 | // IMContext for dead key / compose sequence support. Held as a field | ||
| 60 | // to prevent garbage collection while the controller is alive. | 70 | // to prevent garbage collection while the controller is alive. | ||
| 61 | imContext *gtk.IMContextSimple | 71 | imContext *gtk.IMContextSimple | ||
| @@ -67,12 +77,32 @@ type inputBridge struct { | |||||
| 67 | // show the text caret. | 77 | // show the text caret. | ||
| 68 | glArea *gtk.GLArea | 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 | // onMiddleClick is called when button 2 (middle) is pressed on a link. | 85 | // onMiddleClick is called when button 2 (middle) is pressed on a link. | ||
| 71 | // The callback receives the hovered URI. Set by the factory. | 86 | // The callback receives the hovered URI. Set by the factory. | ||
| 72 | onMiddleClick func(uri string) | 87 | onMiddleClick func(uri string) | ||
| 73 | } | 88 | } | ||
| 74 | 89 | ||||
| 75 | // newInputBridge creates an input bridge with the given HiDPI scale factor. | 90 | // newInputBridge creates an input bridge with the given HiDPI scale factor. | ||
| 91 | + | type browserFocusSyncHost interface { | |||
| 92 | + | WasHidden(hidden int32) | |||
| 93 | + | SetFocus(focus int32) | |||
| 94 | + | Invalidate(elementType purecef.PaintElementType) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func syncWindowlessBrowserFocus(host browserFocusSyncHost) { | |||
| 98 | + | if host == nil { | |||
| 99 | + | return | |||
| 100 | + | } | |||
| 101 | + | host.WasHidden(0) | |||
| 102 | + | host.SetFocus(1) | |||
| 103 | + | host.Invalidate(purecef.PaintElementTypePetView) | |||
| 104 | + | } | |||
| 105 | + | ||||
| 76 | func newInputBridge(ctx context.Context, scale int32) *inputBridge { | 106 | func newInputBridge(ctx context.Context, scale int32) *inputBridge { | ||
| 77 | return &inputBridge{ctx: ctx, scale: scale} | 107 | return &inputBridge{ctx: ctx, scale: scale} | ||
| 78 | } | 108 | } | ||
| @@ -163,6 +193,7 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) { | |||||
| 163 | // Focus — forward to CEF and IMContext. | 193 | // Focus — forward to CEF and IMContext. | ||
| 164 | focus := gtk.NewEventControllerFocus() | 194 | focus := gtk.NewEventControllerFocus() | ||
| 165 | focusEnterCb := func(_ gtk.EventControllerFocus) { | 195 | focusEnterCb := func(_ gtk.EventControllerFocus) { | ||
| 196 | + | ib.gtkFocused.Store(true) | |||
| 166 | if ib.imContext != nil { | 197 | if ib.imContext != nil { | ||
| 167 | ib.imContext.FocusIn() | 198 | ib.imContext.FocusIn() | ||
| 168 | } | 199 | } | ||
| @@ -170,6 +201,7 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) { | |||||
| 170 | } | 201 | } | ||
| 171 | focus.ConnectEnter(&focusEnterCb) | 202 | focus.ConnectEnter(&focusEnterCb) | ||
| 172 | focusLeaveCb := func(_ gtk.EventControllerFocus) { | 203 | focusLeaveCb := func(_ gtk.EventControllerFocus) { | ||
| 204 | + | ib.gtkFocused.Store(false) | |||
| 173 | if ib.imContext != nil { | 205 | if ib.imContext != nil { | ||
| 174 | ib.imContext.Reset() | 206 | ib.imContext.Reset() | ||
| 175 | ib.imContext.FocusOut() | 207 | ib.imContext.FocusOut() | ||
| @@ -200,6 +232,8 @@ func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) { | |||||
| 200 | keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool { | 232 | keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool { | ||
| 201 | mods := uint(state) | 233 | mods := uint(state) | ||
| 202 | 234 | ||||
| 235 | + | ib.maybeMirrorClipboardShortcut(keyval, mods) | |||
| 236 | + | ||||
| 203 | // Intercept Ctrl+V / Ctrl+Shift+V: CEF OSR cannot access the | 237 | // Intercept Ctrl+V / Ctrl+Shift+V: CEF OSR cannot access the | ||
| 204 | // Wayland/X11 clipboard, so we read from GDK and inject via | 238 | // Wayland/X11 clipboard, so we read from GDK and inject via | ||
| 205 | // ImeCommitText instead of letting CEF handle the paste. | 239 | // ImeCommitText instead of letting CEF handle the paste. | ||
| @@ -285,6 +319,13 @@ func (ib *inputBridge) onMousePress(x, y float64, button, mods uint, clickCount | |||||
| 285 | return | 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 | evt := buildMouseEvent(x, y, mods, ib.scale) | 329 | evt := buildMouseEvent(x, y, mods, ib.scale) | ||
| 289 | btn := translateMouseButton(button) | 330 | btn := translateMouseButton(button) | ||
| 290 | host.SendMouseClickEvent(&evt, btn, 0, int32(clickCount)) | 331 | host.SendMouseClickEvent(&evt, btn, 0, int32(clickCount)) | ||
| @@ -321,10 +362,7 @@ func (ib *inputBridge) onFocusIn() { | |||||
| 321 | ib.mu.Lock() | 362 | ib.mu.Lock() | ||
| 322 | host := ib.host | 363 | host := ib.host | ||
| 323 | ib.mu.Unlock() | 364 | ib.mu.Unlock() | ||
| 324 | - | if host == nil { | 365 | + | syncWindowlessBrowserFocus(host) |
| 325 | - | return | |||
| 326 | - | } | |||
| 327 | - | host.SetFocus(1) | |||
| 328 | } | 366 | } | ||
| 329 | 367 | ||||
| 330 | func (ib *inputBridge) onFocusOut() { | 368 | func (ib *inputBridge) onFocusOut() { | ||
| @@ -337,6 +375,46 @@ func (ib *inputBridge) onFocusOut() { | |||||
| 337 | host.SetFocus(0) | 375 | host.SetFocus(0) | ||
| 338 | } | 376 | } | ||
| 339 | 377 | ||||
| 378 | + | func (ib *inputBridge) hasGTKFocus() bool { | |||
| 379 | + | if ib == nil { | |||
| 380 | + | return false | |||
| 381 | + | } | |||
| 382 | + | return ib.gtkFocused.Load() | |||
| 383 | + | } | |||
| 384 | + | ||||
| 385 | + | func (ib *inputBridge) maybeMirrorClipboardShortcut(keyval, mods uint) { | |||
| 386 | + | action, ok := clipboardShortcutAction(keyval, mods) | |||
| 387 | + | if !ok || ib == nil || ib.selectionText == nil || ib.explicitCopyText == nil { | |||
| 388 | + | return | |||
| 389 | + | } | |||
| 390 | + | text := ib.selectionText() | |||
| 391 | + | if text == "" { | |||
| 392 | + | return | |||
| 393 | + | } | |||
| 394 | + | logging.FromContext(ib.ctx).Debug(). | |||
| 395 | + | Str("action", action). | |||
| 396 | + | Int("text_len", utf8.RuneCountInString(text)). | |||
| 397 | + | Msg("cef: clipboard shortcut routed through explicit copy handler") | |||
| 398 | + | ib.explicitCopyText(action, text) | |||
| 399 | + | } | |||
| 400 | + | ||||
| 401 | + | func clipboardShortcutAction(keyval, mods uint) (string, bool) { | |||
| 402 | + | if mods&uint(gdk.ControlMaskValue) == 0 { | |||
| 403 | + | return "", false | |||
| 404 | + | } | |||
| 405 | + | if mods&uint(gdk.ShiftMaskValue) != 0 || mods&uint(gdk.AltMaskValue) != 0 { | |||
| 406 | + | return "", false | |||
| 407 | + | } | |||
| 408 | + | switch keyval { | |||
| 409 | + | case gdkKeyLowercaseC, gdkKeyUppercaseC: | |||
| 410 | + | return "copy", true | |||
| 411 | + | case gdkKeyLowercaseX, gdkKeyUppercaseX: | |||
| 412 | + | return "cut", true | |||
| 413 | + | default: | |||
| 414 | + | return "", false | |||
| 415 | + | } | |||
| 416 | + | } | |||
| 417 | + | ||||
| 340 | func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) { | 418 | func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) { | ||
| 341 | ib.mu.Lock() | 419 | ib.mu.Lock() | ||
| 342 | host := ib.host | 420 | host := ib.host | ||
| @@ -558,8 +636,12 @@ const ( | |||||
| 558 | gdkKeyPageDown = 0xff56 | 636 | gdkKeyPageDown = 0xff56 | ||
| 559 | 637 | ||||
| 560 | // Clipboard shortcut keyvals. | 638 | // Clipboard shortcut keyvals. | ||
| 639 | + | gdkKeyLowercaseC = 0x063 | |||
| 640 | + | gdkKeyUppercaseC = 0x043 | |||
| 561 | gdkKeyLowercaseV = 0x076 | 641 | gdkKeyLowercaseV = 0x076 | ||
| 562 | gdkKeyUppercaseV = 0x056 | 642 | gdkKeyUppercaseV = 0x056 | ||
| 643 | + | gdkKeyLowercaseX = 0x078 | |||
| 644 | + | gdkKeyUppercaseX = 0x058 | |||
| 563 | ) | 645 | ) | ||
| 564 | 646 | ||||
| 565 | // keyvalToChar converts a GDK keyval to a UTF-16 character for CEF CHAR events. | 647 | // keyvalToChar converts a GDK keyval to a UTF-16 character for CEF CHAR events. | ||
A internal/infrastructure/cef/input_bridge_test.go +85 -0
| @@ -0,0 +1,85 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | purecef "github.com/bnema/purego-cef/cef" | |||
| 8 | + | "github.com/bnema/puregotk/v4/gdk" | |||
| 9 | + | "github.com/stretchr/testify/require" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | type focusSyncHostRecorder struct { | |||
| 13 | + | calls []string | |||
| 14 | + | hiddenValues []int32 | |||
| 15 | + | focusValues []int32 | |||
| 16 | + | invalidations []purecef.PaintElementType | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func (h *focusSyncHostRecorder) WasHidden(hidden int32) { | |||
| 20 | + | h.calls = append(h.calls, "WasHidden") | |||
| 21 | + | h.hiddenValues = append(h.hiddenValues, hidden) | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | func (h *focusSyncHostRecorder) SetFocus(focus int32) { | |||
| 25 | + | h.calls = append(h.calls, "SetFocus") | |||
| 26 | + | h.focusValues = append(h.focusValues, focus) | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func (h *focusSyncHostRecorder) Invalidate(elementType purecef.PaintElementType) { | |||
| 30 | + | h.calls = append(h.calls, "Invalidate") | |||
| 31 | + | h.invalidations = append(h.invalidations, elementType) | |||
| 32 | + | } | |||
| 33 | + | ||||
| 34 | + | func TestSyncWindowlessBrowserFocus_ReassertsVisibleFocusedAndInvalidates(t *testing.T) { | |||
| 35 | + | host := &focusSyncHostRecorder{} | |||
| 36 | + | ||||
| 37 | + | syncWindowlessBrowserFocus(host) | |||
| 38 | + | ||||
| 39 | + | require.Equal(t, []string{"WasHidden", "SetFocus", "Invalidate"}, host.calls) | |||
| 40 | + | require.Equal(t, []int32{0}, host.hiddenValues) | |||
| 41 | + | require.Equal(t, []int32{1}, host.focusValues) | |||
| 42 | + | require.Equal(t, []purecef.PaintElementType{purecef.PaintElementTypePetView}, host.invalidations) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestClipboardShortcutAction_RecognizesCopyAndCut(t *testing.T) { | |||
| 46 | + | action, ok := clipboardShortcutAction(gdkKeyLowercaseC, uint(gdk.ControlMaskValue)) | |||
| 47 | + | require.True(t, ok) | |||
| 48 | + | require.Equal(t, "copy", action) | |||
| 49 | + | ||||
| 50 | + | action, ok = clipboardShortcutAction(gdkKeyUppercaseX, uint(gdk.ControlMaskValue)) | |||
| 51 | + | require.True(t, ok) | |||
| 52 | + | require.Equal(t, "cut", action) | |||
| 53 | + | ||||
| 54 | + | _, ok = clipboardShortcutAction(gdkKeyLowercaseC, uint(gdk.ControlMaskValue|gdk.ShiftMaskValue)) | |||
| 55 | + | require.False(t, ok) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestInputBridgeMaybeMirrorClipboardShortcut_RoutesSelectionThroughExplicitHandler(t *testing.T) { | |||
| 59 | + | ib := newInputBridge(context.Background(), 1) | |||
| 60 | + | ib.selectionText = func() string { return "selected text" } | |||
| 61 | + | routed := struct { | |||
| 62 | + | action string | |||
| 63 | + | text string | |||
| 64 | + | }{} | |||
| 65 | + | ib.explicitCopyText = func(action, text string) { | |||
| 66 | + | routed.action = action | |||
| 67 | + | routed.text = text | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | ib.maybeMirrorClipboardShortcut(gdkKeyLowercaseC, uint(gdk.ControlMaskValue)) | |||
| 71 | + | ||||
| 72 | + | require.Equal(t, "copy", routed.action) | |||
| 73 | + | require.Equal(t, "selected text", routed.text) | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func TestInputBridgeMaybeMirrorClipboardShortcut_IgnoresEmptySelection(t *testing.T) { | |||
| 77 | + | ib := newInputBridge(context.Background(), 1) | |||
| 78 | + | ib.selectionText = func() string { return "" } | |||
| 79 | + | called := false | |||
| 80 | + | ib.explicitCopyText = func(string, string) { called = true } | |||
| 81 | + | ||||
| 82 | + | ib.maybeMirrorClipboardShortcut(gdkKeyLowercaseX, uint(gdk.ControlMaskValue)) | |||
| 83 | + | ||||
| 84 | + | require.False(t, called) | |||
| 85 | + | } | |||
M internal/infrastructure/cef/internal_urls.go +33 -1
| @@ -11,6 +11,8 @@ const ( | |||||
| 11 | actualInternalScheme = "https" | 11 | actualInternalScheme = "https" | ||
| 12 | actualInternalHost = "dumber.invalid" | 12 | actualInternalHost = "dumber.invalid" | ||
| 13 | actualInternalOrigin = actualInternalScheme + "://" + actualInternalHost | 13 | actualInternalOrigin = actualInternalScheme + "://" + actualInternalHost | ||
| 14 | + | internalAPIPathPrefix = "/api/" | |||
| 15 | + | internalAPIPathPrefixTrimmed = "api/" | |||
| 14 | ) | 16 | ) | ||
| 15 | 17 | ||||
| 16 | func isConceptualInternalURL(raw string) bool { | 18 | func isConceptualInternalURL(raw string) bool { | ||
| @@ -49,7 +51,7 @@ func toActualInternalURL(raw string) string { | |||||
| 49 | case trimmedPath == "": | 51 | case trimmedPath == "": | ||
| 50 | // Keep page roots at /home instead of /home/ so relative assets resolve | 52 | // Keep page roots at /home instead of /home/ so relative assets resolve | ||
| 51 | // from the origin root. | 53 | // from the origin root. | ||
| 52 | - | case strings.HasPrefix(trimmedPath, "api/"): | 54 | + | case strings.HasPrefix(trimmedPath, internalAPIPathPrefixTrimmed): |
| 53 | actualPath = "/" + trimmedPath | 55 | actualPath = "/" + trimmedPath | ||
| 54 | case path.Ext(trimmedPath) != "": | 56 | case path.Ext(trimmedPath) != "": | ||
| 55 | actualPath = "/" + trimmedPath | 57 | actualPath = "/" + trimmedPath | ||
| @@ -67,6 +69,36 @@ func toActualInternalURL(raw string) string { | |||||
| 67 | return actual.String() | 69 | return actual.String() | ||
| 68 | } | 70 | } | ||
| 69 | 71 | ||||
| 72 | + | func resolveAPIPath(u *url.URL) (string, bool) { | |||
| 73 | + | if u == nil { | |||
| 74 | + | return "", false | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | if strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost) { | |||
| 78 | + | if strings.HasPrefix(u.Path, internalAPIPathPrefix) { | |||
| 79 | + | return u.Path, true | |||
| 80 | + | } | |||
| 81 | + | return "", false | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | if !strings.EqualFold(u.Scheme, "dumb") { | |||
| 85 | + | return "", false | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | if strings.EqualFold(u.Host, "api") { | |||
| 89 | + | if u.Path == "" { | |||
| 90 | + | return "/api", true | |||
| 91 | + | } | |||
| 92 | + | return internalAPIPathPrefix[:len(internalAPIPathPrefix)-1] + u.Path, true | |||
| 93 | + | } | |||
| 94 | + | ||||
| 95 | + | if strings.HasPrefix(u.Path, internalAPIPathPrefix) { | |||
| 96 | + | return u.Path, true | |||
| 97 | + | } | |||
| 98 | + | ||||
| 99 | + | return "", false | |||
| 100 | + | } | |||
| 101 | + | ||||
| 70 | func toConceptualInternalURL(raw string) string { | 102 | func toConceptualInternalURL(raw string) string { | ||
| 71 | if raw == "" || !isActualInternalURL(raw) { | 103 | if raw == "" || !isActualInternalURL(raw) { | ||
| 72 | return raw | 104 | return raw | ||
M internal/infrastructure/cef/internal_urls_test.go +64 -1
| @@ -1,6 +1,9 @@ | |||||
| 1 | package cef | 1 | package cef | ||
| 2 | 2 | ||||
| 3 | - | import "testing" | 3 | + | import ( |
| 4 | + | "net/url" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 4 | 7 | ||||
| 5 | func TestToActualInternalURL(t *testing.T) { | 8 | func TestToActualInternalURL(t *testing.T) { | ||
| 6 | t.Parallel() | 9 | t.Parallel() | ||
| @@ -47,6 +50,66 @@ func TestToActualInternalURL(t *testing.T) { | |||||
| 47 | } | 50 | } | ||
| 48 | } | 51 | } | ||
| 49 | 52 | ||||
| 53 | + | func TestResolveAPIPath(t *testing.T) { | |||
| 54 | + | t.Parallel() | |||
| 55 | + | ||||
| 56 | + | tests := []struct { | |||
| 57 | + | name string | |||
| 58 | + | in string | |||
| 59 | + | want string | |||
| 60 | + | ok bool | |||
| 61 | + | }{ | |||
| 62 | + | { | |||
| 63 | + | name: "nil url returns false", | |||
| 64 | + | in: "", | |||
| 65 | + | want: "", | |||
| 66 | + | ok: false, | |||
| 67 | + | }, | |||
| 68 | + | { | |||
| 69 | + | name: "conceptual api host", | |||
| 70 | + | in: "dumb://api/clipboard-set", | |||
| 71 | + | want: "/api/clipboard-set", | |||
| 72 | + | ok: true, | |||
| 73 | + | }, | |||
| 74 | + | { | |||
| 75 | + | name: "conceptual page api path", | |||
| 76 | + | in: "dumb://home/api/message", | |||
| 77 | + | want: "/api/message", | |||
| 78 | + | ok: true, | |||
| 79 | + | }, | |||
| 80 | + | { | |||
| 81 | + | name: "actual internal api path", | |||
| 82 | + | in: "https://dumber.invalid/api/focus-sync", | |||
| 83 | + | want: "/api/focus-sync", | |||
| 84 | + | ok: true, | |||
| 85 | + | }, | |||
| 86 | + | { | |||
| 87 | + | name: "non api path", | |||
| 88 | + | in: "https://dumber.invalid/home", | |||
| 89 | + | want: "", | |||
| 90 | + | ok: false, | |||
| 91 | + | }, | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | for _, tt := range tests { | |||
| 95 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 96 | + | t.Parallel() | |||
| 97 | + | var parsed *url.URL | |||
| 98 | + | if tt.in != "" { | |||
| 99 | + | var err error | |||
| 100 | + | parsed, err = url.Parse(tt.in) | |||
| 101 | + | if err != nil { | |||
| 102 | + | t.Fatalf("url.Parse(%q) error = %v", tt.in, err) | |||
| 103 | + | } | |||
| 104 | + | } | |||
| 105 | + | got, ok := resolveAPIPath(parsed) | |||
| 106 | + | if got != tt.want || ok != tt.ok { | |||
| 107 | + | t.Fatalf("resolveAPIPath(%q) = (%q, %v), want (%q, %v)", tt.in, got, ok, tt.want, tt.ok) | |||
| 108 | + | } | |||
| 109 | + | }) | |||
| 110 | + | } | |||
| 111 | + | } | |||
| 112 | + | ||||
| 50 | func TestToConceptualInternalURL(t *testing.T) { | 113 | func TestToConceptualInternalURL(t *testing.T) { | ||
| 51 | t.Parallel() | 114 | t.Parallel() | ||
| 52 | 115 | ||||
M internal/infrastructure/cef/render_pipeline.go +55 -0
| @@ -114,6 +114,27 @@ type renderPipeline struct { | |||||
| 114 | onResizeCB func(width, height int32) | 114 | onResizeCB func(width, height int32) | ||
| 115 | } | 115 | } | ||
| 116 | 116 | ||||
| 117 | + | type renderPipelineSnapshot struct { | |||
| 118 | + | Width int32 | |||
| 119 | + | Height int32 | |||
| 120 | + | Scale int32 | |||
| 121 | + | GLReady bool | |||
| 122 | + | NeedsUpload bool | |||
| 123 | + | SizeChanged bool | |||
| 124 | + | ForceFullUpload bool | |||
| 125 | + | ViewRectSeq uint64 | |||
| 126 | + | ScreenInfoSeq uint64 | |||
| 127 | + | PaintSeq uint64 | |||
| 128 | + | ResizeSeq uint64 | |||
| 129 | + | LastQueuedPaintSeq uint64 | |||
| 130 | + | GLRenderSeq uint64 | |||
| 131 | + | PaintCount uint64 | |||
| 132 | + | QueueRenderCount uint64 | |||
| 133 | + | RenderCount uint64 | |||
| 134 | + | UploadCount uint64 | |||
| 135 | + | FullUploadCount uint64 | |||
| 136 | + | } | |||
| 137 | + | ||||
| 117 | // Shader sources for the fullscreen textured quad. | 138 | // Shader sources for the fullscreen textured quad. | ||
| 118 | const vertexShaderSource = "" + | 139 | const vertexShaderSource = "" + | ||
| 119 | "#version 330 core\n" + | 140 | "#version 330 core\n" + | ||
| @@ -806,6 +827,40 @@ func (rp *renderPipeline) maybeLogDiagnostics() { | |||||
| 806 | } | 827 | } | ||
| 807 | 828 | ||||
| 808 | // buildShaderProgram compiles and links the vertex+fragment shaders. | 829 | // buildShaderProgram compiles and links the vertex+fragment shaders. | ||
| 830 | + | func (rp *renderPipeline) diagnosticSnapshot() renderPipelineSnapshot { | |||
| 831 | + | if rp == nil { | |||
| 832 | + | return renderPipelineSnapshot{} | |||
| 833 | + | } | |||
| 834 | + | ||||
| 835 | + | rp.diagMu.Lock() | |||
| 836 | + | snap := renderPipelineSnapshot{ | |||
| 837 | + | Width: rp.widthAtomic.Load(), | |||
| 838 | + | Height: rp.heightAtomic.Load(), | |||
| 839 | + | ViewRectSeq: rp.viewRectSeq.Load(), | |||
| 840 | + | ScreenInfoSeq: rp.screenInfoSeq.Load(), | |||
| 841 | + | PaintSeq: rp.paintSeq.Load(), | |||
| 842 | + | ResizeSeq: rp.resizeSeq.Load(), | |||
| 843 | + | LastQueuedPaintSeq: rp.lastQueuedPaintSeq.Load(), | |||
| 844 | + | GLRenderSeq: rp.glRenderSeq.Load(), | |||
| 845 | + | PaintCount: rp.paintCount, | |||
| 846 | + | QueueRenderCount: rp.queueRenderCount, | |||
| 847 | + | RenderCount: rp.glRenderCount, | |||
| 848 | + | UploadCount: rp.uploadCount, | |||
| 849 | + | FullUploadCount: rp.fullUploadCount, | |||
| 850 | + | } | |||
| 851 | + | rp.diagMu.Unlock() | |||
| 852 | + | ||||
| 853 | + | rp.mu.Lock() | |||
| 854 | + | snap.Scale = rp.scale | |||
| 855 | + | snap.GLReady = rp.glReady | |||
| 856 | + | snap.NeedsUpload = rp.needsUpload | |||
| 857 | + | snap.SizeChanged = rp.sizeChanged | |||
| 858 | + | snap.ForceFullUpload = rp.forceFullUpload | |||
| 859 | + | rp.mu.Unlock() | |||
| 860 | + | ||||
| 861 | + | return snap | |||
| 862 | + | } | |||
| 863 | + | ||||
| 809 | func (rp *renderPipeline) buildShaderProgram() uint32 { | 864 | func (rp *renderPipeline) buildShaderProgram() uint32 { | ||
| 810 | gl := rp.gl | 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 | errorPath: "error.html", | 42 | errorPath: "error.html", | ||
| 43 | } | 43 | } | ||
| 44 | 44 | ||||
| 45 | + | var cefNewResourceHandler = purecef.NewResourceHandler | |||
| 46 | + | ||||
| 45 | // dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual | 47 | // dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual | ||
| 46 | // internal https://dumber.invalid origin used by CEF. | 48 | // internal https://dumber.invalid origin used by CEF. | ||
| 47 | type dumbSchemeHandler struct { | 49 | type dumbSchemeHandler struct { | ||
| @@ -59,6 +61,14 @@ type dumbSchemeHandler struct { | |||||
| 59 | // onClipboardSet is called when JS sends copied text via /api/clipboard-set. | 61 | // onClipboardSet is called when JS sends copied text via /api/clipboard-set. | ||
| 60 | // Set by the engine to write to the GDK system clipboard. | 62 | // Set by the engine to write to the GDK system clipboard. | ||
| 61 | onClipboardSet func(text string) | 63 | onClipboardSet func(text string) | ||
| 64 | + | ||||
| 65 | + | // onEditableFocus is called when JS reports that an editable element gained | |||
| 66 | + | // DOM focus. The engine uses this to reassert CEF browser focus in OSR mode. | |||
| 67 | + | onEditableFocus func(browser purecef.Browser) | |||
| 68 | + | ||||
| 69 | + | // bridgeNonceValidator checks whether a bridge nonce belongs to the active | |||
| 70 | + | // browser/navigation context that issued the request. | |||
| 71 | + | bridgeNonceValidator func(browser purecef.Browser, bridgeNonce string) bool | |||
| 62 | } | 72 | } | ||
| 63 | 73 | ||||
| 64 | // newDumbSchemeHandler creates a handler for internal CEF pages. | 74 | // newDumbSchemeHandler creates a handler for internal CEF pages. | ||
| @@ -99,7 +109,7 @@ func (h *dumbSchemeHandler) setAssets(assets embed.FS) { | |||||
| 99 | 109 | ||||
| 100 | // Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread | 110 | // Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread | ||
| 101 | // for requests to either dumb:// or the internal https origin. | 111 | // for requests to either dumb:// or the internal https origin. | ||
| 102 | - | func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string, request purecef.Request) purecef.ResourceHandler { | 112 | + | func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ string, request purecef.Request) purecef.ResourceHandler { |
| 103 | reqURL := request.GetURL() | 113 | reqURL := request.GetURL() | ||
| 104 | method := request.GetMethod() | 114 | method := request.GetMethod() | ||
| 105 | 115 | ||||
| @@ -119,8 +129,17 @@ func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string, | |||||
| 119 | return h.newRawResourceHandler(http.StatusOK, "text/html; charset=utf-8", []byte(buildCEFCrashPageHTML(originalURI))) | 129 | return h.newRawResourceHandler(http.StatusOK, "text/html; charset=utf-8", []byte(buildCEFCrashPageHTML(originalURI))) | ||
| 120 | } | 130 | } | ||
| 121 | 131 | ||||
| 122 | - | // Keep dumb:// as the app-level abstraction, but redirect CEF loads to a | 132 | + | // Route API requests before redirecting conceptual dumb:// URLs to the |
| 123 | - | // normal HTTPS origin so Chromium applies the standard web security model. | 133 | + | // internal HTTPS origin. External pages use dumb://api/... for clipboard and |
| 134 | + | // focus bridges specifically to bypass page CSP; redirecting those requests | |||
| 135 | + | // to https://dumber.invalid makes site connect-src policies block them. | |||
| 136 | + | if apiPath, ok := resolveAPIPath(u); ok { | |||
| 137 | + | return h.handleAPI(browser, method, apiPath, request) | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | // Keep dumb:// as the app-level abstraction, but redirect CEF page/asset | |||
| 141 | + | // loads to a normal HTTPS origin so Chromium applies the standard web | |||
| 142 | + | // security model. | |||
| 124 | if isConceptualInternalURL(reqURL) { | 143 | if isConceptualInternalURL(reqURL) { | ||
| 125 | targetURL := toActualInternalURL(reqURL) | 144 | targetURL := toActualInternalURL(reqURL) | ||
| 126 | if targetURL != "" && targetURL != reqURL { | 145 | if targetURL != "" && targetURL != reqURL { | ||
| @@ -128,30 +147,27 @@ func (h *dumbSchemeHandler) Create(_ purecef.Browser, _ purecef.Frame, _ string, | |||||
| 128 | } | 147 | } | ||
| 129 | } | 148 | } | ||
| 130 | 149 | ||||
| 131 | - | // Route API requests. | |||
| 132 | - | path := u.Path | |||
| 133 | - | ||||
| 134 | - | if strings.HasPrefix(path, "/api/") { | |||
| 135 | - | return h.handleAPI(method, path, request) | |||
| 136 | - | } | |||
| 137 | - | ||||
| 138 | // Serve static assets. | 150 | // Serve static assets. | ||
| 139 | return h.handleAsset(u) | 151 | return h.handleAsset(u) | ||
| 140 | } | 152 | } | ||
| 141 | 153 | ||||
| 142 | // handleAPI routes API requests to the message router or built-in handlers. | 154 | // handleAPI routes API requests to the message router or built-in handlers. | ||
| 143 | - | func (h *dumbSchemeHandler) handleAPI(method, path string, request purecef.Request) purecef.ResourceHandler { | 155 | + | func (h *dumbSchemeHandler) handleAPI(browser purecef.Browser, method, path string, request purecef.Request) purecef.ResourceHandler { |
| 156 | + | if strings.EqualFold(method, http.MethodOptions) { | |||
| 157 | + | return h.newAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil) | |||
| 158 | + | } | |||
| 159 | + | ||||
| 144 | switch { | 160 | switch { | ||
| 145 | case path == "/api/message" && strings.EqualFold(method, "POST"): | 161 | case path == "/api/message" && strings.EqualFold(method, "POST"): | ||
| 146 | body := readBodyFromHeader(request) | 162 | body := readBodyFromHeader(request) | ||
| 147 | if body == nil { | 163 | if body == nil { | ||
| 148 | - | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"}) | 164 | + | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"}) |
| 149 | } | 165 | } | ||
| 150 | resp, err := h.messageRouter.HandleMessage(h.ctx, 0, body) | 166 | resp, err := h.messageRouter.HandleMessage(h.ctx, 0, body) | ||
| 151 | if err != nil { | 167 | if err != nil { | ||
| 152 | - | return h.newJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()}) | 168 | + | return h.newAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()}) |
| 153 | } | 169 | } | ||
| 154 | - | return h.newRawResourceHandler(http.StatusOK, "application/json", resp) | 170 | + | return h.newAPIRawResourceHandler(http.StatusOK, "application/json", resp) |
| 155 | 171 | ||||
| 156 | case path == "/api/config" && (method == "" || strings.EqualFold(method, "GET")): | 172 | case path == "/api/config" && (method == "" || strings.EqualFold(method, "GET")): | ||
| 157 | return h.handleConfigAPI(h.currentConfigPayload) | 173 | return h.handleConfigAPI(h.currentConfigPayload) | ||
| @@ -163,32 +179,35 @@ func (h *dumbSchemeHandler) handleAPI(method, path string, request purecef.Reque | |||||
| 163 | return h.handleTranscodeAPI(request) | 179 | return h.handleTranscodeAPI(request) | ||
| 164 | 180 | ||||
| 165 | case path == "/api/clipboard-set" && strings.EqualFold(method, "POST"): | 181 | case path == "/api/clipboard-set" && strings.EqualFold(method, "POST"): | ||
| 166 | - | return h.handleClipboardSet(request) | 182 | + | return h.handleClipboardSet(request, browser) |
| 183 | + | ||||
| 184 | + | case path == "/api/focus-sync" && strings.EqualFold(method, "POST"): | |||
| 185 | + | return h.handleFocusSync(request, browser) | |||
| 167 | 186 | ||||
| 168 | default: | 187 | default: | ||
| 169 | - | return h.newJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"}) | 188 | + | return h.newAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"}) |
| 170 | } | 189 | } | ||
| 171 | } | 190 | } | ||
| 172 | 191 | ||||
| 173 | func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler { | 192 | func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef.ResourceHandler { | ||
| 174 | if h.transcoder == nil { | 193 | if h.transcoder == nil { | ||
| 175 | - | return h.newJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"}) | 194 | + | return h.newAPIJSONResourceHandler(http.StatusServiceUnavailable, map[string]string{"error": "transcoder unavailable"}) |
| 176 | } | 195 | } | ||
| 177 | 196 | ||||
| 178 | reqURL := request.GetURL() | 197 | reqURL := request.GetURL() | ||
| 179 | parsed, err := url.Parse(reqURL) | 198 | parsed, err := url.Parse(reqURL) | ||
| 180 | if err != nil { | 199 | if err != nil { | ||
| 181 | - | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid request URL"}) | 200 | + | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid request URL"}) |
| 182 | } | 201 | } | ||
| 183 | 202 | ||||
| 184 | sourceURL := strings.TrimSpace(parsed.Query().Get("src")) | 203 | sourceURL := strings.TrimSpace(parsed.Query().Get("src")) | ||
| 185 | if sourceURL == "" { | 204 | if sourceURL == "" { | ||
| 186 | - | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "missing src"}) | 205 | + | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "missing src"}) |
| 187 | } | 206 | } | ||
| 188 | 207 | ||||
| 189 | sourceParsed, err := url.Parse(sourceURL) | 208 | sourceParsed, err := url.Parse(sourceURL) | ||
| 190 | if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != "https") { | 209 | if err != nil || sourceParsed.Host == "" || (sourceParsed.Scheme != "http" && sourceParsed.Scheme != "https") { | ||
| 191 | - | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"}) | 210 | + | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid src"}) |
| 192 | } | 211 | } | ||
| 193 | 212 | ||||
| 194 | headers := make(map[string]string) | 213 | headers := make(map[string]string) | ||
| @@ -210,7 +229,7 @@ func (h *dumbSchemeHandler) handleTranscodeAPI(request purecef.Request) purecef. | |||||
| 210 | Int("forwarded_header_count", len(headers)). | 229 | Int("forwarded_header_count", len(headers)). | ||
| 211 | Msg("scheme handler returning transcoding stream") | 230 | Msg("scheme handler returning transcoding stream") | ||
| 212 | 231 | ||||
| 213 | - | return purecef.NewResourceHandler(&transcodingResourceHandler{ | 232 | + | return cefNewResourceHandler(&transcodingResourceHandler{ |
| 214 | transcoder: h.transcoder, | 233 | transcoder: h.transcoder, | ||
| 215 | sourceURL: sourceURL, | 234 | sourceURL: sourceURL, | ||
| 216 | headers: headers, | 235 | headers: headers, | ||
| @@ -233,26 +252,37 @@ func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) { | |||||
| 233 | func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purecef.ResourceHandler { | 252 | func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purecef.ResourceHandler { | ||
| 234 | data, err := resolveConfigPayload(build) | 253 | data, err := resolveConfigPayload(build) | ||
| 235 | if err != nil { | 254 | if err != nil { | ||
| 236 | - | return h.newJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()}) | 255 | + | return h.newAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()}) |
| 237 | } | 256 | } | ||
| 238 | - | return h.newRawResourceHandler(http.StatusOK, "application/json", data) | 257 | + | return h.newAPIRawResourceHandler(http.StatusOK, "application/json", data) |
| 239 | } | 258 | } | ||
| 240 | 259 | ||||
| 241 | // handleClipboardSet receives copied text from JS copy/cut events and writes | 260 | // handleClipboardSet receives copied text from JS copy/cut events and writes | ||
| 242 | // it to the system clipboard via the engine callback. | 261 | // it to the system clipboard via the engine callback. | ||
| 243 | - | const maxClipboardBytes = 10 << 20 // 10 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 | h.logger.Debug().Msg("cef: /api/clipboard-set request received") | 271 | h.logger.Debug().Msg("cef: /api/clipboard-set request received") | ||
| 247 | 272 | ||||
| 273 | + | if !h.hasTrustedBridgeNonce(request, browser) { | |||
| 274 | + | h.logger.Warn().Msg("cef: clipboard-set — rejected request without valid bridge nonce") | |||
| 275 | + | return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | |||
| 276 | + | } | |||
| 277 | + | ||||
| 248 | body := readBodyFromHeader(request) | 278 | body := readBodyFromHeader(request) | ||
| 249 | if body == nil { | 279 | if body == nil { | ||
| 250 | h.logger.Debug().Msg("cef: clipboard-set — empty body (no X-Dumber-Body header)") | 280 | h.logger.Debug().Msg("cef: clipboard-set — empty body (no X-Dumber-Body header)") | ||
| 251 | - | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"}) | 281 | + | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"}) |
| 252 | } | 282 | } | ||
| 253 | if len(body) > maxClipboardBytes { | 283 | if len(body) > maxClipboardBytes { | ||
| 254 | h.logger.Warn().Int("body_len", len(body)).Msg("cef: clipboard-set — payload too large") | 284 | h.logger.Warn().Int("body_len", len(body)).Msg("cef: clipboard-set — payload too large") | ||
| 255 | - | return h.newJSONResourceHandler(http.StatusRequestEntityTooLarge, map[string]string{"error": "payload too large"}) | 285 | + | return h.newAPIJSONResourceHandler(http.StatusRequestEntityTooLarge, map[string]string{"error": "payload too large"}) |
| 256 | } | 286 | } | ||
| 257 | 287 | ||||
| 258 | var payload struct { | 288 | var payload struct { | ||
| @@ -260,7 +290,7 @@ func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef. | |||||
| 260 | } | 290 | } | ||
| 261 | if err := json.Unmarshal(body, &payload); err != nil || payload.Text == "" { | 291 | if err := json.Unmarshal(body, &payload); err != nil || payload.Text == "" { | ||
| 262 | h.logger.Debug().Int("body_len", len(body)).Msg("cef: clipboard-set — invalid or empty payload") | 292 | h.logger.Debug().Int("body_len", len(body)).Msg("cef: clipboard-set — invalid or empty payload") | ||
| 263 | - | return h.newJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid payload"}) | 293 | + | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid payload"}) |
| 264 | } | 294 | } | ||
| 265 | 295 | ||||
| 266 | h.logger.Debug().Int("text_len", len(payload.Text)).Msg("cef: clipboard-set — forwarding to GDK clipboard") | 296 | h.logger.Debug().Int("text_len", len(payload.Text)).Msg("cef: clipboard-set — forwarding to GDK clipboard") | ||
| @@ -271,17 +301,51 @@ func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request) purecef. | |||||
| 271 | h.logger.Warn().Msg("cef: clipboard-set — onClipboardSet callback not wired") | 301 | h.logger.Warn().Msg("cef: clipboard-set — onClipboardSet callback not wired") | ||
| 272 | } | 302 | } | ||
| 273 | 303 | ||||
| 274 | - | return h.newJSONResourceHandler(http.StatusOK, map[string]any{"ok": true}) | 304 | + | return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true}) |
| 305 | + | } | |||
| 306 | + | ||||
| 307 | + | func (h *dumbSchemeHandler) handleFocusSync(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler { | |||
| 308 | + | bridgeAction := "" | |||
| 309 | + | if request != nil { | |||
| 310 | + | bridgeAction = strings.TrimSpace(request.GetHeaderByName(dumberBridgeActionHeaderName)) | |||
| 311 | + | } | |||
| 312 | + | if !strings.EqualFold(bridgeAction, dumberBridgeActionFocusSync) { | |||
| 313 | + | h.logger.Warn().Msg("cef: focus-sync — rejected request without trusted bridge action header") | |||
| 314 | + | return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | |||
| 315 | + | } | |||
| 316 | + | if browser == nil { | |||
| 317 | + | h.logger.Debug().Msg("cef: focus-sync — browser unavailable") | |||
| 318 | + | return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"}) | |||
| 319 | + | } | |||
| 320 | + | if !h.hasTrustedBridgeNonce(request, browser) { | |||
| 321 | + | h.logger.Warn().Msg("cef: focus-sync — rejected request without valid bridge nonce") | |||
| 322 | + | return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"}) | |||
| 323 | + | } | |||
| 324 | + | ||||
| 325 | + | if h.onEditableFocus != nil { | |||
| 326 | + | h.onEditableFocus(browser) | |||
| 327 | + | } else { | |||
| 328 | + | h.logger.Debug().Msg("cef: focus-sync — onEditableFocus callback not wired") | |||
| 329 | + | } | |||
| 330 | + | ||||
| 331 | + | return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true}) | |||
| 332 | + | } | |||
| 333 | + | ||||
| 334 | + | func (h *dumbSchemeHandler) hasTrustedBridgeNonce(request purecef.Request, browser purecef.Browser) bool { | |||
| 335 | + | if browser == nil || h == nil || h.bridgeNonceValidator == nil || request == nil { | |||
| 336 | + | return false | |||
| 337 | + | } | |||
| 338 | + | bridgeNonce := strings.TrimSpace(request.GetHeaderByName(dumberBridgeNonceHeaderName)) | |||
| 339 | + | if bridgeNonce == "" { | |||
| 340 | + | return false | |||
| 341 | + | } | |||
| 342 | + | return h.bridgeNonceValidator(browser, bridgeNonce) | |||
| 275 | } | 343 | } | ||
| 276 | 344 | ||||
| 277 | func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler { | 345 | func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler { | ||
| 278 | - | return purecef.NewResourceHandler(&staticResourceHandler{ | 346 | + | return newStaticResourceHandler(status, "text/plain; charset=utf-8", nil, map[string]string{ |
| 279 | - | contentType: "text/plain; charset=utf-8", | 347 | + | "Location": location, |
| 280 | - | statusCode: status, | 348 | + | "Cache-Control": "no-store", |
| 281 | - | headers: map[string]string{ | |||
| 282 | - | "Location": location, | |||
| 283 | - | "Cache-Control": "no-store", | |||
| 284 | - | }, | |||
| 285 | }) | 349 | }) | ||
| 286 | } | 350 | } | ||
| 287 | 351 | ||||
| @@ -439,26 +503,55 @@ type staticResourceHandler struct { | |||||
| 439 | offset int | 503 | offset int | ||
| 440 | } | 504 | } | ||
| 441 | 505 | ||||
| 442 | - | func (h *dumbSchemeHandler) newRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler { | 506 | + | func apiResponseHeaders(extra map[string]string) map[string]string { |
| 443 | - | return purecef.NewResourceHandler(&staticResourceHandler{ | 507 | + | headers := map[string]string{ |
| 508 | + | "Access-Control-Allow-Origin": "*", | |||
| 509 | + | "Access-Control-Allow-Methods": "GET, POST, OPTIONS", | |||
| 510 | + | "Access-Control-Allow-Headers": strings.Join([]string{ | |||
| 511 | + | "Content-Type", | |||
| 512 | + | "X-Dumber-Body", | |||
| 513 | + | "X-Dumber-Bridge-Action", | |||
| 514 | + | "X-Dumber-Bridge-Nonce", | |||
| 515 | + | }, ", "), | |||
| 516 | + | "Access-Control-Max-Age": "86400", | |||
| 517 | + | "Cache-Control": "no-store", | |||
| 518 | + | } | |||
| 519 | + | for name, value := range extra { | |||
| 520 | + | headers[name] = value | |||
| 521 | + | } | |||
| 522 | + | return headers | |||
| 523 | + | } | |||
| 524 | + | ||||
| 525 | + | func newStaticResourceHandler(status int, contentType string, data []byte, headers map[string]string) purecef.ResourceHandler { | |||
| 526 | + | return cefNewResourceHandler(&staticResourceHandler{ | |||
| 444 | data: data, | 527 | data: data, | ||
| 445 | contentType: contentType, | 528 | contentType: contentType, | ||
| 446 | statusCode: status, | 529 | statusCode: status, | ||
| 530 | + | headers: headers, | |||
| 447 | }) | 531 | }) | ||
| 448 | } | 532 | } | ||
| 449 | 533 | ||||
| 534 | + | func (h *dumbSchemeHandler) newRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler { | |||
| 535 | + | return newStaticResourceHandler(status, contentType, data, nil) | |||
| 536 | + | } | |||
| 537 | + | ||||
| 538 | + | func (h *dumbSchemeHandler) newAPIRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler { | |||
| 539 | + | return newStaticResourceHandler(status, contentType, data, apiResponseHeaders(nil)) | |||
| 540 | + | } | |||
| 541 | + | ||||
| 450 | func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler { | 542 | func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler { | ||
| 451 | escaped := html.EscapeString(msg) | 543 | escaped := html.EscapeString(msg) | ||
| 452 | body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped) | 544 | body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped) | ||
| 453 | return h.newRawResourceHandler(status, "text/html; charset=utf-8", []byte(body)) | 545 | return h.newRawResourceHandler(status, "text/html; charset=utf-8", []byte(body)) | ||
| 454 | } | 546 | } | ||
| 455 | 547 | ||||
| 456 | - | func (h *dumbSchemeHandler) newJSONResourceHandler(status int, v any) purecef.ResourceHandler { | 548 | + | func (h *dumbSchemeHandler) newAPIJSONResourceHandler(status int, v any) purecef.ResourceHandler { |
| 457 | data, err := json.Marshal(v) | 549 | data, err := json.Marshal(v) | ||
| 458 | if err != nil { | 550 | if err != nil { | ||
| 459 | - | return h.newErrorResourceHandler(http.StatusInternalServerError, "JSON encoding failed") | 551 | + | fallback, _ := json.Marshal(map[string]string{"error": "JSON encoding failed"}) |
| 552 | + | return h.newAPIRawResourceHandler(http.StatusInternalServerError, "application/json", fallback) | |||
| 460 | } | 553 | } | ||
| 461 | - | return h.newRawResourceHandler(status, "application/json", data) | 554 | + | return h.newAPIRawResourceHandler(status, "application/json", data) |
| 462 | } | 555 | } | ||
| 463 | 556 | ||||
| 464 | // Open handles the request immediately (synchronous). | 557 | // Open handles the request immediately (synchronous). | ||
| @@ -537,7 +630,7 @@ func (rh *staticResourceHandler) Cancel() {} | |||||
| 537 | // The JS bridge base64-encodes the JSON body into this header to avoid the | 630 | // The JS bridge base64-encodes the JSON body into this header to avoid the | ||
| 538 | // purego-cef PostData element wrapping limitation (wrapPostDataElement is unexported). | 631 | // purego-cef PostData element wrapping limitation (wrapPostDataElement is unexported). | ||
| 539 | func readBodyFromHeader(request purecef.Request) []byte { | 632 | func readBodyFromHeader(request purecef.Request) []byte { | ||
| 540 | - | encoded := request.GetHeaderByName("X-Dumber-Body") | 633 | + | encoded := request.GetHeaderByName(dumberBodyHeaderName) |
| 541 | if encoded == "" { | 634 | if encoded == "" { | ||
| 542 | return nil | 635 | return nil | ||
| 543 | } | 636 | } | ||
M internal/infrastructure/cef/scheme_handler_test.go +216 -0
| @@ -2,12 +2,29 @@ package cef | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "encoding/base64" | |||
| 5 | "errors" | 6 | "errors" | ||
| 7 | + | "net/http" | |||
| 6 | "testing" | 8 | "testing" | ||
| 9 | + | "unsafe" | |||
| 7 | 10 | ||||
| 11 | + | purecef "github.com/bnema/purego-cef/cef" | |||
| 12 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |||
| 8 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 9 | ) | 14 | ) | ||
| 10 | 15 | ||||
| 16 | + | func expectAPIResponseHeaders(response *cefmocks.MockResponse) { | |||
| 17 | + | response.EXPECT().SetHeaderByName("Access-Control-Allow-Origin", "*", int32(1)).Once() | |||
| 18 | + | response.EXPECT().SetHeaderByName("Access-Control-Allow-Methods", "GET, POST, OPTIONS", int32(1)).Once() | |||
| 19 | + | response.EXPECT().SetHeaderByName( | |||
| 20 | + | "Access-Control-Allow-Headers", | |||
| 21 | + | "Content-Type, X-Dumber-Body, X-Dumber-Bridge-Action, X-Dumber-Bridge-Nonce", | |||
| 22 | + | int32(1), | |||
| 23 | + | ).Once() | |||
| 24 | + | response.EXPECT().SetHeaderByName("Access-Control-Max-Age", "86400", int32(1)).Once() | |||
| 25 | + | response.EXPECT().SetHeaderByName("Cache-Control", "no-store", int32(1)).Once() | |||
| 26 | + | } | |||
| 27 | + | ||||
| 11 | func TestResolveConfigPayload_UsesInjectedBuilder(t *testing.T) { | 28 | func TestResolveConfigPayload_UsesInjectedBuilder(t *testing.T) { | ||
| 12 | data, err := resolveConfigPayload(func() ([]byte, error) { | 29 | data, err := resolveConfigPayload(func() ([]byte, error) { | ||
| 13 | return []byte(`{"engine_type":"cef"}`), nil | 30 | return []byte(`{"engine_type":"cef"}`), nil | ||
| @@ -43,3 +60,202 @@ func TestNewDumbSchemeHandler_NilDefaultConfigPayloadFails(t *testing.T) { | |||||
| 43 | require.Nil(t, h) | 60 | require.Nil(t, h) | ||
| 44 | require.Contains(t, err.Error(), "default config payload builder") | 61 | require.Contains(t, err.Error(), "default config payload builder") | ||
| 45 | } | 62 | } | ||
| 63 | + | ||||
| 64 | + | func TestReadBodyFromHeader_DecodesBase64Payload(t *testing.T) { | |||
| 65 | + | request := cefmocks.NewMockRequest(t) | |||
| 66 | + | encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`)) | |||
| 67 | + | request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once() | |||
| 68 | + | ||||
| 69 | + | body := readBodyFromHeader(request) | |||
| 70 | + | ||||
| 71 | + | require.JSONEq(t, `{"text":"copied from js"}`, string(body)) | |||
| 72 | + | } | |||
| 73 | + | ||||
| 74 | + | func TestSchemeHandler_APIClipboardSetPathWritesClipboardPayload(t *testing.T) { | |||
| 75 | + | oldNewResourceHandler := cefNewResourceHandler | |||
| 76 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |||
| 77 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |||
| 78 | + | ||||
| 79 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |||
| 80 | + | require.NoError(t, err) | |||
| 81 | + | ||||
| 82 | + | const bridgeNonce = "bridge-nonce" | |||
| 83 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 84 | + | h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool { | |||
| 85 | + | return got == browser && gotNonce == bridgeNonce | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | var copied string | |||
| 89 | + | h.onClipboardSet = func(text string) { copied = text } | |||
| 90 | + | ||||
| 91 | + | request := cefmocks.NewMockRequest(t) | |||
| 92 | + | encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from js"}`)) | |||
| 93 | + | request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once() | |||
| 94 | + | request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once() | |||
| 95 | + | ||||
| 96 | + | response := cefmocks.NewMockResponse(t) | |||
| 97 | + | response.EXPECT().SetStatus(int32(http.StatusOK)).Once() | |||
| 98 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once() | |||
| 99 | + | response.EXPECT().SetMimeType("application/json").Once() | |||
| 100 | + | expectAPIResponseHeaders(response) | |||
| 101 | + | ||||
| 102 | + | handler := h.handleAPI(browser, http.MethodPost, "/api/clipboard-set", request) | |||
| 103 | + | require.NotNil(t, handler) | |||
| 104 | + | ||||
| 105 | + | var responseLength int64 | |||
| 106 | + | handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0) | |||
| 107 | + | require.Positive(t, responseLength) | |||
| 108 | + | require.Equal(t, "copied from js", copied) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func TestSchemeHandler_APIFocusSyncRejectsRequestsWithoutTrustedBridgeHeader(t *testing.T) { | |||
| 112 | + | oldNewResourceHandler := cefNewResourceHandler | |||
| 113 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |||
| 114 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |||
| 115 | + | ||||
| 116 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |||
| 117 | + | require.NoError(t, err) | |||
| 118 | + | ||||
| 119 | + | request := cefmocks.NewMockRequest(t) | |||
| 120 | + | request.EXPECT().GetHeaderByName(dumberBridgeActionHeaderName).Return("").Once() | |||
| 121 | + | ||||
| 122 | + | response := cefmocks.NewMockResponse(t) | |||
| 123 | + | response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once() | |||
| 124 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once() | |||
| 125 | + | response.EXPECT().SetMimeType("application/json").Once() | |||
| 126 | + | expectAPIResponseHeaders(response) | |||
| 127 | + | ||||
| 128 | + | handler := h.handleAPI(nil, http.MethodPost, "/api/focus-sync", request) | |||
| 129 | + | require.NotNil(t, handler) | |||
| 130 | + | ||||
| 131 | + | var responseLength int64 | |||
| 132 | + | handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0) | |||
| 133 | + | require.Positive(t, responseLength) | |||
| 134 | + | } | |||
| 135 | + | ||||
| 136 | + | func TestSchemeHandler_APIFocusSyncInvokesEditableFocusCallback(t *testing.T) { | |||
| 137 | + | oldNewResourceHandler := cefNewResourceHandler | |||
| 138 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |||
| 139 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |||
| 140 | + | ||||
| 141 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |||
| 142 | + | require.NoError(t, err) | |||
| 143 | + | ||||
| 144 | + | const bridgeNonce = "bridge-nonce" | |||
| 145 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 146 | + | h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool { | |||
| 147 | + | return got == browser && gotNonce == bridgeNonce | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | var focused purecef.Browser | |||
| 151 | + | h.onEditableFocus = func(got purecef.Browser) { focused = got } | |||
| 152 | + | ||||
| 153 | + | request := cefmocks.NewMockRequest(t) | |||
| 154 | + | request.EXPECT().GetHeaderByName(dumberBridgeActionHeaderName).Return(dumberBridgeActionFocusSync).Once() | |||
| 155 | + | request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once() | |||
| 156 | + | ||||
| 157 | + | response := cefmocks.NewMockResponse(t) | |||
| 158 | + | response.EXPECT().SetStatus(int32(http.StatusOK)).Once() | |||
| 159 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once() | |||
| 160 | + | response.EXPECT().SetMimeType("application/json").Once() | |||
| 161 | + | expectAPIResponseHeaders(response) | |||
| 162 | + | ||||
| 163 | + | handler := h.handleAPI(browser, http.MethodPost, "/api/focus-sync", request) | |||
| 164 | + | require.NotNil(t, handler) | |||
| 165 | + | ||||
| 166 | + | var responseLength int64 | |||
| 167 | + | handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0) | |||
| 168 | + | require.Positive(t, responseLength) | |||
| 169 | + | require.Same(t, browser, focused) | |||
| 170 | + | } | |||
| 171 | + | ||||
| 172 | + | func TestSchemeHandler_Create_ConceptualAPIRequestBypassesRedirect(t *testing.T) { | |||
| 173 | + | oldNewResourceHandler := cefNewResourceHandler | |||
| 174 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |||
| 175 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |||
| 176 | + | ||||
| 177 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |||
| 178 | + | require.NoError(t, err) | |||
| 179 | + | ||||
| 180 | + | const bridgeNonce = "bridge-nonce" | |||
| 181 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 182 | + | h.bridgeNonceValidator = func(got purecef.Browser, gotNonce string) bool { | |||
| 183 | + | return got == browser && gotNonce == bridgeNonce | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | var copied string | |||
| 187 | + | h.onClipboardSet = func(text string) { copied = text } | |||
| 188 | + | ||||
| 189 | + | request := cefmocks.NewMockRequest(t) | |||
| 190 | + | request.EXPECT().GetURL().Return("dumb://api/clipboard-set").Once() | |||
| 191 | + | request.EXPECT().GetMethod().Return(http.MethodPost).Once() | |||
| 192 | + | request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return(bridgeNonce).Once() | |||
| 193 | + | encoded := base64.StdEncoding.EncodeToString([]byte(`{"text":"copied from create"}`)) | |||
| 194 | + | request.EXPECT().GetHeaderByName(dumberBodyHeaderName).Return(encoded).Once() | |||
| 195 | + | ||||
| 196 | + | response := cefmocks.NewMockResponse(t) | |||
| 197 | + | response.EXPECT().SetStatus(int32(http.StatusOK)).Once() | |||
| 198 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusOK)).Once() | |||
| 199 | + | response.EXPECT().SetMimeType("application/json").Once() | |||
| 200 | + | expectAPIResponseHeaders(response) | |||
| 201 | + | ||||
| 202 | + | handler := h.Create(browser, nil, "", request) | |||
| 203 | + | require.NotNil(t, handler) | |||
| 204 | + | ||||
| 205 | + | var responseLength int64 | |||
| 206 | + | handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0) | |||
| 207 | + | require.Positive(t, responseLength) | |||
| 208 | + | require.Equal(t, "copied from create", copied) | |||
| 209 | + | } | |||
| 210 | + | ||||
| 211 | + | func TestSchemeHandler_APIClipboardSetRejectsInvalidBridgeNonce(t *testing.T) { | |||
| 212 | + | oldNewResourceHandler := cefNewResourceHandler | |||
| 213 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |||
| 214 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |||
| 215 | + | ||||
| 216 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |||
| 217 | + | require.NoError(t, err) | |||
| 218 | + | ||||
| 219 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 220 | + | h.bridgeNonceValidator = func(_ purecef.Browser, _ string) bool { return false } | |||
| 221 | + | ||||
| 222 | + | request := cefmocks.NewMockRequest(t) | |||
| 223 | + | request.EXPECT().GetHeaderByName(dumberBridgeNonceHeaderName).Return("invalid").Once() | |||
| 224 | + | ||||
| 225 | + | response := cefmocks.NewMockResponse(t) | |||
| 226 | + | response.EXPECT().SetStatus(int32(http.StatusForbidden)).Once() | |||
| 227 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusForbidden)).Once() | |||
| 228 | + | response.EXPECT().SetMimeType("application/json").Once() | |||
| 229 | + | expectAPIResponseHeaders(response) | |||
| 230 | + | ||||
| 231 | + | handler := h.handleAPI(browser, http.MethodPost, "/api/clipboard-set", request) | |||
| 232 | + | require.NotNil(t, handler) | |||
| 233 | + | ||||
| 234 | + | var responseLength int64 | |||
| 235 | + | handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0) | |||
| 236 | + | require.Positive(t, responseLength) | |||
| 237 | + | } | |||
| 238 | + | ||||
| 239 | + | func TestSchemeHandler_APIOptionsReturnsCORSPreflightHeaders(t *testing.T) { | |||
| 240 | + | oldNewResourceHandler := cefNewResourceHandler | |||
| 241 | + | cefNewResourceHandler = func(impl purecef.ResourceHandler) purecef.ResourceHandler { return impl } | |||
| 242 | + | defer func() { cefNewResourceHandler = oldNewResourceHandler }() | |||
| 243 | + | ||||
| 244 | + | h, err := newDumbSchemeHandler(context.Background(), nil, nil, func() ([]byte, error) { return []byte(`{}`), nil }, func() ([]byte, error) { return []byte(`{}`), nil }) | |||
| 245 | + | require.NoError(t, err) | |||
| 246 | + | ||||
| 247 | + | request := cefmocks.NewMockRequest(t) | |||
| 248 | + | response := cefmocks.NewMockResponse(t) | |||
| 249 | + | response.EXPECT().SetStatus(int32(http.StatusNoContent)).Once() | |||
| 250 | + | response.EXPECT().SetStatusText(http.StatusText(http.StatusNoContent)).Once() | |||
| 251 | + | response.EXPECT().SetMimeType("text/plain").Once() | |||
| 252 | + | response.EXPECT().SetCharset("utf-8").Once() | |||
| 253 | + | expectAPIResponseHeaders(response) | |||
| 254 | + | ||||
| 255 | + | handler := h.handleAPI(nil, http.MethodOptions, "/api/clipboard-set", request) | |||
| 256 | + | require.NotNil(t, handler) | |||
| 257 | + | ||||
| 258 | + | var responseLength int64 | |||
| 259 | + | handler.GetResponseHeaders(response, unsafe.Pointer(&responseLength), 0) | |||
| 260 | + | require.Zero(t, responseLength) | |||
| 261 | + | } | |||
M internal/infrastructure/cef/session_spawn_environment.go +9 -3
| @@ -9,14 +9,20 @@ import ( | |||||
| 9 | 9 | ||||
| 10 | // SessionSpawnEnvironment exposes the engine-specific launch environment needed | 10 | // SessionSpawnEnvironment exposes the engine-specific launch environment needed | ||
| 11 | // when restoring a session into a freshly spawned browser process. | 11 | // when restoring a session into a freshly spawned browser process. | ||
| 12 | - | type SessionSpawnEnvironment struct{} | 12 | + | type SessionSpawnEnvironment struct { |
| 13 | + | cefUserDataDir string | |||
| 14 | + | } | |||
| 15 | + | ||||
| 16 | + | func NewSessionSpawnEnvironment(cefUserDataDir string) SessionSpawnEnvironment { | |||
| 17 | + | return SessionSpawnEnvironment{cefUserDataDir: cefUserDataDir} | |||
| 18 | + | } | |||
| 13 | 19 | ||||
| 14 | func (SessionSpawnEnvironment) RootCacheEnvVar() string { | 20 | func (SessionSpawnEnvironment) RootCacheEnvVar() string { | ||
| 15 | return CEFRootCachePathEnvVar | 21 | return CEFRootCachePathEnvVar | ||
| 16 | } | 22 | } | ||
| 17 | 23 | ||||
| 18 | - | func (SessionSpawnEnvironment) SessionRootCachePath(sessionID entity.SessionID) string { | 24 | + | func (e SessionSpawnEnvironment) SessionRootCachePath(sessionID entity.SessionID) string { |
| 19 | - | return filepath.Join(DefaultCEFUserDataDir(), "sessions", string(sessionID)) | 25 | + | return filepath.Join(e.cefUserDataDir, "sessions", string(sessionID)) |
| 20 | } | 26 | } | ||
| 21 | 27 | ||||
| 22 | var _ port.SessionSpawnEnvironment = SessionSpawnEnvironment{} | 28 | var _ port.SessionSpawnEnvironment = SessionSpawnEnvironment{} | ||
M internal/infrastructure/cef/webview.go +416 -13
| @@ -2,12 +2,17 @@ package cef | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "crypto/rand" | |||
| 5 | "encoding/base64" | 6 | "encoding/base64" | ||
| 7 | + | "encoding/hex" | |||
| 6 | "errors" | 8 | "errors" | ||
| 7 | "fmt" | 9 | "fmt" | ||
| 8 | "math" | 10 | "math" | ||
| 11 | + | "strconv" | |||
| 12 | + | "strings" | |||
| 9 | "sync" | 13 | "sync" | ||
| 10 | "sync/atomic" | 14 | "sync/atomic" | ||
| 15 | + | "time" | |||
| 11 | 16 | ||||
| 12 | purecef "github.com/bnema/purego-cef/cef" | 17 | purecef "github.com/bnema/purego-cef/cef" | ||
| 13 | "github.com/bnema/puregotk/v4/glib" | 18 | "github.com/bnema/puregotk/v4/glib" | ||
| @@ -30,6 +35,32 @@ var errDestroyed = errors.New("cef: webview is destroyed") | |||||
| 30 | // errNoBrowser is returned when the browser has not been created yet. | 35 | // errNoBrowser is returned when the browser has not been created yet. | ||
| 31 | var errNoBrowser = errors.New("cef: browser not yet created") | 36 | var errNoBrowser = errors.New("cef: browser not yet created") | ||
| 32 | 37 | ||||
| 38 | + | const ( | |||
| 39 | + | clipboardSelectionDebounceDelay = 300 * time.Millisecond | |||
| 40 | + | pendingNavigationRetryDelay = 50 * time.Millisecond | |||
| 41 | + | pendingNavigationMaxRetries = 80 | |||
| 42 | + | ) | |||
| 43 | + | ||||
| 44 | + | type stoppableTimer interface { | |||
| 45 | + | Stop() bool | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | var cefLoadWatchdogDelays = []time.Duration{ | |||
| 49 | + | 250 * time.Millisecond, | |||
| 50 | + | 1 * time.Second, | |||
| 51 | + | 2 * time.Second, | |||
| 52 | + | 5 * time.Second, | |||
| 53 | + | 15 * time.Second, | |||
| 54 | + | 30 * time.Second, | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | var ( | |||
| 58 | + | cefNewTask = purecef.NewTask | |||
| 59 | + | cefPostTask = purecef.PostTask | |||
| 60 | + | cefPostDelayedTask = purecef.PostDelayedTask | |||
| 61 | + | cefScheduleAfter = func(delay time.Duration, fn func()) { time.AfterFunc(delay, fn) } | |||
| 62 | + | ) | |||
| 63 | + | ||||
| 33 | // WebView implements port.WebView using a CEF off-screen browser rendered | 64 | // WebView implements port.WebView using a CEF off-screen browser rendered | ||
| 34 | // through a renderPipeline and driven by an inputBridge. | 65 | // through a renderPipeline and driven by an inputBridge. | ||
| 35 | type WebView struct { | 66 | type WebView struct { | ||
| @@ -65,16 +96,30 @@ type WebView struct { | |||||
| 65 | callbacks *port.WebViewCallbacks | 96 | callbacks *port.WebViewCallbacks | ||
| 66 | 97 | ||||
| 67 | // State cache (mutex-protected). | 98 | // State cache (mutex-protected). | ||
| 68 | - | uri string | 99 | + | uri string |
| 69 | - | title string | 100 | + | title string |
| 70 | - | progress float64 | 101 | + | progress float64 |
| 71 | - | canGoBack bool | 102 | + | canGoBack bool |
| 72 | - | canGoFwd bool | 103 | + | canGoFwd bool |
| 73 | - | isLoading 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 | // Last known hover URI for middle-click → new tab. | 113 | // Last known hover URI for middle-click → new tab. | ||
| 76 | lastHoverURI string | 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 | // Atomic state. | 123 | // Atomic state. | ||
| 79 | destroyed atomic.Bool | 124 | destroyed atomic.Bool | ||
| 80 | fullscreen atomic.Bool | 125 | fullscreen atomic.Bool | ||
| @@ -127,16 +172,14 @@ func (wv *WebView) LoadURI(_ context.Context, uri string) error { | |||||
| 127 | actualURI := toActualInternalURL(uri) | 172 | actualURI := toActualInternalURL(uri) | ||
| 128 | wv.mu.Lock() | 173 | wv.mu.Lock() | ||
| 129 | browser := wv.browser | 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 | if browser == nil { | 179 | if browser == nil { | ||
| 131 | - | // Browser not yet created — queue the URI for OnAfterCreated. | |||
| 132 | - | wv.pendingURI = actualURI | |||
| 133 | - | wv.mu.Unlock() | |||
| 134 | return nil | 180 | return nil | ||
| 135 | } | 181 | } | ||
| 136 | - | wv.mu.Unlock() | 182 | + | wv.schedulePendingNavigationReplay(0) |
| 137 | - | if frame := browser.GetMainFrame(); frame != nil { | |||
| 138 | - | frame.LoadURL(actualURI) | |||
| 139 | - | } | |||
| 140 | return nil | 183 | return nil | ||
| 141 | } | 184 | } | ||
| 142 | 185 | ||||
| @@ -446,6 +489,22 @@ func (wv *WebView) RunJavaScript(_ context.Context, script string) { | |||||
| 446 | // colorScale converts a 0.0–1.0 color component to an 8-bit integer. | 489 | // colorScale converts a 0.0–1.0 color component to an 8-bit integer. | ||
| 447 | const colorScale = 255 | 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 | // SetBackgroundColor sets the background via JS injection (CEF has no runtime API). | 508 | // SetBackgroundColor sets the background via JS injection (CEF has no runtime API). | ||
| 450 | func (wv *WebView) SetBackgroundColor(r, g, b, a float64) { | 509 | func (wv *WebView) SetBackgroundColor(r, g, b, a float64) { | ||
| 451 | script := fmt.Sprintf( | 510 | script := fmt.Sprintf( | ||
| @@ -473,6 +532,7 @@ func (wv *WebView) Destroy() { | |||||
| 473 | if !wv.destroyed.CompareAndSwap(false, true) { | 532 | if !wv.destroyed.CompareAndSwap(false, true) { | ||
| 474 | return | 533 | return | ||
| 475 | } | 534 | } | ||
| 535 | + | wv.cancelSelectionDebounce() | |||
| 476 | if wv.resizeReconciler != nil { | 536 | if wv.resizeReconciler != nil { | ||
| 477 | wv.resizeReconciler.stop() | 537 | wv.resizeReconciler.stop() | ||
| 478 | } | 538 | } | ||
| @@ -507,11 +567,23 @@ func (wv *WebView) NativeWidget() uintptr { | |||||
| 507 | 567 | ||||
| 508 | func (wv *WebView) updateURI(uri string) { | 568 | func (wv *WebView) updateURI(uri string) { | ||
| 509 | uri = toConceptualInternalURL(uri) | 569 | uri = toConceptualInternalURL(uri) | ||
| 570 | + | now := time.Now() | |||
| 510 | wv.mu.Lock() | 571 | wv.mu.Lock() | ||
| 511 | wv.uri = uri | 572 | wv.uri = uri | ||
| 573 | + | wv.loadDiagLastAddressAt = now | |||
| 512 | cb := wv.callbacks | 574 | cb := wv.callbacks | ||
| 575 | + | pendingMatched := pendingURIEquivalent(wv.pendingURI, uri) | |||
| 576 | + | if pendingMatched { | |||
| 577 | + | wv.pendingURI = "" | |||
| 578 | + | } | |||
| 513 | wv.mu.Unlock() | 579 | wv.mu.Unlock() | ||
| 514 | 580 | ||||
| 581 | + | if pendingMatched && wv.ctx != nil { | |||
| 582 | + | logging.FromContext(wv.ctx).Debug(). | |||
| 583 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |||
| 584 | + | Msg("cef: pending navigation acknowledged by address change") | |||
| 585 | + | } | |||
| 586 | + | ||||
| 515 | if cb != nil && cb.OnURIChanged != nil { | 587 | if cb != nil && cb.OnURIChanged != nil { | ||
| 516 | wv.runOnGTK(func() { | 588 | wv.runOnGTK(func() { | ||
| 517 | cb.OnURIChanged(uri) | 589 | cb.OnURIChanged(uri) | ||
| @@ -533,8 +605,10 @@ func (wv *WebView) updateTitle(title string) { | |||||
| 533 | } | 605 | } | ||
| 534 | 606 | ||||
| 535 | func (wv *WebView) updateProgress(progress float64) { | 607 | func (wv *WebView) updateProgress(progress float64) { | ||
| 608 | + | now := time.Now() | |||
| 536 | wv.mu.Lock() | 609 | wv.mu.Lock() | ||
| 537 | wv.progress = progress | 610 | wv.progress = progress | ||
| 611 | + | wv.loadDiagLastProgressAt = now | |||
| 538 | cb := wv.callbacks | 612 | cb := wv.callbacks | ||
| 539 | wv.mu.Unlock() | 613 | wv.mu.Unlock() | ||
| 540 | 614 | ||||
| @@ -546,11 +620,26 @@ func (wv *WebView) updateProgress(progress float64) { | |||||
| 546 | } | 620 | } | ||
| 547 | 621 | ||||
| 548 | func (wv *WebView) updateLoadState(loading, back, fwd bool) { | 622 | func (wv *WebView) updateLoadState(loading, back, fwd bool) { | ||
| 623 | + | now := time.Now() | |||
| 549 | wv.mu.Lock() | 624 | wv.mu.Lock() | ||
| 625 | + | wasLoading := wv.isLoading | |||
| 550 | wv.isLoading = loading | 626 | wv.isLoading = loading | ||
| 551 | wv.canGoBack = back | 627 | wv.canGoBack = back | ||
| 552 | wv.canGoFwd = fwd | 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 | wv.mu.Unlock() | 638 | wv.mu.Unlock() | ||
| 639 | + | ||||
| 640 | + | if loadDiagSeq != 0 { | |||
| 641 | + | wv.scheduleLoadWatchdogs(loadDiagSeq) | |||
| 642 | + | } | |||
| 554 | } | 643 | } | ||
| 555 | 644 | ||||
| 556 | func (wv *WebView) setAudioPlaying(playing bool) { | 645 | func (wv *WebView) setAudioPlaying(playing bool) { | ||
| @@ -565,6 +654,320 @@ func (wv *WebView) setAudioPlaying(playing bool) { | |||||
| 565 | } | 654 | } | ||
| 566 | } | 655 | } | ||
| 567 | 656 | ||||
| 657 | + | func (wv *WebView) setSelectedText(text string) (string, bool) { | |||
| 658 | + | wv.mu.Lock() | |||
| 659 | + | previous := wv.selectedText | |||
| 660 | + | changed := previous != text | |||
| 661 | + | wv.selectedText = text | |||
| 662 | + | wv.mu.Unlock() | |||
| 663 | + | return previous, changed | |||
| 664 | + | } | |||
| 665 | + | ||||
| 666 | + | func (wv *WebView) setEditableFocus(editable bool) { | |||
| 667 | + | if wv == nil { | |||
| 668 | + | return | |||
| 669 | + | } | |||
| 670 | + | wv.mu.Lock() | |||
| 671 | + | previous := wv.focusedEditable | |||
| 672 | + | wv.focusedEditable = editable | |||
| 673 | + | if editable { | |||
| 674 | + | wv.selectionDebounceSeq++ | |||
| 675 | + | timer := wv.selectionDebounceTimer | |||
| 676 | + | wv.selectionDebounceTimer = nil | |||
| 677 | + | wv.mu.Unlock() | |||
| 678 | + | if timer != nil { | |||
| 679 | + | timer.Stop() | |||
| 680 | + | } | |||
| 681 | + | if previous != editable && wv.ctx != nil { | |||
| 682 | + | logging.FromContext(wv.ctx).Debug().Bool("editable", editable).Msg("cef: editable focus changed") | |||
| 683 | + | } | |||
| 684 | + | return | |||
| 685 | + | } | |||
| 686 | + | wv.mu.Unlock() | |||
| 687 | + | if previous != editable && wv.ctx != nil { | |||
| 688 | + | logging.FromContext(wv.ctx).Debug().Bool("editable", editable).Msg("cef: editable focus changed") | |||
| 689 | + | } | |||
| 690 | + | } | |||
| 691 | + | ||||
| 692 | + | func (wv *WebView) cancelSelectionDebounce() { | |||
| 693 | + | if wv == nil { | |||
| 694 | + | return | |||
| 695 | + | } | |||
| 696 | + | wv.mu.Lock() | |||
| 697 | + | wv.selectionDebounceSeq++ | |||
| 698 | + | timer := wv.selectionDebounceTimer | |||
| 699 | + | wv.selectionDebounceTimer = nil | |||
| 700 | + | wv.mu.Unlock() | |||
| 701 | + | if timer != nil { | |||
| 702 | + | timer.Stop() | |||
| 703 | + | } | |||
| 704 | + | } | |||
| 705 | + | ||||
| 706 | + | func (wv *WebView) selectionDebounceInterval() time.Duration { | |||
| 707 | + | if wv == nil || wv.selectionDebounceDelay == nil { | |||
| 708 | + | return clipboardSelectionDebounceDelay | |||
| 709 | + | } | |||
| 710 | + | return *wv.selectionDebounceDelay | |||
| 711 | + | } | |||
| 712 | + | ||||
| 713 | + | func (wv *WebView) selectionDebounceScheduler() func(time.Duration, func()) stoppableTimer { | |||
| 714 | + | if wv != nil && wv.selectionDebounceSchedule != nil { | |||
| 715 | + | return wv.selectionDebounceSchedule | |||
| 716 | + | } | |||
| 717 | + | return func(delay time.Duration, fn func()) stoppableTimer { | |||
| 718 | + | return time.AfterFunc(delay, fn) | |||
| 719 | + | } | |||
| 720 | + | } | |||
| 721 | + | ||||
| 722 | + | func (wv *WebView) scheduleSelectionUpdate(text string) { | |||
| 723 | + | if wv == nil || wv.destroyed.Load() { | |||
| 724 | + | return | |||
| 725 | + | } | |||
| 726 | + | wv.mu.Lock() | |||
| 727 | + | if wv.destroyed.Load() || wv.engine == nil { | |||
| 728 | + | wv.mu.Unlock() | |||
| 729 | + | return | |||
| 730 | + | } | |||
| 731 | + | if wv.focusedEditable { | |||
| 732 | + | wv.mu.Unlock() | |||
| 733 | + | if wv.ctx != nil { | |||
| 734 | + | logging.FromContext(wv.ctx).Debug(). | |||
| 735 | + | Int("text_len", len(text)). | |||
| 736 | + | Msg("cef: selection auto-copy skipped while editable focused") | |||
| 737 | + | } | |||
| 738 | + | return | |||
| 739 | + | } | |||
| 740 | + | wv.selectionDebounceSeq++ | |||
| 741 | + | seq := wv.selectionDebounceSeq | |||
| 742 | + | if timer := wv.selectionDebounceTimer; timer != nil { | |||
| 743 | + | timer.Stop() | |||
| 744 | + | } | |||
| 745 | + | delay := wv.selectionDebounceInterval() | |||
| 746 | + | if delay <= 0 { | |||
| 747 | + | wv.selectionDebounceTimer = nil | |||
| 748 | + | wv.mu.Unlock() | |||
| 749 | + | wv.flushSelectionUpdate(seq, text) | |||
| 750 | + | return | |||
| 751 | + | } | |||
| 752 | + | scheduler := wv.selectionDebounceScheduler() | |||
| 753 | + | wv.selectionDebounceTimer = scheduler(delay, func() { | |||
| 754 | + | wv.flushSelectionUpdate(seq, text) | |||
| 755 | + | }) | |||
| 756 | + | wv.mu.Unlock() | |||
| 757 | + | } | |||
| 758 | + | ||||
| 759 | + | func (wv *WebView) flushSelectionUpdate(seq uint64, text string) { | |||
| 760 | + | if wv == nil || wv.destroyed.Load() { | |||
| 761 | + | return | |||
| 762 | + | } | |||
| 763 | + | wv.mu.Lock() | |||
| 764 | + | if wv.destroyed.Load() || wv.focusedEditable || seq != wv.selectionDebounceSeq { | |||
| 765 | + | wv.mu.Unlock() | |||
| 766 | + | return | |||
| 767 | + | } | |||
| 768 | + | engine := wv.engine | |||
| 769 | + | viewID := wv.id | |||
| 770 | + | wv.mu.Unlock() | |||
| 771 | + | if engine != nil { | |||
| 772 | + | if wv.ctx != nil { | |||
| 773 | + | logging.FromContext(wv.ctx).Debug(). | |||
| 774 | + | Int("text_len", len(text)). | |||
| 775 | + | Msg("cef: debounced selection update flushed") | |||
| 776 | + | } | |||
| 777 | + | engine.handleClipboardSelectionUpdate(viewID, text) | |||
| 778 | + | } | |||
| 779 | + | } | |||
| 780 | + | ||||
| 781 | + | func (wv *WebView) selectedTextSnapshot() string { | |||
| 782 | + | wv.mu.RLock() | |||
| 783 | + | defer wv.mu.RUnlock() | |||
| 784 | + | return wv.selectedText | |||
| 785 | + | } | |||
| 786 | + | ||||
| 787 | + | func (wv *WebView) scheduleLoadWatchdogs(loadSeq uint64) { | |||
| 788 | + | for _, delay := range cefLoadWatchdogDelays { | |||
| 789 | + | d := delay | |||
| 790 | + | time.AfterFunc(d, func() { | |||
| 791 | + | wv.logLoadWatchdog(loadSeq, d) | |||
| 792 | + | }) | |||
| 793 | + | } | |||
| 794 | + | } | |||
| 795 | + | ||||
| 796 | + | func (wv *WebView) logLoadWatchdog(loadSeq uint64, delay time.Duration) { | |||
| 797 | + | if wv == nil || wv.destroyed.Load() || wv.ctx == nil { | |||
| 798 | + | return | |||
| 799 | + | } | |||
| 800 | + | ||||
| 801 | + | wv.mu.RLock() | |||
| 802 | + | currentSeq := wv.loadDiagSeq | |||
| 803 | + | loading := wv.isLoading | |||
| 804 | + | uri := wv.uri | |||
| 805 | + | title := wv.title | |||
| 806 | + | progress := wv.progress | |||
| 807 | + | pendingURI := wv.pendingURI | |||
| 808 | + | startedAt := wv.loadDiagStartedAt | |||
| 809 | + | lastProgressAt := wv.loadDiagLastProgressAt | |||
| 810 | + | lastAddressAt := wv.loadDiagLastAddressAt | |||
| 811 | + | lastLoadStateAt := wv.loadDiagLastLoadStateAt | |||
| 812 | + | wv.mu.RUnlock() | |||
| 813 | + | ||||
| 814 | + | if loadSeq != currentSeq || !loading { | |||
| 815 | + | return | |||
| 816 | + | } | |||
| 817 | + | ||||
| 818 | + | snap := renderPipelineSnapshot{} | |||
| 819 | + | if wv.pipeline != nil { | |||
| 820 | + | snap = wv.pipeline.diagnosticSnapshot() | |||
| 821 | + | } | |||
| 822 | + | ||||
| 823 | + | now := time.Now() | |||
| 824 | + | logging.FromContext(wv.ctx).Debug(). | |||
| 825 | + | Uint64("load_seq", loadSeq). | |||
| 826 | + | Int64("watch_delay_ms", delay.Milliseconds()). | |||
| 827 | + | Int64("since_start_ms", sinceTimestampMs(startedAt, now)). | |||
| 828 | + | Int64("since_progress_ms", sinceTimestampMs(lastProgressAt, now)). | |||
| 829 | + | Int64("since_address_ms", sinceTimestampMs(lastAddressAt, now)). | |||
| 830 | + | Int64("since_load_state_ms", sinceTimestampMs(lastLoadStateAt, now)). | |||
| 831 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |||
| 832 | + | Str("title", title). | |||
| 833 | + | Float64("progress", progress). | |||
| 834 | + | Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)). | |||
| 835 | + | Int32("surface_width", snap.Width). | |||
| 836 | + | Int32("surface_height", snap.Height). | |||
| 837 | + | Int32("surface_scale", snap.Scale). | |||
| 838 | + | Bool("gl_ready", snap.GLReady). | |||
| 839 | + | Bool("needs_upload", snap.NeedsUpload). | |||
| 840 | + | Bool("size_changed", snap.SizeChanged). | |||
| 841 | + | Bool("force_full_upload", snap.ForceFullUpload). | |||
| 842 | + | Uint64("view_rect_seq", snap.ViewRectSeq). | |||
| 843 | + | Uint64("screen_info_seq", snap.ScreenInfoSeq). | |||
| 844 | + | Uint64("paint_seq", snap.PaintSeq). | |||
| 845 | + | Uint64("resize_seq", snap.ResizeSeq). | |||
| 846 | + | Uint64("last_queued_paint_seq", snap.LastQueuedPaintSeq). | |||
| 847 | + | Uint64("gl_render_seq", snap.GLRenderSeq). | |||
| 848 | + | Uint64("paint_total", snap.PaintCount). | |||
| 849 | + | Uint64("queue_total", snap.QueueRenderCount). | |||
| 850 | + | Uint64("render_total", snap.RenderCount). | |||
| 851 | + | Uint64("upload_total", snap.UploadCount). | |||
| 852 | + | Uint64("full_upload_total", snap.FullUploadCount). | |||
| 853 | + | Msg("cef: loading watchdog") | |||
| 854 | + | } | |||
| 855 | + | ||||
| 856 | + | func (wv *WebView) pendingNavigationURI() string { | |||
| 857 | + | if wv == nil { | |||
| 858 | + | return "" | |||
| 859 | + | } | |||
| 860 | + | wv.mu.RLock() | |||
| 861 | + | defer wv.mu.RUnlock() | |||
| 862 | + | return wv.pendingURI | |||
| 863 | + | } | |||
| 864 | + | ||||
| 865 | + | func (wv *WebView) schedulePendingNavigationReplay(attempt int) { | |||
| 866 | + | if wv == nil || wv.destroyed.Load() { | |||
| 867 | + | return | |||
| 868 | + | } | |||
| 869 | + | task := cefNewTask(cefTaskFunc(func() { | |||
| 870 | + | wv.replayPendingNavigation(attempt) | |||
| 871 | + | })) | |||
| 872 | + | if task == nil { | |||
| 873 | + | return | |||
| 874 | + | } | |||
| 875 | + | var result int32 | |||
| 876 | + | if attempt <= 0 { | |||
| 877 | + | result = cefPostTask(purecef.ThreadIDTidUi, task) | |||
| 878 | + | } else { | |||
| 879 | + | result = cefPostDelayedTask(purecef.ThreadIDTidUi, task, int64(pendingNavigationRetryDelay/time.Millisecond)) | |||
| 880 | + | } | |||
| 881 | + | if result == 1 { | |||
| 882 | + | return | |||
| 883 | + | } | |||
| 884 | + | if wv.ctx != nil { | |||
| 885 | + | log := logging.FromContext(wv.ctx).Warn(). | |||
| 886 | + | Int("attempt", attempt). | |||
| 887 | + | Int32("result", result) | |||
| 888 | + | if attempt >= pendingNavigationMaxRetries { | |||
| 889 | + | log.Msg("cef: failed to schedule pending navigation replay; retries exhausted") | |||
| 890 | + | } else { | |||
| 891 | + | log.Msg("cef: failed to schedule pending navigation replay; retrying") | |||
| 892 | + | } | |||
| 893 | + | } | |||
| 894 | + | if attempt >= pendingNavigationMaxRetries { | |||
| 895 | + | return | |||
| 896 | + | } | |||
| 897 | + | cefScheduleAfter(pendingNavigationRetryDelay, func() { | |||
| 898 | + | wv.schedulePendingNavigationReplay(attempt + 1) | |||
| 899 | + | }) | |||
| 900 | + | } | |||
| 901 | + | ||||
| 902 | + | func (wv *WebView) replayPendingNavigation(attempt int) { | |||
| 903 | + | if wv == nil || wv.destroyed.Load() { | |||
| 904 | + | return | |||
| 905 | + | } | |||
| 906 | + | wv.mu.RLock() | |||
| 907 | + | uri := wv.pendingURI | |||
| 908 | + | browser := wv.browser | |||
| 909 | + | wv.mu.RUnlock() | |||
| 910 | + | if uri == "" || browser == nil { | |||
| 911 | + | return | |||
| 912 | + | } | |||
| 913 | + | frame := browser.GetMainFrame() | |||
| 914 | + | if frame == nil { | |||
| 915 | + | if attempt >= pendingNavigationMaxRetries { | |||
| 916 | + | if wv.ctx != nil { | |||
| 917 | + | logging.FromContext(wv.ctx).Warn(). | |||
| 918 | + | Int("attempt", attempt). | |||
| 919 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |||
| 920 | + | Msg("cef: pending navigation replay exhausted without main frame") | |||
| 921 | + | } | |||
| 922 | + | return | |||
| 923 | + | } | |||
| 924 | + | if wv.ctx != nil { | |||
| 925 | + | logging.FromContext(wv.ctx).Debug(). | |||
| 926 | + | Int("attempt", attempt). | |||
| 927 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |||
| 928 | + | Msg("cef: pending navigation replay waiting for main frame") | |||
| 929 | + | } | |||
| 930 | + | wv.schedulePendingNavigationReplay(attempt + 1) | |||
| 931 | + | return | |||
| 932 | + | } | |||
| 933 | + | currentURL := frame.GetURL() | |||
| 934 | + | if pendingURIEquivalent(currentURL, uri) { | |||
| 935 | + | wv.mu.Lock() | |||
| 936 | + | if pendingURIEquivalent(wv.pendingURI, uri) { | |||
| 937 | + | wv.pendingURI = "" | |||
| 938 | + | } | |||
| 939 | + | wv.mu.Unlock() | |||
| 940 | + | if wv.ctx != nil { | |||
| 941 | + | logging.FromContext(wv.ctx).Debug(). | |||
| 942 | + | Int("attempt", attempt). | |||
| 943 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |||
| 944 | + | Msg("cef: pending navigation already active") | |||
| 945 | + | } | |||
| 946 | + | return | |||
| 947 | + | } | |||
| 948 | + | frame.LoadURL(uri) | |||
| 949 | + | if wv.ctx != nil { | |||
| 950 | + | logging.FromContext(wv.ctx).Debug(). | |||
| 951 | + | Int("attempt", attempt). | |||
| 952 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |||
| 953 | + | Msg("cef: replayed pending navigation") | |||
| 954 | + | } | |||
| 955 | + | } | |||
| 956 | + | ||||
| 957 | + | func pendingURIEquivalent(a, b string) bool { | |||
| 958 | + | if a == "" || b == "" { | |||
| 959 | + | return false | |||
| 960 | + | } | |||
| 961 | + | return toConceptualInternalURL(strings.TrimSpace(a)) == toConceptualInternalURL(strings.TrimSpace(b)) | |||
| 962 | + | } | |||
| 963 | + | ||||
| 964 | + | func sinceTimestampMs(ts, now time.Time) int64 { | |||
| 965 | + | if ts.IsZero() { | |||
| 966 | + | return -1 | |||
| 967 | + | } | |||
| 968 | + | return now.Sub(ts).Milliseconds() | |||
| 969 | + | } | |||
| 970 | + | ||||
| 568 | func (wv *WebView) updateHoverURI(uri string) { | 971 | func (wv *WebView) updateHoverURI(uri string) { | ||
| 569 | wv.mu.Lock() | 972 | wv.mu.Lock() | ||
| 570 | wv.lastHoverURI = uri | 973 | wv.lastHoverURI = uri | ||
M internal/infrastructure/cef/webview_handlers.go +191 -26
| @@ -15,6 +15,7 @@ import ( | |||||
| 15 | const ( | 15 | const ( | ||
| 16 | consoleMarkerVideoDiag = "[VIDEO-DIAG]" | 16 | consoleMarkerVideoDiag = "[VIDEO-DIAG]" | ||
| 17 | consoleMarkerRedditVideoPatch = "[REDDIT-VIDEO-PATCH]" | 17 | consoleMarkerRedditVideoPatch = "[REDDIT-VIDEO-PATCH]" | ||
| 18 | + | consoleMarkerAutoCopy = "[AUTO-COPY]" | |||
| 18 | ) | 19 | ) | ||
| 19 | 20 | ||||
| 20 | // handlerSet implements all CEF handler interfaces and dispatches events to the | 21 | // handlerSet implements all CEF handler interfaces and dispatches events to the | ||
| @@ -67,8 +68,46 @@ func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return | |||||
| 67 | func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h } | 68 | func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h } | ||
| 68 | func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h } | 69 | func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h } | ||
| 69 | 70 | ||||
| 70 | - | func (h *handlerSet) OnProcessMessageReceived(_ purecef.Browser, _ purecef.Frame, _ purecef.ProcessID, _ purecef.ProcessMessage) int32 { | 71 | + | func (h *handlerSet) OnProcessMessageReceived( |
| 71 | - | return 0 | 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 | if sizeMatchesCurrentView { | 306 | if sizeMatchesCurrentView { | ||
| 268 | _, _, shouldLog := h.wv.pipeline.markFirstPaintAfterResize() | 307 | _, _, shouldLog := h.wv.pipeline.markFirstPaintAfterResize() | ||
| 269 | if shouldLog { | 308 | if shouldLog { | ||
| 309 | + | logging.Trace().Mark("cef_first_content_paint") | |||
| 270 | logging.FromContext(h.wv.ctx).Debug(). | 310 | logging.FromContext(h.wv.ctx).Debug(). | ||
| 271 | Uint64("webview_id", uint64(h.wv.id)). | 311 | Uint64("webview_id", uint64(h.wv.id)). | ||
| 272 | Uint64("paint_seq", paintSeq). | 312 | Uint64("paint_seq", paintSeq). | ||
| @@ -295,7 +335,12 @@ func (h *handlerSet) shouldAcceptMainViewPaint( | |||||
| 295 | return sizeMatchesCurrentView | 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 | count := h.wv.pipeline.recordAcceleratedPaint() | 344 | count := h.wv.pipeline.recordAcceleratedPaint() | ||
| 300 | if count <= 5 || count%100 == 0 { | 345 | if count <= 5 || count%100 == 0 { | ||
| 301 | logging.FromContext(h.wv.ctx).Info(). | 346 | logging.FromContext(h.wv.ctx).Info(). | ||
| @@ -309,7 +354,12 @@ func (h *handlerSet) GetTouchHandleSize(_ purecef.Browser, _ purecef.HorizontalA | |||||
| 309 | 354 | ||||
| 310 | func (h *handlerSet) OnTouchHandleStateChanged(_ purecef.Browser, _ *purecef.TouchHandleState) {} | 355 | func (h *handlerSet) OnTouchHandleStateChanged(_ purecef.Browser, _ *purecef.TouchHandleState) {} | ||
| 311 | 356 | ||||
| 312 | - | func (h *handlerSet) StartDragging(_ purecef.Browser, _ purecef.DragData, _ purecef.DragOperationsMask, _, _ int32) int32 { | 357 | + | func (h *handlerSet) StartDragging( |
| 358 | + | _ purecef.Browser, | |||
| 359 | + | _ purecef.DragData, | |||
| 360 | + | _ purecef.DragOperationsMask, | |||
| 361 | + | _, _ int32, | |||
| 362 | + | ) int32 { | |||
| 313 | return 0 | 363 | return 0 | ||
| 314 | } | 364 | } | ||
| 315 | 365 | ||||
| @@ -320,7 +370,29 @@ func (h *handlerSet) OnScrollOffsetChanged(_ purecef.Browser, _, _ float64) {} | |||||
| 320 | func (h *handlerSet) OnImeCompositionRangeChanged(_ purecef.Browser, _ *purecef.Range, _ []purecef.Rect) { | 370 | func (h *handlerSet) OnImeCompositionRangeChanged(_ purecef.Browser, _ *purecef.Range, _ []purecef.Rect) { | ||
| 321 | } | 371 | } | ||
| 322 | 372 | ||||
| 323 | - | func (h *handlerSet) OnTextSelectionChanged(_ purecef.Browser, _ string, _ *purecef.Range) {} | 373 | + | func (h *handlerSet) OnTextSelectionChanged(_ purecef.Browser, selectedText string, _ *purecef.Range) { |
| 374 | + | if h == nil || h.wv == nil { | |||
| 375 | + | return | |||
| 376 | + | } | |||
| 377 | + | previous, changed := h.wv.setSelectedText(selectedText) | |||
| 378 | + | if !changed { | |||
| 379 | + | return | |||
| 380 | + | } | |||
| 381 | + | if h.wv.ctx != nil && selectedText == "" { | |||
| 382 | + | if previous != "" { | |||
| 383 | + | logging.FromContext(h.wv.ctx).Debug(). | |||
| 384 | + | Int("prev_text_len", len(previous)). | |||
| 385 | + | Msg("cef: text selection cleared") | |||
| 386 | + | } | |||
| 387 | + | } else if h.wv.ctx != nil { | |||
| 388 | + | logging.FromContext(h.wv.ctx).Debug(). | |||
| 389 | + | Int("text_len", len(selectedText)). | |||
| 390 | + | Msg("cef: text selection changed") | |||
| 391 | + | } | |||
| 392 | + | if h.wv.engine != nil { | |||
| 393 | + | h.wv.scheduleSelectionUpdate(selectedText) | |||
| 394 | + | } | |||
| 395 | + | } | |||
| 324 | 396 | ||||
| 325 | func (h *handlerSet) OnVirtualKeyboardRequested(_ purecef.Browser, _ purecef.TextInputMode) {} | 397 | func (h *handlerSet) OnVirtualKeyboardRequested(_ purecef.Browser, _ purecef.TextInputMode) {} | ||
| 326 | 398 | ||||
| @@ -331,6 +403,11 @@ func (h *handlerSet) OnVirtualKeyboardRequested(_ purecef.Browser, _ purecef.Tex | |||||
| 331 | // OnAddressChange updates the cached URI when the main frame navigates. | 403 | // OnAddressChange updates the cached URI when the main frame navigates. | ||
| 332 | func (h *handlerSet) OnAddressChange(_ purecef.Browser, frame purecef.Frame, url string) { | 404 | func (h *handlerSet) OnAddressChange(_ purecef.Browser, frame purecef.Frame, url string) { | ||
| 333 | if frame != nil && frame.IsMain() { | 405 | if frame != nil && frame.IsMain() { | ||
| 406 | + | if h.wv != nil && h.wv.ctx != nil { | |||
| 407 | + | logging.FromContext(h.wv.ctx).Debug(). | |||
| 408 | + | Str("url", logging.TruncateURL(url, logging.PermissionLogURLMaxLen)). | |||
| 409 | + | Msg("cef: OnAddressChange") | |||
| 410 | + | } | |||
| 334 | h.wv.updateURI(url) | 411 | h.wv.updateURI(url) | ||
| 335 | } | 412 | } | ||
| 336 | } | 413 | } | ||
| @@ -382,9 +459,16 @@ func (h *handlerSet) OnStatusMessage(_ purecef.Browser, value string) { | |||||
| 382 | } | 459 | } | ||
| 383 | } | 460 | } | ||
| 384 | 461 | ||||
| 385 | - | func (h *handlerSet) OnConsoleMessage(_ purecef.Browser, level purecef.LogSeverity, message, source string, line int32) int32 { | 462 | + | func (h *handlerSet) OnConsoleMessage( |
| 463 | + | _ purecef.Browser, | |||
| 464 | + | level purecef.LogSeverity, | |||
| 465 | + | message, source string, | |||
| 466 | + | line int32, | |||
| 467 | + | ) int32 { | |||
| 386 | if h.wv != nil && h.wv.ctx != nil && | 468 | if h.wv != nil && h.wv.ctx != nil && | ||
| 387 | - | (strings.Contains(message, consoleMarkerVideoDiag) || strings.Contains(message, consoleMarkerRedditVideoPatch)) { | 469 | + | (strings.Contains(message, consoleMarkerVideoDiag) || |
| 470 | + | strings.Contains(message, consoleMarkerRedditVideoPatch) || | |||
| 471 | + | strings.Contains(message, consoleMarkerAutoCopy)) { | |||
| 388 | log := logging.FromContext(h.wv.ctx).With(). | 472 | log := logging.FromContext(h.wv.ctx).With(). | ||
| 389 | Str("component", "cef-console"). | 473 | Str("component", "cef-console"). | ||
| 390 | Str("source", source). | 474 | Str("source", source). | ||
| @@ -406,10 +490,20 @@ func (h *handlerSet) OnAutoResize(_ purecef.Browser, _ *purecef.Size) int32 { re | |||||
| 406 | 490 | ||||
| 407 | // OnLoadingProgressChange updates the cached progress value. | 491 | // OnLoadingProgressChange updates the cached progress value. | ||
| 408 | func (h *handlerSet) OnLoadingProgressChange(_ purecef.Browser, progress float64) { | 492 | func (h *handlerSet) OnLoadingProgressChange(_ purecef.Browser, progress float64) { | ||
| 493 | + | if h.wv != nil && h.wv.ctx != nil { | |||
| 494 | + | logging.FromContext(h.wv.ctx).Trace(). | |||
| 495 | + | Float64("progress", progress). | |||
| 496 | + | Msg("cef: OnLoadingProgressChange") | |||
| 497 | + | } | |||
| 409 | h.wv.updateProgress(progress) | 498 | h.wv.updateProgress(progress) | ||
| 410 | } | 499 | } | ||
| 411 | 500 | ||||
| 412 | - | func (h *handlerSet) OnCursorChange(_ purecef.Browser, _ uintptr, cursorType purecef.CursorType, _ *purecef.CursorInfo) int32 { | 501 | + | func (h *handlerSet) OnCursorChange( |
| 502 | + | _ purecef.Browser, | |||
| 503 | + | _ uintptr, | |||
| 504 | + | cursorType purecef.CursorType, | |||
| 505 | + | _ *purecef.CursorInfo, | |||
| 506 | + | ) int32 { | |||
| 413 | name := cefCursorToGDKName(cursorType) | 507 | name := cefCursorToGDKName(cursorType) | ||
| 414 | h.wv.runOnGTK(func() { | 508 | h.wv.runOnGTK(func() { | ||
| 415 | h.wv.pipeline.glArea.SetCursorFromName(&name) | 509 | h.wv.pipeline.glArea.SetCursorFromName(&name) | ||
| @@ -431,6 +525,30 @@ func (h *handlerSet) GetRootWindowScreenRect(_ purecef.Browser, _ *purecef.Rect) | |||||
| 431 | func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangoback, cangoforward int32) { | 525 | func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangoback, cangoforward int32) { | ||
| 432 | loading := isloading != 0 | 526 | loading := isloading != 0 | ||
| 433 | h.wv.updateLoadState(loading, cangoback != 0, cangoforward != 0) | 527 | h.wv.updateLoadState(loading, cangoback != 0, cangoforward != 0) | ||
| 528 | + | if h.wv != nil && h.wv.ctx != nil { | |||
| 529 | + | h.wv.mu.RLock() | |||
| 530 | + | uri := h.wv.uri | |||
| 531 | + | progress := h.wv.progress | |||
| 532 | + | pendingURI := h.wv.pendingURI | |||
| 533 | + | h.wv.mu.RUnlock() | |||
| 534 | + | logging.FromContext(h.wv.ctx).Debug(). | |||
| 535 | + | Bool("loading", loading). | |||
| 536 | + | Bool("can_go_back", cangoback != 0). | |||
| 537 | + | Bool("can_go_forward", cangoforward != 0). | |||
| 538 | + | Float64("progress", progress). | |||
| 539 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |||
| 540 | + | Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)). | |||
| 541 | + | Msg("cef: OnLoadingStateChange") | |||
| 542 | + | } | |||
| 543 | + | ||||
| 544 | + | if !loading && h.wv.input != nil && h.wv.input.hasGTKFocus() { | |||
| 545 | + | h.wv.mu.RLock() | |||
| 546 | + | host := h.wv.host | |||
| 547 | + | h.wv.mu.RUnlock() | |||
| 548 | + | if host != nil { | |||
| 549 | + | syncWindowlessBrowserFocus(host) | |||
| 550 | + | } | |||
| 551 | + | } | |||
| 434 | 552 | ||||
| 435 | h.wv.mu.RLock() | 553 | h.wv.mu.RLock() | ||
| 436 | cb := h.wv.callbacks | 554 | cb := h.wv.callbacks | ||
| @@ -455,6 +573,11 @@ func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purec | |||||
| 455 | if frame == nil || !frame.IsMain() { | 573 | if frame == nil || !frame.IsMain() { | ||
| 456 | return | 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 | h.wv.updateURI(frame.GetURL()) | 581 | h.wv.updateURI(frame.GetURL()) | ||
| 459 | h.wv.mu.RLock() | 582 | h.wv.mu.RLock() | ||
| 460 | cb := h.wv.callbacks | 583 | cb := h.wv.callbacks | ||
| @@ -480,6 +603,18 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu | |||||
| 480 | // Successful load — reset the consecutive crash counter. | 603 | // Successful load — reset the consecutive crash counter. | ||
| 481 | h.wv.crashCount.Store(0) | 604 | h.wv.crashCount.Store(0) | ||
| 482 | 605 | ||||
| 606 | + | // If a queued startup navigation is still pending after about:blank finished, | |||
| 607 | + | // replay it now that the initial main-frame load completed. | |||
| 608 | + | frameURL := frame.GetURL() | |||
| 609 | + | if pendingURI := h.wv.pendingNavigationURI(); pendingURI != "" && !pendingURIEquivalent(frameURL, pendingURI) { | |||
| 610 | + | if strings.EqualFold(strings.TrimSpace(frameURL), "about:blank") { | |||
| 611 | + | log.Debug(). | |||
| 612 | + | Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)). | |||
| 613 | + | Msg("cef: replaying pending navigation after about:blank load end") | |||
| 614 | + | h.wv.schedulePendingNavigationReplay(0) | |||
| 615 | + | } | |||
| 616 | + | } | |||
| 617 | + | ||||
| 483 | // Inject scripts and styles after page load. | 618 | // Inject scripts and styles after page load. | ||
| 484 | // Must run on GTK thread — OnLoadEnd fires on the CEF IO thread, | 619 | // Must run on GTK thread — OnLoadEnd fires on the CEF IO thread, | ||
| 485 | // and JavaScript injection requires the main thread. | 620 | // and JavaScript injection requires the main thread. | ||
| @@ -564,35 +699,47 @@ func (h *handlerSet) OnAfterCreated(browser purecef.Browser) { | |||||
| 564 | h.wv.engine.registerWebView(h.wv) | 699 | h.wv.engine.registerWebView(h.wv) | ||
| 565 | } | 700 | } | ||
| 566 | host := browser.GetHost() | 701 | host := browser.GetHost() | ||
| 702 | + | if host == nil { | |||
| 703 | + | log.Warn().Msg("cef: OnAfterCreated returned nil host") | |||
| 704 | + | return | |||
| 705 | + | } | |||
| 567 | 706 | ||||
| 707 | + | shouldSyncFocus := false | |||
| 708 | + | hasPendingNavigation := false | |||
| 568 | h.wv.mu.Lock() | 709 | h.wv.mu.Lock() | ||
| 569 | h.wv.browser = browser | 710 | h.wv.browser = browser | ||
| 570 | h.wv.host = host | 711 | h.wv.host = host | ||
| 571 | - | uri := h.wv.pendingURI | |||
| 572 | - | h.wv.pendingURI = "" | |||
| 573 | h.wv.input.setHost(host) | 712 | h.wv.input.setHost(host) | ||
| 574 | if h.wv.findCtrl != nil { | 713 | if h.wv.findCtrl != nil { | ||
| 575 | h.wv.findCtrl.setHost(host) | 714 | h.wv.findCtrl.setHost(host) | ||
| 576 | } | 715 | } | ||
| 577 | 716 | ||||
| 578 | // Mark browser as visible — CEF OSR starts in hidden state and suppresses | 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 | host.WasHidden(0) | 719 | host.WasHidden(0) | ||
| 581 | 720 | ||||
| 582 | - | // If the GLArea already has GTK focus (cold start race), tell CEF now. | 721 | + | // If GTK focus entered before the browser existed, reassert browser focus |
| 583 | - | // The focus-enter event fired before the browser existed, so SetFocus | 722 | + | // now. Otherwise CEF may stay internally unfocused and suppress caret paint. |
| 584 | - | // was never called and the caret won't blink until the user clicks. | 723 | + | if h.wv.input != nil { |
| 585 | - | if h.wv.input != nil && h.wv.input.glArea != nil && h.wv.input.glArea.HasFocus() { | 724 | + | shouldSyncFocus = h.wv.input.hasGTKFocus() |
| 586 | - | host.SetFocus(1) | 725 | + | if !shouldSyncFocus && h.wv.input.glArea != nil { |
| 587 | - | } | 726 | + | shouldSyncFocus = h.wv.input.glArea.HasFocus() |
| 588 | - | ||||
| 589 | - | // Replay any navigation that was requested before the browser existed. | |||
| 590 | - | if uri != "" { | |||
| 591 | - | if frame := browser.GetMainFrame(); frame != nil { | |||
| 592 | - | frame.LoadURL(uri) | |||
| 593 | } | 727 | } | ||
| 594 | } | 728 | } | ||
| 729 | + | hasPendingNavigation = strings.TrimSpace(h.wv.pendingURI) != "" | |||
| 730 | + | ||||
| 595 | h.wv.mu.Unlock() | 731 | h.wv.mu.Unlock() | ||
| 732 | + | if shouldSyncFocus { | |||
| 733 | + | syncWindowlessBrowserFocus(host) | |||
| 734 | + | } else { | |||
| 735 | + | // Request an initial OSR frame even before the first real navigation | |||
| 736 | + | // commits. This restores the about:blank warm-up paint that the stable | |||
| 737 | + | // startup path relied on and prevents the render pipeline from staying idle. | |||
| 738 | + | host.Invalidate(purecef.PaintElementTypePetView) | |||
| 739 | + | } | |||
| 740 | + | if hasPendingNavigation { | |||
| 741 | + | h.wv.schedulePendingNavigationReplay(0) | |||
| 742 | + | } | |||
| 596 | 743 | ||||
| 597 | h.wv.scheduleStartBeginFrameLoop() | 744 | h.wv.scheduleStartBeginFrameLoop() | ||
| 598 | } | 745 | } | ||
| @@ -636,7 +783,13 @@ func (h *handlerSet) OnBeforeBrowse(_ purecef.Browser, _ purecef.Frame, _ purece | |||||
| 636 | return false | 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 | return 0 | 793 | return 0 | ||
| 641 | } | 794 | } | ||
| 642 | 795 | ||||
| @@ -644,7 +797,8 @@ func (h *handlerSet) GetResourceRequestHandler( | |||||
| 644 | _ purecef.Browser, _ purecef.Frame, request purecef.Request, | 797 | _ purecef.Browser, _ purecef.Frame, request purecef.Request, | ||
| 645 | _, _ int32, _ string, disableDefaultHandling *int32, | 798 | _, _ int32, _ string, disableDefaultHandling *int32, | ||
| 646 | ) purecef.ResourceRequestHandler { | 799 | ) purecef.ResourceRequestHandler { | ||
| 647 | - | if h.transcodingHandler != nil && request != nil && disableDefaultHandling != nil && transcoder.IsEagerTranscodeURL(request.GetURL()) { | 800 | + | if h.transcodingHandler != nil && request != nil && disableDefaultHandling != nil && |
| 801 | + | transcoder.IsEagerTranscodeURL(request.GetURL()) { | |||
| 648 | *disableDefaultHandling = 1 | 802 | *disableDefaultHandling = 1 | ||
| 649 | if h.wv != nil && h.wv.ctx != nil { | 803 | if h.wv != nil && h.wv.ctx != nil { | ||
| 650 | logging.FromContext(h.wv.ctx).Info(). | 804 | logging.FromContext(h.wv.ctx).Info(). | ||
| @@ -665,7 +819,13 @@ func (h *handlerSet) GetAuthCredentials( | |||||
| 665 | return 0 | 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 | return 0 | 829 | return 0 | ||
| 670 | } | 830 | } | ||
| 671 | 831 | ||||
| @@ -894,7 +1054,12 @@ func (h *handlerSet) OnAudioStreamError(_ purecef.Browser, message string) { | |||||
| 894 | // =========================================================================== | 1054 | // =========================================================================== | ||
| 895 | 1055 | ||||
| 896 | // OnFindResult dispatches CEF find results to the WebView's FindController. | 1056 | // OnFindResult dispatches CEF find results to the WebView's FindController. | ||
| 897 | - | func (h *handlerSet) OnFindResult(_ purecef.Browser, identifier, count int32, _ *purecef.Rect, activematchordinal, finalupdate int32) { | 1057 | + | func (h *handlerSet) OnFindResult( |
| 1058 | + | _ purecef.Browser, | |||
| 1059 | + | identifier, count int32, | |||
| 1060 | + | _ *purecef.Rect, | |||
| 1061 | + | activematchordinal, finalupdate int32, | |||
| 1062 | + | ) { | |||
| 898 | if h.wv.findCtrl != nil { | 1063 | if h.wv.findCtrl != nil { | ||
| 899 | h.wv.findCtrl.handleFindResult(identifier, count, activematchordinal, finalupdate) | 1064 | h.wv.findCtrl.handleFindResult(identifier, count, activematchordinal, finalupdate) | ||
| 900 | } | 1065 | } | ||
M internal/infrastructure/cef/webview_handlers_test.go +240 -0
| @@ -2,15 +2,58 @@ package cef | |||||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | + | "sync" | |||
| 5 | "testing" | 6 | "testing" | ||
| 7 | + | "time" | |||
| 6 | 8 | ||||
| 7 | purecef "github.com/bnema/purego-cef/cef" | 9 | purecef "github.com/bnema/purego-cef/cef" | ||
| 10 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |||
| 8 | "github.com/stretchr/testify/assert" | 11 | "github.com/stretchr/testify/assert" | ||
| 12 | + | "github.com/stretchr/testify/mock" | |||
| 9 | "github.com/stretchr/testify/require" | 13 | "github.com/stretchr/testify/require" | ||
| 10 | 14 | ||||
| 15 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 11 | "github.com/bnema/dumber/internal/application/port" | 16 | "github.com/bnema/dumber/internal/application/port" | ||
| 17 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 12 | ) | 18 | ) | ||
| 13 | 19 | ||||
| 20 | + | type clipboardOrchestratorRecorder struct { | |||
| 21 | + | mu sync.Mutex | |||
| 22 | + | selection dto.SelectionClipboardInput | |||
| 23 | + | selectionCalls int | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | type controlledSelectionTimer struct { | |||
| 27 | + | stopped bool | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | func (t *controlledSelectionTimer) Stop() bool { | |||
| 31 | + | alreadyStopped := t.stopped | |||
| 32 | + | t.stopped = true | |||
| 33 | + | return !alreadyStopped | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | type controlledSelectionScheduler struct { | |||
| 37 | + | timers []*controlledSelectionTimer | |||
| 38 | + | callbacks []func() | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func (s *controlledSelectionScheduler) schedule(_ time.Duration, fn func()) stoppableTimer { | |||
| 42 | + | timer := &controlledSelectionTimer{} | |||
| 43 | + | s.timers = append(s.timers, timer) | |||
| 44 | + | s.callbacks = append(s.callbacks, fn) | |||
| 45 | + | return timer | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func (s *controlledSelectionScheduler) fire(index int) { | |||
| 49 | + | if index < 0 || index >= len(s.callbacks) { | |||
| 50 | + | return | |||
| 51 | + | } | |||
| 52 | + | if !s.timers[index].stopped { | |||
| 53 | + | s.callbacks[index]() | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 14 | func newTestPipeline(w, h, s int32) *renderPipeline { | 57 | func newTestPipeline(w, h, s int32) *renderPipeline { | ||
| 15 | rp := &renderPipeline{scale: s} | 58 | rp := &renderPipeline{scale: s} | ||
| 16 | rp.widthAtomic.Store(w) | 59 | rp.widthAtomic.Store(w) | ||
| @@ -89,6 +132,203 @@ func TestOnLoadEndDoesNotDispatchBrowserLevelCompletion(t *testing.T) { | |||||
| 89 | assert.Empty(t, gotProgress) | 132 | assert.Empty(t, gotProgress) | ||
| 90 | } | 133 | } | ||
| 91 | 134 | ||||
| 135 | + | func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *testing.T) { | |||
| 136 | + | recorder := &clipboardOrchestratorRecorder{} | |||
| 137 | + | orchestrator := portmocks.NewMockClipboardTextOrchestrator(t) | |||
| 138 | + | orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input dto.SelectionClipboardInput) { | |||
| 139 | + | recorder.mu.Lock() | |||
| 140 | + | defer recorder.mu.Unlock() | |||
| 141 | + | recorder.selection = input | |||
| 142 | + | recorder.selectionCalls++ | |||
| 143 | + | }).Return(nil).Once() | |||
| 144 | + | zeroDelay := time.Duration(0) | |||
| 145 | + | wv := &WebView{ | |||
| 146 | + | ctx: context.Background(), | |||
| 147 | + | id: 42, | |||
| 148 | + | selectionDebounceDelay: &zeroDelay, | |||
| 149 | + | engine: &Engine{ | |||
| 150 | + | clipboardTextOrchestrator: orchestrator, | |||
| 151 | + | }, | |||
| 152 | + | } | |||
| 153 | + | h := &handlerSet{wv: wv} | |||
| 154 | + | ||||
| 155 | + | h.OnTextSelectionChanged(nil, "selected text", nil) | |||
| 156 | + | ||||
| 157 | + | recorder.mu.Lock() | |||
| 158 | + | defer recorder.mu.Unlock() | |||
| 159 | + | require.Equal(t, 1, recorder.selectionCalls) | |||
| 160 | + | require.Equal(t, "selected text", recorder.selection.Text) | |||
| 161 | + | require.Equal(t, dto.ClipboardSourceCEF, recorder.selection.SourceEngine) | |||
| 162 | + | require.Equal(t, uint64(42), recorder.selection.ViewID) | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | func TestOnTextSelectionChanged_DebouncesAndCollapsesRapidUpdates(t *testing.T) { | |||
| 166 | + | recorder := &clipboardOrchestratorRecorder{} | |||
| 167 | + | scheduler := &controlledSelectionScheduler{} | |||
| 168 | + | orchestrator := portmocks.NewMockClipboardTextOrchestrator(t) | |||
| 169 | + | orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input dto.SelectionClipboardInput) { | |||
| 170 | + | recorder.mu.Lock() | |||
| 171 | + | defer recorder.mu.Unlock() | |||
| 172 | + | recorder.selection = input | |||
| 173 | + | recorder.selectionCalls++ | |||
| 174 | + | }).Return(nil).Once() | |||
| 175 | + | wv := &WebView{ | |||
| 176 | + | ctx: context.Background(), | |||
| 177 | + | id: 42, | |||
| 178 | + | selectionDebounceSchedule: scheduler.schedule, | |||
| 179 | + | engine: &Engine{ | |||
| 180 | + | clipboardTextOrchestrator: orchestrator, | |||
| 181 | + | }, | |||
| 182 | + | } | |||
| 183 | + | h := &handlerSet{wv: wv} | |||
| 184 | + | ||||
| 185 | + | h.OnTextSelectionChanged(nil, "first selection", nil) | |||
| 186 | + | h.OnTextSelectionChanged(nil, "second selection", nil) | |||
| 187 | + | ||||
| 188 | + | recorder.mu.Lock() | |||
| 189 | + | gotCalls := recorder.selectionCalls | |||
| 190 | + | recorder.mu.Unlock() | |||
| 191 | + | require.Zero(t, gotCalls) | |||
| 192 | + | require.Len(t, scheduler.callbacks, 2) | |||
| 193 | + | ||||
| 194 | + | scheduler.fire(0) | |||
| 195 | + | recorder.mu.Lock() | |||
| 196 | + | gotCalls = recorder.selectionCalls | |||
| 197 | + | recorder.mu.Unlock() | |||
| 198 | + | require.Zero(t, gotCalls) | |||
| 199 | + | ||||
| 200 | + | scheduler.fire(1) | |||
| 201 | + | recorder.mu.Lock() | |||
| 202 | + | defer recorder.mu.Unlock() | |||
| 203 | + | require.Equal(t, 1, recorder.selectionCalls) | |||
| 204 | + | require.Equal(t, "second selection", recorder.selection.Text) | |||
| 205 | + | require.Equal(t, "second selection", wv.selectedTextSnapshot()) | |||
| 206 | + | } | |||
| 207 | + | ||||
| 208 | + | func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResumesWhenCleared(t *testing.T) { | |||
| 209 | + | recorder := &clipboardOrchestratorRecorder{} | |||
| 210 | + | scheduler := &controlledSelectionScheduler{} | |||
| 211 | + | orchestrator := portmocks.NewMockClipboardTextOrchestrator(t) | |||
| 212 | + | orchestrator.EXPECT().HandleSelectionUpdate(mock.Anything, mock.Anything).Run(func(_ context.Context, input dto.SelectionClipboardInput) { | |||
| 213 | + | recorder.mu.Lock() | |||
| 214 | + | defer recorder.mu.Unlock() | |||
| 215 | + | recorder.selection = input | |||
| 216 | + | recorder.selectionCalls++ | |||
| 217 | + | }).Return(nil).Once() | |||
| 218 | + | wv := &WebView{ | |||
| 219 | + | ctx: context.Background(), | |||
| 220 | + | id: 42, | |||
| 221 | + | selectionDebounceSchedule: scheduler.schedule, | |||
| 222 | + | engine: &Engine{ | |||
| 223 | + | clipboardTextOrchestrator: orchestrator, | |||
| 224 | + | }, | |||
| 225 | + | } | |||
| 226 | + | h := &handlerSet{wv: wv} | |||
| 227 | + | frame := cefmocks.NewMockFrame(t) | |||
| 228 | + | oldFactory := newRendererBridgeProcessMessage | |||
| 229 | + | t.Cleanup(func() { newRendererBridgeProcessMessage = oldFactory }) | |||
| 230 | + | ||||
| 231 | + | newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage { | |||
| 232 | + | return newTestBridgeProcessMessage(name, true) | |||
| 233 | + | } | |||
| 234 | + | frame.EXPECT().SendProcessMessage(purecef.ProcessIDPidBrowser, mock.Anything).Run(func(_ purecef.ProcessID, message purecef.ProcessMessage) { | |||
| 235 | + | require.Equal(t, rendererBridgeMessageName, message.GetName()) | |||
| 236 | + | args := message.GetArgumentList() | |||
| 237 | + | require.Equal(t, "editable_focus_changed", args.GetString(0)) | |||
| 238 | + | require.Equal(t, "1", args.GetString(1)) | |||
| 239 | + | h.OnProcessMessageReceived(nil, nil, 0, message) | |||
| 240 | + | }).Once() | |||
| 241 | + | (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: true}) | |||
| 242 | + | ||||
| 243 | + | h.OnTextSelectionChanged(nil, "editable selection", nil) | |||
| 244 | + | require.Equal(t, "editable selection", wv.selectedTextSnapshot()) | |||
| 245 | + | ||||
| 246 | + | recorder.mu.Lock() | |||
| 247 | + | gotCalls := recorder.selectionCalls | |||
| 248 | + | recorder.mu.Unlock() | |||
| 249 | + | require.Zero(t, gotCalls) | |||
| 250 | + | require.Empty(t, scheduler.callbacks) | |||
| 251 | + | ||||
| 252 | + | newRendererBridgeProcessMessage = func(name string) purecef.ProcessMessage { | |||
| 253 | + | return newTestBridgeProcessMessage(name, false) | |||
| 254 | + | } | |||
| 255 | + | frame.EXPECT().SendProcessMessage(purecef.ProcessIDPidBrowser, mock.Anything).Run(func(_ purecef.ProcessID, message purecef.ProcessMessage) { | |||
| 256 | + | require.Equal(t, rendererBridgeMessageName, message.GetName()) | |||
| 257 | + | args := message.GetArgumentList() | |||
| 258 | + | require.Equal(t, "editable_focus_changed", args.GetString(0)) | |||
| 259 | + | require.Equal(t, "0", args.GetString(1)) | |||
| 260 | + | h.OnProcessMessageReceived(nil, nil, 0, message) | |||
| 261 | + | }).Once() | |||
| 262 | + | (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: false}) | |||
| 263 | + | ||||
| 264 | + | h.OnTextSelectionChanged(nil, "free selection", nil) | |||
| 265 | + | require.Equal(t, "free selection", wv.selectedTextSnapshot()) | |||
| 266 | + | recorder.mu.Lock() | |||
| 267 | + | gotCalls = recorder.selectionCalls | |||
| 268 | + | recorder.mu.Unlock() | |||
| 269 | + | require.Zero(t, gotCalls) | |||
| 270 | + | require.Len(t, scheduler.callbacks, 1) | |||
| 271 | + | ||||
| 272 | + | scheduler.fire(0) | |||
| 273 | + | recorder.mu.Lock() | |||
| 274 | + | defer recorder.mu.Unlock() | |||
| 275 | + | require.Equal(t, 1, recorder.selectionCalls) | |||
| 276 | + | require.Equal(t, "free selection", recorder.selection.Text) | |||
| 277 | + | } | |||
| 278 | + | ||||
| 279 | + | func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *testing.T) { | |||
| 280 | + | scheduler := &controlledSelectionScheduler{} | |||
| 281 | + | orchestrator := portmocks.NewMockClipboardTextOrchestrator(t) | |||
| 282 | + | wv := &WebView{ | |||
| 283 | + | ctx: context.Background(), | |||
| 284 | + | id: 42, | |||
| 285 | + | selectionDebounceSchedule: scheduler.schedule, | |||
| 286 | + | engine: &Engine{ | |||
| 287 | + | clipboardTextOrchestrator: orchestrator, | |||
| 288 | + | }, | |||
| 289 | + | } | |||
| 290 | + | h := &handlerSet{wv: wv} | |||
| 291 | + | ||||
| 292 | + | h.OnTextSelectionChanged(nil, "selected text", nil) | |||
| 293 | + | require.Len(t, scheduler.callbacks, 1) | |||
| 294 | + | wv.Destroy() | |||
| 295 | + | scheduler.fire(0) | |||
| 296 | + | ||||
| 297 | + | orchestrator.AssertNotCalled(t, "HandleSelectionUpdate", mock.Anything, mock.Anything) | |||
| 298 | + | require.Equal(t, "selected text", wv.selectedTextSnapshot()) | |||
| 299 | + | } | |||
| 300 | + | ||||
| 301 | + | type stubEditableDomnode struct { | |||
| 302 | + | editable bool | |||
| 303 | + | } | |||
| 304 | + | ||||
| 305 | + | func (n stubEditableDomnode) GetType() purecef.DomNodeType { return 0 } | |||
| 306 | + | func (n stubEditableDomnode) IsText() bool { return false } | |||
| 307 | + | func (n stubEditableDomnode) IsElement() bool { return true } | |||
| 308 | + | func (n stubEditableDomnode) IsEditable() bool { return n.editable } | |||
| 309 | + | func (n stubEditableDomnode) IsFormControlElement() bool { return false } | |||
| 310 | + | func (n stubEditableDomnode) GetFormControlElementType() purecef.DomFormControlType { return 0 } | |||
| 311 | + | func (n stubEditableDomnode) IsSame(that purecef.Domnode) bool { return n == that } | |||
| 312 | + | func (n stubEditableDomnode) GetName() string { return "" } | |||
| 313 | + | func (n stubEditableDomnode) GetValue() string { return "" } | |||
| 314 | + | func (n stubEditableDomnode) SetValue(string) int32 { return 0 } | |||
| 315 | + | func (n stubEditableDomnode) GetAsMarkup() string { return "" } | |||
| 316 | + | func (n stubEditableDomnode) GetDocument() purecef.Domdocument { return nil } | |||
| 317 | + | func (n stubEditableDomnode) GetParent() purecef.Domnode { return nil } | |||
| 318 | + | func (n stubEditableDomnode) GetPreviousSibling() purecef.Domnode { return nil } | |||
| 319 | + | func (n stubEditableDomnode) GetNextSibling() purecef.Domnode { return nil } | |||
| 320 | + | func (n stubEditableDomnode) HasChildren() bool { return false } | |||
| 321 | + | func (n stubEditableDomnode) GetFirstChild() purecef.Domnode { return nil } | |||
| 322 | + | func (n stubEditableDomnode) GetLastChild() purecef.Domnode { return nil } | |||
| 323 | + | func (n stubEditableDomnode) GetElementTagName() string { return "" } | |||
| 324 | + | func (n stubEditableDomnode) HasElementAttributes() bool { return false } | |||
| 325 | + | func (n stubEditableDomnode) HasElementAttribute(string) bool { return false } | |||
| 326 | + | func (n stubEditableDomnode) GetElementAttribute(string) string { return "" } | |||
| 327 | + | func (n stubEditableDomnode) GetElementAttributes(uintptr) {} | |||
| 328 | + | func (n stubEditableDomnode) SetElementAttribute(string, string) int32 { return 0 } | |||
| 329 | + | func (n stubEditableDomnode) GetElementInnerText() string { return "" } | |||
| 330 | + | func (n stubEditableDomnode) GetElementBounds() uintptr { return 0 } | |||
| 331 | + | ||||
| 92 | func TestGetViewRectUsesDIPCoordinates(t *testing.T) { | 332 | func TestGetViewRectUsesDIPCoordinates(t *testing.T) { | ||
| 93 | rect := &purecef.Rect{} | 333 | rect := &purecef.Rect{} | ||
| 94 | h := &handlerSet{ | 334 | h := &handlerSet{ | ||
A internal/infrastructure/cef/webview_navigation_test.go +164 -0
| @@ -0,0 +1,164 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | "time" | |||
| 7 | + | ||||
| 8 | + | purecef "github.com/bnema/purego-cef/cef" | |||
| 9 | + | cefmocks "github.com/bnema/purego-cef/cef/mocks" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestWebViewReplayPendingNavigation_LoadsQueuedURIWhenMainFrameAvailable(t *testing.T) { | |||
| 14 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 15 | + | frame := cefmocks.NewMockFrame(t) | |||
| 16 | + | frame.EXPECT().GetURL().Return("").Once() | |||
| 17 | + | frame.EXPECT().LoadURL("https://github.com/bnema").Once() | |||
| 18 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |||
| 19 | + | ||||
| 20 | + | wv := &WebView{ctx: context.Background(), browser: browser, pendingURI: "https://github.com/bnema"} | |||
| 21 | + | wv.replayPendingNavigation(0) | |||
| 22 | + | ||||
| 23 | + | wv.mu.RLock() | |||
| 24 | + | defer wv.mu.RUnlock() | |||
| 25 | + | require.Equal(t, "https://github.com/bnema", wv.pendingURI) | |||
| 26 | + | } | |||
| 27 | + | ||||
| 28 | + | func TestWebViewReplayPendingNavigation_RetriesWhenMainFrameUnavailable(t *testing.T) { | |||
| 29 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 30 | + | browser.EXPECT().GetMainFrame().Return(purecef.Frame(nil)).Once() | |||
| 31 | + | ||||
| 32 | + | oldTask := cefNewTask | |||
| 33 | + | oldDelayed := cefPostDelayedTask | |||
| 34 | + | defer func() { | |||
| 35 | + | cefNewTask = oldTask | |||
| 36 | + | cefPostDelayedTask = oldDelayed | |||
| 37 | + | }() | |||
| 38 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |||
| 39 | + | ||||
| 40 | + | scheduled := false | |||
| 41 | + | cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 { | |||
| 42 | + | require.Equal(t, purecef.ThreadIDTidUi, threadID) | |||
| 43 | + | require.NotNil(t, task) | |||
| 44 | + | require.Equal(t, int64(pendingNavigationRetryDelay/time.Millisecond), delayMs) | |||
| 45 | + | scheduled = true | |||
| 46 | + | return 1 | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | wv := &WebView{ctx: context.Background(), browser: browser, pendingURI: "https://github.com/bnema"} | |||
| 50 | + | wv.replayPendingNavigation(0) | |||
| 51 | + | ||||
| 52 | + | require.True(t, scheduled) | |||
| 53 | + | wv.mu.RLock() | |||
| 54 | + | defer wv.mu.RUnlock() | |||
| 55 | + | require.Equal(t, "https://github.com/bnema", wv.pendingURI) | |||
| 56 | + | } | |||
| 57 | + | ||||
| 58 | + | func TestWebViewSchedulePendingNavigationReplay_RetriesWhenTaskPostFails(t *testing.T) { | |||
| 59 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 60 | + | ||||
| 61 | + | oldTask := cefNewTask | |||
| 62 | + | oldPost := cefPostTask | |||
| 63 | + | oldDelayed := cefPostDelayedTask | |||
| 64 | + | oldAfter := cefScheduleAfter | |||
| 65 | + | defer func() { | |||
| 66 | + | cefNewTask = oldTask | |||
| 67 | + | cefPostTask = oldPost | |||
| 68 | + | cefPostDelayedTask = oldDelayed | |||
| 69 | + | cefScheduleAfter = oldAfter | |||
| 70 | + | }() | |||
| 71 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |||
| 72 | + | ||||
| 73 | + | retried := false | |||
| 74 | + | cefPostTask = func(threadID purecef.ThreadID, task purecef.Task) int32 { | |||
| 75 | + | require.Equal(t, purecef.ThreadIDTidUi, threadID) | |||
| 76 | + | require.NotNil(t, task) | |||
| 77 | + | return 0 | |||
| 78 | + | } | |||
| 79 | + | cefScheduleAfter = func(delay time.Duration, fn func()) { | |||
| 80 | + | require.Equal(t, pendingNavigationRetryDelay, delay) | |||
| 81 | + | fn() | |||
| 82 | + | } | |||
| 83 | + | cefPostDelayedTask = func(threadID purecef.ThreadID, task purecef.Task, delayMs int64) int32 { | |||
| 84 | + | require.Equal(t, purecef.ThreadIDTidUi, threadID) | |||
| 85 | + | require.NotNil(t, task) | |||
| 86 | + | require.Equal(t, int64(pendingNavigationRetryDelay/time.Millisecond), delayMs) | |||
| 87 | + | retried = true | |||
| 88 | + | return 1 | |||
| 89 | + | } | |||
| 90 | + | ||||
| 91 | + | wv := &WebView{ctx: context.Background(), browser: browser, pendingURI: "https://github.com/bnema"} | |||
| 92 | + | wv.schedulePendingNavigationReplay(0) | |||
| 93 | + | ||||
| 94 | + | require.True(t, retried) | |||
| 95 | + | } | |||
| 96 | + | ||||
| 97 | + | func TestWebViewReplayPendingNavigation_UsesCurrentBrowserAtExecutionTime(t *testing.T) { | |||
| 98 | + | staleBrowser := cefmocks.NewMockBrowser(t) | |||
| 99 | + | activeBrowser := cefmocks.NewMockBrowser(t) | |||
| 100 | + | frame := cefmocks.NewMockFrame(t) | |||
| 101 | + | frame.EXPECT().GetURL().Return("").Once() | |||
| 102 | + | frame.EXPECT().LoadURL("https://github.com/bnema").Once() | |||
| 103 | + | activeBrowser.EXPECT().GetMainFrame().Return(frame).Once() | |||
| 104 | + | ||||
| 105 | + | oldTask := cefNewTask | |||
| 106 | + | oldPost := cefPostTask | |||
| 107 | + | defer func() { | |||
| 108 | + | cefNewTask = oldTask | |||
| 109 | + | cefPostTask = oldPost | |||
| 110 | + | }() | |||
| 111 | + | var scheduled purecef.Task | |||
| 112 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |||
| 113 | + | cefPostTask = func(threadID purecef.ThreadID, task purecef.Task) int32 { | |||
| 114 | + | require.Equal(t, purecef.ThreadIDTidUi, threadID) | |||
| 115 | + | scheduled = task | |||
| 116 | + | return 1 | |||
| 117 | + | } | |||
| 118 | + | ||||
| 119 | + | wv := &WebView{ctx: context.Background(), browser: staleBrowser, pendingURI: "https://github.com/bnema"} | |||
| 120 | + | wv.schedulePendingNavigationReplay(0) | |||
| 121 | + | wv.mu.Lock() | |||
| 122 | + | wv.browser = activeBrowser | |||
| 123 | + | wv.mu.Unlock() | |||
| 124 | + | ||||
| 125 | + | require.NotNil(t, scheduled) | |||
| 126 | + | scheduled.Execute() | |||
| 127 | + | } | |||
| 128 | + | ||||
| 129 | + | func TestWebViewLoadURI_QueuesPendingNavigationReplayForExistingBrowser(t *testing.T) { | |||
| 130 | + | browser := cefmocks.NewMockBrowser(t) | |||
| 131 | + | frame := cefmocks.NewMockFrame(t) | |||
| 132 | + | browser.EXPECT().GetMainFrame().Return(frame).Once() | |||
| 133 | + | frame.EXPECT().GetURL().Return("").Once() | |||
| 134 | + | frame.EXPECT().LoadURL("github.com/bnema").Once() | |||
| 135 | + | ||||
| 136 | + | oldTask := cefNewTask | |||
| 137 | + | oldPost := cefPostTask | |||
| 138 | + | defer func() { | |||
| 139 | + | cefNewTask = oldTask | |||
| 140 | + | cefPostTask = oldPost | |||
| 141 | + | }() | |||
| 142 | + | cefNewTask = func(task purecef.Task) purecef.Task { return task } | |||
| 143 | + | cefPostTask = func(threadID purecef.ThreadID, task purecef.Task) int32 { | |||
| 144 | + | require.Equal(t, purecef.ThreadIDTidUi, threadID) | |||
| 145 | + | require.NotNil(t, task) | |||
| 146 | + | task.Execute() | |||
| 147 | + | return 1 | |||
| 148 | + | } | |||
| 149 | + | ||||
| 150 | + | wv := &WebView{ctx: context.Background(), browser: browser} | |||
| 151 | + | require.NoError(t, wv.LoadURI(context.Background(), "github.com/bnema")) | |||
| 152 | + | ||||
| 153 | + | wv.mu.RLock() | |||
| 154 | + | defer wv.mu.RUnlock() | |||
| 155 | + | require.Equal(t, "github.com/bnema", wv.pendingURI) | |||
| 156 | + | } | |||
| 157 | + | ||||
| 158 | + | func TestWebViewUpdateURI_ClearsMatchingPendingNavigation(t *testing.T) { | |||
| 159 | + | wv := &WebView{ctx: context.Background(), pendingURI: "https://github.com/bnema"} | |||
| 160 | + | ||||
| 161 | + | wv.updateURI("https://github.com/bnema") | |||
| 162 | + | ||||
| 163 | + | require.Empty(t, wv.pendingNavigationURI()) | |||
| 164 | + | } | |||
M internal/infrastructure/config/engine.go +1 -1
| @@ -85,7 +85,7 @@ type CEFEngineConfig struct { | |||||
| 85 | // libcef.so and the Resources/locales subdirectories. | 85 | // libcef.so and the Resources/locales subdirectories. | ||
| 86 | CEFDir string `mapstructure:"cef_dir" toml:"cef_dir" yaml:"cef_dir"` | 86 | CEFDir string `mapstructure:"cef_dir" toml:"cef_dir" yaml:"cef_dir"` | ||
| 87 | // LogFile overrides the CEF internal log destination. When empty, | 87 | // LogFile overrides the CEF internal log destination. When empty, | ||
| 88 | - | // Dumber writes CEF startup logs to .dev/dumber/logs/cef_runtime.log. | 88 | + | // Dumber uses the resolved runtime-profile default engine log path. |
| 89 | LogFile string `mapstructure:"log_file" toml:"log_file" yaml:"log_file"` | 89 | LogFile string `mapstructure:"log_file" toml:"log_file" yaml:"log_file"` | ||
| 90 | // LogSeverity controls CEF's internal log verbosity. | 90 | // LogSeverity controls CEF's internal log verbosity. | ||
| 91 | // 0 = default, 1 = verbose, 2 = info, 3 = warning, 4 = error, 99 = disable. | 91 | // 0 = default, 1 = verbose, 2 = info, 3 = warning, 4 = error, 99 = disable. | ||
M internal/infrastructure/config/xdg.go +39 -25
| @@ -4,6 +4,8 @@ package config | |||||
| 4 | import ( | 4 | import ( | ||
| 5 | "os" | 5 | "os" | ||
| 6 | "path/filepath" | 6 | "path/filepath" | ||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 7 | ) | 9 | ) | ||
| 8 | 10 | ||||
| 9 | // File permission constants | 11 | // File permission constants | ||
| @@ -25,25 +27,42 @@ type XDGDirs struct { | |||||
| 25 | CacheHome string | 27 | CacheHome string | ||
| 26 | } | 28 | } | ||
| 27 | 29 | ||||
| 28 | - | // GetXDGDirs returns the XDG Base Directory paths for dumber. | 30 | + | // GetXDGDirs returns the dumber-specific XDG directories. |
| 29 | - | // It follows the XDG Base Directory specification: | 31 | + | // In prod they match the current XDG layout exactly. |
| 30 | - | // - $XDG_CONFIG_HOME/dumber (default: ~/.config/dumber) | 32 | + | // In dev they resolve into the shared sandbox under .dev/dumber/{config,data,state,cache}. |
| 31 | - | // - $XDG_DATA_HOME/dumber (default: ~/.local/share/dumber) | |||
| 32 | - | // - $XDG_STATE_HOME/dumber (default: ~/.local/state/dumber) | |||
| 33 | func GetXDGDirs() (*XDGDirs, error) { | 33 | func GetXDGDirs() (*XDGDirs, error) { | ||
| 34 | - | // Development mode: use .dev directory in current working 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 | if os.Getenv("ENV") == "dev" { | 64 | if os.Getenv("ENV") == "dev" { | ||
| 36 | - | cwd, err := os.Getwd() | 65 | + | return &XDGDirs{}, nil |
| 37 | - | if err != nil { | |||
| 38 | - | return nil, err | |||
| 39 | - | } | |||
| 40 | - | devDir := filepath.Join(cwd, ".dev", appName) | |||
| 41 | - | return &XDGDirs{ | |||
| 42 | - | ConfigHome: devDir, | |||
| 43 | - | DataHome: devDir, | |||
| 44 | - | StateHome: devDir, | |||
| 45 | - | CacheHome: devDir, | |||
| 46 | - | }, nil | |||
| 47 | } | 66 | } | ||
| 48 | 67 | ||||
| 49 | homeDir, err := os.UserHomeDir() | 68 | homeDir, err := os.UserHomeDir() | ||
| @@ -51,28 +70,24 @@ func GetXDGDirs() (*XDGDirs, error) { | |||||
| 51 | return nil, err | 70 | return nil, err | ||
| 52 | } | 71 | } | ||
| 53 | 72 | ||||
| 54 | - | // XDG_CONFIG_HOME | |||
| 55 | configHome := os.Getenv("XDG_CONFIG_HOME") | 73 | configHome := os.Getenv("XDG_CONFIG_HOME") | ||
| 56 | if configHome == "" { | 74 | if configHome == "" { | ||
| 57 | configHome = filepath.Join(homeDir, ".config") | 75 | configHome = filepath.Join(homeDir, ".config") | ||
| 58 | } | 76 | } | ||
| 59 | configHome = filepath.Join(configHome, appName) | 77 | configHome = filepath.Join(configHome, appName) | ||
| 60 | 78 | ||||
| 61 | - | // XDG_DATA_HOME | |||
| 62 | dataHome := os.Getenv("XDG_DATA_HOME") | 79 | dataHome := os.Getenv("XDG_DATA_HOME") | ||
| 63 | if dataHome == "" { | 80 | if dataHome == "" { | ||
| 64 | dataHome = filepath.Join(homeDir, ".local", "share") | 81 | dataHome = filepath.Join(homeDir, ".local", "share") | ||
| 65 | } | 82 | } | ||
| 66 | dataHome = filepath.Join(dataHome, appName) | 83 | dataHome = filepath.Join(dataHome, appName) | ||
| 67 | 84 | ||||
| 68 | - | // XDG_STATE_HOME | |||
| 69 | stateHome := os.Getenv("XDG_STATE_HOME") | 85 | stateHome := os.Getenv("XDG_STATE_HOME") | ||
| 70 | if stateHome == "" { | 86 | if stateHome == "" { | ||
| 71 | stateHome = filepath.Join(homeDir, ".local", "state") | 87 | stateHome = filepath.Join(homeDir, ".local", "state") | ||
| 72 | } | 88 | } | ||
| 73 | stateHome = filepath.Join(stateHome, appName) | 89 | stateHome = filepath.Join(stateHome, appName) | ||
| 74 | 90 | ||||
| 75 | - | // XDG_CACHE_HOME | |||
| 76 | cacheHome := os.Getenv("XDG_CACHE_HOME") | 91 | cacheHome := os.Getenv("XDG_CACHE_HOME") | ||
| 77 | if cacheHome == "" { | 92 | if cacheHome == "" { | ||
| 78 | cacheHome = filepath.Join(homeDir, ".cache") | 93 | cacheHome = filepath.Join(homeDir, ".cache") | ||
| @@ -114,14 +129,13 @@ func GetStateDir() (string, error) { | |||||
| 114 | return dirs.StateHome, nil | 129 | return dirs.StateHome, nil | ||
| 115 | } | 130 | } | ||
| 116 | 131 | ||||
| 117 | - | // GetLogDir returns the XDG-compliant log directory for dumber. | 132 | + | // GetLogDir returns the shared application log directory for dumber. |
| 118 | - | // Logs are stored in XDG_STATE_HOME as per specification. | |||
| 119 | func GetLogDir() (string, error) { | 133 | func GetLogDir() (string, error) { | ||
| 120 | - | stateDir, err := GetStateDir() | 134 | + | profile, err := resolveSharedProfile() |
| 121 | if err != nil { | 135 | if err != nil { | ||
| 122 | return "", err | 136 | return "", err | ||
| 123 | } | 137 | } | ||
| 124 | - | return filepath.Join(stateDir, "logs"), nil | 138 | + | return profile.Shared.LogDir, nil |
| 125 | } | 139 | } | ||
| 126 | 140 | ||||
| 127 | // GetConfigFile returns the path to the main configuration file. | 141 | // GetConfigFile returns the path to the main configuration file. | ||
A internal/infrastructure/config/xdg_test.go +42 -0
| @@ -0,0 +1,42 @@ | |||||
| 1 | + | package config | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "path/filepath" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestGetXDGDirs_ProdPreservesLayout(t *testing.T) { | |||
| 11 | + | home := t.TempDir() | |||
| 12 | + | t.Setenv("ENV", "") | |||
| 13 | + | t.Setenv("HOME", home) | |||
| 14 | + | t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) | |||
| 15 | + | t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share")) | |||
| 16 | + | t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state")) | |||
| 17 | + | t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) | |||
| 18 | + | ||||
| 19 | + | dirs, err := GetXDGDirs() | |||
| 20 | + | require.NoError(t, err) | |||
| 21 | + | require.Equal(t, filepath.Join(home, ".config", appName), dirs.ConfigHome) | |||
| 22 | + | require.Equal(t, filepath.Join(home, ".local", "share", appName), dirs.DataHome) | |||
| 23 | + | require.Equal(t, filepath.Join(home, ".local", "state", appName), dirs.StateHome) | |||
| 24 | + | require.Equal(t, filepath.Join(home, ".cache", appName), dirs.CacheHome) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestGetXDGDirs_DevUsesSharedSandboxLayout(t *testing.T) { | |||
| 28 | + | wd := t.TempDir() | |||
| 29 | + | t.Chdir(wd) | |||
| 30 | + | ||||
| 31 | + | t.Setenv("ENV", "dev") | |||
| 32 | + | dirs, err := GetXDGDirs() | |||
| 33 | + | require.NoError(t, err) | |||
| 34 | + | require.Equal(t, filepath.Join(wd, ".dev", appName, "config"), dirs.ConfigHome) | |||
| 35 | + | require.Equal(t, filepath.Join(wd, ".dev", appName, "data"), dirs.DataHome) | |||
| 36 | + | require.Equal(t, filepath.Join(wd, ".dev", appName, "state"), dirs.StateHome) | |||
| 37 | + | require.Equal(t, filepath.Join(wd, ".dev", appName, "cache"), dirs.CacheHome) | |||
| 38 | + | ||||
| 39 | + | logDir, err := GetLogDir() | |||
| 40 | + | require.NoError(t, err) | |||
| 41 | + | require.Equal(t, filepath.Join(wd, ".dev", appName, "logs"), logDir) | |||
| 42 | + | } | |||
M internal/infrastructure/desktop/browser_launch_relay.go +9 -22
| @@ -14,6 +14,7 @@ import ( | |||||
| 14 | "time" | 14 | "time" | ||
| 15 | 15 | ||||
| 16 | "github.com/bnema/dumber/internal/application/port" | 16 | "github.com/bnema/dumber/internal/application/port" | ||
| 17 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 17 | "github.com/bnema/dumber/internal/logging" | 18 | "github.com/bnema/dumber/internal/logging" | ||
| 18 | ) | 19 | ) | ||
| 19 | 20 | ||||
| @@ -24,7 +25,7 @@ const browserLaunchIOTimeout = 50 * time.Millisecond | |||||
| 24 | const browserLaunchDirPerm = 0o700 | 25 | const browserLaunchDirPerm = 0o700 | ||
| 25 | 26 | ||||
| 26 | type browserLaunchRelay struct { | 27 | type browserLaunchRelay struct { | ||
| 27 | - | xdg port.XDGPaths | 28 | + | ipc runtimeprofile.IPCPaths |
| 28 | } | 29 | } | ||
| 29 | 30 | ||||
| 30 | type browserLaunchRequest struct { | 31 | type browserLaunchRequest struct { | ||
| @@ -42,8 +43,8 @@ type browserLaunchRelayListener struct { | |||||
| 42 | err error | 43 | err error | ||
| 43 | } | 44 | } | ||
| 44 | 45 | ||||
| 45 | - | func NewBrowserLaunchRelay(xdg port.XDGPaths) port.BrowserLaunchRelay { | 46 | + | func NewBrowserLaunchRelay(ipc runtimeprofile.IPCPaths) port.BrowserLaunchRelay { |
| 46 | - | return &browserLaunchRelay{xdg: xdg} | 47 | + | return &browserLaunchRelay{ipc: ipc} |
| 47 | } | 48 | } | ||
| 48 | 49 | ||||
| 49 | func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) { | 50 | func (r *browserLaunchRelay) DeliverOpenFreshWindow(ctx context.Context, url string) (bool, error) { | ||
| @@ -171,27 +172,13 @@ func (r *browserLaunchRelay) Listen(ctx context.Context, opener port.BrowserWind | |||||
| 171 | } | 172 | } | ||
| 172 | 173 | ||||
| 173 | func (r *browserLaunchRelay) socketPath() (string, error) { | 174 | func (r *browserLaunchRelay) socketPath() (string, error) { | ||
| 174 | - | if r == nil || r.xdg == nil { | 175 | + | if r == nil { |
| 175 | - | return "", errors.New("browser launch relay missing XDG paths") | 176 | + | return "", errors.New("browser launch relay missing IPC paths") |
| 176 | } | 177 | } | ||
| 177 | - | 178 | + | if r.ipc.BrowserLaunchSocket == "" { | |
| 178 | - | runtimeDir, err := r.xdg.RuntimeDir() | 179 | + | return "", errors.New("browser launch relay missing browser launch socket path") |
| 179 | - | if err != nil { | |||
| 180 | - | return "", fmt.Errorf("get runtime dir: %w", err) | |||
| 181 | - | } | |||
| 182 | - | if runtimeDir != "" { | |||
| 183 | - | return filepath.Join(runtimeDir, browserLaunchSocketName), nil | |||
| 184 | - | } | |||
| 185 | - | ||||
| 186 | - | stateDir, err := r.xdg.StateDir() | |||
| 187 | - | if err != nil { | |||
| 188 | - | return "", fmt.Errorf("get state dir: %w", err) | |||
| 189 | } | 180 | } | ||
| 190 | - | if stateDir == "" { | 181 | + | return r.ipc.BrowserLaunchSocket, nil |
| 191 | - | return "", errors.New("browser launch relay needs runtime or state dir") | |||
| 192 | - | } | |||
| 193 | - | ||||
| 194 | - | return filepath.Join(stateDir, "runtime", browserLaunchSocketName), nil | |||
| 195 | } | 182 | } | ||
| 196 | 183 | ||||
| 197 | func (l *browserLaunchRelayListener) Close() error { | 184 | func (l *browserLaunchRelayListener) Close() error { | ||
M internal/infrastructure/desktop/browser_launch_relay_test.go +85 -86
| @@ -9,29 +9,11 @@ import ( | |||||
| 9 | "testing" | 9 | "testing" | ||
| 10 | "time" | 10 | "time" | ||
| 11 | 11 | ||||
| 12 | - | "github.com/bnema/dumber/internal/application/port" | 12 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" |
| 13 | "github.com/stretchr/testify/assert" | 13 | "github.com/stretchr/testify/assert" | ||
| 14 | "github.com/stretchr/testify/require" | 14 | "github.com/stretchr/testify/require" | ||
| 15 | ) | 15 | ) | ||
| 16 | 16 | ||||
| 17 | - | type testXDGPaths struct { | |||
| 18 | - | runtimeDir string | |||
| 19 | - | stateDir string | |||
| 20 | - | } | |||
| 21 | - | ||||
| 22 | - | var _ port.XDGPaths = testXDGPaths{} | |||
| 23 | - | ||||
| 24 | - | func (testXDGPaths) ConfigDir() (string, error) { return "", nil } | |||
| 25 | - | func (testXDGPaths) DataDir() (string, error) { return "", nil } | |||
| 26 | - | func (x testXDGPaths) StateDir() (string, error) { return x.stateDir, nil } | |||
| 27 | - | func (x testXDGPaths) RuntimeDir() (string, error) { return x.runtimeDir, nil } | |||
| 28 | - | func (testXDGPaths) CacheDir() (string, error) { return "", nil } | |||
| 29 | - | func (testXDGPaths) FilterJSONDir() (string, error) { return "", nil } | |||
| 30 | - | func (testXDGPaths) FilterStoreDir() (string, error) { return "", nil } | |||
| 31 | - | func (testXDGPaths) FilterCacheDir() (string, error) { return "", nil } | |||
| 32 | - | func (testXDGPaths) ManDir() (string, error) { return "", nil } | |||
| 33 | - | func (testXDGPaths) DownloadDir() (string, error) { return "", nil } | |||
| 34 | - | ||||
| 35 | type browserWindowOpenerFunc func(context.Context, string) error | 17 | type browserWindowOpenerFunc func(context.Context, string) error | ||
| 36 | 18 | ||||
| 37 | func (f browserWindowOpenerFunc) OpenFreshWindow(ctx context.Context, url string) error { | 19 | func (f browserWindowOpenerFunc) OpenFreshWindow(ctx context.Context, url string) error { | ||
| @@ -68,8 +50,24 @@ func shortTempDir(t *testing.T) string { | |||||
| 68 | return dir | 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 | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_MissingListenerReturnsFalseNil(t *testing.T) { | 69 | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_MissingListenerReturnsFalseNil(t *testing.T) { | ||
| 72 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: shortTempDir(t)}) | 70 | + | relay := NewBrowserLaunchRelay(testIPC(shortTempDir(t))) |
| 73 | 71 | ||||
| 74 | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com") | 72 | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com") | ||
| 75 | 73 | ||||
| @@ -77,39 +75,40 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_MissingListenerReturnsFalseNi | |||||
| 77 | assert.False(t, delivered) | 75 | assert.False(t, delivered) | ||
| 78 | } | 76 | } | ||
| 79 | 77 | ||||
| 80 | - | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) { | 78 | + | func TestBrowserLaunchRelay_SameProfileAndEngine_UsesSameSocket(t *testing.T) { |
| 81 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | 79 | + | ipc := testNamespacedIPC(shortTempDir(t), "cef") |
| 82 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | 80 | + | listenerRelay := NewBrowserLaunchRelay(ipc) |
| 81 | + | clientRelay := NewBrowserLaunchRelay(ipc) | |||
| 83 | 82 | ||||
| 84 | received := make(chan string, 1) | 83 | received := make(chan string, 1) | ||
| 85 | ctx, cancel := context.WithCancel(context.Background()) | 84 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 86 | defer cancel() | 85 | defer cancel() | ||
| 87 | 86 | ||||
| 88 | - | closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error { | 87 | + | closer, err := listenerRelay.Listen(ctx, browserWindowOpenerFunc(func(_ context.Context, url string) error { |
| 89 | received <- url | 88 | received <- url | ||
| 90 | return nil | 89 | return nil | ||
| 91 | })) | 90 | })) | ||
| 92 | require.NoError(t, err) | 91 | require.NoError(t, err) | ||
| 93 | defer closer.Close() | 92 | defer closer.Close() | ||
| 94 | 93 | ||||
| 95 | - | waitForSocket(t, filepath.Join(runtimeDir, "browser-launch.sock")) | 94 | + | waitForSocket(t, ipc.BrowserLaunchSocket) |
| 96 | - | ||||
| 97 | - | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/new") | |||
| 98 | 95 | ||||
| 96 | + | delivered, err := clientRelay.DeliverOpenFreshWindow(context.Background(), "https://example.com/same-namespace") | |||
| 99 | require.NoError(t, err) | 97 | require.NoError(t, err) | ||
| 100 | - | assert.True(t, delivered) | 98 | + | require.True(t, delivered) |
| 99 | + | require.Equal(t, "https://example.com/same-namespace", <-received) | |||
| 100 | + | } | |||
| 101 | 101 | ||||
| 102 | - | select { | 102 | + | func TestBrowserLaunchRelay_DevCEFAndDevWebKit_UseDifferentSockets(t *testing.T) { |
| 103 | - | case got := <-received: | 103 | + | root := t.TempDir() |
| 104 | - | assert.Equal(t, "https://example.com/new", got) | 104 | + | cefIPC := testNamespacedIPC(root, "cef") |
| 105 | - | case <-time.After(time.Second): | 105 | + | wkIPC := testNamespacedIPC(root, "webkit") |
| 106 | - | t.Fatal("expected opener to receive the URL") | 106 | + | require.NotEqual(t, cefIPC.BrowserLaunchSocket, wkIPC.BrowserLaunchSocket) |
| 107 | - | } | |||
| 108 | } | 107 | } | ||
| 109 | 108 | ||||
| 110 | - | func TestBrowserLaunchRelay_UsesStateFallbackWhenRuntimeDirMissing(t *testing.T) { | 109 | + | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RoundTrip(t *testing.T) { |
| 111 | - | stateDir := shortTempDir(t) | 110 | + | ipc := testIPC(shortTempDir(t)) |
| 112 | - | relay := NewBrowserLaunchRelay(testXDGPaths{stateDir: stateDir}) | 111 | + | relay := NewBrowserLaunchRelay(ipc) |
| 113 | 112 | ||||
| 114 | received := make(chan string, 1) | 113 | received := make(chan string, 1) | ||
| 115 | ctx, cancel := context.WithCancel(context.Background()) | 114 | ctx, cancel := context.WithCancel(context.Background()) | ||
| @@ -122,25 +121,32 @@ func TestBrowserLaunchRelay_UsesStateFallbackWhenRuntimeDirMissing(t *testing.T) | |||||
| 122 | require.NoError(t, err) | 121 | require.NoError(t, err) | ||
| 123 | defer closer.Close() | 122 | defer closer.Close() | ||
| 124 | 123 | ||||
| 125 | - | socketPath := filepath.Join(stateDir, "runtime", "browser-launch.sock") | 124 | + | waitForSocket(t, ipc.BrowserLaunchSocket) |
| 126 | - | waitForSocket(t, socketPath) | |||
| 127 | 125 | ||||
| 128 | - | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/fallback") | 126 | + | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/new") |
| 129 | 127 | ||||
| 130 | require.NoError(t, err) | 128 | require.NoError(t, err) | ||
| 131 | assert.True(t, delivered) | 129 | assert.True(t, delivered) | ||
| 132 | 130 | ||||
| 133 | select { | 131 | select { | ||
| 134 | case got := <-received: | 132 | case got := <-received: | ||
| 135 | - | assert.Equal(t, "https://example.com/fallback", got) | 133 | + | assert.Equal(t, "https://example.com/new", got) |
| 136 | case <-time.After(time.Second): | 134 | case <-time.After(time.Second): | ||
| 137 | t.Fatal("expected opener to receive the URL") | 135 | t.Fatal("expected opener to receive the URL") | ||
| 138 | } | 136 | } | ||
| 139 | } | 137 | } | ||
| 140 | 138 | ||||
| 139 | + | func TestBrowserLaunchRelay_RequiresBrowserLaunchSocketPath(t *testing.T) { | |||
| 140 | + | relay := NewBrowserLaunchRelay(runtimeprofile.IPCPaths{}) | |||
| 141 | + | ||||
| 142 | + | _, err := relay.Listen(context.Background(), browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | |||
| 143 | + | require.Error(t, err) | |||
| 144 | + | require.Contains(t, err.Error(), "browser launch socket path") | |||
| 145 | + | } | |||
| 146 | + | ||||
| 141 | func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t *testing.T) { | 147 | func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t *testing.T) { | ||
| 142 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | 148 | + | ipc := testIPC(shortTempDir(t)) |
| 143 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | 149 | + | relay := NewBrowserLaunchRelay(ipc) |
| 144 | 150 | ||||
| 145 | firstReceived := make(chan string, 1) | 151 | firstReceived := make(chan string, 1) | ||
| 146 | ctx, cancel := context.WithCancel(context.Background()) | 152 | ctx, cancel := context.WithCancel(context.Background()) | ||
| @@ -153,7 +159,7 @@ func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t | |||||
| 153 | require.NoError(t, err) | 159 | require.NoError(t, err) | ||
| 154 | defer closer.Close() | 160 | defer closer.Close() | ||
| 155 | 161 | ||||
| 156 | - | waitForSocket(t, filepath.Join(runtimeDir, "browser-launch.sock")) | 162 | + | waitForSocket(t, ipc.BrowserLaunchSocket) |
| 157 | 163 | ||||
| 158 | _, err = relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | 164 | _, err = relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | ||
| 159 | require.Error(t, err) | 165 | require.Error(t, err) | ||
| @@ -171,12 +177,11 @@ func TestBrowserLaunchRelay_SecondListenWhileLiveFailsWithoutReplacingListener(t | |||||
| 171 | } | 177 | } | ||
| 172 | 178 | ||||
| 173 | func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) { | 179 | func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) { | ||
| 174 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | 180 | + | ipc := testIPC(shortTempDir(t)) |
| 175 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | 181 | + | relay := NewBrowserLaunchRelay(ipc) |
| 176 | 182 | ||||
| 177 | - | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | 183 | + | require.NoError(t, os.MkdirAll(filepath.Dir(ipc.BrowserLaunchSocket), 0o700)) |
| 178 | - | require.NoError(t, os.MkdirAll(runtimeDir, 0o700)) | 184 | + | require.NoError(t, os.WriteFile(ipc.BrowserLaunchSocket, []byte("stale"), 0o600)) |
| 179 | - | require.NoError(t, os.WriteFile(socketPath, []byte("stale"), 0o600)) | |||
| 180 | 185 | ||||
| 181 | received := make(chan string, 1) | 186 | received := make(chan string, 1) | ||
| 182 | ctx, cancel := context.WithCancel(context.Background()) | 187 | ctx, cancel := context.WithCancel(context.Background()) | ||
| @@ -189,7 +194,7 @@ func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) { | |||||
| 189 | require.NoError(t, err) | 194 | require.NoError(t, err) | ||
| 190 | defer closer.Close() | 195 | defer closer.Close() | ||
| 191 | 196 | ||||
| 192 | - | waitForSocket(t, socketPath) | 197 | + | waitForSocket(t, ipc.BrowserLaunchSocket) |
| 193 | 198 | ||||
| 194 | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/stale") | 199 | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/stale") | ||
| 195 | require.NoError(t, err) | 200 | require.NoError(t, err) | ||
| @@ -204,8 +209,8 @@ func TestBrowserLaunchRelay_RebindsStaleSocketPath(t *testing.T) { | |||||
| 204 | } | 209 | } | ||
| 205 | 210 | ||||
| 206 | func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) { | 211 | func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) { | ||
| 207 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | 212 | + | ipc := testIPC(shortTempDir(t)) |
| 208 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | 213 | + | relay := NewBrowserLaunchRelay(ipc) |
| 209 | 214 | ||||
| 210 | received := make(chan string, 1) | 215 | received := make(chan string, 1) | ||
| 211 | ctx, cancel := context.WithCancel(context.Background()) | 216 | ctx, cancel := context.WithCancel(context.Background()) | ||
| @@ -218,10 +223,9 @@ func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) { | |||||
| 218 | require.NoError(t, err) | 223 | require.NoError(t, err) | ||
| 219 | defer closer.Close() | 224 | defer closer.Close() | ||
| 220 | 225 | ||||
| 221 | - | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | 226 | + | waitForSocket(t, ipc.BrowserLaunchSocket) |
| 222 | - | waitForSocket(t, socketPath) | |||
| 223 | 227 | ||||
| 224 | - | conn, err := net.Dial("unix", socketPath) | 228 | + | conn, err := net.Dial("unix", ipc.BrowserLaunchSocket) |
| 225 | require.NoError(t, err) | 229 | require.NoError(t, err) | ||
| 226 | 230 | ||||
| 227 | _, err = conn.Write([]byte("not-json")) | 231 | _, err = conn.Write([]byte("not-json")) | ||
| @@ -242,8 +246,8 @@ func TestBrowserLaunchRelay_RejectsMalformedPayloads(t *testing.T) { | |||||
| 242 | } | 246 | } | ||
| 243 | 247 | ||||
| 244 | func TestBrowserLaunchRelay_CloseRemovesSocketPath(t *testing.T) { | 248 | func TestBrowserLaunchRelay_CloseRemovesSocketPath(t *testing.T) { | ||
| 245 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | 249 | + | ipc := testIPC(shortTempDir(t)) |
| 246 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | 250 | + | relay := NewBrowserLaunchRelay(ipc) |
| 247 | 251 | ||||
| 248 | ctx, cancel := context.WithCancel(context.Background()) | 252 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 249 | defer cancel() | 253 | defer cancel() | ||
| @@ -251,26 +255,24 @@ func TestBrowserLaunchRelay_CloseRemovesSocketPath(t *testing.T) { | |||||
| 251 | closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | 255 | closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | ||
| 252 | require.NoError(t, err) | 256 | require.NoError(t, err) | ||
| 253 | 257 | ||||
| 254 | - | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | 258 | + | waitForSocket(t, ipc.BrowserLaunchSocket) |
| 255 | - | waitForSocket(t, socketPath) | |||
| 256 | 259 | ||||
| 257 | require.NoError(t, closer.Close()) | 260 | require.NoError(t, closer.Close()) | ||
| 258 | - | waitForSocketGone(t, socketPath) | 261 | + | waitForSocketGone(t, ipc.BrowserLaunchSocket) |
| 259 | } | 262 | } | ||
| 260 | 263 | ||||
| 261 | func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) { | 264 | func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) { | ||
| 262 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | 265 | + | ipc := testIPC(shortTempDir(t)) |
| 263 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | 266 | + | relay := NewBrowserLaunchRelay(ipc) |
| 264 | 267 | ||||
| 265 | ctx, cancel := context.WithCancel(context.Background()) | 268 | ctx, cancel := context.WithCancel(context.Background()) | ||
| 266 | closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | 269 | closer, err := relay.Listen(ctx, browserWindowOpenerFunc(func(context.Context, string) error { return nil })) | ||
| 267 | require.NoError(t, err) | 270 | require.NoError(t, err) | ||
| 268 | 271 | ||||
| 269 | - | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | 272 | + | waitForSocket(t, ipc.BrowserLaunchSocket) |
| 270 | - | waitForSocket(t, socketPath) | |||
| 271 | 273 | ||||
| 272 | cancel() | 274 | cancel() | ||
| 273 | - | waitForSocketGone(t, socketPath) | 275 | + | waitForSocketGone(t, ipc.BrowserLaunchSocket) |
| 274 | require.NoError(t, closer.Close()) | 276 | require.NoError(t, closer.Close()) | ||
| 275 | 277 | ||||
| 276 | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/after-cancel") | 278 | delivered, err := relay.DeliverOpenFreshWindow(context.Background(), "https://example.com/after-cancel") | ||
| @@ -279,15 +281,14 @@ func TestBrowserLaunchRelay_ContextCancelStopsServing(t *testing.T) { | |||||
| 279 | } | 281 | } | ||
| 280 | 282 | ||||
| 281 | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t *testing.T) { | 283 | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t *testing.T) { | ||
| 282 | - | runtimeDir := shortTempDir(t) | 284 | + | ipc := testIPC(shortTempDir(t)) |
| 283 | - | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | 285 | + | require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700)) |
| 284 | - | require.NoError(t, os.MkdirAll(runtimeDir, 0o700)) | |||
| 285 | 286 | ||||
| 286 | - | listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"}) | 287 | + | listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"}) |
| 287 | require.NoError(t, err) | 288 | require.NoError(t, err) | ||
| 288 | defer func() { | 289 | defer func() { | ||
| 289 | _ = listener.Close() | 290 | _ = listener.Close() | ||
| 290 | - | _ = os.Remove(socketPath) | 291 | + | _ = os.Remove(ipc.BrowserLaunchSocket) |
| 291 | }() | 292 | }() | ||
| 292 | 293 | ||||
| 293 | ready := make(chan struct{}) | 294 | ready := make(chan struct{}) | ||
| @@ -302,7 +303,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t | |||||
| 302 | }() | 303 | }() | ||
| 303 | <-ready | 304 | <-ready | ||
| 304 | 305 | ||||
| 305 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | 306 | + | relay := NewBrowserLaunchRelay(ipc) |
| 306 | ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) | 307 | ctx, cancel := context.WithTimeout(context.Background(), 100*time.Millisecond) | ||
| 307 | defer cancel() | 308 | defer cancel() | ||
| 308 | 309 | ||||
| @@ -313,15 +314,14 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_RespectsResponseReadTimeout(t | |||||
| 313 | } | 314 | } | ||
| 314 | 315 | ||||
| 315 | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsTrueOnNoDeadlineTimeout(t *testing.T) { | 316 | func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsTrueOnNoDeadlineTimeout(t *testing.T) { | ||
| 316 | - | runtimeDir := shortTempDir(t) | 317 | + | ipc := testIPC(shortTempDir(t)) |
| 317 | - | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | 318 | + | require.NoError(t, os.MkdirAll(ipc.RuntimeDir, 0o700)) |
| 318 | - | require.NoError(t, os.MkdirAll(runtimeDir, 0o700)) | |||
| 319 | 319 | ||||
| 320 | - | listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: socketPath, Net: "unix"}) | 320 | + | listener, err := net.ListenUnix("unix", &net.UnixAddr{Name: ipc.BrowserLaunchSocket, Net: "unix"}) |
| 321 | require.NoError(t, err) | 321 | require.NoError(t, err) | ||
| 322 | defer func() { | 322 | defer func() { | ||
| 323 | _ = listener.Close() | 323 | _ = listener.Close() | ||
| 324 | - | _ = os.Remove(socketPath) | 324 | + | _ = os.Remove(ipc.BrowserLaunchSocket) |
| 325 | }() | 325 | }() | ||
| 326 | 326 | ||||
| 327 | ready := make(chan struct{}) | 327 | ready := make(chan struct{}) | ||
| @@ -336,7 +336,7 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsTrueOnNoDeadlineTimeou | |||||
| 336 | }() | 336 | }() | ||
| 337 | <-ready | 337 | <-ready | ||
| 338 | 338 | ||||
| 339 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | 339 | + | relay := NewBrowserLaunchRelay(ipc) |
| 340 | 340 | ||||
| 341 | started := make(chan struct{}) | 341 | started := make(chan struct{}) | ||
| 342 | 342 | ||||
| @@ -362,8 +362,8 @@ func TestBrowserLaunchRelay_DeliverOpenFreshWindow_ReturnsTrueOnNoDeadlineTimeou | |||||
| 362 | } | 362 | } | ||
| 363 | 363 | ||||
| 364 | func TestBrowserLaunchRelay_AcknowledgesBeforeOpenerReturns(t *testing.T) { | 364 | func TestBrowserLaunchRelay_AcknowledgesBeforeOpenerReturns(t *testing.T) { | ||
| 365 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | 365 | + | ipc := testIPC(shortTempDir(t)) |
| 366 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | 366 | + | relay := NewBrowserLaunchRelay(ipc) |
| 367 | 367 | ||||
| 368 | started := make(chan struct{}) | 368 | started := make(chan struct{}) | ||
| 369 | release := make(chan struct{}) | 369 | release := make(chan struct{}) | ||
| @@ -378,9 +378,9 @@ func TestBrowserLaunchRelay_AcknowledgesBeforeOpenerReturns(t *testing.T) { | |||||
| 378 | require.NoError(t, err) | 378 | require.NoError(t, err) | ||
| 379 | defer closer.Close() | 379 | defer closer.Close() | ||
| 380 | 380 | ||||
| 381 | - | waitForSocket(t, filepath.Join(runtimeDir, "browser-launch.sock")) | 381 | + | waitForSocket(t, ipc.BrowserLaunchSocket) |
| 382 | 382 | ||||
| 383 | - | conn, err := net.Dial("unix", filepath.Join(runtimeDir, "browser-launch.sock")) | 383 | + | conn, err := net.Dial("unix", ipc.BrowserLaunchSocket) |
| 384 | require.NoError(t, err) | 384 | require.NoError(t, err) | ||
| 385 | defer conn.Close() | 385 | defer conn.Close() | ||
| 386 | require.NoError(t, conn.SetDeadline(time.Now().Add(300*time.Millisecond))) | 386 | require.NoError(t, conn.SetDeadline(time.Now().Add(300*time.Millisecond))) | ||
| @@ -399,8 +399,8 @@ func TestBrowserLaunchRelay_AcknowledgesBeforeOpenerReturns(t *testing.T) { | |||||
| 399 | } | 399 | } | ||
| 400 | 400 | ||||
| 401 | func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) { | 401 | func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) { | ||
| 402 | - | runtimeDir := filepath.Join(shortTempDir(t), "runtime") | 402 | + | ipc := testIPC(shortTempDir(t)) |
| 403 | - | relay := NewBrowserLaunchRelay(testXDGPaths{runtimeDir: runtimeDir}) | 403 | + | relay := NewBrowserLaunchRelay(ipc) |
| 404 | 404 | ||||
| 405 | received := make(chan string, 1) | 405 | received := make(chan string, 1) | ||
| 406 | ctx, cancel := context.WithCancel(context.Background()) | 406 | ctx, cancel := context.WithCancel(context.Background()) | ||
| @@ -413,10 +413,9 @@ func TestBrowserLaunchRelay_SilentClientDoesNotStallListener(t *testing.T) { | |||||
| 413 | require.NoError(t, err) | 413 | require.NoError(t, err) | ||
| 414 | defer closer.Close() | 414 | defer closer.Close() | ||
| 415 | 415 | ||||
| 416 | - | socketPath := filepath.Join(runtimeDir, "browser-launch.sock") | 416 | + | waitForSocket(t, ipc.BrowserLaunchSocket) |
| 417 | - | waitForSocket(t, socketPath) | |||
| 418 | 417 | ||||
| 419 | - | conn, err := net.Dial("unix", socketPath) | 418 | + | conn, err := net.Dial("unix", ipc.BrowserLaunchSocket) |
| 420 | require.NoError(t, err) | 419 | require.NoError(t, err) | ||
| 421 | defer conn.Close() | 420 | defer conn.Close() | ||
| 422 | time.Sleep(20 * time.Millisecond) | 421 | time.Sleep(20 * time.Millisecond) | ||
M internal/infrastructure/handlers/clipboard.go +46 -28
| @@ -4,23 +4,20 @@ import ( | |||||
| 4 | "context" | 4 | "context" | ||
| 5 | "encoding/json" | 5 | "encoding/json" | ||
| 6 | 6 | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 7 | "github.com/bnema/dumber/internal/application/port" | 8 | "github.com/bnema/dumber/internal/application/port" | ||
| 8 | "github.com/bnema/dumber/internal/logging" | 9 | "github.com/bnema/dumber/internal/logging" | ||
| 9 | ) | 10 | ) | ||
| 10 | 11 | ||||
| 11 | // ClipboardHandler handles clipboard-related messages from webviews. | 12 | // ClipboardHandler handles clipboard-related messages from webviews. | ||
| 12 | type ClipboardHandler struct { | 13 | type ClipboardHandler struct { | ||
| 13 | - | clipboard port.Clipboard | 14 | + | orchestrator port.ClipboardTextOrchestrator |
| 14 | - | autoCopyConfig port.AutoCopyConfig | |||
| 15 | - | onCopied func(textLen int) // Called after successful auto-copy (for toast notification) | |||
| 16 | } | 15 | } | ||
| 17 | 16 | ||||
| 18 | // NewClipboardHandler creates a new ClipboardHandler. | 17 | // NewClipboardHandler creates a new ClipboardHandler. | ||
| 19 | - | func NewClipboardHandler(clipboard port.Clipboard, autoCopyConfig port.AutoCopyConfig, onCopied func(textLen int)) *ClipboardHandler { | 18 | + | func NewClipboardHandler(orchestrator port.ClipboardTextOrchestrator) *ClipboardHandler { |
| 20 | return &ClipboardHandler{ | 19 | return &ClipboardHandler{ | ||
| 21 | - | clipboard: clipboard, | 20 | + | orchestrator: orchestrator, |
| 22 | - | autoCopyConfig: autoCopyConfig, | |||
| 23 | - | onCopied: onCopied, | |||
| 24 | } | 21 | } | ||
| 25 | } | 22 | } | ||
| 26 | 23 | ||||
| @@ -29,38 +26,58 @@ type autoCopyRequest struct { | |||||
| 29 | Text string `json:"text"` | 26 | Text string `json:"text"` | ||
| 30 | } | 27 | } | ||
| 31 | 28 | ||||
| 29 | + | type explicitCopyRequest struct { | |||
| 30 | + | Text string `json:"text"` | |||
| 31 | + | Action string `json:"action"` | |||
| 32 | + | } | |||
| 33 | + | ||||
| 32 | // HandleAutoCopySelection handles the auto_copy_selection message from JS. | 34 | // HandleAutoCopySelection handles the auto_copy_selection message from JS. | ||
| 33 | // It copies the selected text to the clipboard if the feature is enabled. | 35 | // It copies the selected text to the clipboard if the feature is enabled. | ||
| 34 | func (h *ClipboardHandler) HandleAutoCopySelection() port.WebUIMessageHandler { | 36 | func (h *ClipboardHandler) HandleAutoCopySelection() port.WebUIMessageHandler { | ||
| 35 | - | return port.WebUIMessageHandlerFunc(func(ctx context.Context, _ port.WebViewID, payload json.RawMessage) (any, error) { | 37 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, webviewID port.WebViewID, payload json.RawMessage) (any, error) { |
| 36 | log := logging.FromContext(ctx) | 38 | log := logging.FromContext(ctx) | ||
| 37 | 39 | ||||
| 38 | - | // Check if feature is enabled | |||
| 39 | - | if h.autoCopyConfig == nil || !h.autoCopyConfig.IsAutoCopyEnabled() { | |||
| 40 | - | return nil, nil | |||
| 41 | - | } | |||
| 42 | - | ||||
| 43 | var req autoCopyRequest | 40 | var req autoCopyRequest | ||
| 44 | if err := json.Unmarshal(payload, &req); err != nil { | 41 | if err := json.Unmarshal(payload, &req); err != nil { | ||
| 45 | log.Debug().Err(err).Msg("failed to unmarshal auto-copy payload") | 42 | log.Debug().Err(err).Msg("failed to unmarshal auto-copy payload") | ||
| 46 | return nil, nil // Silently ignore malformed requests | 43 | return nil, nil // Silently ignore malformed requests | ||
| 47 | } | 44 | } | ||
| 48 | - | 45 | + | if h.orchestrator == nil { | |
| 49 | - | // Skip empty or very short selections (< 2 chars) | |||
| 50 | - | if len(req.Text) < 2 { | |||
| 51 | return nil, nil | 46 | return nil, nil | ||
| 52 | } | 47 | } | ||
| 53 | - | 48 | + | if err := h.orchestrator.HandleSelectionUpdate(ctx, dto.SelectionClipboardInput{ | |
| 54 | - | if err := h.clipboard.WriteText(ctx, req.Text); err != nil { | 49 | + | Text: req.Text, |
| 55 | - | log.Debug().Err(err).Msg("failed to write selection to clipboard") | 50 | + | SourceEngine: dto.ClipboardSourceWebKit, |
| 56 | - | return nil, nil // Don't propagate error to JS | 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) | 60 | + | // HandleExplicitCopy handles the explicit_text_copy message from JS. |
| 62 | - | if h.onCopied != nil { | 61 | + | func (h *ClipboardHandler) HandleExplicitCopy() port.WebUIMessageHandler { |
| 63 | - | h.onCopied(len(req.Text)) | 62 | + | return port.WebUIMessageHandlerFunc(func(ctx context.Context, webviewID port.WebViewID, payload json.RawMessage) (any, error) { |
| 63 | + | log := logging.FromContext(ctx) | |||
| 64 | + | ||||
| 65 | + | var req explicitCopyRequest | |||
| 66 | + | if err := json.Unmarshal(payload, &req); err != nil { | |||
| 67 | + | log.Debug().Err(err).Msg("failed to unmarshal explicit copy payload") | |||
| 68 | + | return nil, nil | |||
| 69 | + | } | |||
| 70 | + | if h.orchestrator == nil { | |||
| 71 | + | return nil, nil | |||
| 72 | + | } | |||
| 73 | + | if err := h.orchestrator.HandleExplicitCopy(ctx, dto.ExplicitClipboardInput{ | |||
| 74 | + | Text: req.Text, | |||
| 75 | + | Action: req.Action, | |||
| 76 | + | SourceEngine: dto.ClipboardSourceWebKit, | |||
| 77 | + | ViewID: uint64(webviewID), | |||
| 78 | + | NativeHandled: true, | |||
| 79 | + | }); err != nil { | |||
| 80 | + | log.Debug().Err(err).Msg("clipboard explicit handling failed") | |||
| 64 | } | 81 | } | ||
| 65 | 82 | ||||
| 66 | return nil, nil | 83 | return nil, nil | ||
| @@ -71,16 +88,17 @@ func (h *ClipboardHandler) HandleAutoCopySelection() port.WebUIMessageHandler { | |||||
| 71 | func RegisterClipboardHandlers( | 88 | func RegisterClipboardHandlers( | ||
| 72 | ctx context.Context, | 89 | ctx context.Context, | ||
| 73 | router port.WebUIHandlerRouter, | 90 | router port.WebUIHandlerRouter, | ||
| 74 | - | clipboard port.Clipboard, | 91 | + | orchestrator port.ClipboardTextOrchestrator, |
| 75 | - | autoCopyConfig port.AutoCopyConfig, | |||
| 76 | - | onCopied func(textLen int), | |||
| 77 | ) error { | 92 | ) error { | ||
| 78 | - | handler := NewClipboardHandler(clipboard, autoCopyConfig, onCopied) | 93 | + | handler := NewClipboardHandler(orchestrator) |
| 79 | 94 | ||||
| 80 | // Register auto_copy_selection handler | 95 | // Register auto_copy_selection handler | ||
| 81 | if err := router.RegisterHandler("auto_copy_selection", handler.HandleAutoCopySelection()); err != nil { | 96 | if err := router.RegisterHandler("auto_copy_selection", handler.HandleAutoCopySelection()); err != nil { | ||
| 82 | return err | 97 | return err | ||
| 83 | } | 98 | } | ||
| 99 | + | if err := router.RegisterHandler("explicit_text_copy", handler.HandleExplicitCopy()); err != nil { | |||
| 100 | + | return err | |||
| 101 | + | } | |||
| 84 | 102 | ||||
| 85 | log := logging.FromContext(ctx) | 103 | log := logging.FromContext(ctx) | ||
| 86 | log.Info().Msg("registered clipboard handlers") | 104 | log.Info().Msg("registered clipboard handlers") | ||
A internal/infrastructure/handlers/clipboard_test.go +63 -0
| @@ -0,0 +1,63 @@ | |||||
| 1 | + | package handlers | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "encoding/json" | |||
| 6 | + | "sync" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/application/dto" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | type recordingClipboardOrchestrator struct { | |||
| 14 | + | mu sync.Mutex | |||
| 15 | + | selection dto.SelectionClipboardInput | |||
| 16 | + | explicit dto.ExplicitClipboardInput | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func (r *recordingClipboardOrchestrator) HandleSelectionUpdate(_ context.Context, input dto.SelectionClipboardInput) error { | |||
| 20 | + | r.mu.Lock() | |||
| 21 | + | defer r.mu.Unlock() | |||
| 22 | + | r.selection = input | |||
| 23 | + | return nil | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | func (r *recordingClipboardOrchestrator) HandleExplicitCopy(_ context.Context, input dto.ExplicitClipboardInput) error { | |||
| 27 | + | r.mu.Lock() | |||
| 28 | + | defer r.mu.Unlock() | |||
| 29 | + | r.explicit = input | |||
| 30 | + | return nil | |||
| 31 | + | } | |||
| 32 | + | ||||
| 33 | + | func TestClipboardHandler_HandleAutoCopySelection_DelegatesToOrchestrator(t *testing.T) { | |||
| 34 | + | ctx := context.Background() | |||
| 35 | + | orchestrator := &recordingClipboardOrchestrator{} | |||
| 36 | + | handler := NewClipboardHandler(orchestrator) | |||
| 37 | + | ||||
| 38 | + | _, err := handler.HandleAutoCopySelection().Handle(ctx, 42, json.RawMessage(`{"text":"selected text"}`)) | |||
| 39 | + | require.NoError(t, err) | |||
| 40 | + | ||||
| 41 | + | orchestrator.mu.Lock() | |||
| 42 | + | defer orchestrator.mu.Unlock() | |||
| 43 | + | require.Equal(t, "selected text", orchestrator.selection.Text) | |||
| 44 | + | require.Equal(t, dto.ClipboardSourceWebKit, orchestrator.selection.SourceEngine) | |||
| 45 | + | require.Equal(t, uint64(42), orchestrator.selection.ViewID) | |||
| 46 | + | } | |||
| 47 | + | ||||
| 48 | + | func TestClipboardHandler_HandleExplicitCopy_DelegatesToOrchestrator(t *testing.T) { | |||
| 49 | + | ctx := context.Background() | |||
| 50 | + | orchestrator := &recordingClipboardOrchestrator{} | |||
| 51 | + | handler := NewClipboardHandler(orchestrator) | |||
| 52 | + | ||||
| 53 | + | _, err := handler.HandleExplicitCopy().Handle(ctx, 42, json.RawMessage(`{"text":"copied text","action":"copy"}`)) | |||
| 54 | + | require.NoError(t, err) | |||
| 55 | + | ||||
| 56 | + | orchestrator.mu.Lock() | |||
| 57 | + | defer orchestrator.mu.Unlock() | |||
| 58 | + | require.Equal(t, "copied text", orchestrator.explicit.Text) | |||
| 59 | + | require.Equal(t, "copy", orchestrator.explicit.Action) | |||
| 60 | + | require.Equal(t, dto.ClipboardSourceWebKit, orchestrator.explicit.SourceEngine) | |||
| 61 | + | require.Equal(t, uint64(42), orchestrator.explicit.ViewID) | |||
| 62 | + | require.True(t, orchestrator.explicit.NativeHandled) | |||
| 63 | + | } | |||
M internal/infrastructure/handlers/register.go +2 -2
| @@ -52,8 +52,8 @@ func RegisterAll(ctx context.Context, router port.WebUIHandlerRouter, deps port. | |||||
| 52 | } | 52 | } | ||
| 53 | 53 | ||||
| 54 | // Clipboard handlers (for auto-copy on selection feature) | 54 | // Clipboard handlers (for auto-copy on selection feature) | ||
| 55 | - | if deps.Clipboard != nil && deps.AutoCopyConfig != nil { | 55 | + | if deps.ClipboardTextOrchestrator != nil { |
| 56 | - | if err := RegisterClipboardHandlers(ctx, router, deps.Clipboard, deps.AutoCopyConfig, deps.OnClipboardCopied); err != nil { | 56 | + | if err := RegisterClipboardHandlers(ctx, router, deps.ClipboardTextOrchestrator); err != nil { |
| 57 | return err | 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 | "net/url" | 13 | "net/url" | ||
| 14 | "strings" | 14 | "strings" | ||
| 15 | "time" | 15 | "time" | ||
| 16 | + | "unicode/utf8" | |||
| 16 | 17 | ||||
| 17 | "github.com/bnema/dumber/internal/application/port" | 18 | "github.com/bnema/dumber/internal/application/port" | ||
| 18 | "github.com/bnema/dumber/internal/domain/entity" | 19 | "github.com/bnema/dumber/internal/domain/entity" | ||
| @@ -365,6 +366,7 @@ type contextMenuPipeline struct { | |||||
| 365 | builder port.ContextMenuBuilder | 366 | builder port.ContextMenuBuilder | ||
| 366 | executorFactory port.ContextMenuActionExecutorFactory | 367 | executorFactory port.ContextMenuActionExecutorFactory | ||
| 367 | clipboard port.Clipboard | 368 | clipboard port.Clipboard | ||
| 369 | + | onCopied func(textLen int) | |||
| 368 | resolver port.ImageDataResolver | 370 | resolver port.ImageDataResolver | ||
| 369 | saver port.ResolvedImageSaver | 371 | saver port.ResolvedImageSaver | ||
| 370 | renderer *Renderer | 372 | renderer *Renderer | ||
| @@ -374,14 +376,66 @@ func (p *contextMenuPipeline) newExecutor(wv *WebView) port.ContextMenuActionExe | |||||
| 374 | if p == nil || p.executorFactory == nil { | 376 | if p == nil || p.executorFactory == nil { | ||
| 375 | return nil | 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 | return p.executorFactory.NewContextMenuActionExecutor( | 383 | return p.executorFactory.NewContextMenuActionExecutor( | ||
| 378 | - | p.clipboard, | 384 | + | clipboard, |
| 379 | p.resolver, | 385 | p.resolver, | ||
| 380 | p.saver, | 386 | p.saver, | ||
| 381 | &webkitMenuDelegator{wv: wv}, | 387 | &webkitMenuDelegator{wv: wv}, | ||
| 382 | ) | 388 | ) | ||
| 383 | } | 389 | } | ||
| 384 | 390 | ||||
| 391 | + | var _ port.Clipboard = (*clipboardCopyNotifier)(nil) | |||
| 392 | + | ||||
| 393 | + | type clipboardCopyNotifier struct { | |||
| 394 | + | clipboard port.Clipboard | |||
| 395 | + | onCopied func(textLen int) | |||
| 396 | + | } | |||
| 397 | + | ||||
| 398 | + | func (c *clipboardCopyNotifier) WriteText(ctx context.Context, text string) error { | |||
| 399 | + | if c == nil || c.clipboard == nil { | |||
| 400 | + | return fmt.Errorf("clipboard not available") | |||
| 401 | + | } | |||
| 402 | + | if err := c.clipboard.WriteText(ctx, text); err != nil { | |||
| 403 | + | return err | |||
| 404 | + | } | |||
| 405 | + | if c.onCopied != nil { | |||
| 406 | + | c.onCopied(utf8.RuneCountInString(text)) | |||
| 407 | + | } | |||
| 408 | + | return nil | |||
| 409 | + | } | |||
| 410 | + | ||||
| 411 | + | func (c *clipboardCopyNotifier) WriteImage(ctx context.Context, image entity.ImageData) error { | |||
| 412 | + | if c == nil || c.clipboard == nil { | |||
| 413 | + | return fmt.Errorf("clipboard not available") | |||
| 414 | + | } | |||
| 415 | + | return c.clipboard.WriteImage(ctx, image) | |||
| 416 | + | } | |||
| 417 | + | ||||
| 418 | + | func (c *clipboardCopyNotifier) ReadText(ctx context.Context) (string, error) { | |||
| 419 | + | if c == nil || c.clipboard == nil { | |||
| 420 | + | return "", fmt.Errorf("clipboard not available") | |||
| 421 | + | } | |||
| 422 | + | return c.clipboard.ReadText(ctx) | |||
| 423 | + | } | |||
| 424 | + | ||||
| 425 | + | func (c *clipboardCopyNotifier) Clear(ctx context.Context) error { | |||
| 426 | + | if c == nil || c.clipboard == nil { | |||
| 427 | + | return fmt.Errorf("clipboard not available") | |||
| 428 | + | } | |||
| 429 | + | return c.clipboard.Clear(ctx) | |||
| 430 | + | } | |||
| 431 | + | ||||
| 432 | + | func (c *clipboardCopyNotifier) HasText(ctx context.Context) (bool, error) { | |||
| 433 | + | if c == nil || c.clipboard == nil { | |||
| 434 | + | return false, fmt.Errorf("clipboard not available") | |||
| 435 | + | } | |||
| 436 | + | return c.clipboard.HasText(ctx) | |||
| 437 | + | } | |||
| 438 | + | ||||
| 385 | // connectContextMenuSignal wires the WebKit "context-menu" signal to the | 439 | // connectContextMenuSignal wires the WebKit "context-menu" signal to the | ||
| 386 | // shared context menu pipeline. When the user right-clicks, WebKit's native | 440 | // shared context menu pipeline. When the user right-clicks, WebKit's native | ||
| 387 | // menu is suppressed and replaced by the shared GTK popover menu. | 441 | // menu is suppressed and replaced by the shared GTK popover menu. | ||
M internal/infrastructure/webkit/context_menu_test.go +51 -0
| @@ -9,9 +9,13 @@ import ( | |||||
| 9 | "net/http" | 9 | "net/http" | ||
| 10 | "net/http/httptest" | 10 | "net/http/httptest" | ||
| 11 | "testing" | 11 | "testing" | ||
| 12 | + | "unicode/utf8" | |||
| 12 | 13 | ||||
| 13 | "github.com/bnema/dumber/internal/application/port" | 14 | "github.com/bnema/dumber/internal/application/port" | ||
| 15 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 16 | + | "github.com/bnema/dumber/internal/application/usecase" | |||
| 14 | "github.com/stretchr/testify/assert" | 17 | "github.com/stretchr/testify/assert" | ||
| 18 | + | "github.com/stretchr/testify/mock" | |||
| 15 | "github.com/stretchr/testify/require" | 19 | "github.com/stretchr/testify/require" | ||
| 16 | ) | 20 | ) | ||
| 17 | 21 | ||||
| @@ -313,6 +317,53 @@ func TestWebkitMenuDelegator_OpenLinkNewTabRequiresHandlerSuccess(t *testing.T) | |||||
| 313 | assert.Contains(t, err.Error(), "action not handled") | 317 | assert.Contains(t, err.Error(), "action not handled") | ||
| 314 | } | 318 | } | ||
| 315 | 319 | ||||
| 320 | + | func TestContextMenuPipeline_NewExecutor_NotifiesOnCopiedText(t *testing.T) { | |||
| 321 | + | var copiedLen int | |||
| 322 | + | ctx := context.Background() | |||
| 323 | + | baseClipboard := portmocks.NewMockClipboard(t) | |||
| 324 | + | factory := portmocks.NewMockContextMenuActionExecutorFactory(t) | |||
| 325 | + | var capturedClipboard port.Clipboard | |||
| 326 | + | factory.EXPECT().NewContextMenuActionExecutor(mock.Anything, mock.Anything, mock.Anything, mock.Anything).RunAndReturn( | |||
| 327 | + | func( | |||
| 328 | + | clipboard port.Clipboard, | |||
| 329 | + | resolver port.ImageDataResolver, | |||
| 330 | + | saver port.ResolvedImageSaver, | |||
| 331 | + | delegator port.MenuActionDelegator, | |||
| 332 | + | ) port.ContextMenuActionExecutor { | |||
| 333 | + | capturedClipboard = clipboard | |||
| 334 | + | return usecase.NewExecuteContextMenuActionUseCase(clipboard, resolver, saver, delegator) | |||
| 335 | + | }, | |||
| 336 | + | ).Once() | |||
| 337 | + | pipeline := &contextMenuPipeline{ | |||
| 338 | + | clipboard: baseClipboard, | |||
| 339 | + | onCopied: func(textLen int) { copiedLen = textLen }, | |||
| 340 | + | executorFactory: factory, | |||
| 341 | + | } | |||
| 342 | + | ||||
| 343 | + | executor := pipeline.newExecutor(&WebView{}) | |||
| 344 | + | require.NotNil(t, executor) | |||
| 345 | + | ||||
| 346 | + | t.Run("copy link", func(t *testing.T) { | |||
| 347 | + | copiedLen = 0 | |||
| 348 | + | require.NotNil(t, capturedClipboard) | |||
| 349 | + | baseClipboard.EXPECT().WriteText(ctx, "https://example.com/new").Return(nil).Once() | |||
| 350 | + | ||||
| 351 | + | err := executor.ExecuteMenuAction(ctx, port.MenuActionCopyLink, port.MenuContext{LinkURI: "https://example.com/new"}) | |||
| 352 | + | require.NoError(t, err) | |||
| 353 | + | require.Equal(t, utf8.RuneCountInString("https://example.com/new"), copiedLen) | |||
| 354 | + | }) | |||
| 355 | + | ||||
| 356 | + | t.Run("copy selection", func(t *testing.T) { | |||
| 357 | + | copiedLen = 0 | |||
| 358 | + | require.NotNil(t, capturedClipboard) | |||
| 359 | + | baseClipboard.EXPECT().WriteText(ctx, "selected text").Return(nil).Once() | |||
| 360 | + | ||||
| 361 | + | err := executor.ExecuteMenuAction(ctx, port.MenuActionCopySelection, port.MenuContext{HasSelection: true, SelectionText: "selected text"}) | |||
| 362 | + | require.NoError(t, err) | |||
| 363 | + | require.Equal(t, utf8.RuneCountInString("selected text"), copiedLen) | |||
| 364 | + | }) | |||
| 365 | + | } | |||
| 366 | + | ||||
| 316 | // buildMenuContextFromStubHitTest is the test-only equivalent of buildMenuContextFromHitTest | 367 | // buildMenuContextFromStubHitTest is the test-only equivalent of buildMenuContextFromHitTest | ||
| 317 | // that works with our stub type instead of requiring real WebKit objects. | 368 | // that works with our stub type instead of requiring real WebKit objects. | ||
| 318 | func buildMenuContextFromStubHitTest(hit stubHitTest, canGoBack, canGoForward bool, x, y int) port.MenuContext { | 369 | func buildMenuContextFromStubHitTest(hit stubHitTest, canGoBack, canGoForward bool, x, y int) port.MenuContext { | ||
M internal/infrastructure/webkit/engine.go +3 -0
| @@ -26,6 +26,7 @@ type Engine struct { | |||||
| 26 | logger zerolog.Logger | 26 | logger zerolog.Logger | ||
| 27 | ctxMenuBuilder port.ContextMenuBuilder | 27 | ctxMenuBuilder port.ContextMenuBuilder | ||
| 28 | ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory | 28 | ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory | ||
| 29 | + | onClipboardCopied func(textLen int) | |||
| 29 | downloadPath string | 30 | downloadPath string | ||
| 30 | downloadPreparer port.DownloadPreparer | 31 | downloadPreparer port.DownloadPreparer | ||
| 31 | clipboard port.Clipboard // captured during RegisterHandlers for context menu wiring | 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 | // Capture clipboard for context menu pipeline wiring in ConfigureDownloads. | 101 | // Capture clipboard for context menu pipeline wiring in ConfigureDownloads. | ||
| 101 | e.clipboard = deps.Clipboard | 102 | e.clipboard = deps.Clipboard | ||
| 103 | + | e.onClipboardCopied = deps.OnClipboardCopied | |||
| 102 | return handlers.RegisterAll(ctx, e.messageRouter, deps) | 104 | return handlers.RegisterAll(ctx, e.messageRouter, deps) | ||
| 103 | } | 105 | } | ||
| 104 | 106 | ||||
| @@ -179,6 +181,7 @@ func (e *Engine) installContextMenuPipeline() { | |||||
| 179 | builder: e.ctxMenuBuilder, | 181 | builder: e.ctxMenuBuilder, | ||
| 180 | executorFactory: e.ctxMenuExecutorFactory, | 182 | executorFactory: e.ctxMenuExecutorFactory, | ||
| 181 | clipboard: e.clipboard, | 183 | clipboard: e.clipboard, | ||
| 184 | + | onCopied: e.onClipboardCopied, | |||
| 182 | resolver: resolver, | 185 | resolver: resolver, | ||
| 183 | saver: saver, | 186 | saver: saver, | ||
| 184 | renderer: renderer, | 187 | renderer: renderer, | ||
M internal/infrastructure/webkit/engine_init.go +16 -4
| @@ -10,6 +10,7 @@ import ( | |||||
| 10 | "github.com/bnema/dumber/internal/infrastructure/config" | 10 | "github.com/bnema/dumber/internal/infrastructure/config" | ||
| 11 | "github.com/bnema/dumber/internal/infrastructure/env" | 11 | "github.com/bnema/dumber/internal/infrastructure/env" | ||
| 12 | "github.com/bnema/dumber/internal/infrastructure/filtering" | 12 | "github.com/bnema/dumber/internal/infrastructure/filtering" | ||
| 13 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 13 | "github.com/bnema/dumber/internal/logging" | 14 | "github.com/bnema/dumber/internal/logging" | ||
| 14 | "github.com/bnema/dumber/internal/ui/theme" | 15 | "github.com/bnema/dumber/internal/ui/theme" | ||
| 15 | "github.com/bnema/puregotk/v4/gdk" | 16 | "github.com/bnema/puregotk/v4/gdk" | ||
| @@ -22,6 +23,7 @@ func NewEngine( | |||||
| 22 | ctx context.Context, | 23 | ctx context.Context, | ||
| 23 | cfg *config.Config, | 24 | cfg *config.Config, | ||
| 24 | opts port.EngineOptions, | 25 | opts port.EngineOptions, | ||
| 26 | + | profile runtimeprofile.Profile, | |||
| 25 | wkCfg WebKitEngineConfig, | 27 | wkCfg WebKitEngineConfig, | ||
| 26 | themeManager *theme.Manager, | 28 | themeManager *theme.Manager, | ||
| 27 | colorResolver port.ColorSchemeResolver, | 29 | colorResolver port.ColorSchemeResolver, | ||
| @@ -37,7 +39,7 @@ func NewEngine( | |||||
| 37 | logging.Trace().Mark("render_env") | 39 | logging.Trace().Mark("render_env") | ||
| 38 | 40 | ||||
| 39 | // --- Build webKitContextOptions from opts + wkCfg + perfSettings --- | 41 | // --- Build webKitContextOptions from opts + wkCfg + perfSettings --- | ||
| 40 | - | wkOpts := engineBuildContextOptions(opts, wkCfg, &perfSettings) | 42 | + | wkOpts := engineBuildContextOptions(opts, profile, wkCfg, &perfSettings) |
| 41 | logger.Info(). | 43 | logger.Info(). | ||
| 42 | Str("cookie_policy", string(wkOpts.CookiePolicy)). | 44 | Str("cookie_policy", string(wkOpts.CookiePolicy)). | ||
| 43 | Bool("itp_enabled", wkOpts.ITPEnabled). | 45 | Bool("itp_enabled", wkOpts.ITPEnabled). | ||
| @@ -50,7 +52,7 @@ func NewEngine( | |||||
| 50 | logging.Trace().Mark("webkit_context") | 52 | logging.Trace().Mark("webkit_context") | ||
| 51 | 53 | ||||
| 52 | // --- Filter manager --- | 54 | // --- Filter manager --- | ||
| 53 | - | filterManager := engineInitFilterManager(ctx, cfg, opts.DataDir, logger) | 55 | + | filterManager := engineInitFilterManager(ctx, cfg, profile.Shared.DataDir, logger) |
| 54 | 56 | ||||
| 55 | // --- Scheme handler --- | 57 | // --- Scheme handler --- | ||
| 56 | schemeHandler := NewDumbSchemeHandler(ctx) | 58 | schemeHandler := NewDumbSchemeHandler(ctx) | ||
| @@ -203,14 +205,24 @@ func engineConfigureRenderingEnvironment( | |||||
| 203 | // engineBuildContextOptions builds webKitContextOptions from EngineOptions, wkCfg and perfSettings. | 205 | // engineBuildContextOptions builds webKitContextOptions from EngineOptions, wkCfg and perfSettings. | ||
| 204 | func engineBuildContextOptions( | 206 | func engineBuildContextOptions( | ||
| 205 | opts port.EngineOptions, | 207 | opts port.EngineOptions, | ||
| 208 | + | profile runtimeprofile.Profile, | |||
| 206 | wkCfg WebKitEngineConfig, | 209 | wkCfg WebKitEngineConfig, | ||
| 207 | perfSettings *config.ResolvedPerformanceSettings, | 210 | perfSettings *config.ResolvedPerformanceSettings, | ||
| 208 | ) webKitContextOptions { | 211 | ) webKitContextOptions { | ||
| 209 | cp := opts.CookiePolicy // empty preserves runtime default per port contract | 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 | wkOpts := webKitContextOptions{ | 223 | wkOpts := webKitContextOptions{ | ||
| 212 | - | DataDir: opts.DataDir, | 224 | + | DataDir: dataDir, |
| 213 | - | CacheDir: opts.CacheDir, | 225 | + | CacheDir: cacheDir, |
| 214 | CookiePolicy: cp, | 226 | CookiePolicy: cp, | ||
| 215 | ITPEnabled: wkCfg.ITPEnabled, | 227 | ITPEnabled: wkCfg.ITPEnabled, | ||
| 216 | } | 228 | } | ||
A internal/infrastructure/webkit/engine_init_test.go +39 -0
| @@ -0,0 +1,39 @@ | |||||
| 1 | + | package webkit | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "path/filepath" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 8 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func TestEngineBuildContextOptions_UsesResolvedWebKitPaths(t *testing.T) { | |||
| 14 | + | profile := runtimeprofile.Profile{ | |||
| 15 | + | Mode: runtimeprofile.ModeDev, | |||
| 16 | + | Engine: "webkit", | |||
| 17 | + | EnginePaths: runtimeprofile.EnginePaths{ | |||
| 18 | + | RootDir: filepath.Join(t.TempDir(), "engines", "webkit"), | |||
| 19 | + | }, | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | wkOpts := engineBuildContextOptions(port.EngineOptions{}, profile, WebKitEngineConfig{}, &config.ResolvedPerformanceSettings{}) | |||
| 23 | + | require.Equal(t, profile.WebKitDataDir(), wkOpts.DataDir) | |||
| 24 | + | require.Equal(t, profile.WebKitCacheDir(), wkOpts.CacheDir) | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | func TestEngineBuildContextOptions_PrefersExplicitEngineOptionsDirs(t *testing.T) { | |||
| 28 | + | profile := runtimeprofile.Profile{ | |||
| 29 | + | Mode: runtimeprofile.ModeDev, | |||
| 30 | + | Engine: "webkit", | |||
| 31 | + | EnginePaths: runtimeprofile.EnginePaths{ | |||
| 32 | + | RootDir: filepath.Join(t.TempDir(), "engines", "webkit"), | |||
| 33 | + | }, | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | wkOpts := engineBuildContextOptions(port.EngineOptions{DataDir: "/tmp/data", CacheDir: "/tmp/cache"}, profile, WebKitEngineConfig{}, &config.ResolvedPerformanceSettings{}) | |||
| 37 | + | require.Equal(t, "/tmp/data", wkOpts.DataDir) | |||
| 38 | + | require.Equal(t, "/tmp/cache", wkOpts.CacheDir) | |||
| 39 | + | } | |||
M internal/infrastructure/webkit/injector.go +125 -6
| @@ -59,10 +59,117 @@ const autoCopySelectionScript = `(function() { | |||||
| 59 | }); | 59 | }); | ||
| 60 | })();` | 60 | })();` | ||
| 61 | 61 | ||||
| 62 | + | // explicitCopyScript captures explicit copy/cut operations and forwards them to Go. | |||
| 63 | + | const explicitCopyScript = `(function() { | |||
| 64 | + | var pendingCommand = ''; | |||
| 65 | + | ||||
| 66 | + | function isSafeSelectableInput(activeEl) { | |||
| 67 | + | if (!activeEl || activeEl.tagName !== 'INPUT') { | |||
| 68 | + | return false; | |||
| 69 | + | } | |||
| 70 | + | switch (String(activeEl.type || '').toLowerCase()) { | |||
| 71 | + | case 'password': | |||
| 72 | + | case 'button': | |||
| 73 | + | case 'checkbox': | |||
| 74 | + | case 'color': | |||
| 75 | + | case 'file': | |||
| 76 | + | case 'hidden': | |||
| 77 | + | case 'image': | |||
| 78 | + | case 'radio': | |||
| 79 | + | case 'range': | |||
| 80 | + | case 'reset': | |||
| 81 | + | case 'submit': | |||
| 82 | + | return false; | |||
| 83 | + | default: | |||
| 84 | + | return true; | |||
| 85 | + | } | |||
| 86 | + | } | |||
| 87 | + | ||||
| 88 | + | function selectedText() { | |||
| 89 | + | var activeEl = document.activeElement; | |||
| 90 | + | if (activeEl && (isSafeSelectableInput(activeEl) || activeEl.tagName === 'TEXTAREA')) { | |||
| 91 | + | if (typeof activeEl.selectionStart === 'number' && typeof activeEl.selectionEnd === 'number') { | |||
| 92 | + | var start = activeEl.selectionStart; | |||
| 93 | + | var end = activeEl.selectionEnd; | |||
| 94 | + | if (end > start) { | |||
| 95 | + | return activeEl.value.slice(start, end); | |||
| 96 | + | } | |||
| 97 | + | } | |||
| 98 | + | } | |||
| 99 | + | var selection = window.getSelection ? window.getSelection() : null; | |||
| 100 | + | return selection ? selection.toString() : ''; | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | function postExplicitCopy(action, text) { | |||
| 104 | + | if (!text) { | |||
| 105 | + | return; | |||
| 106 | + | } | |||
| 107 | + | if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumber) { | |||
| 108 | + | window.webkit.messageHandlers.dumber.postMessage({ | |||
| 109 | + | type: 'explicit_text_copy', | |||
| 110 | + | payload: { text: text, action: action } | |||
| 111 | + | }); | |||
| 112 | + | } | |||
| 113 | + | } | |||
| 114 | + | ||||
| 115 | + | function consumePendingAction(fallback) { | |||
| 116 | + | var action = pendingCommand || fallback; | |||
| 117 | + | pendingCommand = ''; | |||
| 118 | + | return action; | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | document.addEventListener('copy', function(event) { | |||
| 122 | + | if (event && event.isTrusted === false) { | |||
| 123 | + | return; | |||
| 124 | + | } | |||
| 125 | + | postExplicitCopy(consumePendingAction('copy'), selectedText()); | |||
| 126 | + | }, true); | |||
| 127 | + | ||||
| 128 | + | document.addEventListener('cut', function(event) { | |||
| 129 | + | if (event && event.isTrusted === false) { | |||
| 130 | + | return; | |||
| 131 | + | } | |||
| 132 | + | postExplicitCopy(consumePendingAction('cut'), selectedText()); | |||
| 133 | + | }, true); | |||
| 134 | + | ||||
| 135 | + | if (document.execCommand) { | |||
| 136 | + | var originalExecCommand = document.execCommand.bind(document); | |||
| 137 | + | document.execCommand = function(commandId, showUI, value) { | |||
| 138 | + | var command = String(commandId || '').toLowerCase(); | |||
| 139 | + | var tracked = command === 'copy' || command === 'cut'; | |||
| 140 | + | if (tracked) { | |||
| 141 | + | pendingCommand = command; | |||
| 142 | + | } | |||
| 143 | + | try { | |||
| 144 | + | return originalExecCommand(commandId, showUI, value); | |||
| 145 | + | } finally { | |||
| 146 | + | if (tracked && pendingCommand === command) { | |||
| 147 | + | pendingCommand = ''; | |||
| 148 | + | } | |||
| 149 | + | } | |||
| 150 | + | }; | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | if (navigator.clipboard && typeof navigator.clipboard.writeText === 'function') { | |||
| 154 | + | var originalWriteText = navigator.clipboard.writeText.bind(navigator.clipboard); | |||
| 155 | + | navigator.clipboard.writeText = function(text) { | |||
| 156 | + | return Promise.resolve(originalWriteText(text)).then(function(result) { | |||
| 157 | + | // writeText only has copy semantics, so postExplicitCopy always uses "copy" here. | |||
| 158 | + | postExplicitCopy('copy', typeof text === 'string' ? text : String(text || '')); | |||
| 159 | + | return result; | |||
| 160 | + | }); | |||
| 161 | + | }; | |||
| 162 | + | } | |||
| 163 | + | })();` | |||
| 164 | + | ||||
| 62 | // accentDetectionScript is built at init from entity.AccentMap so the JS | 165 | // accentDetectionScript is built at init from entity.AccentMap so the JS | ||
| 63 | // filter stays in sync with the Go-side accent table. | 166 | // filter stays in sync with the Go-side accent table. | ||
| 64 | var accentDetectionScript string | 167 | var accentDetectionScript string | ||
| 65 | 168 | ||||
| 169 | + | func buildExplicitCopyScript() string { | |||
| 170 | + | return explicitCopyScript | |||
| 171 | + | } | |||
| 172 | + | ||||
| 66 | func init() { | 173 | func init() { | ||
| 67 | // Build JS Set literal from AccentMap keys: "new Set(['a','c','e',...])" | 174 | // Build JS Set literal from AccentMap keys: "new Set(['a','c','e',...])" | ||
| 68 | keys := make([]string, 0, len(entity.AccentMap)) | 175 | keys := make([]string, 0, len(entity.AccentMap)) | ||
| @@ -229,7 +336,19 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 229 | "webrtc-compat-shim", | 336 | "webrtc-compat-shim", | ||
| 230 | ) | 337 | ) | ||
| 231 | 338 | ||||
| 232 | - | // 2. Inject WebView ID for debugging (internal pages only) | 339 | + | // 2. Inject explicit copy capture for all pages. |
| 340 | + | addScript( | |||
| 341 | + | webkit.NewUserScript( | |||
| 342 | + | buildExplicitCopyScript(), | |||
| 343 | + | webkit.UserContentInjectAllFramesValue, | |||
| 344 | + | webkit.UserScriptInjectAtDocumentStartValue, | |||
| 345 | + | nil, | |||
| 346 | + | nil, | |||
| 347 | + | ), | |||
| 348 | + | "explicit-copy-capture", | |||
| 349 | + | ) | |||
| 350 | + | ||||
| 351 | + | // 3. Inject WebView ID for debugging (internal pages only) | |||
| 233 | if webviewID != 0 { | 352 | if webviewID != 0 { | ||
| 234 | idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID)) | 353 | idScript := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(webviewID)) | ||
| 235 | addScript( | 354 | addScript( | ||
| @@ -247,7 +366,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 247 | log.Warn().Msg("webview ID is 0, skipping ID injection") | 366 | log.Warn().Msg("webview ID is 0, skipping ID injection") | ||
| 248 | } | 367 | } | ||
| 249 | 368 | ||||
| 250 | - | // 3. Inject dark mode handler for internal pages only | 369 | + | // 4. Inject dark mode handler for internal pages only |
| 251 | // This sets .dark/.light class on <html> and patches matchMedia for WebUI | 370 | // This sets .dark/.light class on <html> and patches matchMedia for WebUI | ||
| 252 | internalDarkModeScript := webutil.DarkModeScript(prefersDark, "__dumber_gtk_prefers_dark") | 371 | internalDarkModeScript := webutil.DarkModeScript(prefersDark, "__dumber_gtk_prefers_dark") | ||
| 253 | addScript( | 372 | addScript( | ||
| @@ -261,7 +380,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 261 | "internal-dark-mode-handler", | 380 | "internal-dark-mode-handler", | ||
| 262 | ) | 381 | ) | ||
| 263 | 382 | ||||
| 264 | - | // 4. Inject theme CSS for internal pages (dumb://* only) | 383 | + | // 5. Inject theme CSS for internal pages (dumb://* only) |
| 265 | if ci.themeCSSVars != "" { | 384 | if ci.themeCSSVars != "" { | ||
| 266 | escapedCSS := webutil.EscapeForJSString(ci.themeCSSVars) | 385 | escapedCSS := webutil.EscapeForJSString(ci.themeCSSVars) | ||
| 267 | themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS) | 386 | themeCSSInjectionScript := fmt.Sprintf(themeCSSScript, escapedCSS) | ||
| @@ -278,7 +397,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 278 | log.Debug().Msg("theme CSS vars injection configured for internal pages") | 397 | log.Debug().Msg("theme CSS vars injection configured for internal pages") | ||
| 279 | } | 398 | } | ||
| 280 | 399 | ||||
| 281 | - | // 5. Inject find highlight CSS for all pages | 400 | + | // 6. Inject find highlight CSS for all pages |
| 282 | if ci.findCSS != "" { | 401 | if ci.findCSS != "" { | ||
| 283 | stylesheet := webkit.NewUserStyleSheet( | 402 | stylesheet := webkit.NewUserStyleSheet( | ||
| 284 | ci.findCSS, | 403 | ci.findCSS, | ||
| @@ -295,7 +414,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 295 | } | 414 | } | ||
| 296 | } | 415 | } | ||
| 297 | 416 | ||||
| 298 | - | // 6. Inject auto-copy selection script for all pages (if enabled) | 417 | + | // 7. Inject auto-copy selection script for all pages (if enabled) |
| 299 | autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter() | 418 | autoCopyEnabled := ci.autoCopyConfigGetter != nil && ci.autoCopyConfigGetter() | ||
| 300 | if autoCopyEnabled { | 419 | if autoCopyEnabled { | ||
| 301 | addScript( | 420 | addScript( | ||
| @@ -311,7 +430,7 @@ func (ci *ContentInjector) InjectScripts(ctx context.Context, ucm *webkit.UserCo | |||||
| 311 | log.Debug().Msg("auto-copy selection script injected") | 430 | log.Debug().Msg("auto-copy selection script injected") | ||
| 312 | } | 431 | } | ||
| 313 | 432 | ||||
| 314 | - | // 7. Inject accent key detection for all pages and all frames (unconditional). | 433 | + | // 8. Inject accent key detection for all pages and all frames (unconditional). |
| 315 | // JS only reports keydown/keyup events; Go handles timing and picker display. | 434 | // JS only reports keydown/keyup events; Go handles timing and picker display. | ||
| 316 | addScript( | 435 | addScript( | ||
| 317 | webkit.NewUserScript( | 436 | webkit.NewUserScript( | ||
M internal/infrastructure/webkit/injector_accent_test.go +25 -0
| @@ -30,3 +30,28 @@ func TestAccentDetectionScriptTracksLastFocusedEditableElement(t *testing.T) { | |||||
| 30 | "accent detection script must register a focusin listener", | 30 | "accent detection script must register a focusin listener", | ||
| 31 | ) | 31 | ) | ||
| 32 | } | 32 | } | ||
| 33 | + | ||||
| 34 | + | func TestExplicitCopyScriptCapturesClipboardOperations(t *testing.T) { | |||
| 35 | + | script := buildExplicitCopyScript() | |||
| 36 | + | ||||
| 37 | + | assert.Contains(t, script, "explicit_text_copy") | |||
| 38 | + | assert.Contains(t, script, "document.addEventListener('copy'") | |||
| 39 | + | assert.Contains(t, script, "document.addEventListener('cut'") | |||
| 40 | + | assert.Contains(t, script, "document.execCommand") | |||
| 41 | + | assert.Contains(t, script, "navigator.clipboard.writeText") | |||
| 42 | + | assert.Contains(t, script, "try {") | |||
| 43 | + | assert.Contains(t, script, "finally {") | |||
| 44 | + | assert.Contains(t, script, "pendingCommand === command") | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestExplicitCopyScriptReadsInputAndTextareaSelectionFirst(t *testing.T) { | |||
| 48 | + | script := buildExplicitCopyScript() | |||
| 49 | + | ||||
| 50 | + | assert.Contains(t, script, "document.activeElement") | |||
| 51 | + | assert.Contains(t, script, "selectionStart") | |||
| 52 | + | assert.Contains(t, script, "selectionEnd") | |||
| 53 | + | assert.Contains(t, script, "INPUT") | |||
| 54 | + | assert.Contains(t, script, "TEXTAREA") | |||
| 55 | + | assert.Contains(t, script, "type || '').toLowerCase()") | |||
| 56 | + | assert.Contains(t, script, "case 'password':") | |||
| 57 | + | } | |||
M internal/infrastructure/xdg/adapter.go +14 -6
| @@ -9,11 +9,14 @@ import ( | |||||
| 9 | ) | 9 | ) | ||
| 10 | 10 | ||||
| 11 | // Adapter implements port.XDGPaths using config.GetXDGDirs(). | 11 | // Adapter implements port.XDGPaths using config.GetXDGDirs(). | ||
| 12 | - | type Adapter struct{} | 12 | + | type Adapter struct { |
| 13 | + | isDev bool | |||
| 14 | + | runtimeDir string | |||
| 15 | + | } | |||
| 13 | 16 | ||||
| 14 | // New creates a new XDG paths adapter. | 17 | // New creates a new XDG paths adapter. | ||
| 15 | - | func New() *Adapter { | 18 | + | func New(isDev bool, runtimeDir string) *Adapter { |
| 16 | - | return &Adapter{} | 19 | + | return &Adapter{isDev: isDev, runtimeDir: runtimeDir} |
| 17 | } | 20 | } | ||
| 18 | 21 | ||||
| 19 | func (a *Adapter) ConfigDir() (string, error) { | 22 | func (a *Adapter) ConfigDir() (string, error) { | ||
| @@ -29,11 +32,16 @@ func (a *Adapter) StateDir() (string, error) { | |||||
| 29 | } | 32 | } | ||
| 30 | 33 | ||||
| 31 | func (a *Adapter) RuntimeDir() (string, error) { | 34 | func (a *Adapter) RuntimeDir() (string, error) { | ||
| 32 | - | if dir := os.Getenv("XDG_RUNTIME_DIR"); dir != "" { | 35 | + | if a == nil || !a.isDev { |
| 33 | - | return dir, nil | 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 | if err != nil { | 45 | if err != nil { | ||
| 38 | return "", err | 46 | return "", err | ||
| 39 | } | 47 | } | ||
M internal/infrastructure/xdg/adapter_test.go +17 -7
| @@ -10,7 +10,7 @@ import ( | |||||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | func TestAdapter_DownloadDir(t *testing.T) { | 12 | func TestAdapter_DownloadDir(t *testing.T) { | ||
| 13 | - | adapter := New() | 13 | + | adapter := New(false, "") |
| 14 | 14 | ||||
| 15 | t.Run("returns XDG_DOWNLOAD_DIR when set", func(t *testing.T) { | 15 | t.Run("returns XDG_DOWNLOAD_DIR when set", func(t *testing.T) { | ||
| 16 | t.Setenv("XDG_DOWNLOAD_DIR", "/custom/downloads") | 16 | t.Setenv("XDG_DOWNLOAD_DIR", "/custom/downloads") | ||
| @@ -37,9 +37,8 @@ func TestAdapter_DownloadDir(t *testing.T) { | |||||
| 37 | } | 37 | } | ||
| 38 | 38 | ||||
| 39 | func TestAdapter_RuntimeDir(t *testing.T) { | 39 | func TestAdapter_RuntimeDir(t *testing.T) { | ||
| 40 | - | adapter := New() | 40 | + | t.Run("returns XDG_RUNTIME_DIR when set outside dev", func(t *testing.T) { |
| 41 | - | 41 | + | adapter := New(false, "") | |
| 42 | - | t.Run("returns XDG_RUNTIME_DIR when set", func(t *testing.T) { | |||
| 43 | t.Setenv("XDG_RUNTIME_DIR", "/custom/runtime") | 42 | t.Setenv("XDG_RUNTIME_DIR", "/custom/runtime") | ||
| 44 | 43 | ||||
| 45 | dir, err := adapter.RuntimeDir() | 44 | dir, err := adapter.RuntimeDir() | ||
| @@ -48,19 +47,30 @@ func TestAdapter_RuntimeDir(t *testing.T) { | |||||
| 48 | assert.Equal(t, "/custom/runtime", dir) | 47 | assert.Equal(t, "/custom/runtime", dir) | ||
| 49 | }) | 48 | }) | ||
| 50 | 49 | ||||
| 51 | - | t.Run("falls back to state runtime dir when XDG_RUNTIME_DIR not set", func(t *testing.T) { | 50 | + | t.Run("falls back to injected prod runtime dir when XDG_RUNTIME_DIR not set", func(t *testing.T) { |
| 51 | + | adapter := New(false, "/tmp/dumber-state/dumber/runtime") | |||
| 52 | t.Setenv("XDG_RUNTIME_DIR", "") | 52 | t.Setenv("XDG_RUNTIME_DIR", "") | ||
| 53 | - | t.Setenv("XDG_STATE_HOME", "/tmp/dumber-state") | |||
| 54 | 53 | ||||
| 55 | dir, err := adapter.RuntimeDir() | 54 | dir, err := adapter.RuntimeDir() | ||
| 56 | 55 | ||||
| 57 | require.NoError(t, err) | 56 | require.NoError(t, err) | ||
| 58 | assert.Equal(t, "/tmp/dumber-state/dumber/runtime", dir) | 57 | assert.Equal(t, "/tmp/dumber-state/dumber/runtime", dir) | ||
| 59 | }) | 58 | }) | ||
| 59 | + | ||||
| 60 | + | t.Run("uses injected sandbox runtime dir in dev even when XDG_RUNTIME_DIR is set", func(t *testing.T) { | |||
| 61 | + | wd := t.TempDir() | |||
| 62 | + | adapter := New(true, filepath.Join(wd, ".dev", "dumber", "runtime")) | |||
| 63 | + | t.Setenv("XDG_RUNTIME_DIR", "/shared/runtime") | |||
| 64 | + | ||||
| 65 | + | dir, err := adapter.RuntimeDir() | |||
| 66 | + | ||||
| 67 | + | require.NoError(t, err) | |||
| 68 | + | assert.Equal(t, filepath.Join(wd, ".dev", "dumber", "runtime"), dir) | |||
| 69 | + | }) | |||
| 60 | } | 70 | } | ||
| 61 | 71 | ||||
| 62 | func TestNew(t *testing.T) { | 72 | func TestNew(t *testing.T) { | ||
| 63 | - | adapter := New() | 73 | + | adapter := New(false, "") |
| 64 | 74 | ||||
| 65 | assert.NotNil(t, adapter) | 75 | assert.NotNil(t, adapter) | ||
| 66 | } | 76 | } | ||
M internal/ui/app.go +27 -10
| @@ -37,7 +37,8 @@ import ( | |||||
| 37 | 37 | ||||
| 38 | const ( | 38 | const ( | ||
| 39 | // AppID is the application identifier for GTK. | 39 | // AppID is the application identifier for GTK. | ||
| 40 | - | AppID = "com.github.bnema.dumber" | 40 | + | AppID = "com.github.bnema.dumber" |
| 41 | + | appTitle = "Dumber" | |||
| 41 | // crashReportToastDurationMs keeps startup crash-report toast visible longer. | 42 | // crashReportToastDurationMs keeps startup crash-report toast visible longer. | ||
| 42 | crashReportToastDurationMs = 5000 | 43 | crashReportToastDurationMs = 5000 | ||
| 43 | floatingPaneFallbackWidth = 1200 | 44 | floatingPaneFallbackWidth = 1200 | ||
| @@ -181,17 +182,33 @@ func New(deps *Dependencies) (*App, error) { | |||||
| 181 | engine: deps.Engine, | 182 | engine: deps.Engine, | ||
| 182 | cancel: cancel, | 183 | cancel: cancel, | ||
| 183 | } | 184 | } | ||
| 184 | - | // Register message handlers through the engine. | 185 | + | var autoCopyConfig port.AutoCopyConfig |
| 185 | - | if err := deps.Engine.RegisterHandlers(ctx, port.HandlerDependencies{ | 186 | + | var clipboardOrchestrator port.ClipboardTextOrchestrator |
| 186 | - | HistoryUC: deps.HistoryUC, | 187 | + | if deps.Clipboard != nil { |
| 187 | - | FavoritesUC: deps.FavoritesUC, | 188 | + | autoCopyConfig = &autoCopyConfigFn{fn: func() bool { |
| 188 | - | Clipboard: deps.Clipboard, | |||
| 189 | - | AutoCopyConfig: &autoCopyConfigFn{fn: func() bool { | |||
| 190 | if deps.Config == nil { | 189 | if deps.Config == nil { | ||
| 191 | return false | 190 | return false | ||
| 192 | } | 191 | } | ||
| 193 | return deps.Config.Clipboard.AutoCopyOnSelection | 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 | OnClipboardCopied: func(textLen int) { | 212 | OnClipboardCopied: func(textLen int) { | ||
| 196 | cb := glib.SourceFunc(func(_ uintptr) bool { | 213 | cb := glib.SourceFunc(func(_ uintptr) bool { | ||
| 197 | app.showToastOnLastFocusedBrowserWindow(ctx, "Copied to clipboard", component.ToastInfo, | 214 | app.showToastOnLastFocusedBrowserWindow(ctx, "Copied to clipboard", component.ToastInfo, | ||
| @@ -2339,9 +2356,9 @@ func (a *App) updateWindowTitle(pageTitle string, target *browserWindow) { | |||||
| 2339 | return | 2356 | return | ||
| 2340 | } | 2357 | } | ||
| 2341 | 2358 | ||||
| 2342 | - | title := "Dumber" | 2359 | + | title := appTitle |
| 2343 | if pageTitle != "" { | 2360 | if pageTitle != "" { | ||
| 2344 | - | title = pageTitle + " - Dumber" | 2361 | + | title = pageTitle + " - " + appTitle |
| 2345 | } | 2362 | } | ||
| 2346 | target.mainWindow.SetTitle(title) | 2363 | target.mainWindow.SetTitle(title) | ||
| 2347 | } | 2364 | } | ||
A internal/ui/app_clipboard_test.go +70 -0
| @@ -0,0 +1,70 @@ | |||||
| 1 | + | package ui | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/application/port" | |||
| 8 | + | portmocks "github.com/bnema/dumber/internal/application/port/mocks" | |||
| 9 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 10 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 11 | + | "github.com/stretchr/testify/mock" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | func TestNew_DoesNotWireClipboardOrchestratorWhenClipboardMissing(t *testing.T) { | |||
| 16 | + | ctx := context.Background() | |||
| 17 | + | engine := portmocks.NewMockEngine(t) | |||
| 18 | + | var registerDeps port.HandlerDependencies | |||
| 19 | + | engine.EXPECT().RegisterHandlers(mock.Anything, mock.Anything). | |||
| 20 | + | Run(func(_ context.Context, deps port.HandlerDependencies) { | |||
| 21 | + | registerDeps = deps | |||
| 22 | + | }). | |||
| 23 | + | Return(nil). | |||
| 24 | + | Once() | |||
| 25 | + | ||||
| 26 | + | deps := &Dependencies{ | |||
| 27 | + | Ctx: ctx, | |||
| 28 | + | Config: &config.Config{}, | |||
| 29 | + | Engine: engine, | |||
| 30 | + | HandlerDeps: port.HandlerDeps{ | |||
| 31 | + | SaveConfig: func(context.Context, port.WebUIConfig) error { return nil }, | |||
| 32 | + | SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil }, | |||
| 33 | + | }, | |||
| 34 | + | Clipboard: nil, | |||
| 35 | + | } | |||
| 36 | + | ||||
| 37 | + | app, err := New(deps) | |||
| 38 | + | require.NoError(t, err) | |||
| 39 | + | require.NotNil(t, app) | |||
| 40 | + | require.Nil(t, registerDeps.ClipboardTextOrchestrator) | |||
| 41 | + | } | |||
| 42 | + | ||||
| 43 | + | func TestNew_WiresClipboardOrchestratorWhenClipboardPresent(t *testing.T) { | |||
| 44 | + | ctx := context.Background() | |||
| 45 | + | engine := portmocks.NewMockEngine(t) | |||
| 46 | + | clipboard := portmocks.NewMockClipboard(t) | |||
| 47 | + | var registerDeps port.HandlerDependencies | |||
| 48 | + | engine.EXPECT().RegisterHandlers(mock.Anything, mock.Anything). | |||
| 49 | + | Run(func(_ context.Context, deps port.HandlerDependencies) { | |||
| 50 | + | registerDeps = deps | |||
| 51 | + | }). | |||
| 52 | + | Return(nil). | |||
| 53 | + | Once() | |||
| 54 | + | ||||
| 55 | + | deps := &Dependencies{ | |||
| 56 | + | Ctx: ctx, | |||
| 57 | + | Config: &config.Config{}, | |||
| 58 | + | Engine: engine, | |||
| 59 | + | HandlerDeps: port.HandlerDeps{ | |||
| 60 | + | SaveConfig: func(context.Context, port.WebUIConfig) error { return nil }, | |||
| 61 | + | SaveOmniboxInitialBehavior: func(context.Context, entity.OmniboxInitialBehavior) error { return nil }, | |||
| 62 | + | }, | |||
| 63 | + | Clipboard: clipboard, | |||
| 64 | + | } | |||
| 65 | + | ||||
| 66 | + | app, err := New(deps) | |||
| 67 | + | require.NoError(t, err) | |||
| 68 | + | require.NotNil(t, app) | |||
| 69 | + | require.NotNil(t, registerDeps.ClipboardTextOrchestrator) | |||
| 70 | + | } | |||
M internal/ui/component/pane_view.go +2 -2
| @@ -390,7 +390,7 @@ func (pv *PaneView) SetLoadProgress(progress float64) { | |||||
| 390 | ctx = context.Background() | 390 | ctx = context.Background() | ||
| 391 | } | 391 | } | ||
| 392 | logging.FromContext(ctx). | 392 | logging.FromContext(ctx). | ||
| 393 | - | Debug(). | 393 | + | Trace(). |
| 394 | Str("pane_id", string(pv.paneID)). | 394 | Str("pane_id", string(pv.paneID)). | ||
| 395 | Float64("progress", progress). | 395 | Float64("progress", progress). | ||
| 396 | Msg("setting load progress") | 396 | Msg("setting load progress") | ||
| @@ -419,7 +419,7 @@ func (pv *PaneView) SetLoading(loading bool) { | |||||
| 419 | if loading { | 419 | if loading { | ||
| 420 | pb.Show() | 420 | pb.Show() | ||
| 421 | } else { | 421 | } else { | ||
| 422 | - | pb.Hide() | 422 | + | pb.HideWithReason("loading_false") |
| 423 | } | 423 | } | ||
| 424 | } | 424 | } | ||
| 425 | 425 | ||||
M internal/ui/component/progress_bar.go +116 -16
| @@ -4,6 +4,7 @@ package component | |||||
| 4 | import ( | 4 | import ( | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "sync" | 6 | "sync" | ||
| 7 | + | "time" | |||
| 7 | 8 | ||||
| 8 | "github.com/bnema/dumber/internal/logging" | 9 | "github.com/bnema/dumber/internal/logging" | ||
| 9 | "github.com/bnema/puregotk/v4/glib" | 10 | "github.com/bnema/puregotk/v4/glib" | ||
| @@ -29,11 +30,14 @@ type ProgressBar struct { | |||||
| 29 | ctx context.Context | 30 | ctx context.Context | ||
| 30 | progressBar layout.ProgressBarWidget | 31 | progressBar layout.ProgressBarWidget | ||
| 31 | 32 | ||||
| 32 | - | visible bool | 33 | + | visible bool |
| 33 | - | currentValue float64 // Current displayed value | 34 | + | currentValue float64 // Current displayed value |
| 34 | - | targetValue float64 // Target value to animate towards | 35 | + | targetValue float64 // Target value to animate towards |
| 35 | - | animationTimer uint // Timer source ID for animation | 36 | + | animationTimer uint // Timer source ID for animation |
| 36 | - | timeoutTimer uint // Timer source ID for auto-hide timeout | 37 | + | timeoutTimer uint // Timer source ID for auto-hide timeout |
| 38 | + | lastShowAt time.Time | |||
| 39 | + | lastProgressAt time.Time | |||
| 40 | + | progressEventSeq uint64 | |||
| 37 | 41 | ||||
| 38 | mu sync.Mutex | 42 | mu sync.Mutex | ||
| 39 | } | 43 | } | ||
| @@ -85,12 +89,17 @@ func (pb *ProgressBar) SetProgress(progress float64) { | |||||
| 85 | if ctx == nil { | 89 | if ctx == nil { | ||
| 86 | ctx = context.Background() | 90 | ctx = context.Background() | ||
| 87 | } | 91 | } | ||
| 92 | + | now := time.Now() | |||
| 88 | logging.FromContext(ctx). | 93 | logging.FromContext(ctx). | ||
| 89 | - | Debug(). | 94 | + | Trace(). |
| 90 | Float64("incoming_progress", incomingProgress). | 95 | Float64("incoming_progress", incomingProgress). | ||
| 91 | Float64("current_value", pb.currentValue). | 96 | Float64("current_value", pb.currentValue). | ||
| 92 | Float64("target_value", pb.targetValue). | 97 | Float64("target_value", pb.targetValue). | ||
| 93 | Bool("visible", pb.visible). | 98 | Bool("visible", pb.visible). | ||
| 99 | + | Bool("animation_running", pb.animationTimer != 0). | |||
| 100 | + | Bool("timeout_running", pb.timeoutTimer != 0). | |||
| 101 | + | Int64("since_show_ms", sinceTimeMs(pb.lastShowAt, now)). | |||
| 102 | + | Int64("since_last_progress_ms", sinceTimeMs(pb.lastProgressAt, now)). | |||
| 94 | Msg("setting progress") | 103 | Msg("setting progress") | ||
| 95 | 104 | ||||
| 96 | // Clamp progress to valid range | 105 | // Clamp progress to valid range | ||
| @@ -101,6 +110,11 @@ func (pb *ProgressBar) SetProgress(progress float64) { | |||||
| 101 | } | 110 | } | ||
| 102 | 111 | ||||
| 103 | pb.targetValue = progress | 112 | pb.targetValue = progress | ||
| 113 | + | pb.lastProgressAt = now | |||
| 114 | + | pb.progressEventSeq++ | |||
| 115 | + | if pb.visible { | |||
| 116 | + | pb.resetTimeout() | |||
| 117 | + | } | |||
| 104 | 118 | ||||
| 105 | // For large jumps (>0.3) or completion, update immediately | 119 | // For large jumps (>0.3) or completion, update immediately | ||
| 106 | diff := progress - pb.currentValue | 120 | diff := progress - pb.currentValue | ||
| @@ -146,6 +160,16 @@ func (pb *ProgressBar) startAnimation() { | |||||
| 146 | }) | 160 | }) | ||
| 147 | 161 | ||||
| 148 | pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0) | 162 | pb.animationTimer = glib.TimeoutAdd(progressIntervalMs, &cb, 0) | ||
| 163 | + | ctx := pb.ctx | |||
| 164 | + | if ctx == nil { | |||
| 165 | + | ctx = context.Background() | |||
| 166 | + | } | |||
| 167 | + | logging.FromContext(ctx). | |||
| 168 | + | Trace(). | |||
| 169 | + | Uint64("progress_event_seq", pb.progressEventSeq). | |||
| 170 | + | Float64("current_value", pb.currentValue). | |||
| 171 | + | Float64("target_value", pb.targetValue). | |||
| 172 | + | Msg("progress bar animation started") | |||
| 149 | } | 173 | } | ||
| 150 | 174 | ||||
| 151 | // initialProgressFraction is the fraction set when the progress bar first | 175 | // initialProgressFraction is the fraction set when the progress bar first | ||
| @@ -172,6 +196,7 @@ func (pb *ProgressBar) Show() { | |||||
| 172 | 196 | ||||
| 173 | if !pb.visible { | 197 | if !pb.visible { | ||
| 174 | pb.visible = true | 198 | pb.visible = true | ||
| 199 | + | pb.lastShowAt = time.Now() | |||
| 175 | // Set an initial non-zero fraction so the bar is visually noticeable | 200 | // Set an initial non-zero fraction so the bar is visually noticeable | ||
| 176 | // immediately. Without this, the bar is technically visible but | 201 | // immediately. Without this, the bar is technically visible but | ||
| 177 | // renders as empty (0% fill) until the first progress callback, | 202 | // renders as empty (0% fill) until the first progress callback, | ||
| @@ -182,10 +207,10 @@ func (pb *ProgressBar) Show() { | |||||
| 182 | pb.progressBar.SetVisible(true) | 207 | pb.progressBar.SetVisible(true) | ||
| 183 | } | 208 | } | ||
| 184 | 209 | ||||
| 185 | - | // Reset timeout timer on every Show call | 210 | + | // Reset timeout timer on every Show call. |
| 186 | pb.resetTimeout() | 211 | pb.resetTimeout() | ||
| 187 | 212 | ||||
| 188 | - | logging.FromContext(pb.ctx). | 213 | + | logging.FromContext(ctx). |
| 189 | Debug(). | 214 | Debug(). | ||
| 190 | Bool("visible", pb.visible). | 215 | Bool("visible", pb.visible). | ||
| 191 | Float64("current_value", pb.currentValue). | 216 | Float64("current_value", pb.currentValue). | ||
| @@ -196,46 +221,96 @@ func (pb *ProgressBar) Show() { | |||||
| 196 | // resetTimeout cancels any existing timeout and starts a new one. | 221 | // resetTimeout cancels any existing timeout and starts a new one. | ||
| 197 | // Must be called with lock held. | 222 | // Must be called with lock held. | ||
| 198 | func (pb *ProgressBar) resetTimeout() { | 223 | func (pb *ProgressBar) resetTimeout() { | ||
| 199 | - | // Cancel existing 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 | if pb.timeoutTimer != 0 { | 235 | if pb.timeoutTimer != 0 { | ||
| 201 | glib.SourceRemove(pb.timeoutTimer) | 236 | glib.SourceRemove(pb.timeoutTimer) | ||
| 202 | pb.timeoutTimer = 0 | 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 | cb := glib.SourceFunc(func(_ uintptr) bool { | 248 | cb := glib.SourceFunc(func(_ uintptr) bool { | ||
| 207 | pb.mu.Lock() | 249 | pb.mu.Lock() | ||
| 208 | defer pb.mu.Unlock() | 250 | defer pb.mu.Unlock() | ||
| 209 | 251 | ||||
| 210 | - | // Clear timer ID (timer is being removed) | 252 | + | now := time.Now() |
| 253 | + | remaining := pb.timeoutRemainingLocked(now) | |||
| 254 | + | ctx := pb.ctx | |||
| 255 | + | if ctx == nil { | |||
| 256 | + | ctx = context.Background() | |||
| 257 | + | } | |||
| 258 | + | logging.FromContext(ctx). | |||
| 259 | + | Debug(). | |||
| 260 | + | Float64("current_value", pb.currentValue). | |||
| 261 | + | Float64("target_value", pb.targetValue). | |||
| 262 | + | Int64("since_show_ms", sinceTimeMs(pb.lastShowAt, now)). | |||
| 263 | + | Int64("since_last_progress_ms", sinceTimeMs(pb.lastProgressAt, now)). | |||
| 264 | + | Int64("timeout_remaining_ms", remaining.Milliseconds()). | |||
| 265 | + | Msg("progress bar timeout fired") | |||
| 266 | + | ||||
| 267 | + | // Clear timer ID (timer is being removed). | |||
| 211 | pb.timeoutTimer = 0 | 268 | pb.timeoutTimer = 0 | ||
| 212 | 269 | ||||
| 213 | - | // Hide the progress bar inline to avoid race condition | 270 | + | if remaining > 0 { |
| 214 | - | pb.hideInternal() | 271 | + | pb.resetTimeoutAfter(remaining) |
| 272 | + | return false | |||
| 273 | + | } | |||
| 274 | + | ||||
| 275 | + | // Hide the progress bar inline to avoid race condition. | |||
| 276 | + | pb.hideInternal("timeout") | |||
| 215 | return false // Don't repeat | 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 | // Hide makes the progress bar invisible and resets state. | 283 | // Hide makes the progress bar invisible and resets state. | ||
| 222 | func (pb *ProgressBar) Hide() { | 284 | func (pb *ProgressBar) Hide() { | ||
| 285 | + | pb.HideWithReason("manual") | |||
| 286 | + | } | |||
| 287 | + | ||||
| 288 | + | // HideWithReason makes the progress bar invisible and records why it was hidden. | |||
| 289 | + | func (pb *ProgressBar) HideWithReason(reason string) { | |||
| 223 | pb.mu.Lock() | 290 | pb.mu.Lock() | ||
| 224 | defer pb.mu.Unlock() | 291 | defer pb.mu.Unlock() | ||
| 225 | 292 | ||||
| 226 | - | pb.hideInternal() | 293 | + | pb.hideInternal(reason) |
| 227 | } | 294 | } | ||
| 228 | 295 | ||||
| 229 | // hideInternal performs the actual hide operation. | 296 | // hideInternal performs the actual hide operation. | ||
| 230 | // Must be called with lock held. | 297 | // Must be called with lock held. | ||
| 231 | - | func (pb *ProgressBar) hideInternal() { | 298 | + | func (pb *ProgressBar) hideInternal(reason string) { |
| 232 | ctx := pb.ctx | 299 | ctx := pb.ctx | ||
| 233 | if ctx == nil { | 300 | if ctx == nil { | ||
| 234 | ctx = context.Background() | 301 | ctx = context.Background() | ||
| 235 | } | 302 | } | ||
| 303 | + | now := time.Now() | |||
| 236 | logging.FromContext(ctx). | 304 | logging.FromContext(ctx). | ||
| 237 | Debug(). | 305 | Debug(). | ||
| 238 | Bool("visible", pb.visible). | 306 | Bool("visible", pb.visible). | ||
| 307 | + | Str("reason", reason). | |||
| 308 | + | Float64("current_value", pb.currentValue). | |||
| 309 | + | Float64("target_value", pb.targetValue). | |||
| 310 | + | Bool("animation_running", pb.animationTimer != 0). | |||
| 311 | + | Bool("timeout_running", pb.timeoutTimer != 0). | |||
| 312 | + | Int64("since_show_ms", sinceTimeMs(pb.lastShowAt, now)). | |||
| 313 | + | Int64("since_last_progress_ms", sinceTimeMs(pb.lastProgressAt, now)). | |||
| 239 | Msg("progress bar hide before") | 314 | Msg("progress bar hide before") | ||
| 240 | 315 | ||||
| 241 | if pb.visible { | 316 | if pb.visible { | ||
| @@ -257,10 +332,35 @@ func (pb *ProgressBar) hideInternal() { | |||||
| 257 | // Reset values | 332 | // Reset values | ||
| 258 | pb.currentValue = 0 | 333 | pb.currentValue = 0 | ||
| 259 | pb.targetValue = 0 | 334 | pb.targetValue = 0 | ||
| 335 | + | pb.lastShowAt = time.Time{} | |||
| 336 | + | pb.lastProgressAt = time.Time{} | |||
| 260 | pb.progressBar.SetFraction(0) | 337 | pb.progressBar.SetFraction(0) | ||
| 261 | } | 338 | } | ||
| 262 | } | 339 | } | ||
| 263 | 340 | ||||
| 341 | + | func (pb *ProgressBar) timeoutRemainingLocked(now time.Time) time.Duration { | |||
| 342 | + | lastActivityAt := pb.lastShowAt | |||
| 343 | + | if pb.lastProgressAt.After(lastActivityAt) { | |||
| 344 | + | lastActivityAt = pb.lastProgressAt | |||
| 345 | + | } | |||
| 346 | + | if lastActivityAt.IsZero() { | |||
| 347 | + | return 0 | |||
| 348 | + | } | |||
| 349 | + | ||||
| 350 | + | remaining := time.Duration(progressTimeoutMs)*time.Millisecond - now.Sub(lastActivityAt) | |||
| 351 | + | if remaining < 0 { | |||
| 352 | + | return 0 | |||
| 353 | + | } | |||
| 354 | + | return remaining | |||
| 355 | + | } | |||
| 356 | + | ||||
| 357 | + | func sinceTimeMs(start, now time.Time) int64 { | |||
| 358 | + | if start.IsZero() { | |||
| 359 | + | return -1 | |||
| 360 | + | } | |||
| 361 | + | return now.Sub(start).Milliseconds() | |||
| 362 | + | } | |||
| 363 | + | ||||
| 264 | // IsVisible returns whether the progress bar is currently visible. | 364 | // IsVisible returns whether the progress bar is currently visible. | ||
| 265 | func (pb *ProgressBar) IsVisible() bool { | 365 | func (pb *ProgressBar) IsVisible() bool { | ||
| 266 | pb.mu.Lock() | 366 | pb.mu.Lock() | ||
A internal/ui/component/progress_bar_test.go +62 -0
| @@ -0,0 +1,62 @@ | |||||
| 1 | + | package component | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | "time" | |||
| 6 | + | ||||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestProgressBarTimeoutRemainingLocked_UsesLatestProgressEvent(t *testing.T) { | |||
| 11 | + | showAt := time.Unix(0, 0) | |||
| 12 | + | progressAt := showAt.Add(20 * time.Second) | |||
| 13 | + | pb := &ProgressBar{ | |||
| 14 | + | lastShowAt: showAt, | |||
| 15 | + | lastProgressAt: progressAt, | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | remaining := pb.timeoutRemainingLocked(showAt.Add(25 * time.Second)) | |||
| 19 | + | ||||
| 20 | + | require.Equal(t, 25*time.Second, remaining) | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | func TestProgressBarTimeoutRemainingLocked_UsesShowTimeBeforeFirstProgress(t *testing.T) { | |||
| 24 | + | showAt := time.Unix(0, 0) | |||
| 25 | + | pb := &ProgressBar{lastShowAt: showAt} | |||
| 26 | + | ||||
| 27 | + | remaining := pb.timeoutRemainingLocked(showAt.Add(31 * time.Second)) | |||
| 28 | + | ||||
| 29 | + | require.Zero(t, remaining) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func TestProgressBarTimeoutRemainingLocked_ZeroTimestamps(t *testing.T) { | |||
| 33 | + | pb := &ProgressBar{} | |||
| 34 | + | ||||
| 35 | + | remaining := pb.timeoutRemainingLocked(time.Unix(10, 0)) | |||
| 36 | + | ||||
| 37 | + | require.Zero(t, remaining) | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestProgressBarTimeoutRemainingLocked_EqualTimestamps(t *testing.T) { | |||
| 41 | + | showAt := time.Unix(0, 0) | |||
| 42 | + | pb := &ProgressBar{ | |||
| 43 | + | lastShowAt: showAt, | |||
| 44 | + | lastProgressAt: showAt, | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | remaining := pb.timeoutRemainingLocked(showAt.Add(10 * time.Second)) | |||
| 48 | + | ||||
| 49 | + | require.Equal(t, 20*time.Second, remaining) | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | func TestProgressBarTimeoutRemainingLocked_NegativeClampsToZero(t *testing.T) { | |||
| 53 | + | showAt := time.Unix(0, 0) | |||
| 54 | + | pb := &ProgressBar{ | |||
| 55 | + | lastShowAt: showAt, | |||
| 56 | + | lastProgressAt: showAt.Add(5 * time.Second), | |||
| 57 | + | } | |||
| 58 | + | ||||
| 59 | + | remaining := pb.timeoutRemainingLocked(showAt.Add(36 * time.Second)) | |||
| 60 | + | ||||
| 61 | + | require.Zero(t, remaining) | |||
| 62 | + | } | |||