Merge pull request #241 from bnema/feature/use-purego-cef2gtk
Use new package purego-cef2gtk
58 files · +3549 -3799
M .gitignore +2 -1
| @@ -84,10 +84,11 @@ CLAUDE.local.md | |||
| 84 | 84 | memory/ | |
| 85 | 85 | notes/ | |
| 86 | 86 | docs/plans/ | |
| 87 | - | scripts/ | |
| 87 | + | scripts/* | |
| 88 | 88 | !scripts/generate-types.js | |
| 89 | 89 | !scripts/verify_purego_only.sh | |
| 90 | 90 | !scripts/stress_omnibox_callbacks.go | |
| 91 | + | !scripts/cef-gpu-diagnostics.sh | |
| 91 | 92 | specs/ | |
| 92 | 93 | templates/ | |
| 93 | 94 | .serena/ | |
M .golangci.yml +3 -1
| @@ -40,7 +40,9 @@ linters: | |||
| 40 | 40 | statements: 50 | |
| 41 | 41 | goconst: | |
| 42 | 42 | min-len: 2 | |
| 43 | - | min-occurrences: 3 | |
| 43 | + | min-occurrences: 20 | |
| 44 | + | ignore-string-values: | |
| 45 | + | - "^(bool|int|string|error|engine\\.webkit|>=0)$" | |
| 44 | 46 | gocritic: | |
| 45 | 47 | enabled-tags: | |
| 46 | 48 | - diagnostic | |
M Makefile +5 -5
| @@ -42,7 +42,7 @@ help: ## Show this help message | |||
| 42 | 42 | build: build-systemviews ## Build the application (pure Go, no CGO) | |
| 43 | 43 | @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..." | |
| 44 | 44 | @mkdir -p $(DIST_DIR) | |
| 45 | - | CGO_ENABLED=0 go build -buildvcs=false -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | |
| 45 | + | GOFLAGS=-mod=mod CGO_ENABLED=0 go build -buildvcs=false -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | |
| 46 | 46 | @rm -f $(DIST_DIR)/cef-helper | |
| 47 | 47 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | |
| 48 | 48 | ||
| @@ -63,7 +63,7 @@ build-systemviews: generate-systemviews ## Build the WASM systemviews runtime | |||
| 63 | 63 | build-quick: ## Build quickly for backend development | |
| 64 | 64 | @echo "Building $(BINARY_NAME) $(VERSION) (quick)..." | |
| 65 | 65 | @mkdir -p $(DIST_DIR) | |
| 66 | - | CGO_ENABLED=0 go build -buildvcs=false -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | |
| 66 | + | GOFLAGS=-mod=mod CGO_ENABLED=0 go build -buildvcs=false -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | |
| 67 | 67 | @rm -f $(DIST_DIR)/cef-helper | |
| 68 | 68 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | |
| 69 | 69 | ||
| @@ -102,15 +102,15 @@ mocks: ## Generate mock implementations with mockery | |||
| 102 | 102 | # Testing | |
| 103 | 103 | test: ## Run tests | |
| 104 | 104 | @echo "Running tests..." | |
| 105 | - | go test -v ./... | |
| 105 | + | GOFLAGS=-mod=mod go test -v ./... | |
| 106 | 106 | ||
| 107 | 107 | test-race: ## Run tests with race detection | |
| 108 | 108 | @echo "Running tests with race detection..." | |
| 109 | - | go test -race -v ./... | |
| 109 | + | GOFLAGS=-mod=mod go test -race -v ./... | |
| 110 | 110 | ||
| 111 | 111 | test-cover: ## Run tests with coverage | |
| 112 | 112 | @echo "Running tests with coverage..." | |
| 113 | - | go test -coverprofile=coverage.out ./... | |
| 113 | + | GOFLAGS=-mod=mod go test -coverprofile=coverage.out ./... | |
| 114 | 114 | go tool cover -html=coverage.out -o coverage.html | |
| 115 | 115 | @echo "Coverage report: coverage.html" | |
| 116 | 116 | ||
M README.md +2 -0
| @@ -101,6 +101,8 @@ CEF_DIR=/custom/path dumber browse | |||
| 101 | 101 | ||
| 102 | 102 | Then set `engine.type = "cef"` in your config. | |
| 103 | 103 | ||
| 104 | + | CEF uses Dumber's GPU-first Wayland render stack by default: GDK DMABUF presentation with ANGLE/GSK Vulkan. You normally do not need render environment variables or helper scripts. For diagnostics only, set `DUMBER_RENDER_STACK=legacy-gl` to use the older GtkGLArea/OpenGL bridge, or `DUMBER_RENDER_STACK=vulkan-dmabuf` to force the default explicitly. | |
| 105 | + | ||
| 104 | 106 | ## Keyboard-Driven | |
| 105 | 107 | ||
| 106 | 108 | Four modal modes. Enter a mode, act, escape out. Vim and Zellij users already know this pattern. | |
M cmd/dumber/main.go +17 -0
| @@ -24,6 +24,7 @@ import ( | |||
| 24 | 24 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 25 | 25 | "github.com/bnema/dumber/internal/infrastructure/deps" | |
| 26 | 26 | "github.com/bnema/dumber/internal/infrastructure/desktop" | |
| 27 | + | renderenv "github.com/bnema/dumber/internal/infrastructure/env" | |
| 27 | 28 | "github.com/bnema/dumber/internal/infrastructure/favicon" | |
| 28 | 29 | "github.com/bnema/dumber/internal/infrastructure/filesystem" | |
| 29 | 30 | "github.com/bnema/dumber/internal/infrastructure/idle" | |
| @@ -199,6 +200,7 @@ func runGUI(cfg *config.Config) int { | |||
| 199 | 200 | timer.Mark("config") | |
| 200 | 201 | ||
| 201 | 202 | ctx := initStartupContextWithTrace(cfg) | |
| 203 | + | applyCEFRenderStackDefault(ctx, cfg) | |
| 202 | 204 | timer.Mark("logger") | |
| 203 | 205 | bootstrapLog := logging.FromContext(ctx) | |
| 204 | 206 | ||
| @@ -273,6 +275,7 @@ func runStandaloneOmnibox() int { | |||
| 273 | 275 | cfg := initConfig() | |
| 274 | 276 | configureBrowserLaunchRelay(cfg) | |
| 275 | 277 | ctx := initStartupContextWithTrace(cfg) | |
| 278 | + | applyCEFRenderStackDefault(ctx, cfg) | |
| 276 | 279 | ||
| 277 | 280 | initResult, err := runParallelInitPhase(ctx, cfg) | |
| 278 | 281 | if err != nil { | |
| @@ -320,6 +323,20 @@ func runStandaloneOmnibox() int { | |||
| 320 | 323 | return ui.RunStandaloneOmnibox(ctx, runtimeCfg) | |
| 321 | 324 | } | |
| 322 | 325 | ||
| 326 | + | func applyCEFRenderStackDefault(ctx context.Context, cfg *config.Config) { | |
| 327 | + | if cfg == nil || cfg.Engine.ResolveEngineType() != config.EngineTypeCEF { | |
| 328 | + | return | |
| 329 | + | } | |
| 330 | + | logger := logging.NewPortLogger(ctx) | |
| 331 | + | infracef.ApplyDefaultRenderStackEnvironment(logger) | |
| 332 | + | infracef.ApplyDefaultHardwareDecodeEnvironment(ctx, infracef.HardwareDecodeEnvironmentOptions{ | |
| 333 | + | EngineType: cfg.Engine.ResolveEngineType(), | |
| 334 | + | HardwareDecodingDisabled: cfg.Media.HardwareDecodingMode == config.HardwareDecodingDisable, | |
| 335 | + | RenderingEnvManager: renderenv.NewManager(), | |
| 336 | + | Logger: logger, | |
| 337 | + | }) | |
| 338 | + | } | |
| 339 | + | ||
| 323 | 340 | func preInitializeAdwaitaForCEF(cfg *config.Config, initResult *bootstrap.ParallelInitResult, initAdwaita func()) { | |
| 324 | 341 | if cfg == nil || initResult == nil || initAdwaita == nil { | |
| 325 | 342 | return | |
M docs/config/index.md +4 -1
| @@ -690,8 +690,11 @@ All config values can be overridden via environment variables with the prefix `D | |||
| 690 | 690 | DUMBER_DATABASE_PATH=/custom/path/db.sqlite | |
| 691 | 691 | ||
| 692 | 692 | # Rendering (examples) | |
| 693 | - | # Defaults: rendering.disable_dmabuf_renderer=false, rendering.gsk_renderer="auto" | |
| 693 | + | # CEF defaults to the GPU-first Vulkan DMABUF stack. | |
| 694 | + | # Use legacy-gl only as a diagnostic fallback. | |
| 695 | + | # DUMBER_RENDER_STACK=legacy-gl | |
| 694 | 696 | DUMBER_RENDERING_MODE=cpu | |
| 697 | + | # WebKit-specific rendering overrides: | |
| 695 | 698 | # DUMBER_RENDERING_DISABLE_DMABUF_RENDERER=true | |
| 696 | 699 | # DUMBER_RENDERING_GSK_RENDERER=opengl | |
| 697 | 700 | DUMBER_DEFAULT_WEBPAGE_ZOOM=1.5 | |
M docs/reference/configuration.md +1 -0
| @@ -59,6 +59,7 @@ | |||
| 59 | 59 | | `rendering.show_fps` | bool | `false` | | | |
| 60 | 60 | | `rendering.sample_memory` | bool | `false` | | | |
| 61 | 61 | | `rendering.debug_frames` | bool | `false` | | | |
| 62 | + | | `DUMBER_RENDER_STACK` env | string | `vulkan-dmabuf` | `vulkan-dmabuf`, `auto`, `legacy-gl` | | |
| 62 | 63 | | `default_ui_scale` | float | `1.0` | > 0 | | |
| 63 | 64 | | `default_webpage_zoom` | float | `1.2` | > 0 | | |
| 64 | 65 | | `workspace.new_pane_url` | string | `about:blank` | | | |
M docs/reference/dependencies.md +4 -0
| @@ -71,6 +71,10 @@ sudo pacman -S gst-plugins-base gst-plugins-good gst-plugins-bad gst-plugins-ugl | |||
| 71 | 71 | sudo pacman -S vulkan-tools # for vulkaninfo | |
| 72 | 72 | ``` | |
| 73 | 73 | ||
| 74 | + | ### CEF Wayland rendering | |
| 75 | + | ||
| 76 | + | When `engine.type = "cef"`, Dumber defaults to the GPU-first Wayland stack: GDK DMABUF presentation with ANGLE/GSK Vulkan. No launch wrapper or render environment variables are required for normal use. Set `DUMBER_RENDER_STACK=legacy-gl` only when diagnosing the older GtkGLArea/OpenGL fallback path. | |
| 77 | + | ||
| 74 | 78 | ## Debian/Ubuntu | |
| 75 | 79 | ||
| 76 | 80 | ### Core GTK4 and WebKit | |
M go.mod +3 -1
| @@ -7,9 +7,10 @@ require ( | |||
| 7 | 7 | github.com/andybalholm/brotli v1.2.1 | |
| 8 | 8 | github.com/bnema/purego v0.11.0-bnema.2 | |
| 9 | 9 | github.com/bnema/purego-cef v0.12.0 | |
| 10 | + | github.com/bnema/purego-cef2gtk v0.1.0 | |
| 10 | 11 | github.com/bnema/purego-pipewire v0.1.2 | |
| 11 | 12 | github.com/bnema/purego-sqlite v0.1.2 | |
| 12 | - | github.com/bnema/puregotk v0.5.1 | |
| 13 | + | github.com/bnema/puregotk v0.5.2 | |
| 13 | 14 | github.com/charmbracelet/bubbles v1.0.0 | |
| 14 | 15 | github.com/charmbracelet/bubbletea v1.3.10 | |
| 15 | 16 | github.com/charmbracelet/lipgloss v1.1.0 | |
| @@ -57,6 +58,7 @@ require ( | |||
| 57 | 58 | github.com/natefinch/atomic v1.0.1 // indirect | |
| 58 | 59 | github.com/pmezard/go-difflib v1.0.0 // indirect | |
| 59 | 60 | github.com/rivo/uniseg v0.4.7 // indirect | |
| 61 | + | github.com/rogpeppe/go-internal v1.14.1 // indirect | |
| 60 | 62 | github.com/russross/blackfriday/v2 v2.1.0 // indirect | |
| 61 | 63 | github.com/sagikazarmark/locafero v0.12.0 // indirect | |
| 62 | 64 | github.com/sahilm/fuzzy v0.1.1 // indirect | |
M go.sum +6 -6 generated file: collapsed
M internal/application/port/logger.go +19 -0
| @@ -9,3 +9,22 @@ import ( | |||
| 9 | 9 | // LoggerFromContext resolves the current request/session logger from context. | |
| 10 | 10 | // Use cases depend on this boundary instead of importing infrastructure logging. | |
| 11 | 11 | type LoggerFromContext func(ctx context.Context) *zerolog.Logger | |
| 12 | + | ||
| 13 | + | // LogField is a structured logging field carried across application boundaries. | |
| 14 | + | type LogField struct { | |
| 15 | + | Key string | |
| 16 | + | Value any | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // Field creates a structured log field. | |
| 20 | + | func Field(key string, value any) LogField { | |
| 21 | + | return LogField{Key: key, Value: value} | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // Logger is the logging boundary used by adapters that must not import a | |
| 25 | + | // concrete logging implementation directly. | |
| 26 | + | type Logger interface { | |
| 27 | + | Debug(msg string, fields ...LogField) | |
| 28 | + | Info(msg string, fields ...LogField) | |
| 29 | + | Warn(msg string, fields ...LogField) | |
| 30 | + | } | |
M internal/bootstrap/engine.go +5 -2
| @@ -13,6 +13,7 @@ import ( | |||
| 13 | 13 | clipboardinfra "github.com/bnema/dumber/internal/infrastructure/clipboard" | |
| 14 | 14 | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 15 | 15 | "github.com/bnema/dumber/internal/infrastructure/env" | |
| 16 | + | "github.com/bnema/dumber/internal/infrastructure/gtkmenu" | |
| 16 | 17 | "github.com/bnema/dumber/internal/infrastructure/handlers" | |
| 17 | 18 | "github.com/bnema/dumber/internal/infrastructure/runtimeprofile" | |
| 18 | 19 | "github.com/bnema/dumber/internal/infrastructure/webkit" | |
| @@ -90,13 +91,15 @@ func BuildEngine(input EngineInput) (port.Engine, error) { | |||
| 90 | 91 | DefaultConfigPayload: defaultConfigPayload, | |
| 91 | 92 | ContextMenuBuilder: contextMenuBuilder, | |
| 92 | 93 | ContextMenuExecutorFactory: contextMenuExecutorFactory, | |
| 94 | + | ContextMenuRenderer: gtkmenu.NewRenderer(nil), | |
| 93 | 95 | Clipboard: clipboardinfra.New(), | |
| 94 | 96 | ImageDataResolver: webkit.NewContextMenuResolver(), | |
| 95 | 97 | } | |
| 96 | 98 | audioFactory := audiofactory.NewAudioOutputFactory() | |
| 97 | 99 | return cef.NewEngine(input.Ctx, opts, cef.RuntimePaths{ | |
| 98 | - | StateRoot: profile.CEFUserDataDir(), | |
| 99 | - | LogFile: profile.CEFLogFile(), | |
| 100 | + | StateRoot: profile.CEFUserDataDir(), | |
| 101 | + | LogFile: profile.CEFLogFile(), | |
| 102 | + | ProfileLogDir: profile.Shared.LogDir, | |
| 100 | 103 | }, cefCfg, audioFactory, deps) | |
| 101 | 104 | default: | |
| 102 | 105 | return nil, fmt.Errorf("unknown engine type: %q", engineType) | |
M internal/bootstrap/session.go +5 -1
| @@ -118,7 +118,11 @@ func StartBrowserSession( | |||
| 118 | 118 | logger.Info().Str("log_file", logPath).Msg("session log file") | |
| 119 | 119 | } | |
| 120 | 120 | ||
| 121 | - | sessionCtx := corelogging.WithContext(context.Background(), logger) | |
| 121 | + | sessionCtx := corelogging.WithSessionMetadata( | |
| 122 | + | corelogging.WithContext(context.Background(), logger), | |
| 123 | + | string(session.ID), | |
| 124 | + | logPath, | |
| 125 | + | ) | |
| 122 | 126 | ||
| 123 | 127 | bs := &BrowserSession{ | |
| 124 | 128 | Session: session, | |
M internal/infrastructure/cef/app.go +122 -2
| @@ -1,17 +1,21 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "os" | |
| 4 | 5 | "strings" | |
| 5 | 6 | ||
| 6 | 7 | purecef "github.com/bnema/purego-cef/cef" | |
| 8 | + | cef2gtk "github.com/bnema/purego-cef2gtk" | |
| 7 | 9 | ||
| 8 | 10 | "github.com/bnema/dumber/internal/logging" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| 11 | 13 | const ( | |
| 12 | 14 | dumbSchemeName = "dumb" | |
| 15 | + | chromiumEnableFeaturesSwitch = "enable-features" | |
| 13 | 16 | chromiumDisableFeaturesSwitch = "disable-features" | |
| 14 | 17 | chromiumDisableBlinkFeaturesSwitch = "disable-blink-features" | |
| 18 | + | chromiumRenderNodeOverrideSwitch = "render-node-override" | |
| 15 | 19 | ) | |
| 16 | 20 | ||
| 17 | 21 | // Chromium's core Blink runtime flag for the Web Authentication API is | |
| @@ -41,6 +45,11 @@ func configureCommandLine(commandLine purecef.CommandLine) { | |||
| 41 | 45 | return | |
| 42 | 46 | } | |
| 43 | 47 | ||
| 48 | + | // Delegate Wayland accelerated rendering setup to the GTK bridge. | |
| 49 | + | // Preserves any pre-existing ozone-platform value (e.g. set via | |
| 50 | + | // CEF command-line args) — the bridge is a no-op when already present. | |
| 51 | + | cef2gtk.ConfigureCommandLine(commandLine, cef2gtk.CommandLineOptions{}) | |
| 52 | + | ||
| 44 | 53 | // Enable Chromium's built-in smooth scrolling animation — without this, | |
| 45 | 54 | // mouse wheel scroll jumps in discrete steps with no momentum/easing. | |
| 46 | 55 | commandLine.AppendSwitch("enable-smooth-scrolling") | |
| @@ -50,9 +59,116 @@ func configureCommandLine(commandLine purecef.CommandLine) { | |||
| 50 | 59 | // Chromium blocks them, showing an infinite spinner. | |
| 51 | 60 | commandLine.AppendSwitchWithValue("autoplay-policy", "no-user-gesture-required") | |
| 52 | 61 | ||
| 62 | + | configureHardwareVideoDecode(commandLine) | |
| 63 | + | configureEnvChromiumFlags(commandLine) | |
| 64 | + | configureRenderNodeOverride(commandLine) | |
| 53 | 65 | configureWebAuthnFeaturePolicy(commandLine) | |
| 54 | 66 | } | |
| 55 | 67 | ||
| 68 | + | func configureHardwareVideoDecode(commandLine purecef.CommandLine) { | |
| 69 | + | if commandLine == nil || !envBoolEnabled(cefEnableVAAPIEnvVar) { | |
| 70 | + | return | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | appendUniqueCommaSeparatedSwitchValues(commandLine, chromiumEnableFeaturesSwitch, | |
| 74 | + | "AcceleratedVideoDecoder", | |
| 75 | + | "AcceleratedVideoEncoder", | |
| 76 | + | "AcceleratedVideoDecodeLinuxGL", | |
| 77 | + | "AcceleratedVideoDecodeLinuxZeroCopyGL", | |
| 78 | + | "VaapiIgnoreDriverChecks", | |
| 79 | + | ) | |
| 80 | + | appendSwitchIfMissing(commandLine, "ignore-gpu-blocklist") | |
| 81 | + | appendSwitchIfMissing(commandLine, "enable-zero-copy") | |
| 82 | + | appendSwitchIfMissing(commandLine, "disable-gpu-driver-bug-workaround") | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func configureEnvChromiumFlags(commandLine purecef.CommandLine) { | |
| 86 | + | if commandLine == nil { | |
| 87 | + | return | |
| 88 | + | } | |
| 89 | + | ||
| 90 | + | for _, token := range parseChromiumFlagsEnv(os.Getenv(cefChromiumFlagsEnvVar)) { | |
| 91 | + | applyChromiumFlagToken(commandLine, token) | |
| 92 | + | } | |
| 93 | + | } | |
| 94 | + | ||
| 95 | + | func configureRenderNodeOverride(commandLine purecef.CommandLine) { | |
| 96 | + | if commandLine == nil { | |
| 97 | + | return | |
| 98 | + | } | |
| 99 | + | value := strings.TrimSpace(os.Getenv(cefRenderNodeEnvVar)) | |
| 100 | + | if value == "" { | |
| 101 | + | return | |
| 102 | + | } | |
| 103 | + | switch strings.ToLower(value) { | |
| 104 | + | case "auto", "default", "none", "off", "disable", "disabled": | |
| 105 | + | commandLine.RemoveSwitch(chromiumRenderNodeOverrideSwitch) | |
| 106 | + | return | |
| 107 | + | } | |
| 108 | + | commandLine.RemoveSwitch(chromiumRenderNodeOverrideSwitch) | |
| 109 | + | commandLine.AppendSwitchWithValue(chromiumRenderNodeOverrideSwitch, value) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | func parseChromiumFlagsEnv(raw string) []string { | |
| 113 | + | // Reuse Dumber's command-line tokenizer so developer env flags can contain | |
| 114 | + | // quoted values without adding a second shell-like parser in the CEF adapter. | |
| 115 | + | return parseRelaunchCommandLineArgs(raw) | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | func applyChromiumFlagToken(commandLine purecef.CommandLine, token string) { | |
| 119 | + | if commandLine == nil { | |
| 120 | + | return | |
| 121 | + | } | |
| 122 | + | token = strings.TrimSpace(token) | |
| 123 | + | if token == "" || token == "--" { | |
| 124 | + | return | |
| 125 | + | } | |
| 126 | + | name, value, ok := parseChromiumSwitchToken(token) | |
| 127 | + | if !ok { | |
| 128 | + | return | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | switch name { | |
| 132 | + | case chromiumEnableFeaturesSwitch, chromiumDisableFeaturesSwitch, chromiumDisableBlinkFeaturesSwitch: | |
| 133 | + | appendUniqueCommaSeparatedSwitchValues(commandLine, name, strings.Split(value, ",")...) | |
| 134 | + | default: | |
| 135 | + | if value == "" { | |
| 136 | + | appendSwitchIfMissing(commandLine, name) | |
| 137 | + | return | |
| 138 | + | } | |
| 139 | + | commandLine.AppendSwitchWithValue(name, value) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func parseChromiumSwitchToken(token string) (name, value string, ok bool) { | |
| 144 | + | token = strings.TrimSpace(token) | |
| 145 | + | if token == "" { | |
| 146 | + | return "", "", false | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | switch { | |
| 150 | + | case strings.HasPrefix(token, "--"): | |
| 151 | + | token = strings.TrimPrefix(token, "--") | |
| 152 | + | case strings.HasPrefix(token, "-"): | |
| 153 | + | token = strings.TrimPrefix(token, "-") | |
| 154 | + | default: | |
| 155 | + | return "", "", false | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | if token == "" || strings.HasPrefix(token, "-") { | |
| 159 | + | return "", "", false | |
| 160 | + | } | |
| 161 | + | name, value, found := strings.Cut(token, "=") | |
| 162 | + | name = strings.TrimSpace(name) | |
| 163 | + | if name == "" { | |
| 164 | + | return "", "", false | |
| 165 | + | } | |
| 166 | + | if !found { | |
| 167 | + | return name, "", true | |
| 168 | + | } | |
| 169 | + | return name, value, true | |
| 170 | + | } | |
| 171 | + | ||
| 56 | 172 | func configureWebAuthnFeaturePolicy(commandLine purecef.CommandLine) { | |
| 57 | 173 | if commandLine == nil { | |
| 58 | 174 | return | |
| @@ -191,6 +307,7 @@ func (a *dumberApp) OnBeforeCommandLineProcessing(processType string, commandLin | |||
| 191 | 307 | } | |
| 192 | 308 | } | |
| 193 | 309 | ||
| 310 | + | renderNodeOverride := commandLine.GetSwitchValue(chromiumRenderNodeOverrideSwitch) | |
| 194 | 311 | cmdline := commandLine.GetCommandLineString() | |
| 195 | 312 | if len(cmdline) > maxCmdLineLogLen { | |
| 196 | 313 | runes := []rune(cmdline) | |
| @@ -200,6 +317,7 @@ func (a *dumberApp) OnBeforeCommandLineProcessing(processType string, commandLin | |||
| 200 | 317 | } | |
| 201 | 318 | log.Debug(). | |
| 202 | 319 | Str("process_type", processType). | |
| 320 | + | Str("render_node_override", renderNodeOverride). | |
| 203 | 321 | Str("command_line", cmdline). | |
| 204 | 322 | Msg("cef: OnBeforeCommandLineProcessing") | |
| 205 | 323 | } | |
| @@ -240,15 +358,17 @@ func (h *dumberBPH) OnBeforeChildProcessLaunch(commandLine purecef.CommandLine) | |||
| 240 | 358 | commandLineString := "" | |
| 241 | 359 | useAngle := "" | |
| 242 | 360 | ozonePlatform := "" | |
| 361 | + | renderNodeOverride := "" | |
| 243 | 362 | if commandLine != nil { | |
| 244 | - | configureWebAuthnFeaturePolicy(commandLine) | |
| 363 | + | configureCommandLine(commandLine) | |
| 245 | 364 | appendSwitchIfMissing(commandLine, "no-zygote") | |
| 246 | 365 | processType = commandLine.GetSwitchValue("type") | |
| 247 | 366 | commandLineString = commandLine.GetCommandLineString() | |
| 248 | 367 | useAngle = commandLine.GetSwitchValue("use-angle") | |
| 249 | 368 | ozonePlatform = commandLine.GetSwitchValue("ozone-platform") | |
| 369 | + | renderNodeOverride = commandLine.GetSwitchValue(chromiumRenderNodeOverrideSwitch) | |
| 250 | 370 | } | |
| 251 | - | h.engine.recordChildProcessLaunch(processType, useAngle, ozonePlatform, commandLineString) | |
| 371 | + | h.engine.recordChildProcessLaunch(processType, useAngle, ozonePlatform, renderNodeOverride, commandLineString) | |
| 252 | 372 | } | |
| 253 | 373 | ||
| 254 | 374 | func appendSwitchIfMissing(commandLine purecef.CommandLine, name string) { | |
M internal/infrastructure/cef/app_test.go +177 -0
| @@ -261,6 +261,126 @@ func TestConfigureCommandLine_WebAuthnUnsafeOverrideRemovesSwitchWhenOnlyWebAuth | |||
| 261 | 261 | } | |
| 262 | 262 | } | |
| 263 | 263 | ||
| 264 | + | func TestConfigureCommandLine_SetsOzonePlatformToWayland(t *testing.T) { | |
| 265 | + | commandLine := newMutableCommandLineStub() | |
| 266 | + | configureCommandLine(commandLine) | |
| 267 | + | ||
| 268 | + | if got := commandLine.GetSwitchValue("ozone-platform"); got != "wayland" { | |
| 269 | + | t.Fatalf("ozone-platform = %q, want %q", got, "wayland") | |
| 270 | + | } | |
| 271 | + | ||
| 272 | + | // Dumber-specific switches must remain present. | |
| 273 | + | if !commandLine.HasSwitch("enable-smooth-scrolling") { | |
| 274 | + | t.Fatal("expected enable-smooth-scrolling switch to be present") | |
| 275 | + | } | |
| 276 | + | if got := commandLine.GetSwitchValue("autoplay-policy"); got != "no-user-gesture-required" { | |
| 277 | + | t.Fatalf("autoplay-policy = %q, want %q", got, "no-user-gesture-required") | |
| 278 | + | } | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | func TestConfigureCommandLine_PreservesExistingOzonePlatform(t *testing.T) { | |
| 282 | + | commandLine := newMutableCommandLineStub() | |
| 283 | + | commandLine.AppendSwitchWithValue("ozone-platform", "x11") | |
| 284 | + | ||
| 285 | + | configureCommandLine(commandLine) | |
| 286 | + | ||
| 287 | + | if got := commandLine.GetSwitchValue("ozone-platform"); got != "x11" { | |
| 288 | + | t.Fatalf("ozone-platform = %q, want %q (bridge preserves existing value)", got, "x11") | |
| 289 | + | } | |
| 290 | + | ||
| 291 | + | // Dumber-specific switches must remain present. | |
| 292 | + | if !commandLine.HasSwitch("enable-smooth-scrolling") { | |
| 293 | + | t.Fatal("expected enable-smooth-scrolling switch to be present") | |
| 294 | + | } | |
| 295 | + | if got := commandLine.GetSwitchValue("autoplay-policy"); got != "no-user-gesture-required" { | |
| 296 | + | t.Fatalf("autoplay-policy = %q, want %q", got, "no-user-gesture-required") | |
| 297 | + | } | |
| 298 | + | } | |
| 299 | + | ||
| 300 | + | func TestConfigureCommandLine_EnableVAAPIEnvAppendsHardwareDecodeFlags(t *testing.T) { | |
| 301 | + | t.Setenv(cefEnableVAAPIEnvVar, "1") | |
| 302 | + | ||
| 303 | + | commandLine := newMutableCommandLineStub() | |
| 304 | + | commandLine.AppendSwitchWithValue(chromiumEnableFeaturesSwitch, "ExistingFeature") | |
| 305 | + | ||
| 306 | + | configureCommandLine(commandLine) | |
| 307 | + | ||
| 308 | + | gotFeatures := commandLine.GetSwitchValue(chromiumEnableFeaturesSwitch) | |
| 309 | + | for _, feature := range []string{ | |
| 310 | + | "ExistingFeature", | |
| 311 | + | "AcceleratedVideoDecoder", | |
| 312 | + | "AcceleratedVideoEncoder", | |
| 313 | + | "AcceleratedVideoDecodeLinuxGL", | |
| 314 | + | "AcceleratedVideoDecodeLinuxZeroCopyGL", | |
| 315 | + | "VaapiIgnoreDriverChecks", | |
| 316 | + | } { | |
| 317 | + | if !strings.Contains(gotFeatures, feature) { | |
| 318 | + | t.Fatalf("enable-features = %q, missing %q", gotFeatures, feature) | |
| 319 | + | } | |
| 320 | + | } | |
| 321 | + | if !commandLine.HasSwitch("ignore-gpu-blocklist") { | |
| 322 | + | t.Fatal("expected ignore-gpu-blocklist switch") | |
| 323 | + | } | |
| 324 | + | if !commandLine.HasSwitch("enable-zero-copy") { | |
| 325 | + | t.Fatal("expected enable-zero-copy switch") | |
| 326 | + | } | |
| 327 | + | if !commandLine.HasSwitch("disable-gpu-driver-bug-workaround") { | |
| 328 | + | t.Fatal("expected disable-gpu-driver-bug-workaround switch") | |
| 329 | + | } | |
| 330 | + | } | |
| 331 | + | ||
| 332 | + | func TestConfigureCommandLine_ChromiumFlagsEnvAppendsSwitches(t *testing.T) { | |
| 333 | + | t.Setenv(cefChromiumFlagsEnvVar, `--enable-features=VaapiVideoDecoder,VaapiIgnoreDriverChecks --ignore-gpu-blocklist --autoplay-policy=document-user-activation-required ignored-arg`) | |
| 334 | + | ||
| 335 | + | commandLine := newMutableCommandLineStub() | |
| 336 | + | commandLine.AppendSwitchWithValue(chromiumEnableFeaturesSwitch, "ExistingFeature") | |
| 337 | + | ||
| 338 | + | configureCommandLine(commandLine) | |
| 339 | + | ||
| 340 | + | if got := commandLine.GetSwitchValue(chromiumEnableFeaturesSwitch); got != "ExistingFeature,VaapiVideoDecoder,VaapiIgnoreDriverChecks" { | |
| 341 | + | t.Fatalf("enable-features = %q, want env features appended", got) | |
| 342 | + | } | |
| 343 | + | if !commandLine.HasSwitch("ignore-gpu-blocklist") { | |
| 344 | + | t.Fatal("expected ignore-gpu-blocklist switch") | |
| 345 | + | } | |
| 346 | + | if got := commandLine.GetSwitchValue("autoplay-policy"); got != "document-user-activation-required" { | |
| 347 | + | t.Fatalf("autoplay-policy = %q, want env override", got) | |
| 348 | + | } | |
| 349 | + | if commandLine.HasSwitch("ignored-arg") { | |
| 350 | + | t.Fatal("non-switch env token should be ignored") | |
| 351 | + | } | |
| 352 | + | } | |
| 353 | + | ||
| 354 | + | func TestParseChromiumSwitchToken(t *testing.T) { | |
| 355 | + | t.Parallel() | |
| 356 | + | ||
| 357 | + | tests := []struct { | |
| 358 | + | name string | |
| 359 | + | token string | |
| 360 | + | wantName string | |
| 361 | + | wantValue string | |
| 362 | + | wantOK bool | |
| 363 | + | }{ | |
| 364 | + | {name: "long switch with value", token: "--enable-features=VaapiVideoDecoder", wantName: "enable-features", wantValue: "VaapiVideoDecoder", wantOK: true}, | |
| 365 | + | {name: "long switch without value", token: "--ignore-gpu-blocklist", wantName: "ignore-gpu-blocklist", wantOK: true}, | |
| 366 | + | {name: "single dash switch", token: "-enable-zero-copy", wantName: "enable-zero-copy", wantOK: true}, | |
| 367 | + | {name: "empty value", token: "--flag=", wantName: "flag", wantOK: true}, | |
| 368 | + | {name: "embedded equals", token: "--flag=a=b", wantName: "flag", wantValue: "a=b", wantOK: true}, | |
| 369 | + | {name: "non switch", token: "youtube.com", wantOK: false}, | |
| 370 | + | {name: "sentinel", token: "--", wantOK: false}, | |
| 371 | + | {name: "empty", token: " ", wantOK: false}, | |
| 372 | + | } | |
| 373 | + | ||
| 374 | + | for _, tt := range tests { | |
| 375 | + | t.Run(tt.name, func(t *testing.T) { | |
| 376 | + | gotName, gotValue, gotOK := parseChromiumSwitchToken(tt.token) | |
| 377 | + | if gotName != tt.wantName || gotValue != tt.wantValue || gotOK != tt.wantOK { | |
| 378 | + | t.Fatalf("parseChromiumSwitchToken(%q) = (%q, %q, %v), want (%q, %q, %v)", tt.token, gotName, gotValue, gotOK, tt.wantName, tt.wantValue, tt.wantOK) | |
| 379 | + | } | |
| 380 | + | }) | |
| 381 | + | } | |
| 382 | + | } | |
| 383 | + | ||
| 264 | 384 | func TestAppendUniqueCommaSeparatedSwitchValues(t *testing.T) { | |
| 265 | 385 | t.Run("trims whitespace and skips empty existing values", func(t *testing.T) { | |
| 266 | 386 | commandLine := newMutableCommandLineStub() | |
| @@ -313,6 +433,63 @@ func TestDumberBPH_OnBeforeChildProcessLaunch_AppendsNoZygote(t *testing.T) { | |||
| 313 | 433 | } | |
| 314 | 434 | } | |
| 315 | 435 | ||
| 436 | + | func TestConfigureCommandLine_CEFRenderNodeEnvOverridesExistingRenderNode(t *testing.T) { | |
| 437 | + | t.Setenv(cefRenderNodeEnvVar, "/dev/dri/renderD129") | |
| 438 | + | ||
| 439 | + | commandLine := newMutableCommandLineStub() | |
| 440 | + | commandLine.AppendSwitchWithValue(chromiumRenderNodeOverrideSwitch, "/dev/dri/renderD128") | |
| 441 | + | ||
| 442 | + | configureCommandLine(commandLine) | |
| 443 | + | ||
| 444 | + | if got := commandLine.GetSwitchValue(chromiumRenderNodeOverrideSwitch); got != "/dev/dri/renderD129" { | |
| 445 | + | t.Fatalf("render node override = %q, want /dev/dri/renderD129", got) | |
| 446 | + | } | |
| 447 | + | } | |
| 448 | + | ||
| 449 | + | func TestConfigureCommandLine_CEFRenderNodeEnvSentinelsRemoveExistingRenderNode(t *testing.T) { | |
| 450 | + | for _, value := range []string{"auto", "default", "none", "off", "disable", "disabled"} { | |
| 451 | + | t.Run(value, func(t *testing.T) { | |
| 452 | + | t.Setenv(cefRenderNodeEnvVar, value) | |
| 453 | + | ||
| 454 | + | commandLine := newMutableCommandLineStub() | |
| 455 | + | commandLine.AppendSwitchWithValue(chromiumRenderNodeOverrideSwitch, "/dev/dri/renderD128") | |
| 456 | + | ||
| 457 | + | configureCommandLine(commandLine) | |
| 458 | + | ||
| 459 | + | if commandLine.HasSwitch(chromiumRenderNodeOverrideSwitch) { | |
| 460 | + | t.Fatalf("render node override still present: %q", commandLine.GetSwitchValue(chromiumRenderNodeOverrideSwitch)) | |
| 461 | + | } | |
| 462 | + | }) | |
| 463 | + | } | |
| 464 | + | } | |
| 465 | + | ||
| 466 | + | func TestDumberBPH_OnBeforeChildProcessLaunch_AppliesRenderingAndHardwareDecodeFlags(t *testing.T) { | |
| 467 | + | t.Setenv(cef2gtkAngleBackendVar, "vulkan") | |
| 468 | + | t.Setenv(cefEnableVAAPIEnvVar, "1") | |
| 469 | + | ||
| 470 | + | commandLine := newMutableCommandLineStub() | |
| 471 | + | commandLine.AppendSwitchWithValue("type", "gpu-process") | |
| 472 | + | commandLine.AppendSwitchWithValue("use-angle", "gl-egl") | |
| 473 | + | ||
| 474 | + | (&dumberBPH{engine: &Engine{ctx: context.Background()}}).OnBeforeChildProcessLaunch(commandLine) | |
| 475 | + | ||
| 476 | + | if got := commandLine.GetSwitchValue("use-angle"); got != "vulkan" { | |
| 477 | + | t.Fatalf("use-angle = %q, want vulkan", got) | |
| 478 | + | } | |
| 479 | + | if got := commandLine.GetSwitchValue(chromiumEnableFeaturesSwitch); !strings.Contains(got, "AcceleratedVideoDecoder") || !strings.Contains(got, "VulkanFromANGLE") { | |
| 480 | + | t.Fatalf("enable-features = %q, want hardware decode and Vulkan ANGLE features", got) | |
| 481 | + | } | |
| 482 | + | if !commandLine.HasSwitch("ignore-gpu-blocklist") { | |
| 483 | + | t.Fatal("expected ignore-gpu-blocklist switch") | |
| 484 | + | } | |
| 485 | + | if !commandLine.HasSwitch("enable-zero-copy") { | |
| 486 | + | t.Fatal("expected enable-zero-copy switch") | |
| 487 | + | } | |
| 488 | + | if !commandLine.HasSwitch("no-zygote") { | |
| 489 | + | t.Fatal("expected no-zygote switch") | |
| 490 | + | } | |
| 491 | + | } | |
| 492 | + | ||
| 316 | 493 | func TestDumberBPH_OnBeforeChildProcessLaunch_DisablesWebAuthnByDefault(t *testing.T) { | |
| 317 | 494 | t.Setenv(cefEnableWebAuthnUnsafeEnvVar, "") | |
| 318 | 495 | ||
A internal/infrastructure/cef/cef2gtk_adapter.go +277 -0
| @@ -0,0 +1,277 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "errors" | |
| 5 | + | "sync" | |
| 6 | + | "sync/atomic" | |
| 7 | + | ||
| 8 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 9 | + | cef2gtk "github.com/bnema/purego-cef2gtk" | |
| 10 | + | "github.com/bnema/puregotk/v4/gtk" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | // ErrAdapterDestroyed is returned when an operation is attempted on a destroyed | |
| 14 | + | // Cef2gtkAdapter. | |
| 15 | + | var ErrAdapterDestroyed = errors.New("cef2gtk_adapter: adapter is destroyed") | |
| 16 | + | ||
| 17 | + | // Cef2gtkAdapter is a thin infrastructure adapter around *cef2gtk.View. It | |
| 18 | + | // keeps purego-cef2gtk imports confined to internal/infrastructure/cef. | |
| 19 | + | // | |
| 20 | + | // GTK-thread affinity is a hard constraint: most methods touch GTK/CEF view | |
| 21 | + | // lifecycle or input and MUST be called from Dumber's GTK scheduling path | |
| 22 | + | // (e.g. runOnGTK or runOnGTKSync) unless the caller is already executing on | |
| 23 | + | // the GTK main context. Methods are annotated below. | |
| 24 | + | // | |
| 25 | + | // The adapter does NOT own GL/PBO staging, dirty-rect uploads, or GTK input | |
| 26 | + | // controller implementation — those belong to purego-cef2gtk. | |
| 27 | + | type Cef2gtkAdapter struct { | |
| 28 | + | viewMu sync.RWMutex | |
| 29 | + | view *cef2gtk.View | |
| 30 | + | destroyed atomic.Bool | |
| 31 | + | destroyCnt atomic.Uint64 | |
| 32 | + | } | |
| 33 | + | ||
| 34 | + | // NewCef2gtkAdapter creates an accelerated CEF view and wraps it in a thin | |
| 35 | + | // Dumber adapter. | |
| 36 | + | func NewCef2gtkAdapter() *Cef2gtkAdapter { | |
| 37 | + | v := cef2gtk.NewView() | |
| 38 | + | if v == nil { | |
| 39 | + | return nil | |
| 40 | + | } | |
| 41 | + | return &Cef2gtkAdapter{view: v} | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // Widget returns the native GTK widget for embedding into containers. | |
| 45 | + | // The returned pointer must be used from the GTK main thread for packing. | |
| 46 | + | func (a *Cef2gtkAdapter) Widget() *gtk.Widget { | |
| 47 | + | if a == nil || a.destroyed.Load() { | |
| 48 | + | return nil | |
| 49 | + | } | |
| 50 | + | a.viewMu.RLock() | |
| 51 | + | defer a.viewMu.RUnlock() | |
| 52 | + | if a.view == nil { | |
| 53 | + | return nil | |
| 54 | + | } | |
| 55 | + | return a.view.Widget() | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | // GLArea returns the underlying GtkGLArea when the GLArea backend is active. | |
| 59 | + | // It returns nil for the GDK DMABUF backend. | |
| 60 | + | func (a *Cef2gtkAdapter) GLArea() *gtk.GLArea { | |
| 61 | + | if a == nil || a.destroyed.Load() { | |
| 62 | + | return nil | |
| 63 | + | } | |
| 64 | + | a.viewMu.RLock() | |
| 65 | + | defer a.viewMu.RUnlock() | |
| 66 | + | if a.view == nil { | |
| 67 | + | return nil | |
| 68 | + | } | |
| 69 | + | return a.view.GLArea() | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // NativeWidget returns the uintptr of the active GTK widget for embedding via | |
| 73 | + | // port.NativeWidgetProvider. Call on the GTK main thread. | |
| 74 | + | func (a *Cef2gtkAdapter) NativeWidget() uintptr { | |
| 75 | + | widget := a.Widget() | |
| 76 | + | if widget == nil { | |
| 77 | + | return 0 | |
| 78 | + | } | |
| 79 | + | return widget.GoPointer() | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // Size returns the bridge's last observed positive widget size, or 1x1 before | |
| 83 | + | // the GTK widget has been allocated. It is safe to call off the GTK thread. | |
| 84 | + | func (a *Cef2gtkAdapter) Size() (int32, int32) { | |
| 85 | + | if a == nil || a.destroyed.Load() { | |
| 86 | + | return 1, 1 | |
| 87 | + | } | |
| 88 | + | a.viewMu.RLock() | |
| 89 | + | defer a.viewMu.RUnlock() | |
| 90 | + | if a.view == nil { | |
| 91 | + | return 1, 1 | |
| 92 | + | } | |
| 93 | + | return a.view.Size() | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | // DeviceScaleFactor returns the bridge's last observed GTK scale factor, or 1 | |
| 97 | + | // before the widget has reported a scale. It is safe to call off the GTK thread. | |
| 98 | + | func (a *Cef2gtkAdapter) DeviceScaleFactor() float32 { | |
| 99 | + | if a == nil || a.destroyed.Load() { | |
| 100 | + | return 1 | |
| 101 | + | } | |
| 102 | + | a.viewMu.RLock() | |
| 103 | + | defer a.viewMu.RUnlock() | |
| 104 | + | if a.view == nil { | |
| 105 | + | return 1 | |
| 106 | + | } | |
| 107 | + | return a.view.DeviceScaleFactor() | |
| 108 | + | } | |
| 109 | + | ||
| 110 | + | // AddSizeObserver registers a positive-size change callback. Register and | |
| 111 | + | // unregister from the GTK main thread; callbacks are invoked by the bridge from | |
| 112 | + | // GTK size notifications. | |
| 113 | + | func (a *Cef2gtkAdapter) AddSizeObserver(fn func(width, height int32)) func() { | |
| 114 | + | if a == nil || a.destroyed.Load() { | |
| 115 | + | return func() {} | |
| 116 | + | } | |
| 117 | + | a.viewMu.RLock() | |
| 118 | + | defer a.viewMu.RUnlock() | |
| 119 | + | if a.view == nil { | |
| 120 | + | return func() {} | |
| 121 | + | } | |
| 122 | + | return a.view.AddSizeObserver(fn) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // HasFocus reports whether the bridge widget currently has GTK focus. Must be | |
| 126 | + | // called on the GTK main thread. | |
| 127 | + | func (a *Cef2gtkAdapter) HasFocus() bool { | |
| 128 | + | if a == nil || a.destroyed.Load() { | |
| 129 | + | return false | |
| 130 | + | } | |
| 131 | + | a.viewMu.RLock() | |
| 132 | + | defer a.viewMu.RUnlock() | |
| 133 | + | if a.view == nil { | |
| 134 | + | return false | |
| 135 | + | } | |
| 136 | + | return a.view.HasFocus() | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | // SetCursorFromName applies a named cursor to the bridge widget. Must be called | |
| 140 | + | // on the GTK main thread. | |
| 141 | + | func (a *Cef2gtkAdapter) SetCursorFromName(name string) { | |
| 142 | + | if a == nil || a.destroyed.Load() { | |
| 143 | + | return | |
| 144 | + | } | |
| 145 | + | a.viewMu.RLock() | |
| 146 | + | defer a.viewMu.RUnlock() | |
| 147 | + | if a.view == nil { | |
| 148 | + | return | |
| 149 | + | } | |
| 150 | + | a.view.SetCursorFromName(name) | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | // PrepareOnGTKThread initializes renderer resources owned by purego-cef2gtk. | |
| 154 | + | // Must be called on the GTK main thread. | |
| 155 | + | func (a *Cef2gtkAdapter) PrepareOnGTKThread() error { | |
| 156 | + | if a == nil || a.destroyed.Load() { | |
| 157 | + | return ErrAdapterDestroyed | |
| 158 | + | } | |
| 159 | + | a.viewMu.RLock() | |
| 160 | + | defer a.viewMu.RUnlock() | |
| 161 | + | if a.view == nil { | |
| 162 | + | return ErrAdapterDestroyed | |
| 163 | + | } | |
| 164 | + | return a.view.PrepareOnGTKThread() | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // RenderHandler returns a CEF render handler that delegates accelerated paint | |
| 168 | + | // handling to purego-cef2gtk. The returned handler satisfies cef.RenderHandler | |
| 169 | + | // and uses the bridge's selected accelerated DMABUF import path. | |
| 170 | + | // | |
| 171 | + | // hooks may be a zero-value cef2gtk.Hooks if no custom callbacks are needed. | |
| 172 | + | func (a *Cef2gtkAdapter) RenderHandler(hooks cef2gtk.Hooks) purecef.RenderHandler { | |
| 173 | + | if a == nil || a.destroyed.Load() { | |
| 174 | + | return nil | |
| 175 | + | } | |
| 176 | + | a.viewMu.RLock() | |
| 177 | + | defer a.viewMu.RUnlock() | |
| 178 | + | if a.view == nil { | |
| 179 | + | return nil | |
| 180 | + | } | |
| 181 | + | return a.view.RenderHandler(hooks) | |
| 182 | + | } | |
| 183 | + | ||
| 184 | + | // ConfigureProfiling enables or disables purego-cef2gtk render profiling for this view. | |
| 185 | + | func (a *Cef2gtkAdapter) ConfigureProfiling(opts cef2gtk.ProfileOptions) error { | |
| 186 | + | if a == nil || a.destroyed.Load() { | |
| 187 | + | return ErrAdapterDestroyed | |
| 188 | + | } | |
| 189 | + | a.viewMu.RLock() | |
| 190 | + | defer a.viewMu.RUnlock() | |
| 191 | + | if a.view == nil { | |
| 192 | + | return ErrAdapterDestroyed | |
| 193 | + | } | |
| 194 | + | return a.view.ConfigureProfiling(opts) | |
| 195 | + | } | |
| 196 | + | ||
| 197 | + | // AttachInput attaches GTK event controllers to the view and forwards input | |
| 198 | + | // to the given CEF browser host. Must be called on the GTK main thread. | |
| 199 | + | // | |
| 200 | + | // opts.Scale configures HiDPI scale for pointer coordinate translation. | |
| 201 | + | func (a *Cef2gtkAdapter) AttachInput(host purecef.BrowserHost, opts cef2gtk.InputOptions) error { | |
| 202 | + | if a == nil || a.destroyed.Load() { | |
| 203 | + | return ErrAdapterDestroyed | |
| 204 | + | } | |
| 205 | + | a.viewMu.RLock() | |
| 206 | + | defer a.viewMu.RUnlock() | |
| 207 | + | if a.view == nil { | |
| 208 | + | return ErrAdapterDestroyed | |
| 209 | + | } | |
| 210 | + | return a.view.AttachInput(host, opts) | |
| 211 | + | } | |
| 212 | + | ||
| 213 | + | // SetInputHost updates the CEF browser host used by the attached input bridge. | |
| 214 | + | // Must be called on the GTK main thread. Callers must call AttachInput first or | |
| 215 | + | // this returns an error from the underlying bridge. | |
| 216 | + | func (a *Cef2gtkAdapter) SetInputHost(host purecef.BrowserHost) error { | |
| 217 | + | if a == nil || a.destroyed.Load() { | |
| 218 | + | return ErrAdapterDestroyed | |
| 219 | + | } | |
| 220 | + | a.viewMu.RLock() | |
| 221 | + | defer a.viewMu.RUnlock() | |
| 222 | + | if a.view == nil { | |
| 223 | + | return ErrAdapterDestroyed | |
| 224 | + | } | |
| 225 | + | return a.view.SetInputHost(host) | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | // DetachInput removes GTK input controllers attached by AttachInput. | |
| 229 | + | // Must be called on the GTK main thread. | |
| 230 | + | func (a *Cef2gtkAdapter) DetachInput() error { | |
| 231 | + | if a == nil || a.destroyed.Load() { | |
| 232 | + | return ErrAdapterDestroyed | |
| 233 | + | } | |
| 234 | + | a.viewMu.RLock() | |
| 235 | + | defer a.viewMu.RUnlock() | |
| 236 | + | if a.view == nil { | |
| 237 | + | return ErrAdapterDestroyed | |
| 238 | + | } | |
| 239 | + | return a.view.DetachInput() | |
| 240 | + | } | |
| 241 | + | ||
| 242 | + | // Diagnostics returns a point-in-time snapshot of bridge rendering diagnostics | |
| 243 | + | // including accelerated paint counts, import/render failures, and events. | |
| 244 | + | func (a *Cef2gtkAdapter) Diagnostics() cef2gtk.Diagnostics { | |
| 245 | + | if a == nil || a.destroyed.Load() { | |
| 246 | + | return cef2gtk.Diagnostics{} | |
| 247 | + | } | |
| 248 | + | a.viewMu.RLock() | |
| 249 | + | defer a.viewMu.RUnlock() | |
| 250 | + | if a.view == nil { | |
| 251 | + | return cef2gtk.Diagnostics{} | |
| 252 | + | } | |
| 253 | + | return a.view.Diagnostics() | |
| 254 | + | } | |
| 255 | + | ||
| 256 | + | // Destroy releases renderer resources owned by purego-cef2gtk and disconnects GTK | |
| 257 | + | // signal handlers. Must be called on the GTK main thread. After Destroy, | |
| 258 | + | // operations return ErrAdapterDestroyed or a zero-value result. | |
| 259 | + | func (a *Cef2gtkAdapter) Destroy() error { | |
| 260 | + | if a == nil { | |
| 261 | + | return ErrAdapterDestroyed | |
| 262 | + | } | |
| 263 | + | a.viewMu.Lock() | |
| 264 | + | defer a.viewMu.Unlock() | |
| 265 | + | if !a.destroyed.CompareAndSwap(false, true) || a.view == nil { | |
| 266 | + | return ErrAdapterDestroyed | |
| 267 | + | } | |
| 268 | + | err := a.view.Destroy() | |
| 269 | + | a.view = nil | |
| 270 | + | a.destroyCnt.Add(1) | |
| 271 | + | return err | |
| 272 | + | } | |
| 273 | + | ||
| 274 | + | // IsDestroyed returns true if the adapter has been destroyed. | |
| 275 | + | func (a *Cef2gtkAdapter) IsDestroyed() bool { | |
| 276 | + | return a == nil || a.destroyed.Load() | |
| 277 | + | } | |
A internal/infrastructure/cef/cef2gtk_boundary_test.go +82 -0
| @@ -0,0 +1,82 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "go/parser" | |
| 5 | + | "go/token" | |
| 6 | + | "os" | |
| 7 | + | "path/filepath" | |
| 8 | + | "runtime" | |
| 9 | + | "strconv" | |
| 10 | + | "strings" | |
| 11 | + | "testing" | |
| 12 | + | ) | |
| 13 | + | ||
| 14 | + | func TestCEF2GTKImportStaysInInfrastructureCEF(t *testing.T) { | |
| 15 | + | root := repoRoot(t) | |
| 16 | + | if err := filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { | |
| 17 | + | if err != nil { | |
| 18 | + | return err | |
| 19 | + | } | |
| 20 | + | if d.IsDir() { | |
| 21 | + | switch d.Name() { | |
| 22 | + | case ".git", "vendor", "dist", "tmp": | |
| 23 | + | return filepath.SkipDir | |
| 24 | + | } | |
| 25 | + | return nil | |
| 26 | + | } | |
| 27 | + | if filepath.Ext(path) != ".go" || strings.HasSuffix(path, "_test.go") { | |
| 28 | + | return nil | |
| 29 | + | } | |
| 30 | + | importsBridge, err := importsCEF2GTK(path) | |
| 31 | + | if err != nil { | |
| 32 | + | return err | |
| 33 | + | } | |
| 34 | + | if !importsBridge { | |
| 35 | + | return nil | |
| 36 | + | } | |
| 37 | + | rel, err := filepath.Rel(root, path) | |
| 38 | + | if err != nil { | |
| 39 | + | return err | |
| 40 | + | } | |
| 41 | + | if strings.HasPrefix(filepath.ToSlash(rel), "internal/infrastructure/cef/") { | |
| 42 | + | return nil | |
| 43 | + | } | |
| 44 | + | t.Fatalf("purego-cef2gtk imported outside cef infrastructure: %s", rel) | |
| 45 | + | return nil | |
| 46 | + | }); err != nil { | |
| 47 | + | t.Fatalf("scan imports: %v", err) | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func importsCEF2GTK(path string) (bool, error) { | |
| 52 | + | file, err := parser.ParseFile(token.NewFileSet(), path, nil, parser.ImportsOnly) | |
| 53 | + | if err != nil { | |
| 54 | + | return false, err | |
| 55 | + | } | |
| 56 | + | for _, spec := range file.Imports { | |
| 57 | + | importPath, err := strconv.Unquote(spec.Path.Value) | |
| 58 | + | if err == nil && strings.HasPrefix(importPath, "github.com/bnema/purego-cef2gtk") { | |
| 59 | + | return true, nil | |
| 60 | + | } | |
| 61 | + | } | |
| 62 | + | return false, nil | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func repoRoot(t *testing.T) string { | |
| 66 | + | t.Helper() | |
| 67 | + | _, filename, _, ok := runtime.Caller(0) | |
| 68 | + | if !ok { | |
| 69 | + | t.Fatal("resolve caller path") | |
| 70 | + | } | |
| 71 | + | dir := filepath.Dir(filename) | |
| 72 | + | for { | |
| 73 | + | if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil { | |
| 74 | + | return dir | |
| 75 | + | } | |
| 76 | + | parent := filepath.Dir(dir) | |
| 77 | + | if parent == dir { | |
| 78 | + | t.Fatalf("go.mod not found from %s", filename) | |
| 79 | + | } | |
| 80 | + | dir = parent | |
| 81 | + | } | |
| 82 | + | } | |
M internal/infrastructure/cef/context_menu.go +45 -26
| @@ -12,6 +12,13 @@ import ( | |||
| 12 | 12 | "github.com/bnema/puregotk/v4/gtk" | |
| 13 | 13 | ) | |
| 14 | 14 | ||
| 15 | + | // ContextMenuRenderer renders normalized context menu items for the CEF adapter. | |
| 16 | + | // It is implemented by the GTK menu adapter and injected by bootstrap so this | |
| 17 | + | // adapter does not depend on a sibling infrastructure adapter. | |
| 18 | + | type ContextMenuRenderer interface { | |
| 19 | + | Show(items []port.MenuItem, anchor *gtk.Widget, x, y int32, onSelect func(port.MenuItem), onClose func()) | |
| 20 | + | } | |
| 21 | + | ||
| 15 | 22 | // =========================================================================== | |
| 16 | 23 | // ContextMenuHandler (implemented on handlerSet) | |
| 17 | 24 | // =========================================================================== | |
| @@ -19,6 +26,7 @@ import ( | |||
| 19 | 26 | func (h *handlerSet) OnBeforeContextMenu(_ purecef.Browser, _ purecef.Frame, _ purecef.ContextMenuParams, _ purecef.MenuModel) { | |
| 20 | 27 | } | |
| 21 | 28 | ||
| 29 | + | //nolint:gocyclo // CEF callback coordinates menu extraction, rendering, and native fallback in one synchronous call. | |
| 22 | 30 | func (h *handlerSet) RunContextMenu( | |
| 23 | 31 | _ purecef.Browser, _ purecef.Frame, | |
| 24 | 32 | params purecef.ContextMenuParams, model purecef.MenuModel, | |
| @@ -67,31 +75,42 @@ func (h *handlerSet) RunContextMenu( | |||
| 67 | 75 | commandIDByAction[item.Action] = cmdID | |
| 68 | 76 | } | |
| 69 | 77 | } | |
| 70 | - | if h.wv == nil || h.wv.pipeline == nil || h.wv.pipeline.glArea == nil { | |
| 78 | + | if h.wv == nil || h.wv.viewBridge == nil { | |
| 71 | 79 | return 0 | |
| 72 | 80 | } | |
| 73 | 81 | ||
| 74 | - | x, y := contextMenuAnchorPosition(params, h.wv.pipeline.scale) | |
| 75 | - | glArea := h.wv.pipeline.glArea | |
| 76 | - | logContextMenuPopupRequest(h, glArea, params, x, y) | |
| 82 | + | x, y := contextMenuAnchorPosition(params, h.wv.viewBridgeScale()) | |
| 77 | 83 | executor := h.contextMenuExecutor() | |
| 78 | - | ||
| 79 | - | NewRenderer(h.wv.runOnGTK).Show( | |
| 80 | - | items, | |
| 81 | - | &glArea.Widget, | |
| 82 | - | x, | |
| 83 | - | y, | |
| 84 | - | func(item port.MenuItem) { | |
| 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) | |
| 90 | - | }, | |
| 91 | - | func() { | |
| 84 | + | wv := h.wv | |
| 85 | + | wv.runOnGTK(func() { | |
| 86 | + | if wv.viewBridge == nil || wv.viewBridge.Widget() == nil { | |
| 92 | 87 | callback.Cancel() | |
| 93 | - | }, | |
| 94 | - | ) | |
| 88 | + | return | |
| 89 | + | } | |
| 90 | + | widget := wv.viewBridge.Widget() | |
| 91 | + | logContextMenuPopupRequest(h, widget, params, x, y) | |
| 92 | + | renderer := wv.engine.contextMenuRenderer() | |
| 93 | + | if renderer == nil { | |
| 94 | + | callback.Cancel() | |
| 95 | + | return | |
| 96 | + | } | |
| 97 | + | renderer.Show( | |
| 98 | + | items, | |
| 99 | + | widget, | |
| 100 | + | x, | |
| 101 | + | y, | |
| 102 | + | func(item port.MenuItem) { | |
| 103 | + | var copiedCallback func(string) | |
| 104 | + | if wv.engine != nil { | |
| 105 | + | copiedCallback = wv.engine.notifyClipboardCopied | |
| 106 | + | } | |
| 107 | + | dispatchContextMenuSelection(wv.ctx, executor, callback, copiedCallback, commandIDByAction, item, menuContext) | |
| 108 | + | }, | |
| 109 | + | func() { | |
| 110 | + | callback.Cancel() | |
| 111 | + | }, | |
| 112 | + | ) | |
| 113 | + | }) | |
| 95 | 114 | return 1 | |
| 96 | 115 | } | |
| 97 | 116 | ||
| @@ -119,23 +138,23 @@ func contextMenuAnchorPosition(params purecef.ContextMenuParams, scale int32) (i | |||
| 119 | 138 | ||
| 120 | 139 | func logContextMenuPopupRequest( | |
| 121 | 140 | h *handlerSet, | |
| 122 | - | glArea *gtk.GLArea, | |
| 141 | + | widget *gtk.Widget, | |
| 123 | 142 | params purecef.ContextMenuParams, | |
| 124 | 143 | x, y int32, | |
| 125 | 144 | ) { | |
| 126 | - | if h == nil || h.wv == nil || h.wv.ctx == nil || h.wv.pipeline == nil || glArea == nil { | |
| 145 | + | if h == nil || h.wv == nil || h.wv.ctx == nil || widget == nil { | |
| 127 | 146 | return | |
| 128 | 147 | } | |
| 129 | 148 | rawX, rawY := contextMenuRawPosition(params) | |
| 130 | - | parent := glArea.GetParent() | |
| 149 | + | parent := widget.GetParent() | |
| 131 | 150 | logging.FromContext(h.wv.ctx).Debug(). | |
| 132 | 151 | Int32("raw_x", rawX). | |
| 133 | 152 | Int32("raw_y", rawY). | |
| 134 | 153 | Int32("popup_x", x). | |
| 135 | 154 | Int32("popup_y", y). | |
| 136 | - | Int32("scale", h.wv.pipeline.scale). | |
| 137 | - | Int("anchor_width", glArea.Widget.GetAllocatedWidth()). | |
| 138 | - | Int("anchor_height", glArea.Widget.GetAllocatedHeight()). | |
| 155 | + | Int32("scale", h.wv.viewBridgeScale()). | |
| 156 | + | Int("anchor_width", widget.GetAllocatedWidth()). | |
| 157 | + | Int("anchor_height", widget.GetAllocatedHeight()). | |
| 139 | 158 | Int("parent_width", cefWidgetAllocatedWidth(parent)). | |
| 140 | 159 | Int("parent_height", cefWidgetAllocatedHeight(parent)). | |
| 141 | 160 | Msg("cef: context menu popup request") | |
M internal/infrastructure/cef/engine.go +49 -9
| @@ -4,6 +4,7 @@ import ( | |||
| 4 | 4 | "context" | |
| 5 | 5 | "crypto/subtle" | |
| 6 | 6 | "fmt" | |
| 7 | + | "os" | |
| 7 | 8 | "sync" | |
| 8 | 9 | "sync/atomic" | |
| 9 | 10 | "time" | |
| @@ -23,10 +24,11 @@ var _ port.AlreadyRunningAppRelaunchHandlerSetter = (*Engine)(nil) | |||
| 23 | 24 | // Engine implements port.Engine for the CEF browser backend. | |
| 24 | 25 | // It manages the CEF lifecycle and provides access to all engine subsystems. | |
| 25 | 26 | type Engine struct { | |
| 26 | - | ctx context.Context | |
| 27 | - | gl *glLoader | |
| 28 | - | factory *WebViewFactory | |
| 29 | - | pool *WebViewPool | |
| 27 | + | ctx context.Context | |
| 28 | + | factory *WebViewFactory | |
| 29 | + | pool *WebViewPool | |
| 30 | + | profileLogDir string | |
| 31 | + | runtimeCEFDir string | |
| 30 | 32 | ||
| 31 | 33 | messageRouter *MessageRouter | |
| 32 | 34 | schemeHandler *dumbSchemeHandler | |
| @@ -38,6 +40,7 @@ type Engine struct { | |||
| 38 | 40 | defaultConfigPayload func() ([]byte, error) | |
| 39 | 41 | ctxMenuBuilder port.ContextMenuBuilder | |
| 40 | 42 | ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory | |
| 43 | + | ctxMenuRenderer ContextMenuRenderer | |
| 41 | 44 | clipboard port.Clipboard | |
| 42 | 45 | clipboardTextOrchestrator port.ClipboardTextOrchestrator | |
| 43 | 46 | onClipboardCopied func(textLen int) | |
| @@ -56,6 +59,12 @@ type Engine struct { | |||
| 56 | 59 | // during shutdown, replacing the busy-wait poll in closeActiveWebViews. | |
| 57 | 60 | shutdownNotify chan struct{} | |
| 58 | 61 | ||
| 62 | + | // Cross-layer render diagnostics. | |
| 63 | + | cefHeartbeatStop chan struct{} | |
| 64 | + | cefHeartbeatDone chan struct{} | |
| 65 | + | cefUIHeartbeat cefThreadHeartbeatState | |
| 66 | + | cefIOHeartbeat cefThreadHeartbeatState | |
| 67 | + | ||
| 59 | 68 | // Diagnostic counters. | |
| 60 | 69 | contextInitializedCount atomic.Uint64 | |
| 61 | 70 | childLaunchRenderer atomic.Uint64 | |
| @@ -69,13 +78,19 @@ type Engine struct { | |||
| 69 | 78 | browserCreateLastWidth atomic.Int32 | |
| 70 | 79 | browserCreateLastHeight atomic.Int32 | |
| 71 | 80 | browserCreateComplete atomic.Bool | |
| 72 | - | ||
| 73 | 81 | } | |
| 74 | 82 | ||
| 75 | 83 | func (e *Engine) Factory() port.WebViewFactory { | |
| 76 | 84 | return &webViewFactoryAdapter{factory: e.factory} | |
| 77 | 85 | } | |
| 78 | 86 | ||
| 87 | + | func (e *Engine) contextMenuRenderer() ContextMenuRenderer { | |
| 88 | + | if e == nil { | |
| 89 | + | return nil | |
| 90 | + | } | |
| 91 | + | return e.ctxMenuRenderer | |
| 92 | + | } | |
| 93 | + | ||
| 79 | 94 | func (e *Engine) Pool() port.WebViewPool { | |
| 80 | 95 | return &webViewPoolAdapter{pool: e.pool, ctx: e.ctx} | |
| 81 | 96 | } | |
| @@ -197,6 +212,7 @@ func (e *Engine) Close() error { | |||
| 197 | 212 | ||
| 198 | 213 | e.closeActiveWebViews() | |
| 199 | 214 | e.pool.Close() | |
| 215 | + | e.stopCEFHeartbeat() | |
| 200 | 216 | ||
| 201 | 217 | if activeAfter := e.activeWebViewCount(); activeAfter > 0 { | |
| 202 | 218 | log.Warn(). | |
| @@ -209,7 +225,11 @@ func (e *Engine) Close() error { | |||
| 209 | 225 | return nil | |
| 210 | 226 | } | |
| 211 | 227 | ||
| 212 | - | const cefShutdownWaitTimeout = 2 * time.Second | |
| 228 | + | // cefShutdownWaitTimeout gives CEF browser destruction enough time to flush | |
| 229 | + | // GTK/DMABUF texture teardown and child-process shutdown on busy media pages. | |
| 230 | + | // It is intentionally longer than a UI-frame timeout; Close still logs if the | |
| 231 | + | // wait expires so slow shutdown remains visible during diagnostics. | |
| 232 | + | const cefShutdownWaitTimeout = 10 * time.Second | |
| 213 | 233 | ||
| 214 | 234 | func (e *Engine) activeWebViewCount() int { | |
| 215 | 235 | return int(e.activeCount.Load()) | |
| @@ -246,6 +266,7 @@ func (e *Engine) closeActiveWebViews() { | |||
| 246 | 266 | // Check immediately in case all webviews closed synchronously. | |
| 247 | 267 | if e.activeWebViewCount() == 0 { | |
| 248 | 268 | log.Debug().Msg("cef: all active webviews closed before shutdown") | |
| 269 | + | e.destroyClosedWebViewBridges(webViews) | |
| 249 | 270 | return | |
| 250 | 271 | } | |
| 251 | 272 | ||
| @@ -259,6 +280,15 @@ func (e *Engine) closeActiveWebViews() { | |||
| 259 | 280 | Str("timeout", cefShutdownWaitTimeout.String()). | |
| 260 | 281 | Msg("cef: timed out waiting for OnBeforeClose before shutdown") | |
| 261 | 282 | } | |
| 283 | + | e.destroyClosedWebViewBridges(webViews) | |
| 284 | + | } | |
| 285 | + | ||
| 286 | + | func (e *Engine) destroyClosedWebViewBridges(webViews []*WebView) { | |
| 287 | + | for _, wv := range webViews { | |
| 288 | + | if wv != nil && wv.destroyed.Load() { | |
| 289 | + | wv.destroyViewBridgeOnGTKSync() | |
| 290 | + | } | |
| 291 | + | } | |
| 262 | 292 | } | |
| 263 | 293 | ||
| 264 | 294 | // RegisterHandlers registers all WebUI message bridge handlers with the message router. | |
| @@ -369,6 +399,16 @@ func (e *Engine) notifyClipboardCopied(text string) { | |||
| 369 | 399 | // SetHandlerContext sets the base context for message handler dispatch. | |
| 370 | 400 | func (e *Engine) SetHandlerContext(ctx context.Context) { | |
| 371 | 401 | e.ctx = ctx | |
| 402 | + | logger := logging.FromContext(ctx) | |
| 403 | + | logger.Info(). | |
| 404 | + | Str("settings_cef_dir", e.runtimeCEFDir). | |
| 405 | + | Str("env_cef_dir", os.Getenv("CEF_DIR")). | |
| 406 | + | Msg("cef: runtime selection") | |
| 407 | + | if libcefPath := loadedLibCEFPath(); libcefPath != "" { | |
| 408 | + | logger.Info().Str("libcef_path", libcefPath).Msg("cef: runtime library loaded") | |
| 409 | + | } else { | |
| 410 | + | logger.Warn().Msg("cef: runtime library loaded but libcef path was not found in /proc/self/maps") | |
| 411 | + | } | |
| 372 | 412 | if e.messageRouter != nil { | |
| 373 | 413 | e.messageRouter.SetBaseContext(ctx) | |
| 374 | 414 | } | |
| @@ -381,7 +421,7 @@ func (e *Engine) recordContextInitialized() { | |||
| 381 | 421 | Msg("cef: OnContextInitialized") | |
| 382 | 422 | } | |
| 383 | 423 | ||
| 384 | - | func (e *Engine) recordChildProcessLaunch(processType, useAngle, ozonePlatform, commandLine string) { | |
| 424 | + | func (e *Engine) recordChildProcessLaunch(processType, useAngle, ozonePlatform, renderNodeOverride, commandLine string) { | |
| 385 | 425 | switch processType { | |
| 386 | 426 | case "renderer": | |
| 387 | 427 | e.childLaunchRenderer.Add(1) | |
| @@ -395,7 +435,8 @@ func (e *Engine) recordChildProcessLaunch(processType, useAngle, ozonePlatform, | |||
| 395 | 435 | Str("process_type", processType). | |
| 396 | 436 | Str("use_angle", useAngle). | |
| 397 | 437 | Str("ozone_platform", ozonePlatform). | |
| 398 | - | Str("command_line", commandLine). | |
| 438 | + | Str("render_node_override", renderNodeOverride). | |
| 439 | + | Strs("command_line_flags", safeChromiumCmdlineFlags(commandLine)). | |
| 399 | 440 | Uint64("renderer_launches", e.childLaunchRenderer.Load()). | |
| 400 | 441 | Uint64("gpu_launches", e.childLaunchGPU.Load()). | |
| 401 | 442 | Uint64("other_launches", e.childLaunchOther.Load()). | |
| @@ -475,4 +516,3 @@ func (e *Engine) currentDownloadHandler() *downloadHandler { | |||
| 475 | 516 | defer e.downloadMu.RUnlock() | |
| 476 | 517 | return e.downloadHandler | |
| 477 | 518 | } | |
| 478 | - | ||
M internal/infrastructure/cef/engine_init.go +49 -13
| @@ -17,17 +17,18 @@ import ( | |||
| 17 | 17 | ||
| 18 | 18 | "github.com/bnema/dumber/assets" | |
| 19 | 19 | "github.com/bnema/dumber/internal/application/port" | |
| 20 | - | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 21 | 20 | "github.com/bnema/dumber/internal/logging" | |
| 22 | 21 | ) | |
| 23 | 22 | ||
| 24 | 23 | const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE" | |
| 25 | 24 | const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH" | |
| 25 | + | const defaultCEFWindowlessFrameRate = 60 | |
| 26 | 26 | ||
| 27 | 27 | // RuntimePaths contains the concrete filesystem paths the CEF adapter needs. | |
| 28 | 28 | type RuntimePaths struct { | |
| 29 | - | StateRoot string | |
| 30 | - | LogFile string | |
| 29 | + | StateRoot string | |
| 30 | + | LogFile string | |
| 31 | + | ProfileLogDir string | |
| 31 | 32 | } | |
| 32 | 33 | ||
| 33 | 34 | // NewEngine initializes the CEF runtime and returns a ready-to-use Engine. | |
| @@ -42,7 +43,7 @@ func NewEngine( | |||
| 42 | 43 | logger := logging.FromContext(ctx) | |
| 43 | 44 | stateRoot := resolvedStateRoot(paths.StateRoot, opts) | |
| 44 | 45 | cleanStaleSingletonLocks(logger, stateRoot) | |
| 45 | - | windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate() | |
| 46 | + | windowlessFrameRate := normalizedWindowlessFrameRate(cfg.WindowlessFrameRate) | |
| 46 | 47 | ||
| 47 | 48 | settings, err := prepareCEFSettings(opts, paths, cfg, logger) | |
| 48 | 49 | if err != nil { | |
| @@ -56,12 +57,15 @@ func NewEngine( | |||
| 56 | 57 | ||
| 57 | 58 | eng := &Engine{ | |
| 58 | 59 | ctx: ctx, | |
| 60 | + | profileLogDir: paths.ProfileLogDir, | |
| 61 | + | runtimeCEFDir: settings.CEFDir, | |
| 59 | 62 | registerHandlers: deps.RegisterHandlers, | |
| 60 | 63 | registerAccentHandlers: deps.RegisterAccentHandlers, | |
| 61 | 64 | currentConfigPayload: deps.CurrentConfigPayload, | |
| 62 | 65 | defaultConfigPayload: deps.DefaultConfigPayload, | |
| 63 | 66 | ctxMenuBuilder: deps.ContextMenuBuilder, | |
| 64 | 67 | ctxMenuExecutorFactory: deps.ContextMenuExecutorFactory, | |
| 68 | + | ctxMenuRenderer: deps.ContextMenuRenderer, | |
| 65 | 69 | clipboard: deps.Clipboard, | |
| 66 | 70 | resolver: deps.ImageDataResolver, | |
| 67 | 71 | } | |
| @@ -90,6 +94,13 @@ func NewEngine( | |||
| 90 | 94 | ) | |
| 91 | 95 | } | |
| 92 | 96 | ||
| 97 | + | func normalizedWindowlessFrameRate(frameRate int32) int32 { | |
| 98 | + | if frameRate > 0 { | |
| 99 | + | return frameRate | |
| 100 | + | } | |
| 101 | + | return defaultCEFWindowlessFrameRate | |
| 102 | + | } | |
| 103 | + | ||
| 93 | 104 | func resolvedStateRoot(defaultStateRoot string, opts port.EngineOptions) string { | |
| 94 | 105 | if root := os.Getenv(CEFRootCachePathEnvVar); root != "" { | |
| 95 | 106 | return root | |
| @@ -151,28 +162,52 @@ func prepareCEFSettings( | |||
| 151 | 162 | // and browser-process callbacks (OnBeforeCommandLineProcessing, etc.). | |
| 152 | 163 | func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logger) error { | |
| 153 | 164 | app := newDumberApp(eng) | |
| 165 | + | logger.Info(). | |
| 166 | + | Str("settings_cef_dir", settings.CEFDir). | |
| 167 | + | Str("env_cef_dir", os.Getenv("CEF_DIR")). | |
| 168 | + | Msg("cef: runtime selection") | |
| 154 | 169 | logger.Debug().Msg("cef: calling InitWithApp") | |
| 155 | 170 | if err := purecef.InitWithApp(settings, app); err != nil { | |
| 156 | 171 | return fmt.Errorf("cef.InitWithApp: %w", err) | |
| 157 | 172 | } | |
| 173 | + | if libcefPath := loadedLibCEFPath(); libcefPath != "" { | |
| 174 | + | logger.Info().Str("libcef_path", libcefPath).Msg("cef: runtime library loaded") | |
| 175 | + | } else { | |
| 176 | + | logger.Warn().Msg("cef: runtime library loaded but libcef path was not found in /proc/self/maps") | |
| 177 | + | } | |
| 158 | 178 | logger.Debug().Msg("cef: InitWithApp returned OK") | |
| 159 | 179 | return nil | |
| 160 | 180 | } | |
| 161 | 181 | ||
| 162 | - | // wireEngine creates GL loader, factory, pool, and scheme handler after CEF init. | |
| 182 | + | func loadedLibCEFPath() string { | |
| 183 | + | data, err := os.ReadFile("/proc/self/maps") | |
| 184 | + | if err != nil { | |
| 185 | + | return "" | |
| 186 | + | } | |
| 187 | + | return parseLoadedLibCEFPath(string(data)) | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | func parseLoadedLibCEFPath(maps string) string { | |
| 191 | + | for _, line := range strings.Split(maps, "\n") { | |
| 192 | + | if !strings.Contains(line, "libcef.so") { | |
| 193 | + | continue | |
| 194 | + | } | |
| 195 | + | pathStart := strings.Index(line, "/") | |
| 196 | + | if pathStart < 0 { | |
| 197 | + | continue | |
| 198 | + | } | |
| 199 | + | return strings.TrimSpace(line[pathStart:]) | |
| 200 | + | } | |
| 201 | + | return "" | |
| 202 | + | } | |
| 203 | + | ||
| 204 | + | // wireEngine creates factory, pool, and scheme handler after CEF init. | |
| 163 | 205 | func wireEngine( | |
| 164 | 206 | ctx context.Context, eng *Engine, | |
| 165 | 207 | windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger, | |
| 166 | 208 | currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error), | |
| 167 | 209 | ) (*Engine, error) { | |
| 168 | - | gl, err := newGLLoader() | |
| 169 | - | if err != nil { | |
| 170 | - | purecef.Shutdown() | |
| 171 | - | return nil, fmt.Errorf("GL loader: %w", err) | |
| 172 | - | } | |
| 173 | - | ||
| 174 | - | eng.gl = gl | |
| 175 | - | eng.factory = newWebViewFactory(eng, gl, webViewFactoryOptions{ | |
| 210 | + | eng.factory = newWebViewFactory(eng, webViewFactoryOptions{ | |
| 176 | 211 | scale: detectHiDPIScale(logger), | |
| 177 | 212 | windowlessFrameRate: windowlessFrameRate, | |
| 178 | 213 | audioOutputFactory: audioFactory, | |
| @@ -193,6 +228,7 @@ func wireEngine( | |||
| 193 | 228 | } | |
| 194 | 229 | eng.messageRouter = messageRouter | |
| 195 | 230 | eng.schemeHandler = schemeHandler | |
| 231 | + | eng.startCEFHeartbeat() | |
| 196 | 232 | ||
| 197 | 233 | registerEngineSchemeFactories(logger, purecef.NewSchemeHandlerFactory(schemeHandler)) | |
| 198 | 234 | return eng, nil | |
M internal/infrastructure/cef/engine_init_test.go +12 -0
| @@ -46,6 +46,18 @@ func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) { | |||
| 46 | 46 | require.Empty(t, settings.BrowserSubprocessPath) | |
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | + | func TestParseLoadedLibCEFPath(t *testing.T) { | |
| 50 | + | maps := "7f5fd4000000-7f5fd4200000 r--p 00000000 00:00 0 /usr/lib/libvulkan.so.1\n" + | |
| 51 | + | "7f5fe0000000-7f5fe8200000 r-xp 00000000 00:00 0 /opt/cef-vaapi-runtime/usr/lib/cef/libcef.so\n" + | |
| 52 | + | "7f5fe8200000-7f5fe8400000 r--p 08200000 00:00 0 /opt/cef-vaapi-runtime/usr/lib/cef/libcef.so\n" | |
| 53 | + | ||
| 54 | + | require.Equal(t, "/opt/cef-vaapi-runtime/usr/lib/cef/libcef.so", parseLoadedLibCEFPath(maps)) | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestParseLoadedLibCEFPath_ReturnsEmptyWhenAbsent(t *testing.T) { | |
| 58 | + | require.Empty(t, parseLoadedLibCEFPath("7f5fd4000000-7f5fd4200000 r--p 00000000 00:00 0 /usr/lib/libvulkan.so.1\n")) | |
| 59 | + | } | |
| 60 | + | ||
| 49 | 61 | func TestPrepareCEFSettings_RejectsNonDefaultCookiePolicy(t *testing.T) { | |
| 50 | 62 | logger := zerolog.Nop() | |
| 51 | 63 | profile := testCEFDevProfile(t) | |
M internal/infrastructure/cef/env.go +13 -0
| @@ -8,6 +8,11 @@ import ( | |||
| 8 | 8 | const ( | |
| 9 | 9 | cefExternalBeginFrameEnvVar = "DUMBER_CEF_EXTERNAL_BEGIN_FRAME" | |
| 10 | 10 | cefEnableWebAuthnUnsafeEnvVar = "DUMBER_CEF_ENABLE_WEBAUTHN_UNSAFE" | |
| 11 | + | cefChromiumFlagsEnvVar = "DUMBER_CEF_CHROMIUM_FLAGS" | |
| 12 | + | cefEnableVAAPIEnvVar = "DUMBER_CEF_ENABLE_VAAPI" | |
| 13 | + | cefRenderNodeEnvVar = "DUMBER_CEF_RENDER_NODE" | |
| 14 | + | cefRenderStallRecoveryEnvVar = "DUMBER_CEF_RENDER_STALL_RECOVERY" | |
| 15 | + | cefRenderStallBacktraceEnvVar = "DUMBER_CEF_RENDER_STALL_BACKTRACE" | |
| 11 | 16 | ) | |
| 12 | 17 | ||
| 13 | 18 | // envBoolEnabled returns true when the given environment variable is set | |
| @@ -28,3 +33,11 @@ func externalBeginFrameEnabled() bool { | |||
| 28 | 33 | func cefWebAuthnUnsafeEnabled() bool { | |
| 29 | 34 | return envBoolEnabled(cefEnableWebAuthnUnsafeEnvVar) | |
| 30 | 35 | } | |
| 36 | + | ||
| 37 | + | func renderStallRecoveryEnabled() bool { | |
| 38 | + | return envBoolEnabled(cefRenderStallRecoveryEnvVar) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func renderStallBacktraceEnabled() bool { | |
| 42 | + | return envBoolEnabled(cefRenderStallBacktraceEnvVar) | |
| 43 | + | } | |
M internal/infrastructure/cef/factory.go +86 -97
| @@ -7,6 +7,7 @@ import ( | |||
| 7 | 7 | "time" | |
| 8 | 8 | ||
| 9 | 9 | purecef "github.com/bnema/purego-cef/cef" | |
| 10 | + | cef2gtk "github.com/bnema/purego-cef2gtk" | |
| 10 | 11 | ||
| 11 | 12 | "github.com/bnema/dumber/internal/application/port" | |
| 12 | 13 | "github.com/bnema/dumber/internal/logging" | |
| @@ -16,11 +17,10 @@ import ( | |||
| 16 | 17 | var _ port.WebViewFactory = (*WebViewFactory)(nil) | |
| 17 | 18 | ||
| 18 | 19 | // WebViewFactory creates new CEF off-screen browser WebViews. Each WebView | |
| 19 | - | // gets a unique ID, its own renderPipeline and inputBridge, and an | |
| 20 | - | // asynchronously-created CEF browser (via BrowserHostCreateBrowser). | |
| 20 | + | // gets a unique ID, its own cef2gtk bridge view, and an asynchronously-created | |
| 21 | + | // CEF browser (via BrowserHostCreateBrowser). | |
| 21 | 22 | type WebViewFactory struct { | |
| 22 | 23 | engine *Engine | |
| 23 | - | gl *glLoader | |
| 24 | 24 | nextID atomic.Uint64 | |
| 25 | 25 | scale int32 | |
| 26 | 26 | windowlessFrameRate int32 | |
| @@ -47,8 +47,8 @@ const ( | |||
| 47 | 47 | ) | |
| 48 | 48 | ||
| 49 | 49 | // newWebViewFactory returns a factory that will create WebViews using the | |
| 50 | - | // given GL loader and HiDPI scale factor. | |
| 51 | - | func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) *WebViewFactory { | |
| 50 | + | // given HiDPI scale factor. | |
| 51 | + | func newWebViewFactory(engine *Engine, opts webViewFactoryOptions) *WebViewFactory { | |
| 52 | 52 | if opts.scale < 1 { | |
| 53 | 53 | opts.scale = 1 | |
| 54 | 54 | } | |
| @@ -57,10 +57,9 @@ func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions) | |||
| 57 | 57 | } | |
| 58 | 58 | return &WebViewFactory{ | |
| 59 | 59 | engine: engine, | |
| 60 | - | gl: gl, | |
| 61 | 60 | scale: opts.scale, | |
| 62 | 61 | windowlessFrameRate: opts.windowlessFrameRate, | |
| 63 | - | audioOutputFactory: opts.audioOutputFactory, | |
| 62 | + | audioOutputFactory: opts.audioOutputFactory, | |
| 64 | 63 | } | |
| 65 | 64 | } | |
| 66 | 65 | ||
| @@ -75,11 +74,14 @@ func (f *WebViewFactory) setDefaultBackgroundColor(r, g, b, a float64) { | |||
| 75 | 74 | // asynchronously; the returned WebView is usable immediately but navigation | |
| 76 | 75 | // will fail with errNoBrowser until OnAfterCreated fires. | |
| 77 | 76 | func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |
| 78 | - | wv := f.newWebView(ctx) | |
| 77 | + | wv, err := f.newWebView(ctx) | |
| 78 | + | if err != nil { | |
| 79 | + | return nil, err | |
| 80 | + | } | |
| 79 | 81 | ||
| 80 | - | // Configure WindowInfo for off-screen rendering (OSR). | |
| 82 | + | // Delegate windowless/shared-texture setup to the GTK bridge. | |
| 81 | 83 | windowInfo := purecef.NewWindowInfo() | |
| 82 | - | windowInfo.WindowlessRenderingEnabled = 1 | |
| 84 | + | cef2gtk.ConfigureWindowInfo(&windowInfo, cef2gtk.WindowInfoOptions{}) | |
| 83 | 85 | if externalBeginFrameEnabled() { | |
| 84 | 86 | windowInfo.ExternalBeginFrameEnabled = 1 | |
| 85 | 87 | } | |
| @@ -92,125 +94,110 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |||
| 92 | 94 | settings.BackgroundColor = bg | |
| 93 | 95 | } | |
| 94 | 96 | ||
| 95 | - | // Defer browser creation until the GL area has a non-zero size. | |
| 97 | + | // Defer browser creation until the bridge view has a non-zero size. | |
| 96 | 98 | // CEF requires GetViewRect to return a non-empty rect, but the GL area | |
| 97 | 99 | // is not yet realized at this point. | |
| 98 | - | f.configureInitialBrowserCreation(ctx, wv, wv.pipeline, wv.client, &windowInfo, &settings) | |
| 100 | + | f.configureInitialBrowserCreation(ctx, wv, wv.client, &windowInfo, &settings, nil) | |
| 99 | 101 | ||
| 100 | 102 | return wv, nil | |
| 101 | 103 | } | |
| 102 | 104 | ||
| 103 | - | func (f *WebViewFactory) newWebView(ctx context.Context) *WebView { | |
| 105 | + | func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) { | |
| 104 | 106 | id := port.WebViewID(f.nextID.Add(1)) | |
| 105 | - | pipeline := newRenderPipeline(ctx, f.gl, f.scale, id) | |
| 107 | + | viewBridge := NewCef2gtkAdapter() | |
| 108 | + | if viewBridge == nil { | |
| 109 | + | return nil, fmt.Errorf("create cef2gtk view") | |
| 110 | + | } | |
| 106 | 111 | ||
| 107 | 112 | wv := &WebView{ | |
| 108 | 113 | id: id, | |
| 109 | 114 | ctx: ctx, | |
| 110 | 115 | engine: f.engine, | |
| 111 | - | pipeline: pipeline, | |
| 116 | + | viewBridge: viewBridge, | |
| 112 | 117 | audioOutputFactory: f.audioOutputFactory, | |
| 113 | - | resizeReconciler: newResizeReconciler(ctx, id), | |
| 114 | 118 | windowlessFrameRate: f.windowlessFrameRate, | |
| 115 | 119 | backgroundColor: f.bgColor.Load(), | |
| 116 | 120 | } | |
| 117 | 121 | ||
| 118 | - | handlers := &handlerSet{ | |
| 119 | - | wv: wv, | |
| 120 | - | } | |
| 122 | + | handlers := &handlerSet{wv: wv} | |
| 121 | 123 | wv.handlers = handlers | |
| 122 | 124 | wv.findCtrl = newFindController() | |
| 123 | 125 | ||
| 124 | - | input := newInputBridge(ctx, f.scale) | |
| 125 | - | input.selectionText = wv.selectedTextSnapshot | |
| 126 | - | input.explicitCopyText = func(action, text string) { | |
| 127 | - | if f.engine != nil { | |
| 128 | - | f.engine.handleExplicitClipboardBridgeText(wv.id, action, text) | |
| 129 | - | } | |
| 130 | - | } | |
| 131 | - | input.attachTo(pipeline.glArea) | |
| 132 | - | wv.input = input | |
| 133 | - | ||
| 134 | - | // Wire middle-click → new tab using the cached hover URI. | |
| 135 | - | input.onMiddleClick = func(_ string) { | |
| 136 | - | wv.mu.RLock() | |
| 137 | - | hoverURI := wv.lastHoverURI | |
| 138 | - | cb := wv.callbacks | |
| 139 | - | wv.mu.RUnlock() | |
| 140 | - | if hoverURI == "" || cb == nil || cb.OnLinkMiddleClick == nil { | |
| 141 | - | return | |
| 142 | - | } | |
| 143 | - | uri := hoverURI | |
| 144 | - | wv.runOnGTK(func() { | |
| 145 | - | cb.OnLinkMiddleClick(uri) | |
| 146 | - | }) | |
| 147 | - | } | |
| 148 | - | ||
| 149 | 126 | // Build a CEF client backed by our handlerSet. | |
| 150 | 127 | // Store on WebView to prevent GC collection before CEF AddRef's it. | |
| 151 | 128 | wv.client = purecef.NewClient(wv.handlers) | |
| 152 | - | return wv | |
| 129 | + | if opts := f.engine.cef2gtkProfileOptions(wv); opts.Enabled { | |
| 130 | + | if err := viewBridge.ConfigureProfiling(opts); err != nil { | |
| 131 | + | logging.FromContext(ctx).Warn().Err(err).Uint64("webview_id", uint64(id)).Msg("cef2gtk: failed to enable profiling") | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | wv.startRenderStallWatchdog() | |
| 135 | + | return wv, nil | |
| 153 | 136 | } | |
| 154 | 137 | ||
| 155 | 138 | func (f *WebViewFactory) configureInitialBrowserCreation( | |
| 156 | - | ctx context.Context, wv *WebView, pipeline *renderPipeline, client purecef.RawClient, | |
| 139 | + | ctx context.Context, wv *WebView, client purecef.RawClient, | |
| 157 | 140 | windowInfo *purecef.WindowInfo, settings *purecef.BrowserSettings, | |
| 141 | + | onFirstResize func(w, h int32), | |
| 158 | 142 | ) { | |
| 159 | 143 | wv.pendingCreate = &pendingBrowserCreate{ | |
| 160 | 144 | windowInfo: windowInfo, | |
| 161 | 145 | client: client, | |
| 162 | 146 | settings: settings, | |
| 163 | 147 | } | |
| 164 | - | ||
| 165 | - | pipeline.onFirstResize = func(w, h int32) { | |
| 166 | - | logging.FromContext(ctx).Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, scheduling browser creation") | |
| 167 | - | f.postPendingBrowserCreate(ctx, wv, w, h) | |
| 148 | + | if onFirstResize == nil { | |
| 149 | + | onFirstResize = func(w, h int32) { | |
| 150 | + | logging.FromContext(ctx).Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, scheduling browser creation") | |
| 151 | + | f.postPendingBrowserCreate(ctx, wv, w, h) | |
| 152 | + | } | |
| 168 | 153 | } | |
| 169 | - | ||
| 170 | - | // On subsequent resizes, notify CEF so it re-queries GetViewRect. | |
| 171 | - | pipeline.onResizeCB = func(w, h int32) { | |
| 172 | - | log := logging.FromContext(ctx) | |
| 173 | - | wv.mu.RLock() | |
| 174 | - | host := wv.host | |
| 175 | - | wv.mu.RUnlock() | |
| 176 | - | if host == nil { | |
| 177 | - | log.Debug(). | |
| 154 | + | if wv.viewBridge == nil { | |
| 155 | + | return | |
| 156 | + | } | |
| 157 | + | browserCreateScheduled := false | |
| 158 | + | wv.runOnGTKSync(func() { | |
| 159 | + | wv.removeSizeObserver = wv.viewBridge.AddSizeObserver(func(w, h int32) { | |
| 160 | + | // Size observers run on the GTK thread. Prepare the GtkGLArea only after it | |
| 161 | + | // has been packed and allocated; preparing at WebView construction time can | |
| 162 | + | // run before GTK has created a GL context. | |
| 163 | + | if !browserCreateScheduled { | |
| 164 | + | if err := wv.viewBridge.PrepareOnGTKThread(); err != nil { | |
| 165 | + | logging.FromContext(ctx).Warn(). | |
| 166 | + | Err(err). | |
| 167 | + | Uint64("webview_id", uint64(wv.id)). | |
| 168 | + | Int32("resize_width", w). | |
| 169 | + | Int32("resize_height", h). | |
| 170 | + | Msg("cef: cef2gtk view not ready after resize, will retry") | |
| 171 | + | return | |
| 172 | + | } | |
| 173 | + | browserCreateScheduled = true | |
| 174 | + | onFirstResize(w, h) | |
| 175 | + | return | |
| 176 | + | } | |
| 177 | + | ||
| 178 | + | wv.mu.RLock() | |
| 179 | + | host := wv.host | |
| 180 | + | wv.mu.RUnlock() | |
| 181 | + | if host == nil { | |
| 182 | + | logging.FromContext(ctx).Debug(). | |
| 183 | + | Uint64("webview_id", uint64(wv.id)). | |
| 184 | + | Int32("resize_width", w). | |
| 185 | + | Int32("resize_height", h). | |
| 186 | + | Bool("host_nil", true). | |
| 187 | + | Bool("browser_ready", false). | |
| 188 | + | Msg("cef: resize observed before browser host existed") | |
| 189 | + | return | |
| 190 | + | } | |
| 191 | + | notifyBrowserResize(host) | |
| 192 | + | logging.FromContext(ctx).Debug(). | |
| 178 | 193 | Uint64("webview_id", uint64(wv.id)). | |
| 179 | 194 | Int32("resize_width", w). | |
| 180 | 195 | Int32("resize_height", h). | |
| 181 | - | Bool("host_nil", true). | |
| 182 | - | Bool("browser_ready", false). | |
| 183 | - | Msg("cef: resize observed before browser host existed") | |
| 184 | - | return | |
| 185 | - | } | |
| 186 | - | log.Debug(). | |
| 187 | - | Uint64("webview_id", uint64(wv.id)). | |
| 188 | - | Int32("resize_width", w). | |
| 189 | - | Int32("resize_height", h). | |
| 190 | - | Bool("host_nil", false). | |
| 191 | - | Bool("browser_ready", true). | |
| 192 | - | Msg("cef: browser resize observed before WasResized") | |
| 193 | - | notifyBrowserResize(host) | |
| 194 | - | log.Debug(). | |
| 195 | - | Uint64("webview_id", uint64(wv.id)). | |
| 196 | - | Int32("resize_width", w). | |
| 197 | - | Int32("resize_height", h). | |
| 198 | - | Bool("host_nil", false). | |
| 199 | - | Bool("browser_ready", true). | |
| 200 | - | Msg("cef: browser host WasResized invoked") | |
| 201 | - | if wv.resizeReconciler != nil { | |
| 202 | - | resizeSeq, _ := wv.pipeline.latestResizeDiagnostics() | |
| 203 | - | wv.resizeReconciler.start( | |
| 204 | - | resizeSeq, | |
| 205 | - | func() resizeNotifiableBrowserHost { | |
| 206 | - | wv.mu.RLock() | |
| 207 | - | defer wv.mu.RUnlock() | |
| 208 | - | return wv.host | |
| 209 | - | }, | |
| 210 | - | func() bool { return wv.destroyed.Load() }, | |
| 211 | - | ) | |
| 212 | - | } | |
| 213 | - | } | |
| 196 | + | Bool("host_nil", false). | |
| 197 | + | Bool("browser_ready", true). | |
| 198 | + | Msg("cef: browser host WasResized invoked") | |
| 199 | + | }) | |
| 200 | + | }) | |
| 214 | 201 | } | |
| 215 | 202 | ||
| 216 | 203 | func (f *WebViewFactory) postPendingBrowserCreate(ctx context.Context, wv *WebView, w, h int32) { | |
| @@ -319,11 +306,14 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie | |||
| 319 | 306 | return nil, fmt.Errorf("parent webview %d is destroyed", parentID) | |
| 320 | 307 | } | |
| 321 | 308 | ||
| 322 | - | popupWV := f.newWebView(ctx) | |
| 309 | + | popupWV, err := f.newWebView(ctx) | |
| 310 | + | if err != nil { | |
| 311 | + | return nil, err | |
| 312 | + | } | |
| 323 | 313 | popupWV.markNativePopupCandidate(parent) | |
| 324 | 314 | ||
| 325 | 315 | windowInfo := purecef.NewWindowInfo() | |
| 326 | - | windowInfo.WindowlessRenderingEnabled = 1 | |
| 316 | + | cef2gtk.ConfigureWindowInfo(&windowInfo, cef2gtk.WindowInfoOptions{}) | |
| 327 | 317 | if externalBeginFrameEnabled() { | |
| 328 | 318 | windowInfo.ExternalBeginFrameEnabled = 1 | |
| 329 | 319 | } | |
| @@ -335,8 +325,7 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie | |||
| 335 | 325 | settings.BackgroundColor = bg | |
| 336 | 326 | } | |
| 337 | 327 | ||
| 338 | - | f.configureInitialBrowserCreation(ctx, popupWV, popupWV.pipeline, popupWV.client, &windowInfo, &settings) | |
| 339 | - | popupWV.pipeline.onFirstResize = func(w, h int32) { | |
| 328 | + | f.configureInitialBrowserCreation(ctx, popupWV, popupWV.client, &windowInfo, &settings, func(w, h int32) { | |
| 340 | 329 | log := logging.FromContext(ctx) | |
| 341 | 330 | if popupWV.awaitsNativePopupAttachment() { | |
| 342 | 331 | log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell first resize, awaiting native popup attach") | |
| @@ -357,6 +346,6 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie | |||
| 357 | 346 | } | |
| 358 | 347 | log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell first resize, creating browser directly") | |
| 359 | 348 | f.postPendingBrowserCreate(ctx, popupWV, w, h) | |
| 360 | - | } | |
| 349 | + | }) | |
| 361 | 350 | return popupWV, nil | |
| 362 | 351 | } | |
M internal/infrastructure/cef/factory_resize_test.go +13 -0
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "time" | |
| 7 | 7 | ||
| 8 | 8 | purecef "github.com/bnema/purego-cef/cef" | |
| 9 | + | cef2gtk "github.com/bnema/purego-cef2gtk" | |
| 9 | 10 | "github.com/stretchr/testify/require" | |
| 10 | 11 | ) | |
| 11 | 12 | ||
| @@ -29,6 +30,15 @@ func TestNotifyBrowserResize_CallsWasResizedThenInvalidate(t *testing.T) { | |||
| 29 | 30 | require.Equal(t, []string{"WasResized", "Invalidate"}, host.calls) | |
| 30 | 31 | } | |
| 31 | 32 | ||
| 33 | + | func TestConfigureWindowInfo_SetsAcceleratedWindowlessAndSharedTexture(t *testing.T) { | |
| 34 | + | info := purecef.NewWindowInfo() | |
| 35 | + | ||
| 36 | + | cef2gtk.ConfigureWindowInfo(&info, cef2gtk.WindowInfoOptions{}) | |
| 37 | + | ||
| 38 | + | require.Equal(t, int32(1), info.WindowlessRenderingEnabled) | |
| 39 | + | require.Equal(t, int32(1), info.SharedTextureEnabled) | |
| 40 | + | } | |
| 41 | + | ||
| 32 | 42 | func TestPostPendingBrowserCreate_RetriesWhenPostingTaskFails(t *testing.T) { | |
| 33 | 43 | oldNewTask := cefNewTask | |
| 34 | 44 | oldPostTask := cefPostTask | |
| @@ -75,10 +85,13 @@ func TestPostPendingBrowserCreate_RetriesWhenPostingTaskFails(t *testing.T) { | |||
| 75 | 85 | require.NotNil(t, windowInfo) | |
| 76 | 86 | require.NotNil(t, settings) | |
| 77 | 87 | require.Equal(t, "about:blank", url) | |
| 88 | + | require.Equal(t, int32(1), windowInfo.WindowlessRenderingEnabled) | |
| 89 | + | require.Equal(t, int32(1), windowInfo.SharedTextureEnabled) | |
| 78 | 90 | return 1 | |
| 79 | 91 | } | |
| 80 | 92 | ||
| 81 | 93 | windowInfo := purecef.NewWindowInfo() | |
| 94 | + | cef2gtk.ConfigureWindowInfo(&windowInfo, cef2gtk.WindowInfoOptions{}) | |
| 82 | 95 | settings := purecef.NewBrowserSettings() | |
| 83 | 96 | wv := &WebView{ | |
| 84 | 97 | ctx: context.Background(), | |
A internal/infrastructure/cef/focus_bridge.go +16 -0
| @@ -0,0 +1,16 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import purecef "github.com/bnema/purego-cef/cef" | |
| 4 | + | ||
| 5 | + | func syncWindowlessBrowserFocus(host purecef.BrowserHost) { | |
| 6 | + | if host == nil { | |
| 7 | + | return | |
| 8 | + | } | |
| 9 | + | const ( | |
| 10 | + | cefFalse = 0 | |
| 11 | + | cefTrue = 1 | |
| 12 | + | ) | |
| 13 | + | host.WasHidden(cefFalse) // visible | |
| 14 | + | host.SetFocus(cefTrue) // focused | |
| 15 | + | host.Invalidate(purecef.PaintElementTypePetView) | |
| 16 | + | } | |
D internal/infrastructure/cef/gl_loader.go +0 -184
| @@ -1,184 +0,0 @@ | |||
| 1 | - | package cef | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "unsafe" | |
| 6 | - | ||
| 7 | - | "github.com/bnema/purego" | |
| 8 | - | ) | |
| 9 | - | ||
| 10 | - | // OpenGL constants for PBO-based texture rendering. | |
| 11 | - | const ( | |
| 12 | - | glTexture2D = 0x0DE1 | |
| 13 | - | glBGRA = 0x80E1 | |
| 14 | - | glRGBA = 0x1908 | |
| 15 | - | glUnsignedByte = 0x1401 | |
| 16 | - | ||
| 17 | - | glPixelUnpackBuffer = 0x88EC | |
| 18 | - | glStreamDraw = 0x88E0 | |
| 19 | - | glWriteOnly = 0x88B9 | |
| 20 | - | ||
| 21 | - | glColorBufferBit = 0x00004000 | |
| 22 | - | glTriangleStrip = 0x0005 | |
| 23 | - | ||
| 24 | - | glFragmentShader = 0x8B30 | |
| 25 | - | glVertexShader = 0x8B31 | |
| 26 | - | glFloat = 0x1406 | |
| 27 | - | ||
| 28 | - | glArrayBuffer = 0x8892 | |
| 29 | - | glStaticDraw = 0x88E4 | |
| 30 | - | ||
| 31 | - | glLinear = 0x2601 | |
| 32 | - | glTextureMinFilter = 0x2801 | |
| 33 | - | glTextureMagFilter = 0x2800 | |
| 34 | - | glClampToEdge = 0x812F | |
| 35 | - | glTextureWrapS = 0x2802 | |
| 36 | - | glTextureWrapT = 0x2803 | |
| 37 | - | ||
| 38 | - | glUnpackRowLength = 0x0CF2 | |
| 39 | - | ) | |
| 40 | - | ||
| 41 | - | // glLoader holds dynamically loaded OpenGL function pointers. | |
| 42 | - | // All functions are loaded from libGL.so.1 via purego at runtime (no CGo). | |
| 43 | - | type glLoader struct { | |
| 44 | - | handle uintptr | |
| 45 | - | ||
| 46 | - | // Texture functions | |
| 47 | - | genTextures func(n int32, textures *uint32) | |
| 48 | - | deleteTextures func(n int32, textures *uint32) | |
| 49 | - | bindTexture func(target uint32, texture uint32) | |
| 50 | - | texImage2D func( | |
| 51 | - | target uint32, level, internalformat, width, height, border int32, | |
| 52 | - | format, xtype uint32, pixels unsafe.Pointer, | |
| 53 | - | ) | |
| 54 | - | texSubImage2D func( | |
| 55 | - | target uint32, level, xoffset, yoffset, width, height int32, | |
| 56 | - | format, xtype uint32, pixels unsafe.Pointer, | |
| 57 | - | ) | |
| 58 | - | texParameteri func(target uint32, pname uint32, param int32) | |
| 59 | - | ||
| 60 | - | // PBO / buffer functions | |
| 61 | - | genBuffers func(n int32, buffers *uint32) | |
| 62 | - | deleteBuffers func(n int32, buffers *uint32) | |
| 63 | - | bindBuffer func(target uint32, buffer uint32) | |
| 64 | - | bufferData func(target uint32, size int64, data unsafe.Pointer, usage uint32) | |
| 65 | - | mapBuffer func(target uint32, access uint32) unsafe.Pointer | |
| 66 | - | unmapBuffer func(target uint32) bool | |
| 67 | - | ||
| 68 | - | // Shader functions | |
| 69 | - | createShader func(shaderType uint32) uint32 | |
| 70 | - | shaderSource func(shader uint32, count int32, source **byte, length *int32) | |
| 71 | - | compileShader func(shader uint32) | |
| 72 | - | deleteShader func(shader uint32) | |
| 73 | - | createProgram func() uint32 | |
| 74 | - | attachShader func(program uint32, shader uint32) | |
| 75 | - | linkProgram func(program uint32) | |
| 76 | - | useProgram func(program uint32) | |
| 77 | - | getUniformLocation func(program uint32, name *byte) int32 | |
| 78 | - | uniform1i func(location int32, v0 int32) | |
| 79 | - | ||
| 80 | - | // VAO / VBO functions | |
| 81 | - | genVertexArrays func(n int32, arrays *uint32) | |
| 82 | - | bindVertexArray func(array uint32) | |
| 83 | - | deleteVertexArrays func(n int32, arrays *uint32) | |
| 84 | - | vertexAttribPointer func(index uint32, size int32, xtype uint32, normalized bool, stride int32, pointer uintptr) | |
| 85 | - | enableVertexAttribArray func(index uint32) | |
| 86 | - | ||
| 87 | - | // Pixel store | |
| 88 | - | pixelStorei func(pname uint32, param int32) | |
| 89 | - | ||
| 90 | - | // Draw / state functions | |
| 91 | - | clear func(mask uint32) | |
| 92 | - | clearColor func(red float32, green float32, blue float32, alpha float32) | |
| 93 | - | viewport func(x int32, y int32, width int32, height int32) | |
| 94 | - | drawArrays func(mode uint32, first int32, count int32) | |
| 95 | - | enable func(cap uint32) | |
| 96 | - | disable func(cap uint32) | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | // Close releases the OpenGL library handle. | |
| 100 | - | func (gl *glLoader) Close() error { | |
| 101 | - | if gl == nil { | |
| 102 | - | return nil | |
| 103 | - | } | |
| 104 | - | if gl.handle != 0 { | |
| 105 | - | err := purego.Dlclose(gl.handle) | |
| 106 | - | gl.handle = 0 | |
| 107 | - | return err | |
| 108 | - | } | |
| 109 | - | return nil | |
| 110 | - | } | |
| 111 | - | ||
| 112 | - | // newGLLoader opens libGL.so.1 and loads all required OpenGL function pointers. | |
| 113 | - | func newGLLoader() (*glLoader, error) { | |
| 114 | - | handle, err := purego.Dlopen("libGL.so.1", purego.RTLD_LAZY) | |
| 115 | - | if err != nil { | |
| 116 | - | return nil, fmt.Errorf("failed to open libGL.so.1: %w", err) | |
| 117 | - | } | |
| 118 | - | ||
| 119 | - | gl := &glLoader{handle: handle} | |
| 120 | - | ||
| 121 | - | if err := gl.registerAll(); err != nil { | |
| 122 | - | return nil, fmt.Errorf("failed to register GL functions: %w", err) | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | return gl, nil | |
| 126 | - | } | |
| 127 | - | ||
| 128 | - | // registerAll loads every GL function pointer from the shared library. | |
| 129 | - | // It wraps RegisterLibFunc panics into errors for graceful handling. | |
| 130 | - | func (gl *glLoader) registerAll() (retErr error) { | |
| 131 | - | defer func() { | |
| 132 | - | if r := recover(); r != nil { | |
| 133 | - | retErr = fmt.Errorf("GL symbol lookup failed: %v", r) | |
| 134 | - | } | |
| 135 | - | }() | |
| 136 | - | ||
| 137 | - | // Texture | |
| 138 | - | purego.RegisterLibFunc(&gl.genTextures, gl.handle, "glGenTextures") | |
| 139 | - | purego.RegisterLibFunc(&gl.deleteTextures, gl.handle, "glDeleteTextures") | |
| 140 | - | purego.RegisterLibFunc(&gl.bindTexture, gl.handle, "glBindTexture") | |
| 141 | - | purego.RegisterLibFunc(&gl.texImage2D, gl.handle, "glTexImage2D") | |
| 142 | - | purego.RegisterLibFunc(&gl.texSubImage2D, gl.handle, "glTexSubImage2D") | |
| 143 | - | purego.RegisterLibFunc(&gl.texParameteri, gl.handle, "glTexParameteri") | |
| 144 | - | ||
| 145 | - | // PBO / buffers | |
| 146 | - | purego.RegisterLibFunc(&gl.genBuffers, gl.handle, "glGenBuffers") | |
| 147 | - | purego.RegisterLibFunc(&gl.deleteBuffers, gl.handle, "glDeleteBuffers") | |
| 148 | - | purego.RegisterLibFunc(&gl.bindBuffer, gl.handle, "glBindBuffer") | |
| 149 | - | purego.RegisterLibFunc(&gl.bufferData, gl.handle, "glBufferData") | |
| 150 | - | purego.RegisterLibFunc(&gl.mapBuffer, gl.handle, "glMapBuffer") | |
| 151 | - | purego.RegisterLibFunc(&gl.unmapBuffer, gl.handle, "glUnmapBuffer") | |
| 152 | - | ||
| 153 | - | // Shaders | |
| 154 | - | purego.RegisterLibFunc(&gl.createShader, gl.handle, "glCreateShader") | |
| 155 | - | purego.RegisterLibFunc(&gl.shaderSource, gl.handle, "glShaderSource") | |
| 156 | - | purego.RegisterLibFunc(&gl.compileShader, gl.handle, "glCompileShader") | |
| 157 | - | purego.RegisterLibFunc(&gl.deleteShader, gl.handle, "glDeleteShader") | |
| 158 | - | purego.RegisterLibFunc(&gl.createProgram, gl.handle, "glCreateProgram") | |
| 159 | - | purego.RegisterLibFunc(&gl.attachShader, gl.handle, "glAttachShader") | |
| 160 | - | purego.RegisterLibFunc(&gl.linkProgram, gl.handle, "glLinkProgram") | |
| 161 | - | purego.RegisterLibFunc(&gl.useProgram, gl.handle, "glUseProgram") | |
| 162 | - | purego.RegisterLibFunc(&gl.getUniformLocation, gl.handle, "glGetUniformLocation") | |
| 163 | - | purego.RegisterLibFunc(&gl.uniform1i, gl.handle, "glUniform1i") | |
| 164 | - | ||
| 165 | - | // VAO / VBO | |
| 166 | - | purego.RegisterLibFunc(&gl.genVertexArrays, gl.handle, "glGenVertexArrays") | |
| 167 | - | purego.RegisterLibFunc(&gl.bindVertexArray, gl.handle, "glBindVertexArray") | |
| 168 | - | purego.RegisterLibFunc(&gl.deleteVertexArrays, gl.handle, "glDeleteVertexArrays") | |
| 169 | - | purego.RegisterLibFunc(&gl.vertexAttribPointer, gl.handle, "glVertexAttribPointer") | |
| 170 | - | purego.RegisterLibFunc(&gl.enableVertexAttribArray, gl.handle, "glEnableVertexAttribArray") | |
| 171 | - | ||
| 172 | - | // Pixel store | |
| 173 | - | purego.RegisterLibFunc(&gl.pixelStorei, gl.handle, "glPixelStorei") | |
| 174 | - | ||
| 175 | - | // Draw / state | |
| 176 | - | purego.RegisterLibFunc(&gl.clear, gl.handle, "glClear") | |
| 177 | - | purego.RegisterLibFunc(&gl.clearColor, gl.handle, "glClearColor") | |
| 178 | - | purego.RegisterLibFunc(&gl.viewport, gl.handle, "glViewport") | |
| 179 | - | purego.RegisterLibFunc(&gl.drawArrays, gl.handle, "glDrawArrays") | |
| 180 | - | purego.RegisterLibFunc(&gl.enable, gl.handle, "glEnable") | |
| 181 | - | purego.RegisterLibFunc(&gl.disable, gl.handle, "glDisable") | |
| 182 | - | ||
| 183 | - | return nil | |
| 184 | - | } | |
A internal/infrastructure/cef/heartbeat.go +174 -0
| @@ -0,0 +1,174 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync/atomic" | |
| 5 | + | "time" | |
| 6 | + | ||
| 7 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | const ( | |
| 13 | + | cefHeartbeatInterval = 1 * time.Second | |
| 14 | + | cefHeartbeatLogEvery = 10 * time.Second | |
| 15 | + | cefHeartbeatStopTimeout = 2 * time.Second | |
| 16 | + | ) | |
| 17 | + | ||
| 18 | + | type cefThreadHeartbeatState struct { | |
| 19 | + | seq atomic.Uint64 | |
| 20 | + | lastPostUnixNS atomic.Int64 | |
| 21 | + | lastAckUnixNS atomic.Int64 | |
| 22 | + | lastLatencyNS atomic.Int64 | |
| 23 | + | lastPostResult atomic.Int32 | |
| 24 | + | lastLogUnixNS atomic.Int64 | |
| 25 | + | inFlight atomic.Bool | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func (s *cefThreadHeartbeatState) snapshot(now time.Time) cefHeartbeatSnapshot { | |
| 29 | + | if s == nil { | |
| 30 | + | return cefHeartbeatSnapshot{} | |
| 31 | + | } | |
| 32 | + | lastPost := unixNSTime(s.lastPostUnixNS.Load()) | |
| 33 | + | lastAck := unixNSTime(s.lastAckUnixNS.Load()) | |
| 34 | + | snap := cefHeartbeatSnapshot{ | |
| 35 | + | Seq: s.seq.Load(), | |
| 36 | + | PostResult: s.lastPostResult.Load(), | |
| 37 | + | Latency: time.Duration(s.lastLatencyNS.Load()), | |
| 38 | + | LastPostTime: lastPost, | |
| 39 | + | LastAckTime: lastAck, | |
| 40 | + | InFlight: s.inFlight.Load(), | |
| 41 | + | } | |
| 42 | + | if !lastPost.IsZero() { | |
| 43 | + | snap.LastPostAge = now.Sub(lastPost) | |
| 44 | + | } | |
| 45 | + | if !lastAck.IsZero() { | |
| 46 | + | snap.LastAckAge = now.Sub(lastAck) | |
| 47 | + | } | |
| 48 | + | return snap | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | type cefHeartbeatSnapshot struct { | |
| 52 | + | Seq uint64 | |
| 53 | + | PostResult int32 | |
| 54 | + | Latency time.Duration | |
| 55 | + | LastPostTime time.Time | |
| 56 | + | LastAckTime time.Time | |
| 57 | + | LastPostAge time.Duration | |
| 58 | + | LastAckAge time.Duration | |
| 59 | + | InFlight bool | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func (e *Engine) startCEFHeartbeat() { | |
| 63 | + | if e == nil || e.ctx == nil || e.cefHeartbeatStop != nil { | |
| 64 | + | return | |
| 65 | + | } | |
| 66 | + | stop := make(chan struct{}) | |
| 67 | + | done := make(chan struct{}) | |
| 68 | + | e.cefHeartbeatStop = stop | |
| 69 | + | e.cefHeartbeatDone = done | |
| 70 | + | go e.cefHeartbeatLoop(stop, done) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func (e *Engine) stopCEFHeartbeat() { | |
| 74 | + | if e == nil || e.cefHeartbeatStop == nil { | |
| 75 | + | return | |
| 76 | + | } | |
| 77 | + | stop := e.cefHeartbeatStop | |
| 78 | + | done := e.cefHeartbeatDone | |
| 79 | + | e.cefHeartbeatStop = nil | |
| 80 | + | e.cefHeartbeatDone = nil | |
| 81 | + | close(stop) | |
| 82 | + | if done == nil { | |
| 83 | + | return | |
| 84 | + | } | |
| 85 | + | select { | |
| 86 | + | case <-done: | |
| 87 | + | case <-time.After(cefHeartbeatStopTimeout): | |
| 88 | + | if e.ctx != nil { | |
| 89 | + | logging.FromContext(e.ctx).Warn().Msg("cef: heartbeat stop timed out") | |
| 90 | + | } | |
| 91 | + | } | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func (e *Engine) cefHeartbeatLoop(stop <-chan struct{}, done chan<- struct{}) { | |
| 95 | + | defer close(done) | |
| 96 | + | ticker := time.NewTicker(cefHeartbeatInterval) | |
| 97 | + | defer ticker.Stop() | |
| 98 | + | for { | |
| 99 | + | select { | |
| 100 | + | case <-stop: | |
| 101 | + | return | |
| 102 | + | case now := <-ticker.C: | |
| 103 | + | e.postCEFThreadHeartbeat("ui", purecef.ThreadIDTidUi, &e.cefUIHeartbeat, now) | |
| 104 | + | e.postCEFThreadHeartbeat("io", purecef.ThreadIDTidIo, &e.cefIOHeartbeat, now) | |
| 105 | + | } | |
| 106 | + | } | |
| 107 | + | } | |
| 108 | + | ||
| 109 | + | func (e *Engine) postCEFThreadHeartbeat(name string, threadID purecef.ThreadID, state *cefThreadHeartbeatState, now time.Time) { | |
| 110 | + | if e == nil || state == nil { | |
| 111 | + | return | |
| 112 | + | } | |
| 113 | + | seq := state.seq.Add(1) | |
| 114 | + | postedAt := now | |
| 115 | + | state.lastPostUnixNS.Store(postedAt.UnixNano()) | |
| 116 | + | state.inFlight.Store(true) | |
| 117 | + | task := cefNewTask(cefTaskFunc(func() { | |
| 118 | + | ackedAt := time.Now() | |
| 119 | + | state.lastAckUnixNS.Store(ackedAt.UnixNano()) | |
| 120 | + | state.lastLatencyNS.Store(ackedAt.Sub(postedAt).Nanoseconds()) | |
| 121 | + | state.inFlight.Store(false) | |
| 122 | + | })) | |
| 123 | + | if task == nil { | |
| 124 | + | state.lastPostResult.Store(0) | |
| 125 | + | state.inFlight.Store(false) | |
| 126 | + | return | |
| 127 | + | } | |
| 128 | + | result := cefPostTask(threadID, task) | |
| 129 | + | state.lastPostResult.Store(result) | |
| 130 | + | if result != 1 { | |
| 131 | + | state.inFlight.Store(false) | |
| 132 | + | } | |
| 133 | + | lastLog := unixNSTime(state.lastLogUnixNS.Load()) | |
| 134 | + | if result != 1 || lastLog.IsZero() || now.Sub(lastLog) >= cefHeartbeatLogEvery { | |
| 135 | + | state.lastLogUnixNS.Store(now.UnixNano()) | |
| 136 | + | snap := state.snapshot(now) | |
| 137 | + | logging.FromContext(e.ctx).Debug(). | |
| 138 | + | Str("thread", name). | |
| 139 | + | Uint64("seq", seq). | |
| 140 | + | Int32("post_result", result). | |
| 141 | + | Int64("last_ack_age_ms", durationMillis(snap.LastAckAge)). | |
| 142 | + | Int64("latency_ms", durationMillis(snap.Latency)). | |
| 143 | + | Bool("in_flight", snap.InFlight). | |
| 144 | + | Msg("cef: thread heartbeat") | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | func (e *Engine) cefHeartbeatSnapshots(now time.Time) (cefHeartbeatSnapshot, cefHeartbeatSnapshot) { | |
| 149 | + | if e == nil { | |
| 150 | + | return cefHeartbeatSnapshot{}, cefHeartbeatSnapshot{} | |
| 151 | + | } | |
| 152 | + | return e.cefUIHeartbeat.snapshot(now), e.cefIOHeartbeat.snapshot(now) | |
| 153 | + | } | |
| 154 | + | ||
| 155 | + | func (wv *WebView) cefHeartbeatSnapshots(now time.Time) (cefHeartbeatSnapshot, cefHeartbeatSnapshot) { | |
| 156 | + | if wv == nil || wv.engine == nil { | |
| 157 | + | return cefHeartbeatSnapshot{}, cefHeartbeatSnapshot{} | |
| 158 | + | } | |
| 159 | + | return wv.engine.cefHeartbeatSnapshots(now) | |
| 160 | + | } | |
| 161 | + | ||
| 162 | + | func unixNSTime(ns int64) time.Time { | |
| 163 | + | if ns <= 0 { | |
| 164 | + | return time.Time{} | |
| 165 | + | } | |
| 166 | + | return time.Unix(0, ns) | |
| 167 | + | } | |
| 168 | + | ||
| 169 | + | func durationMillis(d time.Duration) int64 { | |
| 170 | + | if d <= 0 { | |
| 171 | + | return 0 | |
| 172 | + | } | |
| 173 | + | return d.Milliseconds() | |
| 174 | + | } | |
D internal/infrastructure/cef/input_bridge.go +0 -780
| @@ -1,780 +0,0 @@ | |||
| 1 | - | package cef | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "sync" | |
| 6 | - | "sync/atomic" | |
| 7 | - | "unicode/utf8" | |
| 8 | - | ||
| 9 | - | purecef "github.com/bnema/purego-cef/cef" | |
| 10 | - | "github.com/bnema/puregotk/v4/gdk" | |
| 11 | - | "github.com/bnema/puregotk/v4/gio" | |
| 12 | - | "github.com/bnema/puregotk/v4/gtk" | |
| 13 | - | ||
| 14 | - | "github.com/bnema/dumber/internal/infrastructure/webutil" | |
| 15 | - | "github.com/bnema/dumber/internal/logging" | |
| 16 | - | ) | |
| 17 | - | ||
| 18 | - | // GDK dead key range boundaries. Dead keys (0xfe50–0xfe8c) are compose | |
| 19 | - | // modifiers (dead_grave, dead_acute, …) that should not generate CEF key | |
| 20 | - | // events by themselves — the IMContext absorbs them and emits a commit | |
| 21 | - | // with the composed character. | |
| 22 | - | const ( | |
| 23 | - | gdkDeadKeyStart = 0xfe50 | |
| 24 | - | gdkDeadKeyEnd = 0xfe8c | |
| 25 | - | ||
| 26 | - | // maxBMPCodepoint is the highest Unicode codepoint in the Basic | |
| 27 | - | // Multilingual Plane that fits in a single uint16 / UTF-16 code unit. | |
| 28 | - | maxBMPCodepoint = 0xFFFF | |
| 29 | - | // minPrintable is the lowest printable Unicode codepoint (space). | |
| 30 | - | minPrintable = 0x20 | |
| 31 | - | // maxSingleByteKeyval is the upper bound for GDK keyvals that can be | |
| 32 | - | // used directly as Windows VK codes (when no explicit mapping exists). | |
| 33 | - | maxSingleByteKeyval = 0x100 | |
| 34 | - | ) | |
| 35 | - | ||
| 36 | - | // inputBridge translates GDK events from GTK event controllers into CEF input | |
| 37 | - | // events and forwards them to the CEF BrowserHost. The host field is nil until | |
| 38 | - | // the browser is created asynchronously (OnAfterCreated), so all dispatch | |
| 39 | - | // methods guard against a nil host. | |
| 40 | - | // | |
| 41 | - | // Dead key / compose support: a GtkIMContextSimple is attached to the key | |
| 42 | - | // controller. When the user types a compose sequence (e.g. dead_acute + e), | |
| 43 | - | // the IMContext absorbs the individual key events and emits a "commit" signal | |
| 44 | - | // with the composed string ("é"). The bridge sends that as CHAR events to CEF. | |
| 45 | - | // Keys not consumed by the IMContext flow through key-pressed as before. | |
| 46 | - | type inputBridge struct { | |
| 47 | - | host purecef.BrowserHost | |
| 48 | - | scale int32 | |
| 49 | - | ctx context.Context | |
| 50 | - | mu sync.Mutex | |
| 51 | - | ||
| 52 | - | // Last known pointer position, used for scroll events which don't | |
| 53 | - | // carry their own coordinates from GDK. | |
| 54 | - | lastX, lastY float64 | |
| 55 | - | ||
| 56 | - | // GDK clipboard for paste support. CEF in OSR mode cannot access the | |
| 57 | - | // Wayland clipboard directly, so we read from GDK and inject via | |
| 58 | - | // ImeCommitText. Set once during attachTo, read-only afterwards. | |
| 59 | - | clipboard *gdk.Clipboard | |
| 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 | - | ||
| 69 | - | // IMContext for dead key / compose sequence support. Held as a field | |
| 70 | - | // to prevent garbage collection while the controller is alive. | |
| 71 | - | imContext *gtk.IMContextSimple | |
| 72 | - | // Prevent GC from collecting signal callbacks. | |
| 73 | - | commitCb func(gtk.IMContext, string) | |
| 74 | - | ||
| 75 | - | // glArea is stored so we can GrabFocus on click. Without explicit | |
| 76 | - | // focus grab, GTK may not fire the focus-enter signal and CEF won't | |
| 77 | - | // show the text caret. | |
| 78 | - | glArea *gtk.GLArea | |
| 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 | - | ||
| 85 | - | // onMiddleClick is called when button 2 (middle) is pressed on a link. | |
| 86 | - | // The callback receives the hovered URI. Set by the factory. | |
| 87 | - | onMiddleClick func(uri string) | |
| 88 | - | } | |
| 89 | - | ||
| 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 | - | ||
| 106 | - | func newInputBridge(ctx context.Context, scale int32) *inputBridge { | |
| 107 | - | return &inputBridge{ctx: ctx, scale: scale} | |
| 108 | - | } | |
| 109 | - | ||
| 110 | - | // setHost is called once the browser is created to enable event dispatch. | |
| 111 | - | func (ib *inputBridge) setHost(host purecef.BrowserHost) { | |
| 112 | - | ib.mu.Lock() | |
| 113 | - | defer ib.mu.Unlock() | |
| 114 | - | ib.host = host | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | // attachTo creates GDK event controllers and connects them to the given GLArea. | |
| 118 | - | func (ib *inputBridge) attachTo(glArea *gtk.GLArea) { | |
| 119 | - | ib.glArea = glArea | |
| 120 | - | ||
| 121 | - | // Acquire GDK clipboard for paste support. CEF OSR can't access the | |
| 122 | - | // Wayland clipboard, so we bridge it via GDK → ImeCommitText. | |
| 123 | - | if display := gdk.DisplayGetDefault(); display != nil { | |
| 124 | - | ib.clipboard = display.GetClipboard() | |
| 125 | - | if ib.clipboard != nil { | |
| 126 | - | logging.FromContext(ib.ctx).Debug().Msg("cef: GDK clipboard acquired for paste bridge") | |
| 127 | - | } else { | |
| 128 | - | logging.FromContext(ib.ctx).Warn().Msg("cef: GDK display found but GetClipboard returned nil") | |
| 129 | - | } | |
| 130 | - | } else { | |
| 131 | - | logging.FromContext(ib.ctx).Warn().Msg("cef: no GDK display — clipboard paste will not work") | |
| 132 | - | } | |
| 133 | - | ||
| 134 | - | // Motion (mouse move + enter/leave) | |
| 135 | - | motion := gtk.NewEventControllerMotion() | |
| 136 | - | motionCb := func(g gtk.EventControllerMotion, x, y float64) { | |
| 137 | - | mods := uint(g.GetCurrentEventState()) | |
| 138 | - | ib.onMouseMove(x, y, mods, false) | |
| 139 | - | } | |
| 140 | - | motion.ConnectMotion(&motionCb) | |
| 141 | - | ||
| 142 | - | leaveCb := func(g gtk.EventControllerMotion) { | |
| 143 | - | mods := uint(g.GetCurrentEventState()) | |
| 144 | - | ib.onMouseMove(0, 0, mods, true) | |
| 145 | - | } | |
| 146 | - | motion.ConnectLeave(&leaveCb) | |
| 147 | - | ||
| 148 | - | glArea.AddController(&motion.EventController) | |
| 149 | - | ||
| 150 | - | // Click (press / release) | |
| 151 | - | click := gtk.NewGestureClick() | |
| 152 | - | click.SetButton(0) // listen to all buttons | |
| 153 | - | ||
| 154 | - | pressedCb := func(g gtk.GestureClick, nPress int, x, y float64) { | |
| 155 | - | // Ensure the GLArea has GTK focus so CEF receives SetFocus(1) | |
| 156 | - | // and renders the text caret. Without this, clicking an input | |
| 157 | - | // field may not show the blinking cursor. | |
| 158 | - | if ib.glArea != nil { | |
| 159 | - | ib.glArea.GrabFocus() | |
| 160 | - | } | |
| 161 | - | btn := g.GetCurrentButton() | |
| 162 | - | mods := uint(g.GetCurrentEventState()) | |
| 163 | - | ib.onMousePress(x, y, btn, mods, nPress) | |
| 164 | - | } | |
| 165 | - | click.ConnectPressed(&pressedCb) | |
| 166 | - | ||
| 167 | - | releasedCb := func(g gtk.GestureClick, nPress int, x, y float64) { | |
| 168 | - | btn := g.GetCurrentButton() | |
| 169 | - | mods := uint(g.GetCurrentEventState()) | |
| 170 | - | ib.onMouseRelease(x, y, btn, mods, nPress) | |
| 171 | - | } | |
| 172 | - | click.ConnectReleased(&releasedCb) | |
| 173 | - | ||
| 174 | - | glArea.AddController(&click.EventController) | |
| 175 | - | ||
| 176 | - | // Scroll | |
| 177 | - | scroll := gtk.NewEventControllerScroll(gtk.EventControllerScrollBothAxesValue | gtk.EventControllerScrollKineticValue) | |
| 178 | - | scrollCb := func(_ gtk.EventControllerScroll, dx, dy float64) bool { | |
| 179 | - | ib.onScroll(dx, dy) | |
| 180 | - | return true | |
| 181 | - | } | |
| 182 | - | scroll.ConnectScroll(&scrollCb) | |
| 183 | - | ||
| 184 | - | glArea.AddController(&scroll.EventController) | |
| 185 | - | ||
| 186 | - | // Focus, keyboard, and IMContext. | |
| 187 | - | ib.attachFocusAndKeyboard(glArea) | |
| 188 | - | } | |
| 189 | - | ||
| 190 | - | // attachFocusAndKeyboard wires focus, keyboard, and IMContext controllers | |
| 191 | - | // to the GLArea. Extracted from attachTo to keep function length manageable. | |
| 192 | - | func (ib *inputBridge) attachFocusAndKeyboard(glArea *gtk.GLArea) { | |
| 193 | - | // Focus — forward to CEF and IMContext. | |
| 194 | - | focus := gtk.NewEventControllerFocus() | |
| 195 | - | focusEnterCb := func(_ gtk.EventControllerFocus) { | |
| 196 | - | ib.gtkFocused.Store(true) | |
| 197 | - | if ib.imContext != nil { | |
| 198 | - | ib.imContext.FocusIn() | |
| 199 | - | } | |
| 200 | - | ib.onFocusIn() | |
| 201 | - | } | |
| 202 | - | focus.ConnectEnter(&focusEnterCb) | |
| 203 | - | focusLeaveCb := func(_ gtk.EventControllerFocus) { | |
| 204 | - | ib.gtkFocused.Store(false) | |
| 205 | - | if ib.imContext != nil { | |
| 206 | - | ib.imContext.Reset() | |
| 207 | - | ib.imContext.FocusOut() | |
| 208 | - | } | |
| 209 | - | ib.onFocusOut() | |
| 210 | - | } | |
| 211 | - | focus.ConnectLeave(&focusLeaveCb) | |
| 212 | - | ||
| 213 | - | glArea.AddController(&focus.EventController) | |
| 214 | - | ||
| 215 | - | // Keyboard + IMContext for dead key / compose support. | |
| 216 | - | key := gtk.NewEventControllerKey() | |
| 217 | - | ||
| 218 | - | // Set up IMContext for dead key / compose support. | |
| 219 | - | // When set on the controller, GTK calls FilterKeypress on every key | |
| 220 | - | // event. If the IM absorbs the key (dead key, compose in-progress), | |
| 221 | - | // key-pressed is NOT emitted; the IM emits "commit" with the result. | |
| 222 | - | ib.imContext = gtk.NewIMContextSimple() | |
| 223 | - | if ib.imContext != nil { | |
| 224 | - | ib.commitCb = func(_ gtk.IMContext, text string) { | |
| 225 | - | ib.onIMCommit(text) | |
| 226 | - | } | |
| 227 | - | ib.imContext.ConnectCommit(&ib.commitCb) | |
| 228 | - | key.SetImContext(&ib.imContext.IMContext) | |
| 229 | - | ib.imContext.SetClientWidget(&glArea.Widget) | |
| 230 | - | } | |
| 231 | - | ||
| 232 | - | keyPressCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) bool { | |
| 233 | - | mods := uint(state) | |
| 234 | - | ||
| 235 | - | ib.maybeMirrorClipboardShortcut(keyval, mods) | |
| 236 | - | ||
| 237 | - | // Intercept Ctrl+V / Ctrl+Shift+V: CEF OSR cannot access the | |
| 238 | - | // Wayland/X11 clipboard, so we read from GDK and inject via | |
| 239 | - | // ImeCommitText instead of letting CEF handle the paste. | |
| 240 | - | if mods&uint(gdk.ControlMaskValue) != 0 && | |
| 241 | - | (keyval == gdkKeyLowercaseV || keyval == gdkKeyUppercaseV) { | |
| 242 | - | logging.FromContext(ib.ctx).Debug(). | |
| 243 | - | Uint("keyval", keyval). | |
| 244 | - | Uint("mods", mods). | |
| 245 | - | Msg("cef: clipboard paste intercepted (Ctrl+V)") | |
| 246 | - | ib.pasteFromClipboard() | |
| 247 | - | return true | |
| 248 | - | } | |
| 249 | - | ||
| 250 | - | ib.onKeyPress(keyval, keycode, mods) | |
| 251 | - | ||
| 252 | - | // Let modifier combos and function keys propagate to the window's | |
| 253 | - | // ShortcutController so that app shortcuts (Ctrl+L, F12, Escape, …) | |
| 254 | - | // still fire. Only consume plain text input keys. | |
| 255 | - | if mods&uint(gdk.ControlMaskValue) != 0 || mods&uint(gdk.AltMaskValue) != 0 { | |
| 256 | - | return false | |
| 257 | - | } | |
| 258 | - | // F-keys (F1–F12) and Escape are app shortcuts, not text input. | |
| 259 | - | if keyval >= gdkKeyF1Start && keyval <= gdkKeyF12End { | |
| 260 | - | return false | |
| 261 | - | } | |
| 262 | - | if keyval == gdkKeyEscape { | |
| 263 | - | return false | |
| 264 | - | } | |
| 265 | - | return true | |
| 266 | - | } | |
| 267 | - | key.ConnectKeyPressed(&keyPressCb) | |
| 268 | - | ||
| 269 | - | keyReleaseCb := func(_ gtk.EventControllerKey, keyval, keycode uint, state gdk.ModifierType) { | |
| 270 | - | ib.onKeyRelease(keyval, keycode, uint(state)) | |
| 271 | - | } | |
| 272 | - | key.ConnectKeyReleased(&keyReleaseCb) | |
| 273 | - | ||
| 274 | - | glArea.AddController(&key.EventController) | |
| 275 | - | ||
| 276 | - | // Make the GLArea focusable so it can receive key events. | |
| 277 | - | glArea.SetFocusable(true) | |
| 278 | - | glArea.SetCanFocus(true) | |
| 279 | - | } | |
| 280 | - | ||
| 281 | - | // --------------------------------------------------------------------------- | |
| 282 | - | // Event handlers | |
| 283 | - | // --------------------------------------------------------------------------- | |
| 284 | - | ||
| 285 | - | func (ib *inputBridge) onMouseMove(x, y float64, mods uint, leave bool) { | |
| 286 | - | ib.mu.Lock() | |
| 287 | - | host := ib.host | |
| 288 | - | if !leave { | |
| 289 | - | ib.lastX = x | |
| 290 | - | ib.lastY = y | |
| 291 | - | } | |
| 292 | - | ib.mu.Unlock() | |
| 293 | - | if host == nil { | |
| 294 | - | return | |
| 295 | - | } | |
| 296 | - | ||
| 297 | - | evt := buildMouseEvent(x, y, mods, ib.scale) | |
| 298 | - | mouseLeave := int32(0) | |
| 299 | - | if leave { | |
| 300 | - | mouseLeave = 1 | |
| 301 | - | } | |
| 302 | - | host.SendMouseMoveEvent(&evt, mouseLeave) | |
| 303 | - | } | |
| 304 | - | ||
| 305 | - | func (ib *inputBridge) onMousePress(x, y float64, button, mods uint, clickCount int) { | |
| 306 | - | ib.mu.Lock() | |
| 307 | - | host := ib.host | |
| 308 | - | middleCB := ib.onMiddleClick | |
| 309 | - | ib.mu.Unlock() | |
| 310 | - | if host == nil { | |
| 311 | - | return | |
| 312 | - | } | |
| 313 | - | ||
| 314 | - | // Middle-click on a link opens in a new tab instead of sending to CEF. | |
| 315 | - | // The empty string parameter is unused — the callback closure resolves | |
| 316 | - | // the actual URI via wv.lastHoverURI at invocation time. | |
| 317 | - | if button == 2 && middleCB != nil { | |
| 318 | - | go middleCB("") | |
| 319 | - | return | |
| 320 | - | } | |
| 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 | - | ||
| 329 | - | evt := buildMouseEvent(x, y, mods, ib.scale) | |
| 330 | - | btn := translateMouseButton(button) | |
| 331 | - | host.SendMouseClickEvent(&evt, btn, 0, int32(clickCount)) | |
| 332 | - | } | |
| 333 | - | ||
| 334 | - | func (ib *inputBridge) onMouseRelease(x, y float64, button, mods uint, clickCount int) { | |
| 335 | - | ib.mu.Lock() | |
| 336 | - | host := ib.host | |
| 337 | - | ib.mu.Unlock() | |
| 338 | - | if host == nil { | |
| 339 | - | return | |
| 340 | - | } | |
| 341 | - | ||
| 342 | - | evt := buildMouseEvent(x, y, mods, ib.scale) | |
| 343 | - | btn := translateMouseButton(button) | |
| 344 | - | host.SendMouseClickEvent(&evt, btn, 1, int32(clickCount)) | |
| 345 | - | } | |
| 346 | - | ||
| 347 | - | func (ib *inputBridge) onScroll(dx, dy float64) { | |
| 348 | - | ib.mu.Lock() | |
| 349 | - | host := ib.host | |
| 350 | - | x, y := ib.lastX, ib.lastY | |
| 351 | - | ib.mu.Unlock() | |
| 352 | - | if host == nil { | |
| 353 | - | return | |
| 354 | - | } | |
| 355 | - | ||
| 356 | - | evt := buildMouseEvent(x, y, 0, ib.scale) | |
| 357 | - | deltaX, deltaY := translateScrollDeltas(dx, dy) | |
| 358 | - | host.SendMouseWheelEvent(&evt, deltaX, deltaY) | |
| 359 | - | } | |
| 360 | - | ||
| 361 | - | func (ib *inputBridge) onFocusIn() { | |
| 362 | - | ib.mu.Lock() | |
| 363 | - | host := ib.host | |
| 364 | - | ib.mu.Unlock() | |
| 365 | - | syncWindowlessBrowserFocus(host) | |
| 366 | - | } | |
| 367 | - | ||
| 368 | - | func (ib *inputBridge) onFocusOut() { | |
| 369 | - | ib.mu.Lock() | |
| 370 | - | host := ib.host | |
| 371 | - | ib.mu.Unlock() | |
| 372 | - | if host == nil { | |
| 373 | - | return | |
| 374 | - | } | |
| 375 | - | host.SetFocus(0) | |
| 376 | - | } | |
| 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 | - | ||
| 418 | - | func (ib *inputBridge) onKeyPress(keyval, keycode, mods uint) { | |
| 419 | - | ib.mu.Lock() | |
| 420 | - | host := ib.host | |
| 421 | - | ib.mu.Unlock() | |
| 422 | - | if host == nil { | |
| 423 | - | return | |
| 424 | - | } | |
| 425 | - | ||
| 426 | - | // Dead keys should have been absorbed by the IMContext. If one leaks | |
| 427 | - | // through (e.g. no IMContext), drop it to avoid sending a bogus VK=0 | |
| 428 | - | // RAWKEYDOWN that confuses CEF. | |
| 429 | - | if keyval >= gdkDeadKeyStart && keyval <= gdkDeadKeyEnd { | |
| 430 | - | return | |
| 431 | - | } | |
| 432 | - | ||
| 433 | - | // Send RAWKEYDOWN first, then CHAR if printable. | |
| 434 | - | evt := buildKeyEvent(keyval, keycode, mods, purecef.KeyEventTypeKeyeventRawkeydown) | |
| 435 | - | host.SendKeyEvent(&evt) | |
| 436 | - | ||
| 437 | - | // Follow up with a CHAR event for printable characters. | |
| 438 | - | if ch := keyvalToChar(keyval); ch != 0 { | |
| 439 | - | charEvt := buildKeyEvent(keyval, keycode, mods, purecef.KeyEventTypeKeyeventChar) | |
| 440 | - | // CHAR events use the character code as WindowsKeyCode (not the VK code). | |
| 441 | - | charEvt.WindowsKeyCode = int32(ch) | |
| 442 | - | charEvt.Character = ch | |
| 443 | - | charEvt.UnmodifiedCharacter = ch | |
| 444 | - | host.SendKeyEvent(&charEvt) | |
| 445 | - | } | |
| 446 | - | } | |
| 447 | - | ||
| 448 | - | // onIMCommit is called when the IMContext finishes a compose sequence and | |
| 449 | - | // commits text (e.g. dead_acute + e → "é"). We send a CHAR event for each | |
| 450 | - | // rune to CEF. No RAWKEYDOWN is needed for composed text — this matches | |
| 451 | - | // Chromium's internal IME integration behavior on Linux. | |
| 452 | - | func (ib *inputBridge) onIMCommit(text string) { | |
| 453 | - | ib.mu.Lock() | |
| 454 | - | host := ib.host | |
| 455 | - | ib.mu.Unlock() | |
| 456 | - | if host == nil { | |
| 457 | - | return | |
| 458 | - | } | |
| 459 | - | ||
| 460 | - | for _, r := range text { | |
| 461 | - | if r > maxBMPCodepoint { | |
| 462 | - | // Non-BMP character (emoji, etc.) — skip for now. | |
| 463 | - | // Full UTF-16 surrogate pair support can be added later. | |
| 464 | - | continue | |
| 465 | - | } | |
| 466 | - | ch := uint16(r) | |
| 467 | - | evt := purecef.NewKeyEvent() | |
| 468 | - | evt.Type = purecef.KeyEventTypeKeyeventChar | |
| 469 | - | evt.WindowsKeyCode = int32(ch) | |
| 470 | - | evt.Character = ch | |
| 471 | - | evt.UnmodifiedCharacter = ch | |
| 472 | - | host.SendKeyEvent(&evt) | |
| 473 | - | } | |
| 474 | - | } | |
| 475 | - | ||
| 476 | - | func (ib *inputBridge) onKeyRelease(keyval, keycode, mods uint) { | |
| 477 | - | ib.mu.Lock() | |
| 478 | - | host := ib.host | |
| 479 | - | ib.mu.Unlock() | |
| 480 | - | if host == nil { | |
| 481 | - | return | |
| 482 | - | } | |
| 483 | - | ||
| 484 | - | evt := buildKeyEvent(keyval, keycode, mods, purecef.KeyEventTypeKeyeventKeyup) | |
| 485 | - | host.SendKeyEvent(&evt) | |
| 486 | - | } | |
| 487 | - | ||
| 488 | - | // pasteFromClipboard reads text from the GDK clipboard asynchronously and | |
| 489 | - | // injects it into the focused CEF input via document.execCommand('insertText'). | |
| 490 | - | // ImeCommitText only works during active IME compositions, so we use JS | |
| 491 | - | // injection which works for any focused editable element. | |
| 492 | - | func (ib *inputBridge) pasteFromClipboard() { | |
| 493 | - | log := logging.FromContext(ib.ctx) | |
| 494 | - | ||
| 495 | - | cb := ib.clipboard | |
| 496 | - | if cb == nil { | |
| 497 | - | log.Warn().Msg("cef: paste aborted — no GDK clipboard reference") | |
| 498 | - | return | |
| 499 | - | } | |
| 500 | - | ib.mu.Lock() | |
| 501 | - | host := ib.host | |
| 502 | - | ib.mu.Unlock() | |
| 503 | - | if host == nil { | |
| 504 | - | log.Warn().Msg("cef: paste aborted — no browser host") | |
| 505 | - | return | |
| 506 | - | } | |
| 507 | - | ||
| 508 | - | log.Debug().Msg("cef: calling ReadTextAsync on GDK clipboard") | |
| 509 | - | ||
| 510 | - | asyncCb := gio.AsyncReadyCallback(func(_, resultPtr, _ uintptr) { | |
| 511 | - | log.Debug().Uint64("result_ptr", uint64(resultPtr)).Msg("cef: ReadTextAsync callback fired") | |
| 512 | - | ||
| 513 | - | result := &gio.AsyncResultBase{Ptr: resultPtr} | |
| 514 | - | text, err := cb.ReadTextFinish(result) | |
| 515 | - | if err != nil { | |
| 516 | - | log.Warn().Err(err).Msg("cef: ReadTextFinish failed") | |
| 517 | - | return | |
| 518 | - | } | |
| 519 | - | if text == "" { | |
| 520 | - | log.Debug().Msg("cef: clipboard text is empty, nothing to paste") | |
| 521 | - | return | |
| 522 | - | } | |
| 523 | - | ||
| 524 | - | log.Debug().Int("text_len", len(text)).Msg("cef: injecting clipboard text via JS execCommand") | |
| 525 | - | ||
| 526 | - | ib.mu.Lock() | |
| 527 | - | h := ib.host | |
| 528 | - | ib.mu.Unlock() | |
| 529 | - | if h == nil { | |
| 530 | - | log.Warn().Msg("cef: paste — host became nil before JS injection") | |
| 531 | - | return | |
| 532 | - | } | |
| 533 | - | ||
| 534 | - | browser := h.GetBrowser() | |
| 535 | - | if browser == nil { | |
| 536 | - | log.Warn().Msg("cef: paste — GetBrowser returned nil") | |
| 537 | - | return | |
| 538 | - | } | |
| 539 | - | frame := browser.GetMainFrame() | |
| 540 | - | if frame == nil { | |
| 541 | - | log.Warn().Msg("cef: paste — GetMainFrame returned nil") | |
| 542 | - | return | |
| 543 | - | } | |
| 544 | - | ||
| 545 | - | // Escape the text for embedding in a JS string literal. | |
| 546 | - | escaped := webutil.EscapeForJSString(text) | |
| 547 | - | js := "document.execCommand('insertText',false,'" + escaped + "')" | |
| 548 | - | frame.ExecuteJavaScript(js, "", 0) | |
| 549 | - | log.Debug().Msg("cef: paste JS executed") | |
| 550 | - | }) | |
| 551 | - | ||
| 552 | - | cb.ReadTextAsync(nil, &asyncCb, 0) | |
| 553 | - | log.Debug().Msg("cef: ReadTextAsync dispatched") | |
| 554 | - | } | |
| 555 | - | ||
| 556 | - | // --------------------------------------------------------------------------- | |
| 557 | - | // Translation helpers | |
| 558 | - | // --------------------------------------------------------------------------- | |
| 559 | - | ||
| 560 | - | func buildMouseEvent(x, y float64, gdkMods uint, scale int32) purecef.MouseEvent { | |
| 561 | - | return purecef.MouseEvent{ | |
| 562 | - | X: int32(x * float64(scale)), | |
| 563 | - | Y: int32(y * float64(scale)), | |
| 564 | - | Modifiers: translateModifiers(gdkMods), | |
| 565 | - | } | |
| 566 | - | } | |
| 567 | - | ||
| 568 | - | func buildKeyEvent(keyval, keycode, gdkMods uint, eventType purecef.KeyEventType) purecef.KeyEvent { | |
| 569 | - | evt := purecef.NewKeyEvent() | |
| 570 | - | evt.Type = eventType | |
| 571 | - | evt.WindowsKeyCode = gdkKeyvalToWindowsVK(keyval) | |
| 572 | - | evt.NativeKeyCode = int32(keycode) | |
| 573 | - | evt.Modifiers = translateModifiers(gdkMods) | |
| 574 | - | return evt | |
| 575 | - | } | |
| 576 | - | ||
| 577 | - | // translateModifiers maps GDK modifier masks to CEF EventFlags values. | |
| 578 | - | func translateModifiers(gdkState uint) uint32 { | |
| 579 | - | var flags uint32 | |
| 580 | - | ||
| 581 | - | if gdkState&uint(gdk.ShiftMaskValue) != 0 { | |
| 582 | - | flags |= uint32(purecef.EventFlagsEventflagShiftDown) | |
| 583 | - | } | |
| 584 | - | if gdkState&uint(gdk.ControlMaskValue) != 0 { | |
| 585 | - | flags |= uint32(purecef.EventFlagsEventflagControlDown) | |
| 586 | - | } | |
| 587 | - | if gdkState&uint(gdk.AltMaskValue) != 0 { | |
| 588 | - | flags |= uint32(purecef.EventFlagsEventflagAltDown) | |
| 589 | - | } | |
| 590 | - | if gdkState&uint(gdk.Button1MaskValue) != 0 { | |
| 591 | - | flags |= uint32(purecef.EventFlagsEventflagLeftMouseButton) | |
| 592 | - | } | |
| 593 | - | if gdkState&uint(gdk.Button2MaskValue) != 0 { | |
| 594 | - | flags |= uint32(purecef.EventFlagsEventflagMiddleMouseButton) | |
| 595 | - | } | |
| 596 | - | if gdkState&uint(gdk.Button3MaskValue) != 0 { | |
| 597 | - | flags |= uint32(purecef.EventFlagsEventflagRightMouseButton) | |
| 598 | - | } | |
| 599 | - | ||
| 600 | - | return flags | |
| 601 | - | } | |
| 602 | - | ||
| 603 | - | // translateMouseButton maps a GDK button number to a CEF MouseButtonType. | |
| 604 | - | func translateMouseButton(gdkButton uint) purecef.MouseButtonType { | |
| 605 | - | switch gdkButton { | |
| 606 | - | case 1: | |
| 607 | - | return purecef.MouseButtonTypeMbtLeft | |
| 608 | - | case 2: | |
| 609 | - | return purecef.MouseButtonTypeMbtMiddle | |
| 610 | - | case 3: | |
| 611 | - | return purecef.MouseButtonTypeMbtRight | |
| 612 | - | default: | |
| 613 | - | return purecef.MouseButtonTypeMbtLeft | |
| 614 | - | } | |
| 615 | - | } | |
| 616 | - | ||
| 617 | - | // cefScrollUnitsPerNotch is the number of scroll units CEF expects per mouse wheel notch. | |
| 618 | - | const cefScrollUnitsPerNotch = 120 | |
| 619 | - | ||
| 620 | - | // translateScrollDeltas converts GDK scroll deltas to CEF units. | |
| 621 | - | func translateScrollDeltas(dx, dy float64) (int32, int32) { | |
| 622 | - | return int32(dx * cefScrollUnitsPerNotch), int32(-dy * cefScrollUnitsPerNotch) // CEF: positive Y = scroll up | |
| 623 | - | } | |
| 624 | - | ||
| 625 | - | // GDK keyval constants for special keys. | |
| 626 | - | const ( | |
| 627 | - | gdkKeyReturn = 0xff0d | |
| 628 | - | gdkKeyTab = 0xff09 | |
| 629 | - | gdkKeyBackSpace = 0xff08 | |
| 630 | - | gdkKeyEscape = 0xff1b | |
| 631 | - | gdkKeyDelete = 0xffff | |
| 632 | - | gdkKeySpace = 0x020 | |
| 633 | - | gdkKeyHome = 0xff50 | |
| 634 | - | gdkKeyEnd = 0xff57 | |
| 635 | - | gdkKeyPageUp = 0xff55 | |
| 636 | - | gdkKeyPageDown = 0xff56 | |
| 637 | - | ||
| 638 | - | // Clipboard shortcut keyvals. | |
| 639 | - | gdkKeyLowercaseC = 0x063 | |
| 640 | - | gdkKeyUppercaseC = 0x043 | |
| 641 | - | gdkKeyLowercaseV = 0x076 | |
| 642 | - | gdkKeyUppercaseV = 0x056 | |
| 643 | - | gdkKeyLowercaseX = 0x078 | |
| 644 | - | gdkKeyUppercaseX = 0x058 | |
| 645 | - | ) | |
| 646 | - | ||
| 647 | - | // keyvalToChar converts a GDK keyval to a UTF-16 character for CEF CHAR events. | |
| 648 | - | // Returns 0 for non-printable keys (arrows, F-keys, modifiers, dead keys, …). | |
| 649 | - | // Uses gdk.KeyvalToUnicode for full Unicode coverage (accented Latin, Cyrillic, …). | |
| 650 | - | func keyvalToChar(keyval uint) uint16 { | |
| 651 | - | switch keyval { | |
| 652 | - | case gdkKeyReturn: | |
| 653 | - | return '\r' | |
| 654 | - | case gdkKeyTab: | |
| 655 | - | return '\t' | |
| 656 | - | case gdkKeyBackSpace: | |
| 657 | - | return '\b' | |
| 658 | - | } | |
| 659 | - | ||
| 660 | - | cp := gdk.KeyvalToUnicode(keyval) | |
| 661 | - | if cp == 0 || cp > maxBMPCodepoint { | |
| 662 | - | return 0 | |
| 663 | - | } | |
| 664 | - | // Filter control characters (except the three handled above). | |
| 665 | - | if cp < minPrintable { | |
| 666 | - | return 0 | |
| 667 | - | } | |
| 668 | - | return uint16(cp) | |
| 669 | - | } | |
| 670 | - | ||
| 671 | - | // gdkKeyvalToWindowsVK translates a GDK keyval to a Windows virtual-key code. | |
| 672 | - | func gdkKeyvalToWindowsVK(keyval uint) int32 { | |
| 673 | - | if vk, ok := gdkKeyvalToVKRange(keyval); ok { | |
| 674 | - | return vk | |
| 675 | - | } | |
| 676 | - | return gdkKeyvalToVKLookup(keyval) | |
| 677 | - | } | |
| 678 | - | ||
| 679 | - | // GDK keyval range boundaries for contiguous mappings. | |
| 680 | - | const ( | |
| 681 | - | gdkKeyLowercaseAStart = 0x061 | |
| 682 | - | gdkKeyLowercaseAEnd = 0x07a | |
| 683 | - | gdkKeyUppercaseAStart = 0x041 | |
| 684 | - | gdkKeyUppercaseAEnd = 0x05a | |
| 685 | - | gdkKeyDigit0Start = 0x030 | |
| 686 | - | gdkKeyDigit9End = 0x039 | |
| 687 | - | gdkKeyF1Start = 0xffbe | |
| 688 | - | gdkKeyF12End = 0xffc9 | |
| 689 | - | gdkKeyArrowStart = 0xff51 | |
| 690 | - | gdkKeyArrowEnd = 0xff54 | |
| 691 | - | ||
| 692 | - | vkA = 0x41 | |
| 693 | - | vkF1 = 0x70 | |
| 694 | - | vkArrowLeft = 0x25 | |
| 695 | - | ) | |
| 696 | - | ||
| 697 | - | // gdkKeyvalToVKRange handles contiguous GDK keyval ranges that map linearly | |
| 698 | - | // to Windows virtual-key codes (letters, digits, F-keys, arrows). | |
| 699 | - | func gdkKeyvalToVKRange(keyval uint) (int32, bool) { | |
| 700 | - | switch { | |
| 701 | - | // Lowercase letters -> uppercase VK codes (A-Z = 0x41-0x5A) | |
| 702 | - | case keyval >= gdkKeyLowercaseAStart && keyval <= gdkKeyLowercaseAEnd: | |
| 703 | - | return int32(keyval-gdkKeyLowercaseAStart) + vkA, true | |
| 704 | - | // Uppercase letters | |
| 705 | - | case keyval >= gdkKeyUppercaseAStart && keyval <= gdkKeyUppercaseAEnd: | |
| 706 | - | return int32(keyval-gdkKeyUppercaseAStart) + vkA, true | |
| 707 | - | // Digits 0-9 = 0x30-0x39 | |
| 708 | - | case keyval >= gdkKeyDigit0Start && keyval <= gdkKeyDigit9End: | |
| 709 | - | return int32(keyval), true | |
| 710 | - | // F1-F12: GDK 0xffbe-0xffc9 -> VK 0x70-0x7B | |
| 711 | - | case keyval >= gdkKeyF1Start && keyval <= gdkKeyF12End: | |
| 712 | - | return int32(keyval-gdkKeyF1Start) + vkF1, true | |
| 713 | - | // Arrow keys: GDK 0xff51-0xff54 -> VK Left=0x25..Down=0x28 | |
| 714 | - | case keyval >= gdkKeyArrowStart && keyval <= gdkKeyArrowEnd: | |
| 715 | - | return int32(keyval-gdkKeyArrowStart) + vkArrowLeft, true | |
| 716 | - | default: | |
| 717 | - | return 0, false | |
| 718 | - | } | |
| 719 | - | } | |
| 720 | - | ||
| 721 | - | // gdkKeyvalToVKMap maps individual GDK keyvals to Windows virtual-key codes. | |
| 722 | - | // | |
| 723 | - | // Punctuation ASCII values collide with Windows VK codes for navigation keys | |
| 724 | - | // (e.g. '.' = 0x2E = VK_DELETE, '#' = 0x23 = VK_END) so they MUST be mapped | |
| 725 | - | // explicitly. Shifted digit symbols (!, @, #, …) are mapped to their base | |
| 726 | - | // digit VK code to avoid triggering navigation actions. | |
| 727 | - | var gdkKeyvalToVKMap = map[uint]int32{ | |
| 728 | - | // Navigation / editing | |
| 729 | - | gdkKeyReturn: 0x0D, // VK_RETURN | |
| 730 | - | gdkKeyEscape: 0x1B, // VK_ESCAPE | |
| 731 | - | gdkKeyTab: 0x09, // VK_TAB | |
| 732 | - | gdkKeyBackSpace: 0x08, // VK_BACK | |
| 733 | - | gdkKeyDelete: 0x2E, // VK_DELETE | |
| 734 | - | gdkKeySpace: 0x20, // VK_SPACE | |
| 735 | - | gdkKeyHome: 0x24, // VK_HOME | |
| 736 | - | gdkKeyEnd: 0x23, // VK_END | |
| 737 | - | gdkKeyPageUp: 0x21, // VK_PRIOR | |
| 738 | - | gdkKeyPageDown: 0x22, // VK_NEXT | |
| 739 | - | 0xff63: 0x2D, // Insert → VK_INSERT | |
| 740 | - | ||
| 741 | - | // Modifiers | |
| 742 | - | 0xffe1: 0xA0, // Shift_L → VK_LSHIFT | |
| 743 | - | 0xffe2: 0xA1, // Shift_R → VK_RSHIFT | |
| 744 | - | 0xffe3: 0xA2, // Control_L → VK_LCONTROL | |
| 745 | - | 0xffe4: 0xA3, // Control_R → VK_RCONTROL | |
| 746 | - | 0xffe9: 0xA4, // Alt_L → VK_LMENU | |
| 747 | - | 0xffea: 0xA5, // Alt_R → VK_RMENU | |
| 748 | - | ||
| 749 | - | // OEM punctuation — unshifted + shifted share the same VK | |
| 750 | - | '.': 0xBE, '>': 0xBE, // VK_OEM_PERIOD | |
| 751 | - | ',': 0xBC, '<': 0xBC, // VK_OEM_COMMA | |
| 752 | - | '-': 0xBD, '_': 0xBD, // VK_OEM_MINUS | |
| 753 | - | '=': 0xBB, '+': 0xBB, // VK_OEM_PLUS | |
| 754 | - | ';': 0xBA, ':': 0xBA, // VK_OEM_1 | |
| 755 | - | '/': 0xBF, '?': 0xBF, // VK_OEM_2 | |
| 756 | - | '`': 0xC0, '~': 0xC0, // VK_OEM_3 | |
| 757 | - | '[': 0xDB, '{': 0xDB, // VK_OEM_4 | |
| 758 | - | '\\': 0xDC, '|': 0xDC, // VK_OEM_5 | |
| 759 | - | ']': 0xDD, '}': 0xDD, // VK_OEM_6 | |
| 760 | - | '\'': 0xDE, '"': 0xDE, // VK_OEM_7 | |
| 761 | - | ||
| 762 | - | // Shifted digit symbols (Shift+1→'!' etc.) | |
| 763 | - | '!': 0x31, '@': 0x32, '#': 0x33, '$': 0x34, '%': 0x35, | |
| 764 | - | '^': 0x36, '&': 0x37, '*': 0x38, '(': 0x39, ')': 0x30, | |
| 765 | - | } | |
| 766 | - | ||
| 767 | - | // gdkKeyvalToVKLookup handles individual GDK keyvals that don't fall into | |
| 768 | - | // contiguous ranges (letters, digits, F-keys, arrows). | |
| 769 | - | func gdkKeyvalToVKLookup(keyval uint) int32 { | |
| 770 | - | if vk, ok := gdkKeyvalToVKMap[keyval]; ok { | |
| 771 | - | return vk | |
| 772 | - | } | |
| 773 | - | // For unmapped keys, return the keyval if it fits in a reasonable range. | |
| 774 | - | // This covers misc keys whose ASCII value doesn't collide with | |
| 775 | - | // dangerous VK codes (e.g. space=0x20=VK_SPACE — correct match). | |
| 776 | - | if keyval < maxSingleByteKeyval { | |
| 777 | - | return int32(keyval) | |
| 778 | - | } | |
| 779 | - | return 0 | |
| 780 | - | } | |
D internal/infrastructure/cef/input_bridge_test.go +0 -85
| @@ -1,85 +0,0 @@ | |||
| 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 | - | } | |
A internal/infrastructure/cef/process_diagnostics.go +378 -0
| @@ -0,0 +1,378 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "context" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "os/exec" | |
| 9 | + | "path/filepath" | |
| 10 | + | "sort" | |
| 11 | + | "strconv" | |
| 12 | + | "strings" | |
| 13 | + | "sync/atomic" | |
| 14 | + | "time" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/dumber/internal/logging" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | const ( | |
| 20 | + | procRoot = "/proc" | |
| 21 | + | maxLoggedThreadsPerProcess = 8 | |
| 22 | + | maxDetailedThreadsPerProcess = 48 | |
| 23 | + | threadStatUTimeIndex = 11 | |
| 24 | + | threadStatSTimeIndex = 12 | |
| 25 | + | maxKernelStackLines = 6 | |
| 26 | + | renderStallProcessDiagnosticsCooldown = 30 * time.Second | |
| 27 | + | renderStallBacktraceCooldown = 30 * time.Second | |
| 28 | + | renderStallBacktraceCommandLimit = 64 * 1024 | |
| 29 | + | ) | |
| 30 | + | ||
| 31 | + | var ( | |
| 32 | + | renderStallProcessDiagnosticsLastUnixNS atomic.Int64 | |
| 33 | + | renderStallBacktraceLastUnixNS atomic.Int64 | |
| 34 | + | ) | |
| 35 | + | ||
| 36 | + | func (wv *WebView) logCEFProcessDiagnostics(reason string, classification renderStallClassification) { | |
| 37 | + | if wv == nil || wv.ctx == nil { | |
| 38 | + | return | |
| 39 | + | } | |
| 40 | + | if !reserveRenderStallProcessDiagnostics(time.Now()) { | |
| 41 | + | return | |
| 42 | + | } | |
| 43 | + | pids := append([]int{os.Getpid()}, childPIDs(os.Getpid())...) | |
| 44 | + | for _, pid := range pids { | |
| 45 | + | cmdline := procCmdline(pid) | |
| 46 | + | processType := chromiumProcessType(cmdline) | |
| 47 | + | threads := procThreadSummaries(pid, maxLoggedThreadsPerProcess) | |
| 48 | + | detailedThreads := []string(nil) | |
| 49 | + | if classification.CEFUIBlocked && processType == "browser" { | |
| 50 | + | detailedThreads = procDetailedThreadSummaries(pid, maxDetailedThreadsPerProcess) | |
| 51 | + | } | |
| 52 | + | logging.FromContext(wv.ctx).Warn(). | |
| 53 | + | Str("reason", reason). | |
| 54 | + | Str("classification", classification.Category). | |
| 55 | + | Bool("cef_ui_blocked", classification.CEFUIBlocked). | |
| 56 | + | Bool("cef_io_alive", classification.CEFIOAlive). | |
| 57 | + | Int("pid", pid). | |
| 58 | + | Str("process_type", processType). | |
| 59 | + | Strs("cmdline_flags", safeChromiumCmdlineFlags(cmdline)). | |
| 60 | + | Int("thread_count", procThreadCount(pid)). | |
| 61 | + | Strs("hot_threads", threads). | |
| 62 | + | Strs("detailed_threads", detailedThreads). | |
| 63 | + | Strs("gpu_fdinfo", gpuFDInfoSummaries(pid)). | |
| 64 | + | Msg("cef: process diagnostic snapshot") | |
| 65 | + | } | |
| 66 | + | if classification.CEFUIBlocked { | |
| 67 | + | wv.maybeCaptureRenderStallBacktrace(reason, classification) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | ||
| 71 | + | func reserveRenderStallProcessDiagnostics(now time.Time) bool { | |
| 72 | + | nowNS := now.UnixNano() | |
| 73 | + | for { | |
| 74 | + | lastNS := renderStallProcessDiagnosticsLastUnixNS.Load() | |
| 75 | + | last := unixNSTime(lastNS) | |
| 76 | + | if !last.IsZero() && now.Sub(last) < renderStallProcessDiagnosticsCooldown { | |
| 77 | + | return false | |
| 78 | + | } | |
| 79 | + | if renderStallProcessDiagnosticsLastUnixNS.CompareAndSwap(lastNS, nowNS) { | |
| 80 | + | return true | |
| 81 | + | } | |
| 82 | + | } | |
| 83 | + | } | |
| 84 | + | ||
| 85 | + | func safeChromiumCmdlineFlags(cmdline string) []string { | |
| 86 | + | fields := strings.Fields(cmdline) | |
| 87 | + | flags := make([]string, 0, len(fields)) | |
| 88 | + | seen := make(map[string]struct{}) | |
| 89 | + | for _, field := range fields { | |
| 90 | + | if !strings.HasPrefix(field, "--") { | |
| 91 | + | continue | |
| 92 | + | } | |
| 93 | + | name, value, hasValue := strings.Cut(field, "=") | |
| 94 | + | var safe string | |
| 95 | + | switch name { | |
| 96 | + | case "--type", "--lang", "--enable-features", "--disable-features", "--ozone-platform", | |
| 97 | + | "--use-gl", "--use-angle", "--enable-logging", "--log-severity": | |
| 98 | + | if hasValue && value != "" { | |
| 99 | + | safe = name + "=" + value | |
| 100 | + | } else { | |
| 101 | + | safe = name | |
| 102 | + | } | |
| 103 | + | case "--enable-gpu-rasterization", "--disable-gpu", "--disable-software-rasterizer", "--in-process-gpu", "--no-sandbox", "--no-zygote": | |
| 104 | + | safe = name | |
| 105 | + | default: | |
| 106 | + | continue | |
| 107 | + | } | |
| 108 | + | if _, ok := seen[safe]; ok { | |
| 109 | + | continue | |
| 110 | + | } | |
| 111 | + | seen[safe] = struct{}{} | |
| 112 | + | flags = append(flags, safe) | |
| 113 | + | } | |
| 114 | + | return flags | |
| 115 | + | } | |
| 116 | + | ||
| 117 | + | func procPath(parts ...string) string { | |
| 118 | + | return filepath.Join(append([]string{procRoot}, parts...)...) | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | func childPIDs(parent int) []int { | |
| 122 | + | if out := childPIDsFromProcChildren(parent); len(out) > 0 { | |
| 123 | + | return out | |
| 124 | + | } | |
| 125 | + | entries, err := os.ReadDir(procRoot) | |
| 126 | + | if err != nil { | |
| 127 | + | return nil | |
| 128 | + | } | |
| 129 | + | var out []int | |
| 130 | + | for _, entry := range entries { | |
| 131 | + | if !entry.IsDir() { | |
| 132 | + | continue | |
| 133 | + | } | |
| 134 | + | pid, err := strconv.Atoi(entry.Name()) | |
| 135 | + | if err != nil { | |
| 136 | + | continue | |
| 137 | + | } | |
| 138 | + | if procPPID(pid) == parent { | |
| 139 | + | out = append(out, pid) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | sort.Ints(out) | |
| 143 | + | return out | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func childPIDsFromProcChildren(parent int) []int { | |
| 147 | + | path := procPath(strconv.Itoa(parent), "task", strconv.Itoa(parent), "children") | |
| 148 | + | b, err := os.ReadFile(path) | |
| 149 | + | if err != nil { | |
| 150 | + | return nil | |
| 151 | + | } | |
| 152 | + | fields := strings.Fields(string(b)) | |
| 153 | + | out := make([]int, 0, len(fields)) | |
| 154 | + | for _, field := range fields { | |
| 155 | + | pid, err := strconv.Atoi(field) | |
| 156 | + | if err != nil { | |
| 157 | + | continue | |
| 158 | + | } | |
| 159 | + | out = append(out, pid) | |
| 160 | + | } | |
| 161 | + | sort.Ints(out) | |
| 162 | + | return out | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | func procPPID(pid int) int { | |
| 166 | + | b, err := os.ReadFile(procPath(strconv.Itoa(pid), "status")) | |
| 167 | + | if err != nil { | |
| 168 | + | return 0 | |
| 169 | + | } | |
| 170 | + | for _, line := range strings.Split(string(b), "\n") { | |
| 171 | + | if !strings.HasPrefix(line, "PPid:") { | |
| 172 | + | continue | |
| 173 | + | } | |
| 174 | + | fields := strings.Fields(line) | |
| 175 | + | if len(fields) >= 2 { | |
| 176 | + | ppid, _ := strconv.Atoi(fields[1]) | |
| 177 | + | return ppid | |
| 178 | + | } | |
| 179 | + | } | |
| 180 | + | return 0 | |
| 181 | + | } | |
| 182 | + | ||
| 183 | + | func procCmdline(pid int) string { | |
| 184 | + | b, err := os.ReadFile(procPath(strconv.Itoa(pid), "cmdline")) | |
| 185 | + | if err != nil { | |
| 186 | + | return "" | |
| 187 | + | } | |
| 188 | + | return strings.TrimSpace(string(bytes.ReplaceAll(b, []byte{0}, []byte{' '}))) | |
| 189 | + | } | |
| 190 | + | ||
| 191 | + | func chromiumProcessType(cmdline string) string { | |
| 192 | + | for _, field := range strings.Fields(cmdline) { | |
| 193 | + | if strings.HasPrefix(field, "--type=") { | |
| 194 | + | return strings.TrimPrefix(field, "--type=") | |
| 195 | + | } | |
| 196 | + | } | |
| 197 | + | return "browser" | |
| 198 | + | } | |
| 199 | + | ||
| 200 | + | func procThreadCount(pid int) int { | |
| 201 | + | entries, err := os.ReadDir(procPath(strconv.Itoa(pid), "task")) | |
| 202 | + | if err != nil { | |
| 203 | + | return 0 | |
| 204 | + | } | |
| 205 | + | return len(entries) | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | type procThreadSummary struct { | |
| 209 | + | tid int | |
| 210 | + | text string | |
| 211 | + | ticks uint64 | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | func procThreadSummaries(pid, limit int) []string { | |
| 215 | + | return procThreadSummariesWithDetails(pid, limit, false) | |
| 216 | + | } | |
| 217 | + | ||
| 218 | + | func procDetailedThreadSummaries(pid, limit int) []string { | |
| 219 | + | return procThreadSummariesWithDetails(pid, limit, true) | |
| 220 | + | } | |
| 221 | + | ||
| 222 | + | func procThreadSummariesWithDetails(pid, limit int, detailed bool) []string { | |
| 223 | + | entries, err := os.ReadDir(procPath(strconv.Itoa(pid), "task")) | |
| 224 | + | if err != nil { | |
| 225 | + | return nil | |
| 226 | + | } | |
| 227 | + | items := make([]procThreadSummary, 0, len(entries)) | |
| 228 | + | for _, entry := range entries { | |
| 229 | + | tid, err := strconv.Atoi(entry.Name()) | |
| 230 | + | if err != nil { | |
| 231 | + | continue | |
| 232 | + | } | |
| 233 | + | b, err := os.ReadFile(procPath(strconv.Itoa(pid), "task", strconv.Itoa(tid), "stat")) | |
| 234 | + | if err != nil { | |
| 235 | + | continue | |
| 236 | + | } | |
| 237 | + | text, ticks := parseThreadStat(tid, string(b)) | |
| 238 | + | items = append(items, procThreadSummary{tid: tid, text: text, ticks: ticks}) | |
| 239 | + | } | |
| 240 | + | sort.Slice(items, func(i, j int) bool { return items[i].ticks > items[j].ticks }) | |
| 241 | + | if limit > 0 && len(items) > limit { | |
| 242 | + | items = items[:limit] | |
| 243 | + | } | |
| 244 | + | if detailed { | |
| 245 | + | for i := range items { | |
| 246 | + | text := items[i].text + " wchan=" + procThreadWchan(pid, items[i].tid) | |
| 247 | + | if stack := procThreadKernelStack(pid, items[i].tid); stack != "" { | |
| 248 | + | text = text + " kernel_stack=" + stack | |
| 249 | + | } | |
| 250 | + | items[i].text = text | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | out := make([]string, 0, len(items)) | |
| 254 | + | for _, item := range items { | |
| 255 | + | out = append(out, item.text) | |
| 256 | + | } | |
| 257 | + | return out | |
| 258 | + | } | |
| 259 | + | ||
| 260 | + | func parseThreadStat(tid int, stat string) (string, uint64) { | |
| 261 | + | open := strings.IndexByte(stat, '(') | |
| 262 | + | closeIdx := strings.LastIndexByte(stat, ')') | |
| 263 | + | if open < 0 || closeIdx <= open || closeIdx+2 >= len(stat) { | |
| 264 | + | return strconv.Itoa(tid), 0 | |
| 265 | + | } | |
| 266 | + | comm := stat[open+1 : closeIdx] | |
| 267 | + | rest := strings.Fields(stat[closeIdx+2:]) | |
| 268 | + | state := "?" | |
| 269 | + | if len(rest) > 0 { | |
| 270 | + | state = rest[0] | |
| 271 | + | } | |
| 272 | + | var ticks uint64 | |
| 273 | + | if len(rest) > threadStatSTimeIndex { | |
| 274 | + | utime, _ := strconv.ParseUint(rest[threadStatUTimeIndex], 10, 64) | |
| 275 | + | stime, _ := strconv.ParseUint(rest[threadStatSTimeIndex], 10, 64) | |
| 276 | + | ticks = utime + stime | |
| 277 | + | } | |
| 278 | + | return "tid=" + strconv.Itoa(tid) + " state=" + state + " ticks=" + strconv.FormatUint(ticks, 10) + " comm=" + comm, ticks | |
| 279 | + | } | |
| 280 | + | ||
| 281 | + | func procThreadWchan(pid, tid int) string { | |
| 282 | + | b, err := os.ReadFile(procPath(strconv.Itoa(pid), "task", strconv.Itoa(tid), "wchan")) | |
| 283 | + | if err != nil { | |
| 284 | + | return "" | |
| 285 | + | } | |
| 286 | + | return strings.TrimSpace(string(b)) | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | func procThreadKernelStack(pid, tid int) string { | |
| 290 | + | b, err := os.ReadFile(procPath(strconv.Itoa(pid), "task", strconv.Itoa(tid), "stack")) | |
| 291 | + | if err != nil { | |
| 292 | + | return "" | |
| 293 | + | } | |
| 294 | + | lines := strings.Split(strings.TrimSpace(string(b)), "\n") | |
| 295 | + | if len(lines) > maxKernelStackLines { | |
| 296 | + | lines = lines[:maxKernelStackLines] | |
| 297 | + | } | |
| 298 | + | return strings.Join(lines, " | ") | |
| 299 | + | } | |
| 300 | + | ||
| 301 | + | func (wv *WebView) maybeCaptureRenderStallBacktrace(reason string, classification renderStallClassification) { | |
| 302 | + | if wv == nil || wv.ctx == nil || wv.destroyed.Load() || !renderStallBacktraceEnabled() { | |
| 303 | + | return | |
| 304 | + | } | |
| 305 | + | now := time.Now() | |
| 306 | + | nowNS := now.UnixNano() | |
| 307 | + | for { | |
| 308 | + | lastNS := renderStallBacktraceLastUnixNS.Load() | |
| 309 | + | last := unixNSTime(lastNS) | |
| 310 | + | if !last.IsZero() && now.Sub(last) < renderStallBacktraceCooldown { | |
| 311 | + | return | |
| 312 | + | } | |
| 313 | + | if renderStallBacktraceLastUnixNS.CompareAndSwap(lastNS, nowNS) { | |
| 314 | + | break | |
| 315 | + | } | |
| 316 | + | } | |
| 317 | + | pid := os.Getpid() | |
| 318 | + | if _, err := exec.LookPath("gdb"); err != nil { | |
| 319 | + | logging.FromContext(wv.ctx).Warn(). | |
| 320 | + | Err(err). | |
| 321 | + | Str("reason", reason). | |
| 322 | + | Str("classification", classification.Category). | |
| 323 | + | Int("pid", pid). | |
| 324 | + | Str("external_command", fmt.Sprintf("gdb -p %d -batch -ex 'thread apply all bt'", pid)). | |
| 325 | + | Msg("cef: render stall native backtrace skipped; gdb not available") | |
| 326 | + | return | |
| 327 | + | } | |
| 328 | + | if wv.ctx == nil || wv.destroyed.Load() || !renderStallBacktraceEnabled() { | |
| 329 | + | return | |
| 330 | + | } | |
| 331 | + | logCtx := wv.ctx | |
| 332 | + | go func() { | |
| 333 | + | ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) | |
| 334 | + | defer cancel() | |
| 335 | + | cmd := exec.CommandContext(ctx, "gdb", "-p", strconv.Itoa(pid), "-batch", "-ex", "thread apply all bt") | |
| 336 | + | out, err := cmd.CombinedOutput() | |
| 337 | + | truncated := false | |
| 338 | + | if len(out) > renderStallBacktraceCommandLimit { | |
| 339 | + | out = out[:renderStallBacktraceCommandLimit] | |
| 340 | + | truncated = true | |
| 341 | + | } | |
| 342 | + | log := logging.FromContext(logCtx).Warn(). | |
| 343 | + | Str("reason", reason). | |
| 344 | + | Str("classification", classification.Category). | |
| 345 | + | Int("pid", pid). | |
| 346 | + | Bool("truncated", truncated). | |
| 347 | + | Str("backtrace", string(out)) | |
| 348 | + | if ctx.Err() != nil { | |
| 349 | + | log = log.Err(ctx.Err()) | |
| 350 | + | } else if err != nil { | |
| 351 | + | log = log.Err(err) | |
| 352 | + | } | |
| 353 | + | log.Msg("cef: render stall native backtrace") | |
| 354 | + | }() | |
| 355 | + | } | |
| 356 | + | ||
| 357 | + | func gpuFDInfoSummaries(pid int) []string { | |
| 358 | + | entries, err := os.ReadDir(procPath(strconv.Itoa(pid), "fdinfo")) | |
| 359 | + | if err != nil { | |
| 360 | + | return nil | |
| 361 | + | } | |
| 362 | + | var out []string | |
| 363 | + | for _, entry := range entries { | |
| 364 | + | b, err := os.ReadFile(procPath(strconv.Itoa(pid), "fdinfo", entry.Name())) | |
| 365 | + | if err != nil { | |
| 366 | + | continue | |
| 367 | + | } | |
| 368 | + | for _, line := range strings.Split(string(b), "\n") { | |
| 369 | + | if strings.Contains(line, "drm-engine-") || strings.HasPrefix(line, "drm-client-id") || strings.HasPrefix(line, "drm-pdev") { | |
| 370 | + | out = append(out, "fd="+entry.Name()+" "+strings.TrimSpace(line)) | |
| 371 | + | } | |
| 372 | + | } | |
| 373 | + | } | |
| 374 | + | if len(out) > 32 { | |
| 375 | + | out = out[:32] | |
| 376 | + | } | |
| 377 | + | return out | |
| 378 | + | } | |
A internal/infrastructure/cef/process_diagnostics_test.go +35 -0
| @@ -0,0 +1,35 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "reflect" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | func TestSafeChromiumCmdlineFlagsOmitsPathBearingAndUnknownFlags(t *testing.T) { | |
| 10 | + | cmdline := "/tmp/dumber --type=gpu-process --user-data-dir=/home/user/.config/dumber --use-gl=angle --password=secret --no-sandbox" | |
| 11 | + | ||
| 12 | + | got := safeChromiumCmdlineFlags(cmdline) | |
| 13 | + | want := []string{"--type=gpu-process", "--use-gl=angle", "--no-sandbox"} | |
| 14 | + | ||
| 15 | + | if !reflect.DeepEqual(got, want) { | |
| 16 | + | t.Fatalf("safe flags = %#v, want %#v", got, want) | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | func TestReserveRenderStallProcessDiagnosticsAppliesCooldown(t *testing.T) { | |
| 21 | + | prev := renderStallProcessDiagnosticsLastUnixNS.Load() | |
| 22 | + | t.Cleanup(func() { renderStallProcessDiagnosticsLastUnixNS.Store(prev) }) | |
| 23 | + | renderStallProcessDiagnosticsLastUnixNS.Store(0) | |
| 24 | + | now := time.Unix(100, 0) | |
| 25 | + | ||
| 26 | + | if !reserveRenderStallProcessDiagnostics(now) { | |
| 27 | + | t.Fatal("first process diagnostic reservation failed") | |
| 28 | + | } | |
| 29 | + | if reserveRenderStallProcessDiagnostics(now.Add(renderStallProcessDiagnosticsCooldown / 2)) { | |
| 30 | + | t.Fatal("process diagnostic reservation inside cooldown succeeded") | |
| 31 | + | } | |
| 32 | + | if !reserveRenderStallProcessDiagnostics(now.Add(renderStallProcessDiagnosticsCooldown)) { | |
| 33 | + | t.Fatal("process diagnostic reservation at cooldown boundary failed") | |
| 34 | + | } | |
| 35 | + | } | |
A internal/infrastructure/cef/profile.go +183 -0
| @@ -0,0 +1,183 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "errors" | |
| 6 | + | "fmt" | |
| 7 | + | "os" | |
| 8 | + | "path/filepath" | |
| 9 | + | "strconv" | |
| 10 | + | "strings" | |
| 11 | + | "sync" | |
| 12 | + | "time" | |
| 13 | + | ||
| 14 | + | cef2gtk "github.com/bnema/purego-cef2gtk" | |
| 15 | + | ||
| 16 | + | "github.com/bnema/dumber/internal/logging" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | const ( | |
| 20 | + | cef2gtkProfileEnv = "DUMBER_CEF2GTK_PROFILE" | |
| 21 | + | cef2gtkProfileIntervalEnv = "DUMBER_CEF2GTK_PROFILE_INTERVAL" | |
| 22 | + | cef2gtkProfileOutputEnv = "DUMBER_CEF2GTK_PROFILE_OUTPUT" | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | var errProfileWriterClosed = errors.New("cef2gtk profile writer is closed") | |
| 26 | + | ||
| 27 | + | type lockedProfileWriter struct { | |
| 28 | + | mu sync.Mutex | |
| 29 | + | file *os.File | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func (w *lockedProfileWriter) Write(p []byte) (int, error) { | |
| 33 | + | if w == nil { | |
| 34 | + | return 0, errProfileWriterClosed | |
| 35 | + | } | |
| 36 | + | w.mu.Lock() | |
| 37 | + | defer w.mu.Unlock() | |
| 38 | + | if w.file == nil { | |
| 39 | + | return 0, errProfileWriterClosed | |
| 40 | + | } | |
| 41 | + | return w.file.Write(p) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func (w *lockedProfileWriter) Close() error { | |
| 45 | + | if w == nil { | |
| 46 | + | return nil | |
| 47 | + | } | |
| 48 | + | w.mu.Lock() | |
| 49 | + | defer w.mu.Unlock() | |
| 50 | + | if w.file == nil { | |
| 51 | + | return nil | |
| 52 | + | } | |
| 53 | + | err := w.file.Close() | |
| 54 | + | w.file = nil | |
| 55 | + | return err | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | func cef2gtkProfileEnabled() bool { | |
| 59 | + | v := strings.TrimSpace(strings.ToLower(os.Getenv(cef2gtkProfileEnv))) | |
| 60 | + | if v == "" { | |
| 61 | + | return false | |
| 62 | + | } | |
| 63 | + | if b, err := strconv.ParseBool(v); err == nil { | |
| 64 | + | return b | |
| 65 | + | } | |
| 66 | + | switch v { | |
| 67 | + | case "1", "yes", "on", "enabled": | |
| 68 | + | return true | |
| 69 | + | default: | |
| 70 | + | return false | |
| 71 | + | } | |
| 72 | + | } | |
| 73 | + | ||
| 74 | + | func cef2gtkProfileInterval() time.Duration { | |
| 75 | + | v := strings.TrimSpace(os.Getenv(cef2gtkProfileIntervalEnv)) | |
| 76 | + | if v == "" { | |
| 77 | + | return time.Second | |
| 78 | + | } | |
| 79 | + | d, err := time.ParseDuration(v) | |
| 80 | + | if err != nil || d <= 0 { | |
| 81 | + | return time.Second | |
| 82 | + | } | |
| 83 | + | return d | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | type cef2gtkProfileRecord struct { | |
| 87 | + | WebViewID uint64 `json:"webview_id"` | |
| 88 | + | Snapshot cef2gtk.ProfileSnapshot `json:"snapshot"` | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | const ( | |
| 92 | + | profileLogDirPerm = 0o755 | |
| 93 | + | profileLogFilePerm = 0o644 | |
| 94 | + | ) | |
| 95 | + | ||
| 96 | + | func (e *Engine) cef2gtkProfileOptions(wv *WebView) cef2gtk.ProfileOptions { | |
| 97 | + | if e == nil || wv == nil || !cef2gtkProfileEnabled() { | |
| 98 | + | return cef2gtk.ProfileOptions{} | |
| 99 | + | } | |
| 100 | + | path := e.cef2gtkProfilePath() | |
| 101 | + | if path == "" { | |
| 102 | + | return cef2gtk.ProfileOptions{} | |
| 103 | + | } | |
| 104 | + | if err := os.MkdirAll(filepath.Dir(path), profileLogDirPerm); err != nil { | |
| 105 | + | logging.FromContext(e.ctx).Warn().Err(err).Str("path", path).Msg("cef2gtk: failed to create profile log directory") | |
| 106 | + | return cef2gtk.ProfileOptions{} | |
| 107 | + | } | |
| 108 | + | file, err := os.OpenFile(path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, profileLogFilePerm) | |
| 109 | + | if err != nil { | |
| 110 | + | logging.FromContext(e.ctx).Warn().Err(err).Str("path", path).Msg("cef2gtk: failed to open profile log") | |
| 111 | + | return cef2gtk.ProfileOptions{} | |
| 112 | + | } | |
| 113 | + | writer := &lockedProfileWriter{file: file} | |
| 114 | + | wv.profileCleanup = func() { _ = writer.Close() } | |
| 115 | + | logging.FromContext(e.ctx).Info().Str("profile_log", path).Uint64("webview_id", uint64(wv.id)).Msg("cef2gtk profiling enabled") | |
| 116 | + | return cef2gtk.ProfileOptions{ | |
| 117 | + | Enabled: true, | |
| 118 | + | Interval: cef2gtkProfileInterval(), | |
| 119 | + | OnSnapshot: func(s cef2gtk.ProfileSnapshot) { | |
| 120 | + | wv.recordCEF2GTKProfileSnapshot(s) | |
| 121 | + | record := cef2gtkProfileRecord{WebViewID: uint64(wv.id), Snapshot: s} | |
| 122 | + | if b, err := json.Marshal(record); err == nil { | |
| 123 | + | _, _ = writer.Write(append(b, '\n')) | |
| 124 | + | } | |
| 125 | + | logging.FromContext(e.ctx).Debug(). | |
| 126 | + | Uint64("webview_id", uint64(wv.id)). | |
| 127 | + | Uint64("frames_received", s.FramesReceived). | |
| 128 | + | Uint64("frames_rendered", s.FramesRendered). | |
| 129 | + | Uint32("gc_delta", s.GC.NumGCDelta). | |
| 130 | + | Msg("cef2gtk profile snapshot") | |
| 131 | + | }, | |
| 132 | + | } | |
| 133 | + | } | |
| 134 | + | ||
| 135 | + | func (wv *WebView) recordCEF2GTKProfileSnapshot(snapshot cef2gtk.ProfileSnapshot) { | |
| 136 | + | if wv == nil { | |
| 137 | + | return | |
| 138 | + | } | |
| 139 | + | wv.profileMu.Lock() | |
| 140 | + | wv.previousProfileSnapshot = wv.latestProfileSnapshot | |
| 141 | + | wv.latestProfileSnapshot = snapshot | |
| 142 | + | wv.latestProfileSnapshotAt = snapshot.Time | |
| 143 | + | wv.profileMu.Unlock() | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | func (wv *WebView) latestCEF2GTKProfileSnapshot(now time.Time) (cef2gtk.ProfileSnapshot, time.Duration, bool) { | |
| 147 | + | if wv == nil { | |
| 148 | + | return cef2gtk.ProfileSnapshot{}, 0, false | |
| 149 | + | } | |
| 150 | + | wv.profileMu.Lock() | |
| 151 | + | snapshot := wv.latestProfileSnapshot | |
| 152 | + | snapshotAt := wv.latestProfileSnapshotAt | |
| 153 | + | wv.profileMu.Unlock() | |
| 154 | + | if snapshotAt.IsZero() { | |
| 155 | + | return cef2gtk.ProfileSnapshot{}, 0, false | |
| 156 | + | } | |
| 157 | + | age := now.Sub(snapshotAt) | |
| 158 | + | if age < 0 { | |
| 159 | + | age = 0 | |
| 160 | + | } | |
| 161 | + | return snapshot, age, true | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | func (e *Engine) cef2gtkProfilePath() string { | |
| 165 | + | if explicit := strings.TrimSpace(os.Getenv(cef2gtkProfileOutputEnv)); explicit != "" { | |
| 166 | + | return explicit | |
| 167 | + | } | |
| 168 | + | metadata, ok := logging.SessionMetadataFromContext(e.ctx) | |
| 169 | + | if !ok || metadata.ID == "" { | |
| 170 | + | if e.profileLogDir == "" { | |
| 171 | + | return "" | |
| 172 | + | } | |
| 173 | + | return filepath.Join(e.profileLogDir, "cef2gtk_profile.jsonl") | |
| 174 | + | } | |
| 175 | + | logDir := e.profileLogDir | |
| 176 | + | if metadata.LogPath != "" { | |
| 177 | + | logDir = filepath.Dir(metadata.LogPath) | |
| 178 | + | } | |
| 179 | + | if logDir == "" { | |
| 180 | + | return "" | |
| 181 | + | } | |
| 182 | + | return filepath.Join(logDir, fmt.Sprintf("session_%s_cef2gtk_profile.jsonl", metadata.ID)) | |
| 183 | + | } | |
A internal/infrastructure/cef/profile_test.go +66 -0
| @@ -0,0 +1,66 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "errors" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | "github.com/bnema/dumber/internal/logging" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func TestCEF2GTKProfilePathUsesSessionLogDirectory(t *testing.T) { | |
| 14 | + | t.Setenv(cef2gtkProfileEnv, "1") | |
| 15 | + | logDir := t.TempDir() | |
| 16 | + | ctx := logging.WithSessionMetadata(context.Background(), "20260430_120000_abcd", filepath.Join(logDir, "session_20260430_120000_abcd.log")) | |
| 17 | + | eng := &Engine{ctx: ctx, profileLogDir: filepath.Join(t.TempDir(), "fallback")} | |
| 18 | + | ||
| 19 | + | got := eng.cef2gtkProfilePath() | |
| 20 | + | want := filepath.Join(logDir, "session_20260430_120000_abcd_cef2gtk_profile.jsonl") | |
| 21 | + | if got != want { | |
| 22 | + | t.Fatalf("path = %q, want %q", got, want) | |
| 23 | + | } | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | func TestCEF2GTKProfilePathUsesEngineProfileLogDirFallback(t *testing.T) { | |
| 27 | + | profileLogDir := t.TempDir() | |
| 28 | + | eng := &Engine{ctx: context.Background(), profileLogDir: profileLogDir} | |
| 29 | + | ||
| 30 | + | got := eng.cef2gtkProfilePath() | |
| 31 | + | want := filepath.Join(profileLogDir, "cef2gtk_profile.jsonl") | |
| 32 | + | if got != want { | |
| 33 | + | t.Fatalf("path = %q, want fallback output", got) | |
| 34 | + | } | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func TestCEF2GTKProfilePathAllowsExplicitOutput(t *testing.T) { | |
| 38 | + | explicit := filepath.Join(t.TempDir(), "profile.jsonl") | |
| 39 | + | t.Setenv(cef2gtkProfileOutputEnv, explicit) | |
| 40 | + | eng := &Engine{ctx: context.Background()} | |
| 41 | + | ||
| 42 | + | if got := eng.cef2gtkProfilePath(); got != explicit { | |
| 43 | + | t.Fatalf("path = %q, want explicit output", got) | |
| 44 | + | } | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestCEF2GTKProfileOptionsDisabledByDefault(t *testing.T) { | |
| 48 | + | eng := &Engine{ctx: context.Background()} | |
| 49 | + | wv := &WebView{id: port.WebViewID(7)} | |
| 50 | + | ||
| 51 | + | if opts := eng.cef2gtkProfileOptions(wv); opts.Enabled { | |
| 52 | + | t.Fatal("profile options enabled without env") | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestLockedProfileWriterWriteReturnsErrorWhenClosed(t *testing.T) { | |
| 57 | + | var nilWriter *lockedProfileWriter | |
| 58 | + | if _, err := nilWriter.Write([]byte("x")); !errors.Is(err, errProfileWriterClosed) { | |
| 59 | + | t.Fatalf("nil writer error = %v, want %v", err, errProfileWriterClosed) | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | writer := &lockedProfileWriter{} | |
| 63 | + | if _, err := writer.Write([]byte("x")); !errors.Is(err, errProfileWriterClosed) { | |
| 64 | + | t.Fatalf("closed writer error = %v, want %v", err, errProfileWriterClosed) | |
| 65 | + | } | |
| 66 | + | } | |
A internal/infrastructure/cef/render_diagnostics.go +199 -0
| @@ -0,0 +1,199 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "time" | |
| 5 | + | ||
| 6 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 7 | + | cef2gtk "github.com/bnema/purego-cef2gtk" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/application/port" | |
| 10 | + | "github.com/bnema/dumber/internal/logging" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | const renderStallRecoveryCooldown = 10 * time.Second | |
| 14 | + | ||
| 15 | + | func (e *Engine) logAllWebViewRenderSnapshots(now time.Time, _ port.WebViewID, reason string, classification renderStallClassification) { | |
| 16 | + | if e == nil { | |
| 17 | + | return | |
| 18 | + | } | |
| 19 | + | e.activeWebViews.Range(func(_, value any) bool { | |
| 20 | + | wv, ok := value.(*WebView) | |
| 21 | + | if !ok || wv == nil || wv.viewBridge == nil || wv.destroyed.Load() { | |
| 22 | + | return true | |
| 23 | + | } | |
| 24 | + | diag := wv.viewBridge.Diagnostics() | |
| 25 | + | lastPaintAge := time.Duration(0) | |
| 26 | + | if lastPaint := latestDiagnosticEventTime(diag, "accelerated-paint"); !lastPaint.IsZero() { | |
| 27 | + | lastPaintAge = now.Sub(lastPaint) | |
| 28 | + | } | |
| 29 | + | wv.mu.RLock() | |
| 30 | + | browser := wv.browser | |
| 31 | + | uri := wv.uri | |
| 32 | + | title := wv.title | |
| 33 | + | isLoading := wv.isLoading | |
| 34 | + | pendingURI := wv.pendingURI | |
| 35 | + | wv.mu.RUnlock() | |
| 36 | + | wv.logRenderDiagnosticSnapshot( | |
| 37 | + | now, reason, classification, wv.viewBridge, diag, lastPaintAge, | |
| 38 | + | browser, uri, title, isLoading, pendingURI, wv.audioPlaying.Load(), | |
| 39 | + | ) | |
| 40 | + | return true | |
| 41 | + | }) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func (wv *WebView) logRenderDiagnosticSnapshot( | |
| 45 | + | now time.Time, | |
| 46 | + | reason string, | |
| 47 | + | classification renderStallClassification, | |
| 48 | + | bridge *Cef2gtkAdapter, | |
| 49 | + | diag cef2gtk.Diagnostics, | |
| 50 | + | lastPaintAge time.Duration, | |
| 51 | + | browser purecef.Browser, | |
| 52 | + | uri, title string, | |
| 53 | + | isLoading bool, | |
| 54 | + | pendingURI string, | |
| 55 | + | audioPlaying bool, | |
| 56 | + | ) { | |
| 57 | + | if wv == nil || wv.ctx == nil { | |
| 58 | + | return | |
| 59 | + | } | |
| 60 | + | var browserID int32 | |
| 61 | + | if browser != nil { | |
| 62 | + | browserID = browser.GetIdentifier() | |
| 63 | + | } | |
| 64 | + | width, height := int32(0), int32(0) | |
| 65 | + | scale := float32(0) | |
| 66 | + | if bridge != nil { | |
| 67 | + | width, height = bridge.Size() | |
| 68 | + | scale = bridge.DeviceScaleFactor() | |
| 69 | + | } | |
| 70 | + | uiHeartbeat, ioHeartbeat := wv.cefHeartbeatSnapshots(now) | |
| 71 | + | profile, profileAge, hasProfile := wv.latestCEF2GTKProfileSnapshot(now) | |
| 72 | + | logging.FromContext(wv.ctx).Warn(). | |
| 73 | + | Str("reason", reason). | |
| 74 | + | Str("classification", classification.Category). | |
| 75 | + | Bool("cef_ui_blocked", classification.CEFUIBlocked). | |
| 76 | + | Bool("cef_io_alive", classification.CEFIOAlive). | |
| 77 | + | Uint64("webview_id", uint64(wv.id)). | |
| 78 | + | Int32("browser_id", browserID). | |
| 79 | + | Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)). | |
| 80 | + | Str("title", title). | |
| 81 | + | Bool("is_loading", isLoading). | |
| 82 | + | Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)). | |
| 83 | + | Bool("audio_playing", audioPlaying). | |
| 84 | + | Uint64("audio_packets", wv.audioPacketCount.Load()). | |
| 85 | + | Uint64("audio_writes", wv.audioWriteCount.Load()). | |
| 86 | + | Int64("last_accelerated_paint_age_ms", durationMillis(lastPaintAge)). | |
| 87 | + | Str("render_backend", diag.Backend). | |
| 88 | + | Int("accelerated_paints", diag.AcceleratedPaints). | |
| 89 | + | Int("unsupported_paints", diag.UnsupportedPaints). | |
| 90 | + | Int("accelerated_paint_errors", diag.AcceleratedPaintErrors). | |
| 91 | + | Int("import_failures", diag.ImportFailures). | |
| 92 | + | Int("render_failures", diag.RenderFailures). | |
| 93 | + | Int("textures_built", diag.TexturesBuilt). | |
| 94 | + | Int("texture_build_failures", diag.TextureBuildFailures). | |
| 95 | + | Int("fd_dup_failures", diag.FDDupFailures). | |
| 96 | + | Int("unsupported_formats", diag.UnsupportedFormats). | |
| 97 | + | Int("paintable_swaps", diag.PaintableSwaps). | |
| 98 | + | Bool("pending_frame", diag.PendingFrame). | |
| 99 | + | Bool("pending_scheduled", diag.PendingScheduled). | |
| 100 | + | Int64("pending_age_ms", durationMillis(diag.PendingAge)). | |
| 101 | + | Uint("pending_source_id", diag.PendingSourceID). | |
| 102 | + | Int("pending_reschedules", diag.PendingReschedules). | |
| 103 | + | Int("pending_schedule_failures", diag.PendingScheduleFailures). | |
| 104 | + | Int("pending_idle_callbacks", diag.PendingIdleCallbacks). | |
| 105 | + | Int32("surface_width", width). | |
| 106 | + | Int32("surface_height", height). | |
| 107 | + | Float32("surface_scale", scale). | |
| 108 | + | Bool("external_begin_frame", externalBeginFrameEnabled()). | |
| 109 | + | Bool("profile_snapshot_available", hasProfile). | |
| 110 | + | Int64("profile_snapshot_age_ms", durationMillis(profileAge)). | |
| 111 | + | Uint64("profile_frames_received", profile.FramesReceived). | |
| 112 | + | Uint64("profile_frames_queued", profile.FramesQueued). | |
| 113 | + | Uint64("profile_frames_rendered", profile.FramesRendered). | |
| 114 | + | Uint64("profile_import_failures", profile.ImportFailures). | |
| 115 | + | Uint64("profile_render_failures", profile.RenderFailures). | |
| 116 | + | Uint32("profile_gc_delta", profile.GC.NumGCDelta). | |
| 117 | + | Int64("cef_ui_heartbeat_ack_age_ms", durationMillis(uiHeartbeat.LastAckAge)). | |
| 118 | + | Int64("cef_ui_heartbeat_latency_ms", durationMillis(uiHeartbeat.Latency)). | |
| 119 | + | Int32("cef_ui_heartbeat_post_result", uiHeartbeat.PostResult). | |
| 120 | + | Bool("cef_ui_heartbeat_in_flight", uiHeartbeat.InFlight). | |
| 121 | + | Int64("cef_io_heartbeat_ack_age_ms", durationMillis(ioHeartbeat.LastAckAge)). | |
| 122 | + | Int64("cef_io_heartbeat_latency_ms", durationMillis(ioHeartbeat.Latency)). | |
| 123 | + | Int32("cef_io_heartbeat_post_result", ioHeartbeat.PostResult). | |
| 124 | + | Bool("cef_io_heartbeat_in_flight", ioHeartbeat.InFlight). | |
| 125 | + | Msg("cef: render diagnostic snapshot") | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | func (wv *WebView) maybeRunRenderStallRecovery(now time.Time, _ *Cef2gtkAdapter, paintsBefore int) { | |
| 129 | + | if wv == nil || wv.ctx == nil || wv.destroyed.Load() || !renderStallRecoveryEnabled() { | |
| 130 | + | return | |
| 131 | + | } | |
| 132 | + | wv.renderStallMu.Lock() | |
| 133 | + | last := wv.renderStallRecoveryLastAt | |
| 134 | + | if !last.IsZero() && now.Sub(last) < renderStallRecoveryCooldown { | |
| 135 | + | wv.renderStallMu.Unlock() | |
| 136 | + | return | |
| 137 | + | } | |
| 138 | + | wv.renderStallMu.Unlock() | |
| 139 | + | ||
| 140 | + | wv.mu.RLock() | |
| 141 | + | host := wv.host | |
| 142 | + | browser := wv.browser | |
| 143 | + | wv.mu.RUnlock() | |
| 144 | + | if host == nil { | |
| 145 | + | return | |
| 146 | + | } | |
| 147 | + | browserID := int32(0) | |
| 148 | + | if browser != nil { | |
| 149 | + | browserID = browser.GetIdentifier() | |
| 150 | + | } | |
| 151 | + | logging.FromContext(wv.ctx).Warn(). | |
| 152 | + | Uint64("webview_id", uint64(wv.id)). | |
| 153 | + | Int32("browser_id", browserID). | |
| 154 | + | Int("accelerated_paints_before", paintsBefore). | |
| 155 | + | Msg("cef: render stall recovery pulse scheduled") | |
| 156 | + | task := cefNewTask(cefTaskFunc(func() { | |
| 157 | + | if wv == nil || wv.viewBridge == nil || wv.destroyed.Load() || wv.ctx == nil || host == nil { | |
| 158 | + | return | |
| 159 | + | } | |
| 160 | + | host.Invalidate(purecef.PaintElementTypePetView) | |
| 161 | + | host.WasHidden(1) | |
| 162 | + | host.WasHidden(0) | |
| 163 | + | host.NotifyScreenInfoChanged() | |
| 164 | + | host.WasResized() | |
| 165 | + | host.Invalidate(purecef.PaintElementTypePetView) | |
| 166 | + | logging.FromContext(wv.ctx).Warn(). | |
| 167 | + | Uint64("webview_id", uint64(wv.id)). | |
| 168 | + | Int32("browser_id", browserID). | |
| 169 | + | Msg("cef: render stall recovery pulse executed") | |
| 170 | + | })) | |
| 171 | + | if task == nil { | |
| 172 | + | return | |
| 173 | + | } | |
| 174 | + | result := cefPostTask(purecef.ThreadIDTidUi, task) | |
| 175 | + | logging.FromContext(wv.ctx).Warn(). | |
| 176 | + | Uint64("webview_id", uint64(wv.id)). | |
| 177 | + | Int32("browser_id", browserID). | |
| 178 | + | Int32("post_result", result). | |
| 179 | + | Msg("cef: render stall recovery pulse posted") | |
| 180 | + | if result != 1 { | |
| 181 | + | return | |
| 182 | + | } | |
| 183 | + | wv.renderStallMu.Lock() | |
| 184 | + | wv.renderStallRecoveryLastAt = now | |
| 185 | + | wv.renderStallMu.Unlock() | |
| 186 | + | cefScheduleAfter(2*time.Second, func() { | |
| 187 | + | if wv == nil || wv.viewBridge == nil || wv.destroyed.Load() || wv.ctx == nil { | |
| 188 | + | return | |
| 189 | + | } | |
| 190 | + | after := wv.viewBridge.Diagnostics().AcceleratedPaints | |
| 191 | + | logging.FromContext(wv.ctx).Warn(). | |
| 192 | + | Uint64("webview_id", uint64(wv.id)). | |
| 193 | + | Int32("browser_id", browserID). | |
| 194 | + | Int("accelerated_paints_before", paintsBefore). | |
| 195 | + | Int("accelerated_paints_after", after). | |
| 196 | + | Bool("recovered", after > paintsBefore). | |
| 197 | + | Msg("cef: render stall recovery pulse result") | |
| 198 | + | }) | |
| 199 | + | } | |
A internal/infrastructure/cef/render_handler_adapter.go +59 -0
| @@ -0,0 +1,59 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "sync" | |
| 5 | + | ||
| 6 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 7 | + | cef2gtk "github.com/bnema/purego-cef2gtk" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func newDumberRenderHandler(wv *WebView) purecef.RenderHandler { | |
| 13 | + | if wv == nil || wv.viewBridge == nil { | |
| 14 | + | return nil | |
| 15 | + | } | |
| 16 | + | var unsupportedPaintOnce sync.Once | |
| 17 | + | hooks := cef2gtk.Hooks{ | |
| 18 | + | OnUnsupportedPaint: func() { | |
| 19 | + | unsupportedPaintOnce.Do(func() { | |
| 20 | + | if wv.ctx != nil { | |
| 21 | + | logging.FromContext(wv.ctx).Warn().Msg("cef: unsupported CPU paint from accelerated bridge") | |
| 22 | + | } | |
| 23 | + | }) | |
| 24 | + | }, | |
| 25 | + | OnError: func(err error) { | |
| 26 | + | if wv.ctx != nil { | |
| 27 | + | logging.FromContext(wv.ctx).Warn().Err(err).Msg("cef: accelerated render bridge error") | |
| 28 | + | } | |
| 29 | + | }, | |
| 30 | + | OnTextSelectionChanged: func(selectedText string, _ *purecef.Range) { | |
| 31 | + | handleRenderTextSelectionChanged(wv, selectedText) | |
| 32 | + | }, | |
| 33 | + | } | |
| 34 | + | return wv.viewBridge.RenderHandler(hooks) | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func handleRenderTextSelectionChanged(wv *WebView, selectedText string) { | |
| 38 | + | if wv == nil { | |
| 39 | + | return | |
| 40 | + | } | |
| 41 | + | previous, changed := wv.setSelectedText(selectedText) | |
| 42 | + | if !changed { | |
| 43 | + | return | |
| 44 | + | } | |
| 45 | + | if wv.ctx != nil && selectedText == "" { | |
| 46 | + | if previous != "" { | |
| 47 | + | logging.FromContext(wv.ctx).Debug(). | |
| 48 | + | Int("prev_text_len", len(previous)). | |
| 49 | + | Msg("cef: text selection cleared") | |
| 50 | + | } | |
| 51 | + | } else if wv.ctx != nil { | |
| 52 | + | logging.FromContext(wv.ctx).Debug(). | |
| 53 | + | Int("text_len", len(selectedText)). | |
| 54 | + | Msg("cef: text selection changed") | |
| 55 | + | } | |
| 56 | + | if wv.engine != nil { | |
| 57 | + | wv.scheduleSelectionUpdate(selectedText) | |
| 58 | + | } | |
| 59 | + | } | |
D internal/infrastructure/cef/render_pipeline.go +0 -1384
| @@ -1,1384 +0,0 @@ | |||
| 1 | - | package cef | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "sync" | |
| 6 | - | "sync/atomic" | |
| 7 | - | "time" | |
| 8 | - | "unsafe" | |
| 9 | - | ||
| 10 | - | "github.com/bnema/dumber/internal/application/port" | |
| 11 | - | "github.com/bnema/dumber/internal/logging" | |
| 12 | - | "github.com/bnema/puregotk/v4/gdk" | |
| 13 | - | "github.com/bnema/puregotk/v4/gtk" | |
| 14 | - | ) | |
| 15 | - | ||
| 16 | - | // rect is a simple rectangle for dirty rect tracking. | |
| 17 | - | type rect struct { | |
| 18 | - | X, Y, Width, Height int32 | |
| 19 | - | } | |
| 20 | - | ||
| 21 | - | // renderPipeline implements PBO double-buffered OpenGL rendering of CEF's | |
| 22 | - | // off-screen BGRA pixel buffers into a GtkGLArea widget. | |
| 23 | - | // | |
| 24 | - | // CEF delivers frames via OnPaint (BGRA buffer + dirty rects). handlePaint | |
| 25 | - | // copies dirty regions into a CPU-side staging buffer on the CEF UI thread, | |
| 26 | - | // and GTK later uploads from staging → PBO → texture before drawing a | |
| 27 | - | // fullscreen quad. Two PBOs alternate so GPU DMA and CPU writes overlap. | |
| 28 | - | type renderPipeline struct { | |
| 29 | - | webviewID port.WebViewID | |
| 30 | - | ctx context.Context | |
| 31 | - | gl *glLoader | |
| 32 | - | glArea *gtk.GLArea | |
| 33 | - | ||
| 34 | - | // GL resources (created on first render). | |
| 35 | - | texture uint32 | |
| 36 | - | popupTexture uint32 | |
| 37 | - | pbo [2]uint32 | |
| 38 | - | pboIndex int | |
| 39 | - | program uint32 | |
| 40 | - | vao uint32 | |
| 41 | - | vbo uint32 | |
| 42 | - | ||
| 43 | - | // Surface dimensions (in device pixels, i.e. scaled). | |
| 44 | - | // Atomics allow lock-free reads from CEF callbacks (viewRectSize). | |
| 45 | - | widthAtomic atomic.Int32 | |
| 46 | - | heightAtomic atomic.Int32 | |
| 47 | - | scale int32 | |
| 48 | - | ||
| 49 | - | // Staging: handlePaint copies dirty rects here, render signal uploads. | |
| 50 | - | mu sync.Mutex | |
| 51 | - | staging []byte | |
| 52 | - | dirtyRects []rect | |
| 53 | - | needsUpload bool | |
| 54 | - | sizeChanged bool | |
| 55 | - | forceFullUpload bool | |
| 56 | - | ||
| 57 | - | // Popup staging for native CEF widgets like <select> menus. | |
| 58 | - | popupVisible bool | |
| 59 | - | popupRect rect | |
| 60 | - | popupStaging []byte | |
| 61 | - | popupWidth int32 | |
| 62 | - | popupHeight int32 | |
| 63 | - | popupNeedsUpload bool | |
| 64 | - | popupSizeChanged bool | |
| 65 | - | ||
| 66 | - | // GL initialized flag. | |
| 67 | - | glReady bool | |
| 68 | - | glWidth int32 | |
| 69 | - | glHeight int32 | |
| 70 | - | ||
| 71 | - | // Diagnostics can be updated from both the CEF UI thread and GTK thread. | |
| 72 | - | diagMu sync.Mutex | |
| 73 | - | viewRectSeq atomic.Uint64 | |
| 74 | - | screenInfoSeq atomic.Uint64 | |
| 75 | - | paintSeq atomic.Uint64 | |
| 76 | - | resizeSeq atomic.Uint64 | |
| 77 | - | lastQueuedPaintSeq atomic.Uint64 | |
| 78 | - | lastResizeUnixNano atomic.Int64 | |
| 79 | - | lastResizePaintLogSeq atomic.Uint64 | |
| 80 | - | lastResizeStaleLogSeq atomic.Uint64 | |
| 81 | - | glRenderSeq atomic.Uint64 | |
| 82 | - | paintCount uint64 | |
| 83 | - | acceleratedPaintCount uint64 | |
| 84 | - | queueRenderCount uint64 | |
| 85 | - | glRenderCount uint64 | |
| 86 | - | uploadCount uint64 | |
| 87 | - | fullUploadCount uint64 | |
| 88 | - | paintBytes uint64 | |
| 89 | - | uploadBytes uint64 | |
| 90 | - | dirtyRectCount uint64 | |
| 91 | - | paintCopyTotalNs uint64 | |
| 92 | - | uploadTotalNs uint64 | |
| 93 | - | renderTotalNs uint64 | |
| 94 | - | maxPaintCopyNs int64 | |
| 95 | - | maxUploadNs int64 | |
| 96 | - | maxRenderNs int64 | |
| 97 | - | diagLastLogAt time.Time | |
| 98 | - | diagLastPaintCount uint64 | |
| 99 | - | diagLastAccelCount uint64 | |
| 100 | - | diagLastQueueCount uint64 | |
| 101 | - | diagLastRenderCount uint64 | |
| 102 | - | diagLastUploadCount uint64 | |
| 103 | - | diagLastFullUploads uint64 | |
| 104 | - | diagLastPaintBytes uint64 | |
| 105 | - | diagLastUploadBytes uint64 | |
| 106 | - | diagLastPaintCopyNs uint64 | |
| 107 | - | diagLastUploadNs uint64 | |
| 108 | - | diagLastRenderNs uint64 | |
| 109 | - | ||
| 110 | - | // onFirstResize is called once when the first non-zero resize occurs. | |
| 111 | - | onFirstResize func(width, height int32) | |
| 112 | - | ||
| 113 | - | // onResizeCB is called on every non-zero resize after the first. | |
| 114 | - | onResizeCB func(width, height int32) | |
| 115 | - | } | |
| 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 | - | ||
| 138 | - | // Shader sources for the fullscreen textured quad. | |
| 139 | - | const vertexShaderSource = "" + | |
| 140 | - | "#version 330 core\n" + | |
| 141 | - | "layout(location=0) in vec2 aPos;\n" + | |
| 142 | - | "layout(location=1) in vec2 aUV;\n" + | |
| 143 | - | "out vec2 vUV;\n" + | |
| 144 | - | "void main() {\n" + | |
| 145 | - | " gl_Position = vec4(aPos, 0.0, 1.0);\n" + | |
| 146 | - | " vUV = aUV;\n" + | |
| 147 | - | "}\x00" | |
| 148 | - | ||
| 149 | - | const fragmentShaderSource = "" + | |
| 150 | - | "#version 330 core\n" + | |
| 151 | - | "in vec2 vUV;\n" + | |
| 152 | - | "out vec4 fragColor;\n" + | |
| 153 | - | "uniform sampler2D tex;\n" + | |
| 154 | - | "void main() {\n" + | |
| 155 | - | " fragColor = texture(tex, vUV);\n" + | |
| 156 | - | "}\x00" | |
| 157 | - | ||
| 158 | - | // Fullscreen quad: position (x,y) + UV (u,v), triangle strip. | |
| 159 | - | // UV Y is flipped because CEF's buffer origin is top-left, GL is bottom-left. | |
| 160 | - | var quadVertices = [16]float32{ | |
| 161 | - | -1, -1, 0, 1, // bottom-left | |
| 162 | - | 1, -1, 1, 1, // bottom-right | |
| 163 | - | -1, 1, 0, 0, // top-left | |
| 164 | - | 1, 1, 1, 0, // top-right | |
| 165 | - | } | |
| 166 | - | ||
| 167 | - | // newRenderPipeline creates a GtkGLArea and wires up the render and resize | |
| 168 | - | // signals. The returned pipeline is ready to receive handlePaint calls. | |
| 169 | - | func newRenderPipeline(ctx context.Context, gl *glLoader, scale int32, webviewID port.WebViewID) *renderPipeline { | |
| 170 | - | if scale < 1 { | |
| 171 | - | scale = 1 | |
| 172 | - | } | |
| 173 | - | ||
| 174 | - | rp := &renderPipeline{ | |
| 175 | - | webviewID: webviewID, | |
| 176 | - | ctx: ctx, | |
| 177 | - | gl: gl, | |
| 178 | - | scale: scale, | |
| 179 | - | } | |
| 180 | - | ||
| 181 | - | rp.glArea = gtk.NewGLArea() | |
| 182 | - | rp.glArea.SetRequiredVersion(3, 3) | |
| 183 | - | rp.glArea.SetAutoRender(false) | |
| 184 | - | rp.glArea.SetHasDepthBuffer(false) | |
| 185 | - | rp.glArea.SetHasStencilBuffer(false) | |
| 186 | - | ||
| 187 | - | // Wire signals. puregotk currently exposes the render context as a raw pointer. | |
| 188 | - | renderCb := func(_ gtk.GLArea, contextPtr uintptr) bool { | |
| 189 | - | _ = gdk.GLContextNewFromInternalPtr(contextPtr) | |
| 190 | - | return rp.onGLRender() | |
| 191 | - | } | |
| 192 | - | rp.glArea.ConnectRender(&renderCb) | |
| 193 | - | ||
| 194 | - | resizeCb := func(_ gtk.GLArea, w int, h int) { | |
| 195 | - | rp.onResize(int32(w), int32(h)) | |
| 196 | - | } | |
| 197 | - | rp.glArea.ConnectResize(&resizeCb) | |
| 198 | - | ||
| 199 | - | return rp | |
| 200 | - | } | |
| 201 | - | ||
| 202 | - | // handlePaint is called from CEF's OnPaint callback. It copies dirty rect | |
| 203 | - | // regions from the transient CEF buffer into the persistent staging buffer and | |
| 204 | - | // marks the next GTK render for upload. | |
| 205 | - | func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects []rect, paintSeq uint64) { | |
| 206 | - | if len(buffer) == 0 || width <= 0 || height <= 0 { | |
| 207 | - | return | |
| 208 | - | } | |
| 209 | - | ||
| 210 | - | startedAt := time.Now() | |
| 211 | - | rp.lastQueuedPaintSeq.Store(paintSeq) | |
| 212 | - | if rp.ctx != nil { | |
| 213 | - | logging.FromContext(rp.ctx).Trace(). | |
| 214 | - | Uint64("paint_seq", paintSeq). | |
| 215 | - | Int32("width", width). | |
| 216 | - | Int32("height", height). | |
| 217 | - | Int("rect_count", len(rects)). | |
| 218 | - | Int("buffer_len", len(buffer)). | |
| 219 | - | Msg("cef: handlePaint begin") | |
| 220 | - | } | |
| 221 | - | rp.mu.Lock() | |
| 222 | - | ||
| 223 | - | bufSize, ok := bgraBufferSize(width, height) | |
| 224 | - | if !ok { | |
| 225 | - | rp.mu.Unlock() | |
| 226 | - | return | |
| 227 | - | } | |
| 228 | - | copiedBytes := uint64(0) | |
| 229 | - | sizeChanged := false | |
| 230 | - | ||
| 231 | - | // Detect size change, or first paint (staging not yet allocated). | |
| 232 | - | if stagingNeedsReset(rp.staging, rp.widthAtomic.Load(), rp.heightAtomic.Load(), width, height, bufSize) { | |
| 233 | - | rp.widthAtomic.Store(width) | |
| 234 | - | rp.heightAtomic.Store(height) | |
| 235 | - | rp.staging = make([]byte, bufSize) | |
| 236 | - | rp.sizeChanged = true | |
| 237 | - | rp.forceFullUpload = true | |
| 238 | - | sizeChanged = true | |
| 239 | - | // On size change, copy the entire buffer. | |
| 240 | - | copiedBytes = uint64(copy(rp.staging, buffer)) | |
| 241 | - | } else { | |
| 242 | - | // Copy only dirty rect rows. | |
| 243 | - | var truncated bool | |
| 244 | - | copiedBytes, rects, truncated = copyDirtyRectsIntoStaging(rp.staging, buffer, width, height, rects) | |
| 245 | - | if truncated && rp.ctx != nil { | |
| 246 | - | logging.FromContext(rp.ctx).Warn(). | |
| 247 | - | Uint64("paint_seq", paintSeq). | |
| 248 | - | Int32("width", width). | |
| 249 | - | Int32("height", height). | |
| 250 | - | Int("buffer_len", len(buffer)). | |
| 251 | - | Int("expected_len", bufSize). | |
| 252 | - | Msg("cef: truncated paint buffer while copying dirty rects") | |
| 253 | - | } | |
| 254 | - | } | |
| 255 | - | ||
| 256 | - | // Accumulate dirty rects across multiple OnPaint calls between renders. | |
| 257 | - | // If we replaced instead of appending, paints that arrive before the | |
| 258 | - | // next GL render would lose their dirty rect metadata — the pixels are | |
| 259 | - | // in staging but never uploaded to the texture, causing trails/ghosts. | |
| 260 | - | rp.dirtyRects = append(rp.dirtyRects, rects...) | |
| 261 | - | rp.dirtyRects = coalesceDirtyRects(rp.dirtyRects, rp.widthAtomic.Load(), rp.heightAtomic.Load()) | |
| 262 | - | rp.needsUpload = true | |
| 263 | - | ||
| 264 | - | rp.mu.Unlock() | |
| 265 | - | ||
| 266 | - | copyDuration := time.Since(startedAt) | |
| 267 | - | rp.diagMu.Lock() | |
| 268 | - | rp.paintCount++ | |
| 269 | - | rp.paintBytes += copiedBytes | |
| 270 | - | rp.dirtyRectCount += uint64(len(rects)) | |
| 271 | - | rp.paintCopyTotalNs += uint64(copyDuration) | |
| 272 | - | if copyDuration.Nanoseconds() > rp.maxPaintCopyNs { | |
| 273 | - | rp.maxPaintCopyNs = copyDuration.Nanoseconds() | |
| 274 | - | } | |
| 275 | - | rp.diagMu.Unlock() | |
| 276 | - | ||
| 277 | - | if rp.ctx != nil { | |
| 278 | - | if sizeChanged && copiedBytes < uint64(bufSize) { | |
| 279 | - | logging.FromContext(rp.ctx).Warn(). | |
| 280 | - | Uint64("paint_seq", paintSeq). | |
| 281 | - | Int32("width", width). | |
| 282 | - | Int32("height", height). | |
| 283 | - | Int("buffer_len", len(buffer)). | |
| 284 | - | Int("expected_len", bufSize). | |
| 285 | - | Msg("cef: truncated full paint buffer on resize") | |
| 286 | - | } | |
| 287 | - | logging.FromContext(rp.ctx).Trace(). | |
| 288 | - | Uint64("paint_seq", paintSeq). | |
| 289 | - | Uint64("copied_bytes", copiedBytes). | |
| 290 | - | Bool("size_changed", sizeChanged). | |
| 291 | - | Dur("copy_duration", copyDuration). | |
| 292 | - | Msg("cef: handlePaint end") | |
| 293 | - | } | |
| 294 | - | rp.maybeLogDiagnostics() | |
| 295 | - | } | |
| 296 | - | ||
| 297 | - | func (rp *renderPipeline) queuePaintRender() { | |
| 298 | - | rp.diagMu.Lock() | |
| 299 | - | rp.queueRenderCount++ | |
| 300 | - | rp.diagMu.Unlock() | |
| 301 | - | rp.glArea.QueueRender() | |
| 302 | - | } | |
| 303 | - | ||
| 304 | - | // onGLRender is the GTK "render" signal handler. GL context is current. | |
| 305 | - | func (rp *renderPipeline) onGLRender() bool { | |
| 306 | - | renderSeq := rp.glRenderSeq.Add(1) | |
| 307 | - | paintSeq := rp.lastQueuedPaintSeq.Load() | |
| 308 | - | renderStartedAt := time.Now() | |
| 309 | - | if rp.ctx != nil { | |
| 310 | - | logging.FromContext(rp.ctx).Trace(). | |
| 311 | - | Uint64("render_seq", renderSeq). | |
| 312 | - | Uint64("paint_seq", paintSeq). | |
| 313 | - | Msg("cef: onGLRender begin") | |
| 314 | - | } | |
| 315 | - | rp.mu.Lock() | |
| 316 | - | ||
| 317 | - | needsUpload := rp.needsUpload | |
| 318 | - | sizeChanged := rp.sizeChanged | |
| 319 | - | uploadWidth := rp.widthAtomic.Load() | |
| 320 | - | uploadHeight := rp.heightAtomic.Load() | |
| 321 | - | ||
| 322 | - | rp.mu.Unlock() | |
| 323 | - | ||
| 324 | - | if rp.ctx != nil && sizeChanged && !needsUpload { | |
| 325 | - | resizeSeq, resizeAgeMs := rp.latestResizeDiagnostics() | |
| 326 | - | logging.FromContext(rp.ctx).Warn(). | |
| 327 | - | Uint64("webview_id", uint64(rp.webviewID)). | |
| 328 | - | Uint64("resize_seq", resizeSeq). | |
| 329 | - | Int64("time_since_resize_ms", resizeAgeMs). | |
| 330 | - | Int32("upload_width", uploadWidth). | |
| 331 | - | Int32("upload_height", uploadHeight). | |
| 332 | - | Bool("gl_ready", rp.glReady). | |
| 333 | - | Bool("needs_upload", needsUpload). | |
| 334 | - | Bool("size_changed", sizeChanged). | |
| 335 | - | Uint64("last_queued_paint_seq", paintSeq). | |
| 336 | - | Msg("cef: render after resize without fresh paint") | |
| 337 | - | } | |
| 338 | - | ||
| 339 | - | if !rp.glReady || sizeChanged { | |
| 340 | - | rp.initGL(uploadWidth, uploadHeight) | |
| 341 | - | } | |
| 342 | - | ||
| 343 | - | if !rp.glReady { | |
| 344 | - | rp.recordGLRenderDuration(renderStartedAt) | |
| 345 | - | return true | |
| 346 | - | } | |
| 347 | - | ||
| 348 | - | if needsUpload { | |
| 349 | - | uploadStartedAt := time.Now() | |
| 350 | - | uploadedBytes, fullUpload, didUpload := rp.uploadToPBO(renderSeq, paintSeq) | |
| 351 | - | if didUpload { | |
| 352 | - | rp.recordGLUploadDuration(uploadStartedAt, uploadedBytes, fullUpload) | |
| 353 | - | } | |
| 354 | - | } | |
| 355 | - | ||
| 356 | - | // Draw the main view texture. | |
| 357 | - | gl := rp.gl | |
| 358 | - | w, h, _ := rp.viewRectSize() | |
| 359 | - | gl.viewport(0, 0, w, h) | |
| 360 | - | gl.clearColor(0, 0, 0, 1) | |
| 361 | - | gl.clear(glColorBufferBit) | |
| 362 | - | rp.drawTexture(rp.texture) | |
| 363 | - | ||
| 364 | - | if rp.uploadPopupTexture(renderSeq, paintSeq) { | |
| 365 | - | rp.drawPopupOverlay(w, h) | |
| 366 | - | } | |
| 367 | - | ||
| 368 | - | renderDuration := time.Since(renderStartedAt) | |
| 369 | - | rp.recordGLRenderDuration(renderStartedAt) | |
| 370 | - | rp.maybeLogDiagnostics() | |
| 371 | - | if rp.ctx != nil { | |
| 372 | - | logging.FromContext(rp.ctx).Trace(). | |
| 373 | - | Uint64("render_seq", renderSeq). | |
| 374 | - | Uint64("paint_seq", paintSeq). | |
| 375 | - | Dur("render_duration", renderDuration). | |
| 376 | - | Bool("needs_upload", needsUpload). | |
| 377 | - | Bool("size_changed", sizeChanged). | |
| 378 | - | Msg("cef: onGLRender end") | |
| 379 | - | } | |
| 380 | - | ||
| 381 | - | return true | |
| 382 | - | } | |
| 383 | - | ||
| 384 | - | func (rp *renderPipeline) recordGLRenderDuration(startedAt time.Time) { | |
| 385 | - | renderDuration := time.Since(startedAt) | |
| 386 | - | rp.diagMu.Lock() | |
| 387 | - | rp.glRenderCount++ | |
| 388 | - | rp.renderTotalNs += uint64(renderDuration) | |
| 389 | - | if renderDuration.Nanoseconds() > rp.maxRenderNs { | |
| 390 | - | rp.maxRenderNs = renderDuration.Nanoseconds() | |
| 391 | - | } | |
| 392 | - | rp.diagMu.Unlock() | |
| 393 | - | rp.maybeLogDiagnostics() | |
| 394 | - | } | |
| 395 | - | ||
| 396 | - | func (rp *renderPipeline) recordGLUploadDuration(startedAt time.Time, uploadedBytes uint64, fullUpload bool) { | |
| 397 | - | uploadDuration := time.Since(startedAt) | |
| 398 | - | rp.diagMu.Lock() | |
| 399 | - | rp.uploadCount++ | |
| 400 | - | if fullUpload { | |
| 401 | - | rp.fullUploadCount++ | |
| 402 | - | } | |
| 403 | - | rp.uploadBytes += uploadedBytes | |
| 404 | - | rp.uploadTotalNs += uint64(uploadDuration) | |
| 405 | - | if uploadDuration.Nanoseconds() > rp.maxUploadNs { | |
| 406 | - | rp.maxUploadNs = uploadDuration.Nanoseconds() | |
| 407 | - | } | |
| 408 | - | rp.diagMu.Unlock() | |
| 409 | - | } | |
| 410 | - | ||
| 411 | - | func (rp *renderPipeline) drawTexture(texture uint32) { | |
| 412 | - | if texture == 0 { | |
| 413 | - | return | |
| 414 | - | } | |
| 415 | - | gl := rp.gl | |
| 416 | - | gl.useProgram(rp.program) | |
| 417 | - | gl.bindTexture(glTexture2D, texture) | |
| 418 | - | gl.bindVertexArray(rp.vao) | |
| 419 | - | gl.drawArrays(glTriangleStrip, 0, 4) | |
| 420 | - | gl.bindVertexArray(0) | |
| 421 | - | gl.bindTexture(glTexture2D, 0) | |
| 422 | - | gl.useProgram(0) | |
| 423 | - | } | |
| 424 | - | ||
| 425 | - | // uploadToPBO uploads dirty regions from the staging buffer into a PBO, then | |
| 426 | - | // transfers to the GL texture via async DMA. The mutex serializes CEF-thread | |
| 427 | - | // staging writes against GTK-thread PBO copies so the upload sees a consistent | |
| 428 | - | // staging buffer and dirty-rect set. | |
| 429 | - | func (rp *renderPipeline) uploadToPBO(renderSeq, paintSeq uint64) (uint64, bool, bool) { | |
| 430 | - | gl := rp.gl | |
| 431 | - | ||
| 432 | - | rp.mu.Lock() | |
| 433 | - | w := rp.glWidth | |
| 434 | - | h := rp.glHeight | |
| 435 | - | needsUpload := rp.needsUpload | |
| 436 | - | rp.mu.Unlock() | |
| 437 | - | if !needsUpload || w <= 0 || h <= 0 { | |
| 438 | - | return 0, false, false | |
| 439 | - | } | |
| 440 | - | ||
| 441 | - | if rp.ctx != nil { | |
| 442 | - | logging.FromContext(rp.ctx).Trace(). | |
| 443 | - | Uint64("render_seq", renderSeq). | |
| 444 | - | Uint64("paint_seq", paintSeq). | |
| 445 | - | Int32("width", w). | |
| 446 | - | Int32("height", h). | |
| 447 | - | Msg("cef: uploadToPBO begin") | |
| 448 | - | } | |
| 449 | - | ||
| 450 | - | bufSize := int64(w) * int64(h) * 4 | |
| 451 | - | stride := int(w) * 4 | |
| 452 | - | uploadedBytes, fullUpload, dirtyRectCount, ok := rp.uploadMappedPBO(gl, bufSize, stride, w, h, renderSeq, paintSeq) | |
| 453 | - | if !ok { | |
| 454 | - | return 0, fullUpload, false | |
| 455 | - | } | |
| 456 | - | ||
| 457 | - | // Swap PBO index for next frame. | |
| 458 | - | rp.pboIndex = 1 - rp.pboIndex | |
| 459 | - | ||
| 460 | - | gl.bindBuffer(glPixelUnpackBuffer, 0) | |
| 461 | - | if rp.ctx != nil { | |
| 462 | - | logging.FromContext(rp.ctx).Trace(). | |
| 463 | - | Uint64("render_seq", renderSeq). | |
| 464 | - | Uint64("paint_seq", paintSeq). | |
| 465 | - | Uint64("uploaded_bytes", uploadedBytes). | |
| 466 | - | Int("dirty_rect_count", dirtyRectCount). | |
| 467 | - | Bool("full_upload", fullUpload). | |
| 468 | - | Int("next_pbo_index", rp.pboIndex). | |
| 469 | - | Msg("cef: uploadToPBO end") | |
| 470 | - | } | |
| 471 | - | return uploadedBytes, fullUpload, true | |
| 472 | - | } | |
| 473 | - | ||
| 474 | - | func (rp *renderPipeline) uploadMappedPBO( | |
| 475 | - | gl *glLoader, bufSize int64, stride int, w, h int32, renderSeq, paintSeq uint64, | |
| 476 | - | ) (uint64, bool, int, bool) { | |
| 477 | - | gl.bindBuffer(glPixelUnpackBuffer, rp.pbo[rp.pboIndex]) | |
| 478 | - | gl.bufferData(glPixelUnpackBuffer, bufSize, nil, glStreamDraw) | |
| 479 | - | mapped := gl.mapBuffer(glPixelUnpackBuffer, glWriteOnly) | |
| 480 | - | if mapped == nil { | |
| 481 | - | gl.bindBuffer(glPixelUnpackBuffer, 0) | |
| 482 | - | return 0, false, 0, false | |
| 483 | - | } | |
| 484 | - | ||
| 485 | - | mappedSlice := unsafe.Slice((*byte)(mapped), int(bufSize)) | |
| 486 | - | defer gl.bindBuffer(glPixelUnpackBuffer, 0) | |
| 487 | - | ||
| 488 | - | rp.mu.Lock() | |
| 489 | - | if !rp.needsUpload { | |
| 490 | - | rp.mu.Unlock() | |
| 491 | - | if !gl.unmapBuffer(glPixelUnpackBuffer) { | |
| 492 | - | return 0, false, 0, false | |
| 493 | - | } | |
| 494 | - | return 0, false, 0, false | |
| 495 | - | } | |
| 496 | - | ||
| 497 | - | if rp.widthAtomic.Load() != rp.glWidth || rp.heightAtomic.Load() != rp.glHeight { | |
| 498 | - | rp.mu.Unlock() | |
| 499 | - | if !gl.unmapBuffer(glPixelUnpackBuffer) { | |
| 500 | - | return 0, false, 0, false | |
| 501 | - | } | |
| 502 | - | if rp.ctx != nil { | |
| 503 | - | logging.FromContext(rp.ctx).Trace(). | |
| 504 | - | Uint64("render_seq", renderSeq). | |
| 505 | - | Uint64("paint_seq", paintSeq). | |
| 506 | - | Msg("cef: skipping upload while newer surface resize is pending") | |
| 507 | - | } | |
| 508 | - | return 0, false, 0, false | |
| 509 | - | } | |
| 510 | - | ||
| 511 | - | fullUpload := rp.forceFullUpload | |
| 512 | - | dirtyRectCount, dirtyRects := len(rp.dirtyRects), make([]rect, 0, len(rp.dirtyRects)) | |
| 513 | - | if fullUpload { | |
| 514 | - | copied := copy(mappedSlice, rp.staging) | |
| 515 | - | if copied < len(mappedSlice) { | |
| 516 | - | clear(mappedSlice[copied:]) | |
| 517 | - | } | |
| 518 | - | } else { | |
| 519 | - | dirtyRects = append(dirtyRects, rp.dirtyRects...) | |
| 520 | - | } | |
| 521 | - | rp.needsUpload, rp.forceFullUpload = false, false | |
| 522 | - | rp.dirtyRects = rp.dirtyRects[:0] | |
| 523 | - | rp.mu.Unlock() | |
| 524 | - | ||
| 525 | - | if !gl.unmapBuffer(glPixelUnpackBuffer) { | |
| 526 | - | if rp.ctx != nil { | |
| 527 | - | logging.FromContext(rp.ctx).Error(). | |
| 528 | - | Int32("width", w). | |
| 529 | - | Int32("height", h). | |
| 530 | - | Bool("full_upload", fullUpload). | |
| 531 | - | Msg("cef: glUnmapBuffer reported corrupted PBO contents; dropping frame") | |
| 532 | - | } | |
| 533 | - | return 0, fullUpload, dirtyRectCount, false | |
| 534 | - | } | |
| 535 | - | ||
| 536 | - | gl.bindTexture(glTexture2D, rp.texture) | |
| 537 | - | if fullUpload { | |
| 538 | - | gl.texSubImage2D(glTexture2D, 0, 0, 0, w, h, glBGRA, glUnsignedByte, nil) | |
| 539 | - | gl.bindTexture(glTexture2D, 0) | |
| 540 | - | return uint64(len(mappedSlice)), true, dirtyRectCount, true | |
| 541 | - | } | |
| 542 | - | ||
| 543 | - | gl.pixelStorei(glUnpackRowLength, w) | |
| 544 | - | uploadedBytes := copyDirtyRectsRaw(mappedSlice, rp.staging, w, h, dirtyRects) | |
| 545 | - | for _, rawRect := range dirtyRects { | |
| 546 | - | r, ok := clampDirtyRect(rawRect, w, h) | |
| 547 | - | if !ok { | |
| 548 | - | continue | |
| 549 | - | } | |
| 550 | - | offset := uintptr(int(r.Y)*stride + int(r.X)*4) | |
| 551 | - | gl.texSubImage2D(glTexture2D, 0, r.X, r.Y, r.Width, r.Height, | |
| 552 | - | glBGRA, glUnsignedByte, pboOffset(offset)) | |
| 553 | - | } | |
| 554 | - | gl.pixelStorei(glUnpackRowLength, 0) | |
| 555 | - | gl.bindTexture(glTexture2D, 0) | |
| 556 | - | return uploadedBytes, false, dirtyRectCount, true | |
| 557 | - | } | |
| 558 | - | ||
| 559 | - | func (rp *renderPipeline) uploadPopupTexture(renderSeq, paintSeq uint64) bool { | |
| 560 | - | // Copy staging data under lock, then release to avoid holding the mutex | |
| 561 | - | // during GL upload calls which may block on GPU synchronization. | |
| 562 | - | rp.mu.Lock() | |
| 563 | - | ||
| 564 | - | if !rp.popupVisible || rp.popupWidth <= 0 || rp.popupHeight <= 0 || len(rp.popupStaging) == 0 { | |
| 565 | - | rp.mu.Unlock() | |
| 566 | - | return false | |
| 567 | - | } | |
| 568 | - | ||
| 569 | - | if !rp.popupNeedsUpload && !rp.popupSizeChanged && rp.popupTexture != 0 { | |
| 570 | - | rp.mu.Unlock() | |
| 571 | - | return true | |
| 572 | - | } | |
| 573 | - | ||
| 574 | - | // Snapshot the staging data and dimensions while holding the lock. | |
| 575 | - | pixelsCopy := make([]byte, len(rp.popupStaging)) | |
| 576 | - | copy(pixelsCopy, rp.popupStaging) | |
| 577 | - | popupW := rp.popupWidth | |
| 578 | - | popupH := rp.popupHeight | |
| 579 | - | sizeChanged := rp.popupSizeChanged | |
| 580 | - | needsCreate := rp.popupTexture == 0 | |
| 581 | - | ||
| 582 | - | rp.mu.Unlock() | |
| 583 | - | ||
| 584 | - | // Perform GL operations without the lock. | |
| 585 | - | gl := rp.gl | |
| 586 | - | ||
| 587 | - | if needsCreate { | |
| 588 | - | var tex uint32 | |
| 589 | - | gl.genTextures(1, &tex) | |
| 590 | - | gl.bindTexture(glTexture2D, tex) | |
| 591 | - | gl.texParameteri(glTexture2D, glTextureMinFilter, int32(glLinear)) | |
| 592 | - | gl.texParameteri(glTexture2D, glTextureMagFilter, int32(glLinear)) | |
| 593 | - | gl.texParameteri(glTexture2D, glTextureWrapS, int32(glClampToEdge)) | |
| 594 | - | gl.texParameteri(glTexture2D, glTextureWrapT, int32(glClampToEdge)) | |
| 595 | - | ||
| 596 | - | // Store the texture ID under lock. | |
| 597 | - | rp.mu.Lock() | |
| 598 | - | rp.popupTexture = tex | |
| 599 | - | rp.mu.Unlock() | |
| 600 | - | } else { | |
| 601 | - | rp.mu.Lock() | |
| 602 | - | tex := rp.popupTexture | |
| 603 | - | rp.mu.Unlock() | |
| 604 | - | gl.bindTexture(glTexture2D, tex) | |
| 605 | - | } | |
| 606 | - | ||
| 607 | - | pixels := unsafe.Pointer(&pixelsCopy[0]) | |
| 608 | - | if sizeChanged { | |
| 609 | - | gl.texImage2D(glTexture2D, 0, int32(glRGBA), popupW, popupH, 0, glBGRA, glUnsignedByte, pixels) | |
| 610 | - | } else { | |
| 611 | - | gl.texSubImage2D(glTexture2D, 0, 0, 0, popupW, popupH, glBGRA, glUnsignedByte, pixels) | |
| 612 | - | } | |
| 613 | - | gl.bindTexture(glTexture2D, 0) | |
| 614 | - | ||
| 615 | - | // Update state under lock. | |
| 616 | - | rp.mu.Lock() | |
| 617 | - | rp.popupNeedsUpload = false | |
| 618 | - | rp.popupSizeChanged = false | |
| 619 | - | rp.mu.Unlock() | |
| 620 | - | ||
| 621 | - | if rp.ctx != nil { | |
| 622 | - | logging.FromContext(rp.ctx).Debug(). | |
| 623 | - | Uint64("render_seq", renderSeq). | |
| 624 | - | Uint64("paint_seq", paintSeq). | |
| 625 | - | Int32("popup_width", popupW). | |
| 626 | - | Int32("popup_height", popupH). | |
| 627 | - | Msg("cef: popup texture uploaded") | |
| 628 | - | } | |
| 629 | - | ||
| 630 | - | return true | |
| 631 | - | } | |
| 632 | - | ||
| 633 | - | func (rp *renderPipeline) drawPopupOverlay(viewWidth, viewHeight int32) { | |
| 634 | - | rp.mu.Lock() | |
| 635 | - | popupVisible := rp.popupVisible | |
| 636 | - | popupRect := rp.popupRect | |
| 637 | - | popupWidth := rp.popupWidth | |
| 638 | - | popupHeight := rp.popupHeight | |
| 639 | - | texture := rp.popupTexture | |
| 640 | - | rp.mu.Unlock() | |
| 641 | - | ||
| 642 | - | if !popupVisible || texture == 0 || popupWidth <= 0 || popupHeight <= 0 || viewWidth <= 0 || viewHeight <= 0 { | |
| 643 | - | return | |
| 644 | - | } | |
| 645 | - | ||
| 646 | - | popupRect.Width = popupWidth | |
| 647 | - | popupRect.Height = popupHeight | |
| 648 | - | popupRect, ok := clampDirtyRect(popupRect, viewWidth, viewHeight) | |
| 649 | - | if !ok { | |
| 650 | - | return | |
| 651 | - | } | |
| 652 | - | ||
| 653 | - | viewportY := viewHeight - popupRect.Y - popupRect.Height | |
| 654 | - | if viewportY < 0 { | |
| 655 | - | viewportY = 0 | |
| 656 | - | } | |
| 657 | - | ||
| 658 | - | rp.gl.viewport(popupRect.X, viewportY, popupRect.Width, popupRect.Height) | |
| 659 | - | rp.drawTexture(texture) | |
| 660 | - | rp.gl.viewport(0, 0, viewWidth, viewHeight) | |
| 661 | - | } | |
| 662 | - | ||
| 663 | - | // initGL creates or recreates all GL resources for the provided surface size. | |
| 664 | - | func (rp *renderPipeline) initGL(width, height int32) { | |
| 665 | - | if width <= 0 || height <= 0 { | |
| 666 | - | return | |
| 667 | - | } | |
| 668 | - | ||
| 669 | - | gl := rp.gl | |
| 670 | - | ||
| 671 | - | // Clean up previous resources if reinitializing. | |
| 672 | - | if rp.glReady { | |
| 673 | - | rp.deleteGLResources() | |
| 674 | - | } | |
| 675 | - | ||
| 676 | - | rp.mu.Lock() | |
| 677 | - | if rp.widthAtomic.Load() == width && rp.heightAtomic.Load() == height { | |
| 678 | - | rp.sizeChanged = false | |
| 679 | - | } | |
| 680 | - | rp.forceFullUpload = true | |
| 681 | - | rp.glWidth = width | |
| 682 | - | rp.glHeight = height | |
| 683 | - | rp.mu.Unlock() | |
| 684 | - | ||
| 685 | - | // Texture. | |
| 686 | - | gl.genTextures(1, &rp.texture) | |
| 687 | - | gl.bindTexture(glTexture2D, rp.texture) | |
| 688 | - | gl.texParameteri(glTexture2D, glTextureMinFilter, int32(glLinear)) | |
| 689 | - | gl.texParameteri(glTexture2D, glTextureMagFilter, int32(glLinear)) | |
| 690 | - | gl.texParameteri(glTexture2D, glTextureWrapS, int32(glClampToEdge)) | |
| 691 | - | gl.texParameteri(glTexture2D, glTextureWrapT, int32(glClampToEdge)) | |
| 692 | - | gl.texImage2D(glTexture2D, 0, int32(glRGBA), width, height, 0, glBGRA, glUnsignedByte, nil) | |
| 693 | - | gl.bindTexture(glTexture2D, 0) | |
| 694 | - | ||
| 695 | - | // PBOs. | |
| 696 | - | bufSize := int64(width) * int64(height) * 4 | |
| 697 | - | gl.genBuffers(1, &rp.pbo[0]) | |
| 698 | - | gl.genBuffers(1, &rp.pbo[1]) | |
| 699 | - | for i := 0; i < 2; i++ { | |
| 700 | - | gl.bindBuffer(glPixelUnpackBuffer, rp.pbo[i]) | |
| 701 | - | gl.bufferData(glPixelUnpackBuffer, bufSize, nil, glStreamDraw) | |
| 702 | - | } | |
| 703 | - | gl.bindBuffer(glPixelUnpackBuffer, 0) | |
| 704 | - | rp.pboIndex = 0 | |
| 705 | - | ||
| 706 | - | // Shaders. | |
| 707 | - | rp.program = rp.buildShaderProgram() | |
| 708 | - | ||
| 709 | - | // VAO + VBO. | |
| 710 | - | gl.genVertexArrays(1, &rp.vao) | |
| 711 | - | gl.bindVertexArray(rp.vao) | |
| 712 | - | ||
| 713 | - | gl.genBuffers(1, &rp.vbo) | |
| 714 | - | gl.bindBuffer(glArrayBuffer, rp.vbo) | |
| 715 | - | gl.bufferData(glArrayBuffer, int64(len(quadVertices)*4), unsafe.Pointer(&quadVertices[0]), glStaticDraw) | |
| 716 | - | ||
| 717 | - | // Position attribute: location 0, 2 floats, stride 16, offset 0. | |
| 718 | - | gl.vertexAttribPointer(0, 2, glFloat, false, 4*4, 0) | |
| 719 | - | gl.enableVertexAttribArray(0) | |
| 720 | - | ||
| 721 | - | // UV attribute: location 1, 2 floats, stride 16, offset 8. | |
| 722 | - | gl.vertexAttribPointer(1, 2, glFloat, false, 4*4, 2*4) | |
| 723 | - | gl.enableVertexAttribArray(1) | |
| 724 | - | ||
| 725 | - | gl.bindBuffer(glArrayBuffer, 0) | |
| 726 | - | gl.bindVertexArray(0) | |
| 727 | - | ||
| 728 | - | // Set the "tex" uniform to texture unit 0. | |
| 729 | - | gl.useProgram(rp.program) | |
| 730 | - | texName := []byte("tex\x00") | |
| 731 | - | loc := gl.getUniformLocation(rp.program, &texName[0]) | |
| 732 | - | gl.uniform1i(loc, 0) | |
| 733 | - | gl.useProgram(0) | |
| 734 | - | ||
| 735 | - | rp.glReady = true | |
| 736 | - | } | |
| 737 | - | ||
| 738 | - | func (rp *renderPipeline) recordAcceleratedPaint() uint64 { | |
| 739 | - | rp.diagMu.Lock() | |
| 740 | - | rp.acceleratedPaintCount++ | |
| 741 | - | count := rp.acceleratedPaintCount | |
| 742 | - | rp.diagMu.Unlock() | |
| 743 | - | rp.maybeLogDiagnostics() | |
| 744 | - | return count | |
| 745 | - | } | |
| 746 | - | ||
| 747 | - | func (rp *renderPipeline) maybeLogDiagnostics() { | |
| 748 | - | rp.diagMu.Lock() | |
| 749 | - | defer rp.diagMu.Unlock() | |
| 750 | - | ||
| 751 | - | now := time.Now() | |
| 752 | - | if !rp.diagLastLogAt.IsZero() && now.Sub(rp.diagLastLogAt) < 2*time.Second { | |
| 753 | - | return | |
| 754 | - | } | |
| 755 | - | ||
| 756 | - | paintDelta := rp.paintCount - rp.diagLastPaintCount | |
| 757 | - | accelDelta := rp.acceleratedPaintCount - rp.diagLastAccelCount | |
| 758 | - | queueDelta := rp.queueRenderCount - rp.diagLastQueueCount | |
| 759 | - | renderDelta := rp.glRenderCount - rp.diagLastRenderCount | |
| 760 | - | uploadDelta := rp.uploadCount - rp.diagLastUploadCount | |
| 761 | - | fullUploadDelta := rp.fullUploadCount - rp.diagLastFullUploads | |
| 762 | - | paintBytesDelta := rp.paintBytes - rp.diagLastPaintBytes | |
| 763 | - | uploadBytesDelta := rp.uploadBytes - rp.diagLastUploadBytes | |
| 764 | - | paintCopyNsDelta := rp.paintCopyTotalNs - rp.diagLastPaintCopyNs | |
| 765 | - | uploadNsDelta := rp.uploadTotalNs - rp.diagLastUploadNs | |
| 766 | - | renderNsDelta := rp.renderTotalNs - rp.diagLastRenderNs | |
| 767 | - | if paintDelta == 0 && accelDelta == 0 && queueDelta == 0 && renderDelta == 0 && uploadDelta == 0 { | |
| 768 | - | return | |
| 769 | - | } | |
| 770 | - | ||
| 771 | - | elapsed := now.Sub(rp.diagLastLogAt) | |
| 772 | - | if rp.diagLastLogAt.IsZero() || elapsed <= 0 { | |
| 773 | - | elapsed = time.Second | |
| 774 | - | } | |
| 775 | - | elapsedSec := elapsed.Seconds() | |
| 776 | - | ||
| 777 | - | paintAvgUs := float64(0) | |
| 778 | - | if paintDelta > 0 { | |
| 779 | - | paintAvgUs = float64(paintCopyNsDelta) / float64(paintDelta) / 1_000 | |
| 780 | - | } | |
| 781 | - | uploadAvgUs := float64(0) | |
| 782 | - | if uploadDelta > 0 { | |
| 783 | - | uploadAvgUs = float64(uploadNsDelta) / float64(uploadDelta) / 1_000 | |
| 784 | - | } | |
| 785 | - | renderAvgUs := float64(0) | |
| 786 | - | if renderDelta > 0 { | |
| 787 | - | renderAvgUs = float64(renderNsDelta) / float64(renderDelta) / 1_000 | |
| 788 | - | } | |
| 789 | - | ||
| 790 | - | logging.FromContext(rp.ctx).Debug(). | |
| 791 | - | Float64("paint_hz", float64(paintDelta)/elapsedSec). | |
| 792 | - | Float64("queue_hz", float64(queueDelta)/elapsedSec). | |
| 793 | - | Float64("render_hz", float64(renderDelta)/elapsedSec). | |
| 794 | - | Float64("upload_hz", float64(uploadDelta)/elapsedSec). | |
| 795 | - | Uint64("paint_delta", paintDelta). | |
| 796 | - | Uint64("accelerated_paint_delta", accelDelta). | |
| 797 | - | Uint64("queue_delta", queueDelta). | |
| 798 | - | Uint64("render_delta", renderDelta). | |
| 799 | - | Uint64("upload_delta", uploadDelta). | |
| 800 | - | Uint64("full_upload_delta", fullUploadDelta). | |
| 801 | - | Float64("paint_mb", float64(paintBytesDelta)/(1024*1024)). | |
| 802 | - | Float64("upload_mb", float64(uploadBytesDelta)/(1024*1024)). | |
| 803 | - | Float64("avg_paint_copy_us", paintAvgUs). | |
| 804 | - | Float64("avg_upload_us", uploadAvgUs). | |
| 805 | - | Float64("avg_render_us", renderAvgUs). | |
| 806 | - | Float64("max_paint_copy_us", float64(rp.maxPaintCopyNs)/1_000). | |
| 807 | - | Float64("max_upload_us", float64(rp.maxUploadNs)/1_000). | |
| 808 | - | Float64("max_render_us", float64(rp.maxRenderNs)/1_000). | |
| 809 | - | Uint64("paint_total", rp.paintCount). | |
| 810 | - | Uint64("accelerated_paint_total", rp.acceleratedPaintCount). | |
| 811 | - | Uint64("render_total", rp.glRenderCount). | |
| 812 | - | Uint64("upload_total", rp.uploadCount). | |
| 813 | - | Msg("cef: render pipeline activity") | |
| 814 | - | ||
| 815 | - | rp.diagLastLogAt = now | |
| 816 | - | rp.diagLastPaintCount = rp.paintCount | |
| 817 | - | rp.diagLastAccelCount = rp.acceleratedPaintCount | |
| 818 | - | rp.diagLastQueueCount = rp.queueRenderCount | |
| 819 | - | rp.diagLastRenderCount = rp.glRenderCount | |
| 820 | - | rp.diagLastUploadCount = rp.uploadCount | |
| 821 | - | rp.diagLastFullUploads = rp.fullUploadCount | |
| 822 | - | rp.diagLastPaintBytes = rp.paintBytes | |
| 823 | - | rp.diagLastUploadBytes = rp.uploadBytes | |
| 824 | - | rp.diagLastPaintCopyNs = rp.paintCopyTotalNs | |
| 825 | - | rp.diagLastUploadNs = rp.uploadTotalNs | |
| 826 | - | rp.diagLastRenderNs = rp.renderTotalNs | |
| 827 | - | } | |
| 828 | - | ||
| 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 | - | ||
| 864 | - | func (rp *renderPipeline) buildShaderProgram() uint32 { | |
| 865 | - | gl := rp.gl | |
| 866 | - | ||
| 867 | - | vs := compileShader(gl, glVertexShader, vertexShaderSource) | |
| 868 | - | fs := compileShader(gl, glFragmentShader, fragmentShaderSource) | |
| 869 | - | ||
| 870 | - | prog := gl.createProgram() | |
| 871 | - | gl.attachShader(prog, vs) | |
| 872 | - | gl.attachShader(prog, fs) | |
| 873 | - | gl.linkProgram(prog) | |
| 874 | - | ||
| 875 | - | // Shaders can be deleted after linking. | |
| 876 | - | gl.deleteShader(vs) | |
| 877 | - | gl.deleteShader(fs) | |
| 878 | - | ||
| 879 | - | return prog | |
| 880 | - | } | |
| 881 | - | ||
| 882 | - | // compileShader compiles a single GLSL shader and returns its GL handle. | |
| 883 | - | // TODO: add glGetShaderiv + glGetShaderInfoLog to gl_loader to check compilation status. | |
| 884 | - | func compileShader(gl *glLoader, shaderType uint32, source string) uint32 { | |
| 885 | - | shader := gl.createShader(shaderType) | |
| 886 | - | ||
| 887 | - | // source already has null terminator. | |
| 888 | - | srcBytes := []byte(source) | |
| 889 | - | srcPtr := &srcBytes[0] | |
| 890 | - | srcLen := int32(len(source) - 1) // exclude null terminator from length | |
| 891 | - | gl.shaderSource(shader, 1, &srcPtr, &srcLen) | |
| 892 | - | gl.compileShader(shader) | |
| 893 | - | ||
| 894 | - | return shader | |
| 895 | - | } | |
| 896 | - | ||
| 897 | - | // onResize is the GTK "resize" signal handler. Dimensions are in CSS pixels; | |
| 898 | - | // we multiply by scale to get device pixels. | |
| 899 | - | func (rp *renderPipeline) onResize(width, height int32) { | |
| 900 | - | if width <= 0 || height <= 0 { | |
| 901 | - | return | |
| 902 | - | } | |
| 903 | - | ||
| 904 | - | rp.mu.Lock() | |
| 905 | - | ||
| 906 | - | prevWidth := rp.widthAtomic.Load() | |
| 907 | - | prevHeight := rp.heightAtomic.Load() | |
| 908 | - | scale := rp.scale | |
| 909 | - | if scale <= 0 { | |
| 910 | - | scale = 1 | |
| 911 | - | } | |
| 912 | - | ||
| 913 | - | scaled := func(v int32) int32 { | |
| 914 | - | return v * scale | |
| 915 | - | } | |
| 916 | - | ||
| 917 | - | w, h := scaled(width), scaled(height) | |
| 918 | - | resizeSeq := rp.resizeSeq.Add(1) | |
| 919 | - | now := time.Now() | |
| 920 | - | rp.lastResizeUnixNano.Store(now.UnixNano()) | |
| 921 | - | rp.widthAtomic.Store(w) | |
| 922 | - | rp.heightAtomic.Store(h) | |
| 923 | - | rp.sizeChanged = true | |
| 924 | - | ||
| 925 | - | firstCB := rp.onFirstResize | |
| 926 | - | if firstCB != nil { | |
| 927 | - | rp.onFirstResize = nil | |
| 928 | - | } | |
| 929 | - | resizeCB := rp.onResizeCB | |
| 930 | - | rp.mu.Unlock() | |
| 931 | - | ||
| 932 | - | if rp.ctx != nil { | |
| 933 | - | logging.FromContext(rp.ctx).Debug(). | |
| 934 | - | Uint64("webview_id", uint64(rp.webviewID)). | |
| 935 | - | Uint64("resize_seq", resizeSeq). | |
| 936 | - | Int32("css_width", width). | |
| 937 | - | Int32("css_height", height). | |
| 938 | - | Int32("scaled_width", w). | |
| 939 | - | Int32("scaled_height", h). | |
| 940 | - | Int32("prev_width", prevWidth). | |
| 941 | - | Int32("prev_height", prevHeight). | |
| 942 | - | Int32("scale", scale). | |
| 943 | - | Bool("first_resize", firstCB != nil). | |
| 944 | - | Bool("has_resize_cb", resizeCB != nil). | |
| 945 | - | Msg("cef: GLArea resize") | |
| 946 | - | } | |
| 947 | - | ||
| 948 | - | if firstCB != nil { | |
| 949 | - | firstCB(w, h) | |
| 950 | - | } else if resizeCB != nil { | |
| 951 | - | resizeCB(w, h) | |
| 952 | - | } | |
| 953 | - | } | |
| 954 | - | ||
| 955 | - | func (rp *renderPipeline) setPopupVisible(show bool) { | |
| 956 | - | rp.mu.Lock() | |
| 957 | - | rp.popupVisible = show | |
| 958 | - | if !show { | |
| 959 | - | rp.popupStaging = nil | |
| 960 | - | rp.popupWidth = 0 | |
| 961 | - | rp.popupHeight = 0 | |
| 962 | - | rp.popupNeedsUpload = false | |
| 963 | - | rp.popupSizeChanged = false | |
| 964 | - | } | |
| 965 | - | rp.mu.Unlock() | |
| 966 | - | rp.glArea.QueueRender() | |
| 967 | - | } | |
| 968 | - | ||
| 969 | - | func (rp *renderPipeline) setPopupRect(popup rect) { | |
| 970 | - | rp.mu.Lock() | |
| 971 | - | rp.popupRect = rect{ | |
| 972 | - | X: popup.X * rp.scale, | |
| 973 | - | Y: popup.Y * rp.scale, | |
| 974 | - | Width: popup.Width * rp.scale, | |
| 975 | - | Height: popup.Height * rp.scale, | |
| 976 | - | } | |
| 977 | - | rp.mu.Unlock() | |
| 978 | - | rp.glArea.QueueRender() | |
| 979 | - | } | |
| 980 | - | ||
| 981 | - | func (rp *renderPipeline) handlePopupPaint(buffer []byte, width, height int32, paintSeq uint64) { | |
| 982 | - | if len(buffer) == 0 || width <= 0 || height <= 0 { | |
| 983 | - | return | |
| 984 | - | } | |
| 985 | - | ||
| 986 | - | bufSize, ok := bgraBufferSize(width, height) | |
| 987 | - | if !ok { | |
| 988 | - | return | |
| 989 | - | } | |
| 990 | - | ||
| 991 | - | rp.mu.Lock() | |
| 992 | - | if len(rp.popupStaging) != bufSize { | |
| 993 | - | rp.popupStaging = make([]byte, bufSize) | |
| 994 | - | rp.popupSizeChanged = true | |
| 995 | - | } else if rp.popupWidth != width || rp.popupHeight != height { | |
| 996 | - | rp.popupSizeChanged = true | |
| 997 | - | } | |
| 998 | - | copied := copy(rp.popupStaging, buffer) | |
| 999 | - | if copied < len(rp.popupStaging) { | |
| 1000 | - | clear(rp.popupStaging[copied:]) | |
| 1001 | - | } | |
| 1002 | - | rp.popupWidth = width | |
| 1003 | - | rp.popupHeight = height | |
| 1004 | - | rp.popupNeedsUpload = true | |
| 1005 | - | rp.popupVisible = true | |
| 1006 | - | rp.mu.Unlock() | |
| 1007 | - | ||
| 1008 | - | if rp.ctx != nil { | |
| 1009 | - | logging.FromContext(rp.ctx).Debug(). | |
| 1010 | - | Uint64("paint_seq", paintSeq). | |
| 1011 | - | Int32("popup_width", width). | |
| 1012 | - | Int32("popup_height", height). | |
| 1013 | - | Msg("cef: handlePopupPaint") | |
| 1014 | - | } | |
| 1015 | - | ||
| 1016 | - | rp.glArea.QueueRender() | |
| 1017 | - | } | |
| 1018 | - | ||
| 1019 | - | func (rp *renderPipeline) viewRectSize() (width, height, scale int32) { | |
| 1020 | - | width = rp.widthAtomic.Load() | |
| 1021 | - | height = rp.heightAtomic.Load() | |
| 1022 | - | scale = rp.scale | |
| 1023 | - | if scale <= 0 { | |
| 1024 | - | scale = 1 | |
| 1025 | - | } | |
| 1026 | - | return width, height, scale | |
| 1027 | - | } | |
| 1028 | - | ||
| 1029 | - | func (rp *renderPipeline) latestResizeDiagnostics() (resizeSeq uint64, ageMs int64) { | |
| 1030 | - | resizeSeq = rp.resizeSeq.Load() | |
| 1031 | - | lastResizeUnixNano := rp.lastResizeUnixNano.Load() | |
| 1032 | - | if lastResizeUnixNano == 0 { | |
| 1033 | - | return resizeSeq, 0 | |
| 1034 | - | } | |
| 1035 | - | age := time.Since(time.Unix(0, lastResizeUnixNano)) | |
| 1036 | - | if age <= 0 { | |
| 1037 | - | return resizeSeq, 0 | |
| 1038 | - | } | |
| 1039 | - | return resizeSeq, age.Milliseconds() | |
| 1040 | - | } | |
| 1041 | - | ||
| 1042 | - | func (rp *renderPipeline) markFirstPaintAfterResize() (resizeSeq uint64, ageMs int64, shouldLog bool) { | |
| 1043 | - | resizeSeq, ageMs = rp.latestResizeDiagnostics() | |
| 1044 | - | if resizeSeq == 0 { | |
| 1045 | - | return resizeSeq, ageMs, false | |
| 1046 | - | } | |
| 1047 | - | for { | |
| 1048 | - | lastLogged := rp.lastResizePaintLogSeq.Load() | |
| 1049 | - | if lastLogged >= resizeSeq { | |
| 1050 | - | return resizeSeq, ageMs, false | |
| 1051 | - | } | |
| 1052 | - | if rp.lastResizePaintLogSeq.CompareAndSwap(lastLogged, resizeSeq) { | |
| 1053 | - | return resizeSeq, ageMs, true | |
| 1054 | - | } | |
| 1055 | - | } | |
| 1056 | - | } | |
| 1057 | - | ||
| 1058 | - | func (rp *renderPipeline) markStalePaintAfterResize(resizeSeq uint64) bool { | |
| 1059 | - | if resizeSeq == 0 { | |
| 1060 | - | return false | |
| 1061 | - | } | |
| 1062 | - | for { | |
| 1063 | - | lastLogged := rp.lastResizeStaleLogSeq.Load() | |
| 1064 | - | if lastLogged >= resizeSeq { | |
| 1065 | - | return false | |
| 1066 | - | } | |
| 1067 | - | if rp.lastResizeStaleLogSeq.CompareAndSwap(lastLogged, resizeSeq) { | |
| 1068 | - | return true | |
| 1069 | - | } | |
| 1070 | - | } | |
| 1071 | - | } | |
| 1072 | - | ||
| 1073 | - | func (rp *renderPipeline) nextViewRectSeq() uint64 { | |
| 1074 | - | return rp.viewRectSeq.Add(1) | |
| 1075 | - | } | |
| 1076 | - | ||
| 1077 | - | func (rp *renderPipeline) nextScreenInfoSeq() uint64 { | |
| 1078 | - | return rp.screenInfoSeq.Add(1) | |
| 1079 | - | } | |
| 1080 | - | ||
| 1081 | - | func (rp *renderPipeline) nextPaintSeq() uint64 { | |
| 1082 | - | return rp.paintSeq.Add(1) | |
| 1083 | - | } | |
| 1084 | - | ||
| 1085 | - | func clampDirtyRect(r rect, width, height int32) (rect, bool) { | |
| 1086 | - | if width <= 0 || height <= 0 || r.Width <= 0 || r.Height <= 0 { | |
| 1087 | - | return rect{}, false | |
| 1088 | - | } | |
| 1089 | - | ||
| 1090 | - | x0 := max(r.X, 0) | |
| 1091 | - | y0 := max(r.Y, 0) | |
| 1092 | - | x1 := min(r.X+r.Width, width) | |
| 1093 | - | y1 := min(r.Y+r.Height, height) | |
| 1094 | - | ||
| 1095 | - | if x1 <= x0 || y1 <= y0 { | |
| 1096 | - | return rect{}, false | |
| 1097 | - | } | |
| 1098 | - | ||
| 1099 | - | return rect{ | |
| 1100 | - | X: x0, | |
| 1101 | - | Y: y0, | |
| 1102 | - | Width: x1 - x0, | |
| 1103 | - | Height: y1 - y0, | |
| 1104 | - | }, true | |
| 1105 | - | } | |
| 1106 | - | ||
| 1107 | - | // destroy releases all GL resources. | |
| 1108 | - | func (rp *renderPipeline) destroy() { | |
| 1109 | - | if !rp.glReady { | |
| 1110 | - | return | |
| 1111 | - | } | |
| 1112 | - | rp.deleteGLResources() | |
| 1113 | - | rp.glReady = false | |
| 1114 | - | } | |
| 1115 | - | ||
| 1116 | - | // deleteGLResources frees texture, PBOs, VAO, VBO, and program. | |
| 1117 | - | func (rp *renderPipeline) deleteGLResources() { | |
| 1118 | - | gl := rp.gl | |
| 1119 | - | if rp.texture != 0 { | |
| 1120 | - | gl.deleteTextures(1, &rp.texture) | |
| 1121 | - | rp.texture = 0 | |
| 1122 | - | } | |
| 1123 | - | if rp.popupTexture != 0 { | |
| 1124 | - | gl.deleteTextures(1, &rp.popupTexture) | |
| 1125 | - | rp.popupTexture = 0 | |
| 1126 | - | } | |
| 1127 | - | for i := 0; i < 2; i++ { | |
| 1128 | - | if rp.pbo[i] != 0 { | |
| 1129 | - | gl.deleteBuffers(1, &rp.pbo[i]) | |
| 1130 | - | rp.pbo[i] = 0 | |
| 1131 | - | } | |
| 1132 | - | } | |
| 1133 | - | if rp.vao != 0 { | |
| 1134 | - | gl.deleteVertexArrays(1, &rp.vao) | |
| 1135 | - | rp.vao = 0 | |
| 1136 | - | } | |
| 1137 | - | if rp.vbo != 0 { | |
| 1138 | - | gl.deleteBuffers(1, &rp.vbo) | |
| 1139 | - | rp.vbo = 0 | |
| 1140 | - | } | |
| 1141 | - | if rp.program != 0 { | |
| 1142 | - | // deleteProgram not in glLoader — just zero it. The program leaks on | |
| 1143 | - | // reinit but that only happens on resize which is rare. We can add | |
| 1144 | - | // glDeleteProgram to glLoader if needed. | |
| 1145 | - | rp.program = 0 | |
| 1146 | - | } | |
| 1147 | - | rp.glWidth = 0 | |
| 1148 | - | rp.glHeight = 0 | |
| 1149 | - | } | |
| 1150 | - | ||
| 1151 | - | func bgraBufferSize(width, height int32) (int, bool) { | |
| 1152 | - | if width <= 0 || height <= 0 { | |
| 1153 | - | return 0, false | |
| 1154 | - | } | |
| 1155 | - | size64 := int64(width) * int64(height) * 4 | |
| 1156 | - | if size64 <= 0 { | |
| 1157 | - | return 0, false | |
| 1158 | - | } | |
| 1159 | - | return int(size64), true | |
| 1160 | - | } | |
| 1161 | - | ||
| 1162 | - | // coalesceDirtyRects merges overlapping or edge-adjacent dirty rects to reduce | |
| 1163 | - | // redundant row-by-row PBO copies. If the total dirty area exceeds 60% of the | |
| 1164 | - | // surface, it collapses to a single full-surface rect. | |
| 1165 | - | // | |
| 1166 | - | // This is called inside handlePaint's mu.Lock() section so it must be fast for | |
| 1167 | - | // small n. O(n^2) is fine since n is typically < 20. | |
| 1168 | - | func coalesceDirtyRects(rects []rect, surfaceW, surfaceH int32) []rect { | |
| 1169 | - | if len(rects) <= 1 { | |
| 1170 | - | return rects | |
| 1171 | - | } | |
| 1172 | - | ||
| 1173 | - | // Check if total dirty area exceeds 60% of the surface — if so, | |
| 1174 | - | // a single full-surface upload is cheaper than per-rect copies. | |
| 1175 | - | totalArea := int64(0) | |
| 1176 | - | surfaceArea := int64(surfaceW) * int64(surfaceH) | |
| 1177 | - | for _, r := range rects { | |
| 1178 | - | totalArea += int64(r.Width) * int64(r.Height) | |
| 1179 | - | } | |
| 1180 | - | if surfaceArea > 0 && totalArea*100 > surfaceArea*60 { | |
| 1181 | - | return []rect{{0, 0, surfaceW, surfaceH}} | |
| 1182 | - | } | |
| 1183 | - | ||
| 1184 | - | // Merge overlapping or edge-adjacent rects. Two rects touch if their | |
| 1185 | - | // X ranges overlap-or-touch AND Y ranges overlap-or-touch. When merged, | |
| 1186 | - | // both are replaced with their bounding box. Repeat until stable. | |
| 1187 | - | merged := make([]rect, len(rects)) | |
| 1188 | - | copy(merged, rects) | |
| 1189 | - | ||
| 1190 | - | changed := true | |
| 1191 | - | for changed { | |
| 1192 | - | changed = false | |
| 1193 | - | for i := 0; i < len(merged); i++ { | |
| 1194 | - | for j := i + 1; j < len(merged); { | |
| 1195 | - | if !rectsTouch(merged[i], merged[j]) { | |
| 1196 | - | j++ | |
| 1197 | - | continue | |
| 1198 | - | } | |
| 1199 | - | merged[i] = boundingBox(merged[i], merged[j]) | |
| 1200 | - | // Remove j by swapping with last element. | |
| 1201 | - | merged[j] = merged[len(merged)-1] | |
| 1202 | - | merged = merged[:len(merged)-1] | |
| 1203 | - | changed = true | |
| 1204 | - | } | |
| 1205 | - | } | |
| 1206 | - | } | |
| 1207 | - | ||
| 1208 | - | return merged | |
| 1209 | - | } | |
| 1210 | - | ||
| 1211 | - | // rectsTouch returns true if two rects overlap or share an edge (adjacent). | |
| 1212 | - | func rectsTouch(a, b rect) bool { | |
| 1213 | - | // a's right edge <= b's left edge → no horizontal contact | |
| 1214 | - | if a.X+a.Width < b.X || b.X+b.Width < a.X { | |
| 1215 | - | return false | |
| 1216 | - | } | |
| 1217 | - | // a's bottom edge <= b's top edge → no vertical contact | |
| 1218 | - | if a.Y+a.Height < b.Y || b.Y+b.Height < a.Y { | |
| 1219 | - | return false | |
| 1220 | - | } | |
| 1221 | - | return true | |
| 1222 | - | } | |
| 1223 | - | ||
| 1224 | - | // boundingBox returns the smallest rect containing both a and b. | |
| 1225 | - | func boundingBox(a, b rect) rect { | |
| 1226 | - | x := a.X | |
| 1227 | - | if b.X < x { | |
| 1228 | - | x = b.X | |
| 1229 | - | } | |
| 1230 | - | y := a.Y | |
| 1231 | - | if b.Y < y { | |
| 1232 | - | y = b.Y | |
| 1233 | - | } | |
| 1234 | - | right := a.X + a.Width | |
| 1235 | - | if br := b.X + b.Width; br > right { | |
| 1236 | - | right = br | |
| 1237 | - | } | |
| 1238 | - | bottom := a.Y + a.Height | |
| 1239 | - | if bb := b.Y + b.Height; bb > bottom { | |
| 1240 | - | bottom = bb | |
| 1241 | - | } | |
| 1242 | - | return rect{X: x, Y: y, Width: right - x, Height: bottom - y} | |
| 1243 | - | } | |
| 1244 | - | ||
| 1245 | - | func stagingNeedsReset(staging []byte, currentWidth, currentHeight, nextWidth, nextHeight int32, expectedBytes int) bool { | |
| 1246 | - | return staging == nil || | |
| 1247 | - | len(staging) != expectedBytes || | |
| 1248 | - | currentWidth != nextWidth || | |
| 1249 | - | currentHeight != nextHeight | |
| 1250 | - | } | |
| 1251 | - | ||
| 1252 | - | func copyDirtyRectsIntoStaging(dst, src []byte, width, height int32, rects []rect) (uint64, []rect, bool) { | |
| 1253 | - | if len(dst) == 0 || len(src) == 0 || width <= 0 || height <= 0 { | |
| 1254 | - | return 0, nil, false | |
| 1255 | - | } | |
| 1256 | - | ||
| 1257 | - | stride := int(width) * 4 | |
| 1258 | - | copiedBytes := uint64(0) | |
| 1259 | - | truncated := false | |
| 1260 | - | sanitized := make([]rect, 0, len(rects)) | |
| 1261 | - | ||
| 1262 | - | for _, rawRect := range rects { | |
| 1263 | - | r, ok := clampDirtyRect(rawRect, width, height) | |
| 1264 | - | if !ok { | |
| 1265 | - | continue | |
| 1266 | - | } | |
| 1267 | - | sanitized = append(sanitized, r) | |
| 1268 | - | ||
| 1269 | - | for row := r.Y; row < r.Y+r.Height; row++ { | |
| 1270 | - | dstOff := int(row)*stride + int(r.X)*4 | |
| 1271 | - | if dstOff < 0 || dstOff >= len(dst) { | |
| 1272 | - | truncated = true | |
| 1273 | - | break | |
| 1274 | - | } | |
| 1275 | - | ||
| 1276 | - | rowBytes := int(r.Width) * 4 | |
| 1277 | - | dstEnd := dstOff + rowBytes | |
| 1278 | - | if dstEnd > len(dst) { | |
| 1279 | - | dstEnd = len(dst) | |
| 1280 | - | truncated = true | |
| 1281 | - | } | |
| 1282 | - | ||
| 1283 | - | srcOff := int(row)*stride + int(r.X)*4 | |
| 1284 | - | if srcOff < 0 || srcOff >= len(src) { | |
| 1285 | - | clear(dst[dstOff:dstEnd]) | |
| 1286 | - | truncated = true | |
| 1287 | - | continue | |
| 1288 | - | } | |
| 1289 | - | ||
| 1290 | - | srcEnd := srcOff + rowBytes | |
| 1291 | - | if srcEnd > len(src) { | |
| 1292 | - | srcEnd = len(src) | |
| 1293 | - | truncated = true | |
| 1294 | - | } | |
| 1295 | - | ||
| 1296 | - | copyLen := srcEnd - srcOff | |
| 1297 | - | maxDstLen := dstEnd - dstOff | |
| 1298 | - | if copyLen > maxDstLen { | |
| 1299 | - | copyLen = maxDstLen | |
| 1300 | - | truncated = true | |
| 1301 | - | } | |
| 1302 | - | ||
| 1303 | - | if copyLen > 0 { | |
| 1304 | - | copy(dst[dstOff:dstOff+copyLen], src[srcOff:srcOff+copyLen]) | |
| 1305 | - | copiedBytes += uint64(copyLen) | |
| 1306 | - | } | |
| 1307 | - | ||
| 1308 | - | if dstOff+copyLen < dstEnd { | |
| 1309 | - | clear(dst[dstOff+copyLen : dstEnd]) | |
| 1310 | - | } | |
| 1311 | - | } | |
| 1312 | - | } | |
| 1313 | - | ||
| 1314 | - | return copiedBytes, sanitized, truncated | |
| 1315 | - | } | |
| 1316 | - | ||
| 1317 | - | // copyDirtyRectsRaw copies only the rows covered by dirty rects from src into | |
| 1318 | - | // dst at matching offsets. Both src and dst must be BGRA buffers of dimensions | |
| 1319 | - | // width x height. Unlike copyDirtyRectsIntoStaging, the destination buffer's | |
| 1320 | - | // existing data outside dirty rect rows is left untouched — the caller must | |
| 1321 | - | // ensure that only the copied rows will be read downstream. | |
| 1322 | - | // | |
| 1323 | - | // On a full-surface dirty rect (e.g. resize), this degrades to copying all | |
| 1324 | - | // rows — equivalent to a full buffer copy. | |
| 1325 | - | // | |
| 1326 | - | // Returns the total bytes copied. | |
| 1327 | - | func copyDirtyRectsRaw(dst, src []byte, width, height int32, rects []rect) uint64 { | |
| 1328 | - | if len(dst) == 0 || len(src) == 0 || width <= 0 || height <= 0 { | |
| 1329 | - | return 0 | |
| 1330 | - | } | |
| 1331 | - | ||
| 1332 | - | stride := int(width) * 4 | |
| 1333 | - | copiedBytes := uint64(0) | |
| 1334 | - | ||
| 1335 | - | for _, rawRect := range rects { | |
| 1336 | - | r, ok := clampDirtyRect(rawRect, width, height) | |
| 1337 | - | if !ok { | |
| 1338 | - | continue | |
| 1339 | - | } | |
| 1340 | - | ||
| 1341 | - | for row := r.Y; row < r.Y+r.Height; row++ { | |
| 1342 | - | dstOff := int(row)*stride + int(r.X)*4 | |
| 1343 | - | if dstOff < 0 || dstOff >= len(dst) { | |
| 1344 | - | break | |
| 1345 | - | } | |
| 1346 | - | ||
| 1347 | - | rowBytes := int(r.Width) * 4 | |
| 1348 | - | dstEnd := dstOff + rowBytes | |
| 1349 | - | if dstEnd > len(dst) { | |
| 1350 | - | dstEnd = len(dst) | |
| 1351 | - | } | |
| 1352 | - | ||
| 1353 | - | srcOff := int(row)*stride + int(r.X)*4 | |
| 1354 | - | if srcOff < 0 || srcOff >= len(src) { | |
| 1355 | - | continue | |
| 1356 | - | } | |
| 1357 | - | ||
| 1358 | - | srcEnd := srcOff + rowBytes | |
| 1359 | - | if srcEnd > len(src) { | |
| 1360 | - | srcEnd = len(src) | |
| 1361 | - | } | |
| 1362 | - | ||
| 1363 | - | copyLen := srcEnd - srcOff | |
| 1364 | - | maxDstLen := dstEnd - dstOff | |
| 1365 | - | if copyLen > maxDstLen { | |
| 1366 | - | copyLen = maxDstLen | |
| 1367 | - | } | |
| 1368 | - | ||
| 1369 | - | if copyLen > 0 { | |
| 1370 | - | copy(dst[dstOff:dstOff+copyLen], src[srcOff:srcOff+copyLen]) | |
| 1371 | - | copiedBytes += uint64(copyLen) | |
| 1372 | - | } | |
| 1373 | - | } | |
| 1374 | - | } | |
| 1375 | - | ||
| 1376 | - | return copiedBytes | |
| 1377 | - | } | |
| 1378 | - | ||
| 1379 | - | // pboOffset converts a byte offset into an unsafe.Pointer for use with | |
| 1380 | - | // glTexSubImage2D when a PBO is bound. OpenGL interprets the pointer as a | |
| 1381 | - | // byte offset into the bound buffer rather than a real memory address. | |
| 1382 | - | func pboOffset(offset uintptr) unsafe.Pointer { | |
| 1383 | - | return unsafe.Add(nil, offset) | |
| 1384 | - | } | |
D internal/infrastructure/cef/render_pipeline_test.go +0 -271
| @@ -1,271 +0,0 @@ | |||
| 1 | - | package cef | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "bytes" | |
| 5 | - | "context" | |
| 6 | - | "sort" | |
| 7 | - | "testing" | |
| 8 | - | ||
| 9 | - | "github.com/stretchr/testify/assert" | |
| 10 | - | "github.com/stretchr/testify/require" | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | - | func TestStagingNeedsResetWhenBufferSizeMismatches(t *testing.T) { | |
| 14 | - | staging := make([]byte, 8) | |
| 15 | - | ||
| 16 | - | require.True(t, stagingNeedsReset(staging, 2, 2, 2, 2, 16)) | |
| 17 | - | require.False(t, stagingNeedsReset(make([]byte, 16), 2, 2, 2, 2, 16)) | |
| 18 | - | } | |
| 19 | - | ||
| 20 | - | func TestCopyDirtyRectsIntoStagingClampsTruncatedBuffer(t *testing.T) { | |
| 21 | - | const ( | |
| 22 | - | width = int32(4) | |
| 23 | - | height = int32(2) | |
| 24 | - | ) | |
| 25 | - | ||
| 26 | - | dst := bytes.Repeat([]byte{0xaa}, 32) | |
| 27 | - | src := make([]byte, 28) | |
| 28 | - | for i := range src { | |
| 29 | - | src[i] = byte(i + 1) | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | copied, sanitized, truncated := copyDirtyRectsIntoStaging(dst, src, width, height, []rect{{ | |
| 33 | - | X: 0, | |
| 34 | - | Y: 0, | |
| 35 | - | Width: width, | |
| 36 | - | Height: height, | |
| 37 | - | }}) | |
| 38 | - | ||
| 39 | - | require.True(t, truncated) | |
| 40 | - | require.Equal(t, uint64(len(src)), copied) | |
| 41 | - | require.Len(t, sanitized, 1) | |
| 42 | - | require.Equal(t, src, dst[:len(src)]) | |
| 43 | - | require.Equal(t, []byte{0, 0, 0, 0}, dst[len(src):]) | |
| 44 | - | } | |
| 45 | - | ||
| 46 | - | func TestHandlePaintCopiesDirtyRectsIntoPersistentStaging(t *testing.T) { | |
| 47 | - | rp := &renderPipeline{ | |
| 48 | - | ctx: context.Background(), | |
| 49 | - | scale: 1, | |
| 50 | - | } | |
| 51 | - | ||
| 52 | - | initial := []byte{ | |
| 53 | - | 1, 2, 3, 4, | |
| 54 | - | 5, 6, 7, 8, | |
| 55 | - | 9, 10, 11, 12, | |
| 56 | - | 13, 14, 15, 16, | |
| 57 | - | } | |
| 58 | - | rp.handlePaint(initial, 2, 2, []rect{{X: 0, Y: 0, Width: 2, Height: 2}}, 1) | |
| 59 | - | ||
| 60 | - | require.Equal(t, initial, rp.staging) | |
| 61 | - | require.True(t, rp.needsUpload) | |
| 62 | - | require.True(t, rp.sizeChanged) | |
| 63 | - | require.True(t, rp.forceFullUpload) | |
| 64 | - | ||
| 65 | - | rp.mu.Lock() | |
| 66 | - | rp.dirtyRects = nil | |
| 67 | - | rp.needsUpload = false | |
| 68 | - | rp.sizeChanged = false | |
| 69 | - | rp.forceFullUpload = false | |
| 70 | - | rp.mu.Unlock() | |
| 71 | - | ||
| 72 | - | updated := append([]byte(nil), initial...) | |
| 73 | - | copy(updated[8:12], []byte{42, 43, 44, 45}) | |
| 74 | - | rp.handlePaint(updated, 2, 2, []rect{{X: 0, Y: 1, Width: 1, Height: 1}}, 2) | |
| 75 | - | ||
| 76 | - | require.Equal(t, updated, rp.staging) | |
| 77 | - | require.Equal(t, []rect{{X: 0, Y: 1, Width: 1, Height: 1}}, rp.dirtyRects) | |
| 78 | - | require.True(t, rp.needsUpload) | |
| 79 | - | require.False(t, rp.sizeChanged) | |
| 80 | - | require.False(t, rp.forceFullUpload) | |
| 81 | - | } | |
| 82 | - | ||
| 83 | - | func TestOnResizeIgnoresZeroFirstResize(t *testing.T) { | |
| 84 | - | called := false | |
| 85 | - | rp := &renderPipeline{ | |
| 86 | - | scale: 1, | |
| 87 | - | onFirstResize: func(_, _ int32) { | |
| 88 | - | called = true | |
| 89 | - | }, | |
| 90 | - | } | |
| 91 | - | rp.widthAtomic.Store(123) | |
| 92 | - | rp.heightAtomic.Store(456) | |
| 93 | - | ||
| 94 | - | rp.onResize(0, 0) | |
| 95 | - | ||
| 96 | - | require.False(t, called) | |
| 97 | - | require.Equal(t, int32(123), rp.widthAtomic.Load()) | |
| 98 | - | require.Equal(t, int32(456), rp.heightAtomic.Load()) | |
| 99 | - | require.False(t, rp.sizeChanged) | |
| 100 | - | require.NotNil(t, rp.onFirstResize) | |
| 101 | - | ||
| 102 | - | rp.onResize(10, 20) | |
| 103 | - | ||
| 104 | - | require.True(t, called) | |
| 105 | - | require.Equal(t, int32(10), rp.widthAtomic.Load()) | |
| 106 | - | require.Equal(t, int32(20), rp.heightAtomic.Load()) | |
| 107 | - | require.True(t, rp.sizeChanged) | |
| 108 | - | require.Nil(t, rp.onFirstResize) | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | // sortRects sorts rects by (X, Y) so tests can compare without caring about order. | |
| 112 | - | func sortRects(rects []rect) { | |
| 113 | - | sort.Slice(rects, func(i, j int) bool { | |
| 114 | - | if rects[i].X != rects[j].X { | |
| 115 | - | return rects[i].X < rects[j].X | |
| 116 | - | } | |
| 117 | - | return rects[i].Y < rects[j].Y | |
| 118 | - | }) | |
| 119 | - | } | |
| 120 | - | ||
| 121 | - | func TestCoalesceDirtyRectsEmpty(t *testing.T) { | |
| 122 | - | result := coalesceDirtyRects(nil, 1920, 1080) | |
| 123 | - | assert.Nil(t, result) | |
| 124 | - | ||
| 125 | - | result = coalesceDirtyRects([]rect{}, 1920, 1080) | |
| 126 | - | assert.Empty(t, result) | |
| 127 | - | } | |
| 128 | - | ||
| 129 | - | func TestCoalesceDirtyRectsSingle(t *testing.T) { | |
| 130 | - | input := []rect{{10, 20, 30, 40}} | |
| 131 | - | result := coalesceDirtyRects(input, 1920, 1080) | |
| 132 | - | require.Len(t, result, 1) | |
| 133 | - | assert.Equal(t, rect{10, 20, 30, 40}, result[0]) | |
| 134 | - | } | |
| 135 | - | ||
| 136 | - | func TestCoalesceDirtyRectsDisjoint(t *testing.T) { | |
| 137 | - | // Top-left cursor blink and bottom-right scrollbar: should NOT merge. | |
| 138 | - | input := []rect{ | |
| 139 | - | {0, 0, 2, 16}, // cursor at top-left | |
| 140 | - | {1900, 1060, 20, 20}, // scrollbar at bottom-right | |
| 141 | - | } | |
| 142 | - | result := coalesceDirtyRects(input, 1920, 1080) | |
| 143 | - | require.Len(t, result, 2) | |
| 144 | - | sortRects(result) | |
| 145 | - | assert.Equal(t, rect{0, 0, 2, 16}, result[0]) | |
| 146 | - | assert.Equal(t, rect{1900, 1060, 20, 20}, result[1]) | |
| 147 | - | } | |
| 148 | - | ||
| 149 | - | func TestCoalesceDirtyRectsOverlapping(t *testing.T) { | |
| 150 | - | // Two overlapping rects merge to their bounding box. | |
| 151 | - | input := []rect{ | |
| 152 | - | {10, 10, 30, 30}, | |
| 153 | - | {20, 20, 30, 30}, | |
| 154 | - | } | |
| 155 | - | result := coalesceDirtyRects(input, 1920, 1080) | |
| 156 | - | require.Len(t, result, 1) | |
| 157 | - | assert.Equal(t, rect{10, 10, 40, 40}, result[0]) | |
| 158 | - | } | |
| 159 | - | ||
| 160 | - | func TestCoalesceDirtyRectsEdgeAdjacent(t *testing.T) { | |
| 161 | - | // Two rects sharing an edge (right edge of A touches left edge of B). | |
| 162 | - | input := []rect{ | |
| 163 | - | {0, 0, 50, 100}, | |
| 164 | - | {50, 0, 50, 100}, | |
| 165 | - | } | |
| 166 | - | result := coalesceDirtyRects(input, 1920, 1080) | |
| 167 | - | require.Len(t, result, 1) | |
| 168 | - | assert.Equal(t, rect{0, 0, 100, 100}, result[0]) | |
| 169 | - | } | |
| 170 | - | ||
| 171 | - | func TestCoalesceDirtyRectsEdgeAdjacentVertical(t *testing.T) { | |
| 172 | - | // Two rects sharing a vertical edge (bottom of A touches top of B). | |
| 173 | - | input := []rect{ | |
| 174 | - | {0, 0, 100, 50}, | |
| 175 | - | {0, 50, 100, 50}, | |
| 176 | - | } | |
| 177 | - | result := coalesceDirtyRects(input, 1920, 1080) | |
| 178 | - | require.Len(t, result, 1) | |
| 179 | - | assert.Equal(t, rect{0, 0, 100, 100}, result[0]) | |
| 180 | - | } | |
| 181 | - | ||
| 182 | - | func TestCoalesceDirtyRectsTwoOverlapOneDisjoint(t *testing.T) { | |
| 183 | - | // Three rects: first two overlap, third is disjoint. | |
| 184 | - | input := []rect{ | |
| 185 | - | {10, 10, 20, 20}, | |
| 186 | - | {20, 10, 20, 20}, | |
| 187 | - | {500, 500, 10, 10}, | |
| 188 | - | } | |
| 189 | - | result := coalesceDirtyRects(input, 1920, 1080) | |
| 190 | - | require.Len(t, result, 2) | |
| 191 | - | sortRects(result) | |
| 192 | - | assert.Equal(t, rect{10, 10, 30, 20}, result[0]) | |
| 193 | - | assert.Equal(t, rect{500, 500, 10, 10}, result[1]) | |
| 194 | - | } | |
| 195 | - | ||
| 196 | - | func TestCoalesceDirtyRectsAreaThreshold(t *testing.T) { | |
| 197 | - | // Total area > 60% of surface → collapses to single full-surface rect. | |
| 198 | - | // Surface is 100x100 = 10000 pixels. 60% = 6000. | |
| 199 | - | // Two rects: 4000 + 3000 = 7000 > 6000. | |
| 200 | - | input := []rect{ | |
| 201 | - | {0, 0, 100, 40}, // 4000 pixels | |
| 202 | - | {0, 40, 100, 30}, // 3000 pixels | |
| 203 | - | } | |
| 204 | - | result := coalesceDirtyRects(input, 100, 100) | |
| 205 | - | require.Len(t, result, 1) | |
| 206 | - | assert.Equal(t, rect{0, 0, 100, 100}, result[0]) | |
| 207 | - | } | |
| 208 | - | ||
| 209 | - | func TestCoalesceDirtyRectsAreaThresholdBelowDoesNotCollapse(t *testing.T) { | |
| 210 | - | // Total area <= 60% of surface → no full-surface collapse. | |
| 211 | - | // Surface is 100x100 = 10000. 59% = 5900. | |
| 212 | - | input := []rect{ | |
| 213 | - | {0, 0, 100, 29}, // 2900 pixels | |
| 214 | - | {0, 70, 100, 29}, // 2900 pixels → total 5800 < 6000 | |
| 215 | - | } | |
| 216 | - | result := coalesceDirtyRects(input, 100, 100) | |
| 217 | - | // These two rects are disjoint (gap from Y=29 to Y=70), so no merge. | |
| 218 | - | require.Len(t, result, 2) | |
| 219 | - | } | |
| 220 | - | ||
| 221 | - | func TestCoalesceDirtyRectsFullyContained(t *testing.T) { | |
| 222 | - | // Rect A fully inside rect B → merges to rect B. | |
| 223 | - | input := []rect{ | |
| 224 | - | {10, 10, 5, 5}, // small rect inside | |
| 225 | - | {0, 0, 100, 100}, // large outer rect | |
| 226 | - | } | |
| 227 | - | result := coalesceDirtyRects(input, 1920, 1080) | |
| 228 | - | require.Len(t, result, 1) | |
| 229 | - | assert.Equal(t, rect{0, 0, 100, 100}, result[0]) | |
| 230 | - | } | |
| 231 | - | ||
| 232 | - | func TestCoalesceDirtyRectsChainMerge(t *testing.T) { | |
| 233 | - | // Three rects in a chain: A overlaps B, B overlaps C, but A and C don't. | |
| 234 | - | // All three should merge into one bounding box after iterative merging. | |
| 235 | - | input := []rect{ | |
| 236 | - | {0, 0, 20, 20}, | |
| 237 | - | {15, 0, 20, 20}, | |
| 238 | - | {30, 0, 20, 20}, | |
| 239 | - | } | |
| 240 | - | result := coalesceDirtyRects(input, 1920, 1080) | |
| 241 | - | require.Len(t, result, 1) | |
| 242 | - | assert.Equal(t, rect{0, 0, 50, 20}, result[0]) | |
| 243 | - | } | |
| 244 | - | ||
| 245 | - | func TestCoalesceDirtyRectsDoesNotMutateInput(t *testing.T) { | |
| 246 | - | input := []rect{ | |
| 247 | - | {10, 10, 30, 30}, | |
| 248 | - | {20, 20, 30, 30}, | |
| 249 | - | } | |
| 250 | - | inputCopy := make([]rect, len(input)) | |
| 251 | - | copy(inputCopy, input) | |
| 252 | - | ||
| 253 | - | coalesceDirtyRects(input, 1920, 1080) | |
| 254 | - | ||
| 255 | - | // Original input should be unchanged. | |
| 256 | - | assert.Equal(t, inputCopy, input) | |
| 257 | - | } | |
| 258 | - | ||
| 259 | - | func TestRectsTouchSeparatedByGap(t *testing.T) { | |
| 260 | - | // One pixel gap between rects → should NOT touch. | |
| 261 | - | a := rect{0, 0, 10, 10} | |
| 262 | - | b := rect{11, 0, 10, 10} | |
| 263 | - | assert.False(t, rectsTouch(a, b)) | |
| 264 | - | } | |
| 265 | - | ||
| 266 | - | func TestRectsTouchExactlyAdjacent(t *testing.T) { | |
| 267 | - | // Right edge of A at X=10, left edge of B at X=10 → touching. | |
| 268 | - | a := rect{0, 0, 10, 10} | |
| 269 | - | b := rect{10, 0, 10, 10} | |
| 270 | - | assert.True(t, rectsTouch(a, b)) | |
| 271 | - | } | |
A internal/infrastructure/cef/render_stack_env.go +170 -0
| @@ -0,0 +1,170 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | const ( | |
| 12 | + | dumberRenderStackEnvVar = "DUMBER_RENDER_STACK" | |
| 13 | + | dumberRenderStackAllowSplitEnvVar = "DUMBER_RENDER_STACK_ALLOW_SPLIT" | |
| 14 | + | ||
| 15 | + | cefEngineType = "cef" | |
| 16 | + | renderStackAuto = "auto" | |
| 17 | + | renderStackVulkanDMABUF = "vulkan-dmabuf" | |
| 18 | + | renderStackLegacyGL = "legacy-gl" | |
| 19 | + | cef2gtkBackendEnvVar = "PUREGO_CEF2GTK_BACKEND" | |
| 20 | + | cef2gtkAngleBackendVar = "PUREGO_CEF2GTK_ANGLE_BACKEND" | |
| 21 | + | gskRendererEnvVar = "GSK_RENDERER" | |
| 22 | + | cef2gtkBackendGDKDMABUF = "gdk-dmabuf" | |
| 23 | + | cef2gtkBackendGLArea = "glarea" | |
| 24 | + | cef2gtkAngleVulkan = "vulkan" | |
| 25 | + | cef2gtkAngleGLEGL = "gl-egl" | |
| 26 | + | gskRendererVulkan = "vulkan" | |
| 27 | + | gskRendererOpenGL = "opengl" | |
| 28 | + | ) | |
| 29 | + | ||
| 30 | + | // ApplyDefaultRenderStackEnvironment configures the process environment for | |
| 31 | + | // Dumber's default CEF presentation stack before GTK/libadwaita initialize. | |
| 32 | + | // | |
| 33 | + | // DUMBER_RENDER_STACK accepts: | |
| 34 | + | // - empty/default or vulkan-dmabuf: use GDK DMABUF presentation with GSK Vulkan and CEF ANGLE Vulkan | |
| 35 | + | // - auto: alias for vulkan-dmabuf | |
| 36 | + | // - legacy-gl: force the older GtkGLArea/OpenGL bridge for diagnostics | |
| 37 | + | // | |
| 38 | + | // Low-level env vars are treated as library-development escape hatches only. | |
| 39 | + | // Dumber keeps the selected stack coherent by default and overwrites conflicts; | |
| 40 | + | // set DUMBER_RENDER_STACK_ALLOW_SPLIT=1 to preserve explicit split-stack values. | |
| 41 | + | func ApplyDefaultRenderStackEnvironment(logger port.Logger) string { | |
| 42 | + | return applyDefaultRenderStackEnvironment(logger) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func applyDefaultRenderStackEnvironment(logger port.Logger) string { | |
| 46 | + | stack := normalizeRenderStack(logger, os.Getenv(dumberRenderStackEnvVar)) | |
| 47 | + | switch stack { | |
| 48 | + | case renderStackLegacyGL: | |
| 49 | + | setRenderStackEnv(logger, gskRendererEnvVar, gskRendererOpenGL) | |
| 50 | + | setRenderStackEnv(logger, cef2gtkBackendEnvVar, cef2gtkBackendGLArea) | |
| 51 | + | setRenderStackEnv(logger, cef2gtkAngleBackendVar, cef2gtkAngleGLEGL) | |
| 52 | + | default: | |
| 53 | + | stack = renderStackVulkanDMABUF | |
| 54 | + | setRenderStackEnv(logger, gskRendererEnvVar, gskRendererVulkan) | |
| 55 | + | setRenderStackEnv(logger, cef2gtkBackendEnvVar, cef2gtkBackendGDKDMABUF) | |
| 56 | + | setRenderStackEnv(logger, cef2gtkAngleBackendVar, cef2gtkAngleVulkan) | |
| 57 | + | } | |
| 58 | + | logRenderStackEnvironment(logger, stack) | |
| 59 | + | return stack | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | func logRenderStackEnvironment(logger port.Logger, stack string) { | |
| 63 | + | logInfo(logger, "cef: render stack environment configured", | |
| 64 | + | port.Field("render_stack", stack), | |
| 65 | + | port.Field("gsk_renderer", os.Getenv(gskRendererEnvVar)), | |
| 66 | + | port.Field("cef2gtk_backend", os.Getenv(cef2gtkBackendEnvVar)), | |
| 67 | + | port.Field("cef2gtk_angle_backend", os.Getenv(cef2gtkAngleBackendVar)), | |
| 68 | + | port.Field("split_stack_allowed", envBoolEnabled(dumberRenderStackAllowSplitEnvVar)), | |
| 69 | + | ) | |
| 70 | + | } | |
| 71 | + | ||
| 72 | + | // HardwareDecodeEnvironmentOptions carries config-derived CEF environment | |
| 73 | + | // decisions from the composition root without making the CEF adapter import | |
| 74 | + | // sibling configuration or environment adapters directly. | |
| 75 | + | type HardwareDecodeEnvironmentOptions struct { | |
| 76 | + | EngineType string | |
| 77 | + | HardwareDecodingDisabled bool | |
| 78 | + | RenderingEnvManager port.RenderingEnvManager | |
| 79 | + | Logger port.Logger | |
| 80 | + | } | |
| 81 | + | ||
| 82 | + | // ApplyDefaultHardwareDecodeEnvironment maps Dumber's media config to CEF's | |
| 83 | + | // developer-facing VAAPI switch env var before CEF command-line callbacks run. | |
| 84 | + | // Existing explicit env values are preserved as low-level escape hatches. | |
| 85 | + | func ApplyDefaultHardwareDecodeEnvironment(ctx context.Context, opts HardwareDecodeEnvironmentOptions) { | |
| 86 | + | if opts.EngineType != cefEngineType { | |
| 87 | + | return | |
| 88 | + | } | |
| 89 | + | if opts.HardwareDecodingDisabled { | |
| 90 | + | setEnvDefault(opts.Logger, cefEnableVAAPIEnvVar, "0") | |
| 91 | + | return | |
| 92 | + | } | |
| 93 | + | setEnvDefault(opts.Logger, cefEnableVAAPIEnvVar, "1") | |
| 94 | + | applyDefaultLIBVADriverEnvironment(ctx, opts.RenderingEnvManager, opts.Logger) | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func applyDefaultLIBVADriverEnvironment(ctx context.Context, manager port.RenderingEnvManager, logger port.Logger) { | |
| 98 | + | if strings.TrimSpace(os.Getenv("LIBVA_DRIVER_NAME")) != "" || manager == nil { | |
| 99 | + | return | |
| 100 | + | } | |
| 101 | + | switch manager.DetectGPUVendor(ctx) { | |
| 102 | + | case port.GPUVendorAMD: | |
| 103 | + | setEnvDefault(logger, "LIBVA_DRIVER_NAME", "radeonsi") | |
| 104 | + | case port.GPUVendorIntel: | |
| 105 | + | setEnvDefault(logger, "LIBVA_DRIVER_NAME", "iHD") | |
| 106 | + | case port.GPUVendorNVIDIA: | |
| 107 | + | logWarn(logger, "cef: not defaulting LIBVA_DRIVER_NAME for NVIDIA; Chromium/CEF VAAPI support is driver-dependent") | |
| 108 | + | } | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func normalizeRenderStack(logger port.Logger, value string) string { | |
| 112 | + | normalized := strings.ToLower(strings.TrimSpace(value)) | |
| 113 | + | switch normalized { | |
| 114 | + | case "", renderStackAuto, renderStackVulkanDMABUF: | |
| 115 | + | return renderStackVulkanDMABUF | |
| 116 | + | case renderStackLegacyGL: | |
| 117 | + | return renderStackLegacyGL | |
| 118 | + | default: | |
| 119 | + | logWarn(logger, "cef: unknown render stack, falling back to default", | |
| 120 | + | port.Field("render_stack", value), | |
| 121 | + | port.Field("fallback", renderStackVulkanDMABUF), | |
| 122 | + | ) | |
| 123 | + | return renderStackVulkanDMABUF | |
| 124 | + | } | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | func setRenderStackEnv(logger port.Logger, key, value string) { | |
| 128 | + | if existing := strings.TrimSpace(os.Getenv(key)); existing != "" { | |
| 129 | + | fields := []port.LogField{ | |
| 130 | + | port.Field("key", key), | |
| 131 | + | port.Field("value", existing), | |
| 132 | + | port.Field("stack_value", value), | |
| 133 | + | } | |
| 134 | + | if envBoolEnabled(dumberRenderStackAllowSplitEnvVar) { | |
| 135 | + | logWarn(logger, "cef: preserving explicit split render stack environment override", fields...) | |
| 136 | + | return | |
| 137 | + | } | |
| 138 | + | logWarn(logger, "cef: overriding low-level render stack environment to keep Dumber stack coherent", fields...) | |
| 139 | + | } | |
| 140 | + | _ = os.Setenv(key, value) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func setEnvDefault(logger port.Logger, key, value string) { | |
| 144 | + | if existing := strings.TrimSpace(os.Getenv(key)); existing != "" { | |
| 145 | + | logDebug(logger, "cef: preserving explicit environment override", | |
| 146 | + | port.Field("key", key), | |
| 147 | + | port.Field("value", existing), | |
| 148 | + | ) | |
| 149 | + | return | |
| 150 | + | } | |
| 151 | + | _ = os.Setenv(key, value) | |
| 152 | + | } | |
| 153 | + | ||
| 154 | + | func logDebug(logger port.Logger, msg string, fields ...port.LogField) { | |
| 155 | + | if logger != nil { | |
| 156 | + | logger.Debug(msg, fields...) | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func logInfo(logger port.Logger, msg string, fields ...port.LogField) { | |
| 161 | + | if logger != nil { | |
| 162 | + | logger.Info(msg, fields...) | |
| 163 | + | } | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | func logWarn(logger port.Logger, msg string, fields ...port.LogField) { | |
| 167 | + | if logger != nil { | |
| 168 | + | logger.Warn(msg, fields...) | |
| 169 | + | } | |
| 170 | + | } | |
A internal/infrastructure/cef/render_stack_env_test.go +158 -0
| @@ -0,0 +1,158 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "testing" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestApplyDefaultRenderStackEnvironment_DefaultsToGDKDMABUFWithANGLEVulkan(t *testing.T) { | |
| 12 | + | t.Setenv(dumberRenderStackEnvVar, "") | |
| 13 | + | t.Setenv("GSK_RENDERER", "") | |
| 14 | + | t.Setenv("PUREGO_CEF2GTK_BACKEND", "") | |
| 15 | + | t.Setenv("PUREGO_CEF2GTK_ANGLE_BACKEND", "") | |
| 16 | + | ||
| 17 | + | got := applyDefaultRenderStackEnvironment(nil) | |
| 18 | + | ||
| 19 | + | if got != renderStackVulkanDMABUF { | |
| 20 | + | t.Fatalf("render stack = %q, want %q", got, renderStackVulkanDMABUF) | |
| 21 | + | } | |
| 22 | + | if got := os.Getenv("GSK_RENDERER"); got != "vulkan" { | |
| 23 | + | t.Fatalf("GSK_RENDERER = %q, want vulkan", got) | |
| 24 | + | } | |
| 25 | + | if got := os.Getenv("PUREGO_CEF2GTK_BACKEND"); got != "gdk-dmabuf" { | |
| 26 | + | t.Fatalf("PUREGO_CEF2GTK_BACKEND = %q, want gdk-dmabuf", got) | |
| 27 | + | } | |
| 28 | + | if got := os.Getenv("PUREGO_CEF2GTK_ANGLE_BACKEND"); got != "vulkan" { | |
| 29 | + | t.Fatalf("PUREGO_CEF2GTK_ANGLE_BACKEND = %q, want vulkan", got) | |
| 30 | + | } | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | func TestApplyDefaultRenderStackEnvironment_OverridesConflictingLowLevelDefaults(t *testing.T) { | |
| 34 | + | t.Setenv(dumberRenderStackEnvVar, "") | |
| 35 | + | t.Setenv(dumberRenderStackAllowSplitEnvVar, "") | |
| 36 | + | t.Setenv("GSK_RENDERER", "ngl") | |
| 37 | + | t.Setenv("PUREGO_CEF2GTK_BACKEND", "glarea") | |
| 38 | + | t.Setenv("PUREGO_CEF2GTK_ANGLE_BACKEND", "gl-egl") | |
| 39 | + | ||
| 40 | + | got := applyDefaultRenderStackEnvironment(nil) | |
| 41 | + | ||
| 42 | + | if got != renderStackVulkanDMABUF { | |
| 43 | + | t.Fatalf("render stack = %q, want %q", got, renderStackVulkanDMABUF) | |
| 44 | + | } | |
| 45 | + | if got := os.Getenv("GSK_RENDERER"); got != "vulkan" { | |
| 46 | + | t.Fatalf("GSK_RENDERER = %q, want coherent vulkan default", got) | |
| 47 | + | } | |
| 48 | + | if got := os.Getenv("PUREGO_CEF2GTK_BACKEND"); got != "gdk-dmabuf" { | |
| 49 | + | t.Fatalf("PUREGO_CEF2GTK_BACKEND = %q, want coherent gdk-dmabuf default", got) | |
| 50 | + | } | |
| 51 | + | if got := os.Getenv("PUREGO_CEF2GTK_ANGLE_BACKEND"); got != "vulkan" { | |
| 52 | + | t.Fatalf("PUREGO_CEF2GTK_ANGLE_BACKEND = %q, want coherent vulkan default", got) | |
| 53 | + | } | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | func TestApplyDefaultRenderStackEnvironment_AllowSplitPreservesExplicitLowLevelOverrides(t *testing.T) { | |
| 57 | + | t.Setenv(dumberRenderStackEnvVar, "") | |
| 58 | + | t.Setenv(dumberRenderStackAllowSplitEnvVar, "1") | |
| 59 | + | t.Setenv("GSK_RENDERER", "ngl") | |
| 60 | + | t.Setenv("PUREGO_CEF2GTK_BACKEND", "glarea") | |
| 61 | + | t.Setenv("PUREGO_CEF2GTK_ANGLE_BACKEND", "gl-egl") | |
| 62 | + | ||
| 63 | + | got := applyDefaultRenderStackEnvironment(nil) | |
| 64 | + | ||
| 65 | + | if got != renderStackVulkanDMABUF { | |
| 66 | + | t.Fatalf("render stack = %q, want %q", got, renderStackVulkanDMABUF) | |
| 67 | + | } | |
| 68 | + | if got := os.Getenv("GSK_RENDERER"); got != "ngl" { | |
| 69 | + | t.Fatalf("GSK_RENDERER = %q, want explicit ngl", got) | |
| 70 | + | } | |
| 71 | + | if got := os.Getenv("PUREGO_CEF2GTK_BACKEND"); got != "glarea" { | |
| 72 | + | t.Fatalf("PUREGO_CEF2GTK_BACKEND = %q, want explicit glarea", got) | |
| 73 | + | } | |
| 74 | + | if got := os.Getenv("PUREGO_CEF2GTK_ANGLE_BACKEND"); got != "gl-egl" { | |
| 75 | + | t.Fatalf("PUREGO_CEF2GTK_ANGLE_BACKEND = %q, want explicit gl-egl", got) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestApplyDefaultRenderStackEnvironment_LegacyGLUsesGLArea(t *testing.T) { | |
| 80 | + | t.Setenv(dumberRenderStackEnvVar, "legacy-gl") | |
| 81 | + | t.Setenv("GSK_RENDERER", "") | |
| 82 | + | t.Setenv("PUREGO_CEF2GTK_BACKEND", "") | |
| 83 | + | t.Setenv("PUREGO_CEF2GTK_ANGLE_BACKEND", "") | |
| 84 | + | ||
| 85 | + | got := applyDefaultRenderStackEnvironment(nil) | |
| 86 | + | ||
| 87 | + | if got != renderStackLegacyGL { | |
| 88 | + | t.Fatalf("render stack = %q, want %q", got, renderStackLegacyGL) | |
| 89 | + | } | |
| 90 | + | if got := os.Getenv("PUREGO_CEF2GTK_BACKEND"); got != "glarea" { | |
| 91 | + | t.Fatalf("PUREGO_CEF2GTK_BACKEND = %q, want glarea", got) | |
| 92 | + | } | |
| 93 | + | if got := os.Getenv("PUREGO_CEF2GTK_ANGLE_BACKEND"); got != "gl-egl" { | |
| 94 | + | t.Fatalf("PUREGO_CEF2GTK_ANGLE_BACKEND = %q, want gl-egl", got) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | func TestNormalizeRenderStackUnknownFallsBackToGDKDMABUF(t *testing.T) { | |
| 99 | + | got := normalizeRenderStack(nil, "vulkan-dmabuff") | |
| 100 | + | ||
| 101 | + | if got != renderStackVulkanDMABUF { | |
| 102 | + | t.Fatalf("render stack = %q, want %q", got, renderStackVulkanDMABUF) | |
| 103 | + | } | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | func TestApplyDefaultHardwareDecodeEnvironment_DefaultsCEFToVAAPIForAuto(t *testing.T) { | |
| 107 | + | t.Setenv(cefEnableVAAPIEnvVar, "") | |
| 108 | + | ||
| 109 | + | ApplyDefaultHardwareDecodeEnvironment(context.Background(), HardwareDecodeEnvironmentOptions{EngineType: cefEngineType}) | |
| 110 | + | ||
| 111 | + | if got := os.Getenv(cefEnableVAAPIEnvVar); got != "1" { | |
| 112 | + | t.Fatalf("%s = %q, want 1", cefEnableVAAPIEnvVar, got) | |
| 113 | + | } | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | func TestApplyDefaultHardwareDecodeEnvironment_PreservesExplicitLIBVADriver(t *testing.T) { | |
| 117 | + | t.Setenv(cefEnableVAAPIEnvVar, "") | |
| 118 | + | t.Setenv("LIBVA_DRIVER_NAME", "custom-driver") | |
| 119 | + | ||
| 120 | + | ApplyDefaultHardwareDecodeEnvironment(context.Background(), HardwareDecodeEnvironmentOptions{ | |
| 121 | + | EngineType: cefEngineType, | |
| 122 | + | RenderingEnvManager: stubRenderingEnvManager{}, | |
| 123 | + | }) | |
| 124 | + | ||
| 125 | + | if got := os.Getenv("LIBVA_DRIVER_NAME"); got != "custom-driver" { | |
| 126 | + | t.Fatalf("LIBVA_DRIVER_NAME = %q, want explicit custom-driver", got) | |
| 127 | + | } | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | func TestApplyDefaultHardwareDecodeEnvironment_DisablesCEFVAAPIWhenMediaDisabled(t *testing.T) { | |
| 131 | + | t.Setenv(cefEnableVAAPIEnvVar, "") | |
| 132 | + | ||
| 133 | + | ApplyDefaultHardwareDecodeEnvironment(context.Background(), HardwareDecodeEnvironmentOptions{ | |
| 134 | + | EngineType: cefEngineType, | |
| 135 | + | HardwareDecodingDisabled: true, | |
| 136 | + | }) | |
| 137 | + | ||
| 138 | + | if got := os.Getenv(cefEnableVAAPIEnvVar); got != "0" { | |
| 139 | + | t.Fatalf("%s = %q, want 0", cefEnableVAAPIEnvVar, got) | |
| 140 | + | } | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | func TestApplyDefaultHardwareDecodeEnvironment_PreservesExplicitCEFVAAPIOverride(t *testing.T) { | |
| 144 | + | t.Setenv(cefEnableVAAPIEnvVar, "0") | |
| 145 | + | ||
| 146 | + | ApplyDefaultHardwareDecodeEnvironment(context.Background(), HardwareDecodeEnvironmentOptions{EngineType: cefEngineType}) | |
| 147 | + | ||
| 148 | + | if got := os.Getenv(cefEnableVAAPIEnvVar); got != "0" { | |
| 149 | + | t.Fatalf("%s = %q, want explicit 0", cefEnableVAAPIEnvVar, got) | |
| 150 | + | } | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | type stubRenderingEnvManager struct{} | |
| 154 | + | ||
| 155 | + | func (stubRenderingEnvManager) DetectGPUVendor(context.Context) port.GPUVendor { | |
| 156 | + | return port.GPUVendorAMD | |
| 157 | + | } | |
| 158 | + | func (stubRenderingEnvManager) GetAppliedVars() map[string]string { return nil } | |
A internal/infrastructure/cef/render_stall_watchdog.go +265 -0
| @@ -0,0 +1,265 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "time" | |
| 5 | + | ||
| 6 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 7 | + | cef2gtk "github.com/bnema/purego-cef2gtk" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/logging" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | const ( | |
| 13 | + | renderStallWatchdogInterval = 1 * time.Second | |
| 14 | + | renderStallWarnAfter = 3 * time.Second | |
| 15 | + | renderStallWarnRepeat = 10 * time.Second | |
| 16 | + | renderStallCEFUIBlockedAfter = 3 * time.Second | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | type renderStallClassification struct { | |
| 20 | + | Category string | |
| 21 | + | CEFUIBlocked bool | |
| 22 | + | CEFIOAlive bool | |
| 23 | + | } | |
| 24 | + | ||
| 25 | + | type renderStallDiagnostics struct { | |
| 26 | + | browser purecef.Browser | |
| 27 | + | uri string | |
| 28 | + | title string | |
| 29 | + | isLoading bool | |
| 30 | + | pendingURI string | |
| 31 | + | audioPlaying bool | |
| 32 | + | diag cef2gtk.Diagnostics | |
| 33 | + | lastPaintAge time.Duration | |
| 34 | + | latestEvent cef2gtk.DiagnosticEvent | |
| 35 | + | } | |
| 36 | + | ||
| 37 | + | func classifyRenderStall(uiHeartbeat, ioHeartbeat cefHeartbeatSnapshot) renderStallClassification { | |
| 38 | + | uiBlocked := uiHeartbeat.PostResult == 1 && uiHeartbeat.InFlight && uiHeartbeat.LastAckAge >= renderStallCEFUIBlockedAfter | |
| 39 | + | ioAlive := ioHeartbeat.PostResult == 1 && | |
| 40 | + | !ioHeartbeat.InFlight && | |
| 41 | + | ioHeartbeat.LastAckAge > 0 && | |
| 42 | + | ioHeartbeat.LastAckAge < renderStallCEFUIBlockedAfter | |
| 43 | + | category := "osr-paint-stalled" | |
| 44 | + | if uiBlocked { | |
| 45 | + | category = "cef-ui-task-runner-blocked" | |
| 46 | + | if ioAlive { | |
| 47 | + | category = "cef-ui-task-runner-blocked-io-alive" | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | return renderStallClassification{Category: category, CEFUIBlocked: uiBlocked, CEFIOAlive: ioAlive} | |
| 51 | + | } | |
| 52 | + | ||
| 53 | + | func (wv *WebView) startRenderStallWatchdog() { | |
| 54 | + | if wv == nil || wv.ctx == nil || wv.viewBridge == nil { | |
| 55 | + | return | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | wv.renderStallMu.Lock() | |
| 59 | + | defer wv.renderStallMu.Unlock() | |
| 60 | + | if wv.renderStallStop != nil { | |
| 61 | + | return | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | stop := make(chan struct{}) | |
| 65 | + | done := make(chan struct{}) | |
| 66 | + | bridge := wv.viewBridge | |
| 67 | + | wv.renderStallStop = stop | |
| 68 | + | wv.renderStallDone = done | |
| 69 | + | ||
| 70 | + | go wv.renderStallWatchdogLoop(bridge, stop, done) | |
| 71 | + | } | |
| 72 | + | ||
| 73 | + | func (wv *WebView) stopRenderStallWatchdog() { | |
| 74 | + | if wv == nil { | |
| 75 | + | return | |
| 76 | + | } | |
| 77 | + | wv.renderStallMu.Lock() | |
| 78 | + | stop := wv.renderStallStop | |
| 79 | + | done := wv.renderStallDone | |
| 80 | + | wv.renderStallStop = nil | |
| 81 | + | wv.renderStallDone = nil | |
| 82 | + | wv.renderStallMu.Unlock() | |
| 83 | + | ||
| 84 | + | if stop == nil || done == nil { | |
| 85 | + | return | |
| 86 | + | } | |
| 87 | + | close(stop) | |
| 88 | + | select { | |
| 89 | + | case <-done: | |
| 90 | + | case <-time.After(cefHeartbeatStopTimeout): | |
| 91 | + | if wv.ctx != nil { | |
| 92 | + | logging.FromContext(wv.ctx).Warn().Msg("cef: render stall watchdog stop timed out") | |
| 93 | + | } | |
| 94 | + | } | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | func (wv *WebView) renderStallWatchdogLoop(bridge *Cef2gtkAdapter, stop <-chan struct{}, done chan<- struct{}) { | |
| 98 | + | defer close(done) | |
| 99 | + | ticker := time.NewTicker(renderStallWatchdogInterval) | |
| 100 | + | defer ticker.Stop() | |
| 101 | + | ||
| 102 | + | var lastWarn time.Time | |
| 103 | + | for { | |
| 104 | + | select { | |
| 105 | + | case <-stop: | |
| 106 | + | return | |
| 107 | + | case now := <-ticker.C: | |
| 108 | + | if wv.maybeLogRenderStall(bridge, now, lastWarn) { | |
| 109 | + | lastWarn = now | |
| 110 | + | } | |
| 111 | + | } | |
| 112 | + | } | |
| 113 | + | } | |
| 114 | + | ||
| 115 | + | func (wv *WebView) maybeLogRenderStall(bridge *Cef2gtkAdapter, now, lastWarn time.Time) bool { | |
| 116 | + | diag, ok := wv.shouldLogRenderStall(bridge, now, lastWarn) | |
| 117 | + | if !ok { | |
| 118 | + | return false | |
| 119 | + | } | |
| 120 | + | wv.logRenderStall(bridge, now, diag) | |
| 121 | + | return true | |
| 122 | + | } | |
| 123 | + | ||
| 124 | + | func (wv *WebView) shouldLogRenderStall(bridge *Cef2gtkAdapter, now, lastWarn time.Time) (renderStallDiagnostics, bool) { | |
| 125 | + | if wv == nil || bridge == nil || wv.destroyed.Load() || wv.ctx == nil { | |
| 126 | + | return renderStallDiagnostics{}, false | |
| 127 | + | } | |
| 128 | + | diag, ok := wv.collectStallDiagnostics(bridge, now) | |
| 129 | + | if !ok { | |
| 130 | + | return renderStallDiagnostics{}, false | |
| 131 | + | } | |
| 132 | + | if diag.lastPaintAge < renderStallWarnAfter { | |
| 133 | + | return renderStallDiagnostics{}, false | |
| 134 | + | } | |
| 135 | + | if !lastWarn.IsZero() && now.Sub(lastWarn) < renderStallWarnRepeat { | |
| 136 | + | return renderStallDiagnostics{}, false | |
| 137 | + | } | |
| 138 | + | return diag, true | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | func (wv *WebView) collectStallDiagnostics(bridge *Cef2gtkAdapter, now time.Time) (renderStallDiagnostics, bool) { | |
| 142 | + | wv.mu.RLock() | |
| 143 | + | browser := wv.browser | |
| 144 | + | uri := wv.uri | |
| 145 | + | title := wv.title | |
| 146 | + | isLoading := wv.isLoading | |
| 147 | + | pendingURI := wv.pendingURI | |
| 148 | + | wv.mu.RUnlock() | |
| 149 | + | if browser == nil { | |
| 150 | + | return renderStallDiagnostics{}, false | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | audioPlaying := wv.audioPlaying.Load() | |
| 154 | + | if !audioPlaying && !isLoading && pendingURI == "" { | |
| 155 | + | return renderStallDiagnostics{}, false | |
| 156 | + | } | |
| 157 | + | ||
| 158 | + | diag := bridge.Diagnostics() | |
| 159 | + | lastPaint := latestDiagnosticEventTime(diag, "accelerated-paint") | |
| 160 | + | if lastPaint.IsZero() { | |
| 161 | + | return renderStallDiagnostics{}, false | |
| 162 | + | } | |
| 163 | + | return renderStallDiagnostics{ | |
| 164 | + | browser: browser, | |
| 165 | + | uri: uri, | |
| 166 | + | title: title, | |
| 167 | + | isLoading: isLoading, | |
| 168 | + | pendingURI: pendingURI, | |
| 169 | + | audioPlaying: audioPlaying, | |
| 170 | + | diag: diag, | |
| 171 | + | lastPaintAge: now.Sub(lastPaint), | |
| 172 | + | latestEvent: latestDiagnosticEvent(diag), | |
| 173 | + | }, true | |
| 174 | + | } | |
| 175 | + | ||
| 176 | + | func (wv *WebView) logRenderStall(bridge *Cef2gtkAdapter, now time.Time, diag renderStallDiagnostics) { | |
| 177 | + | uiHeartbeat, ioHeartbeat := wv.cefHeartbeatSnapshots(now) | |
| 178 | + | classification := classifyRenderStall(uiHeartbeat, ioHeartbeat) | |
| 179 | + | wv.logRenderDiagnosticSnapshot( | |
| 180 | + | now, "stall", classification, bridge, diag.diag, diag.lastPaintAge, | |
| 181 | + | diag.browser, diag.uri, diag.title, diag.isLoading, diag.pendingURI, diag.audioPlaying, | |
| 182 | + | ) | |
| 183 | + | wv.logCEFProcessDiagnostics("stall", classification) | |
| 184 | + | if wv.engine != nil { | |
| 185 | + | wv.engine.logAllWebViewRenderSnapshots(now, wv.id, "stall-global", classification) | |
| 186 | + | } | |
| 187 | + | wv.maybeRunRenderStallRecovery(now, bridge, diag.diag.AcceleratedPaints) | |
| 188 | + | profile, profileAge, hasProfile := wv.latestCEF2GTKProfileSnapshot(now) | |
| 189 | + | logging.FromContext(wv.ctx).Warn(). | |
| 190 | + | Str("classification", classification.Category). | |
| 191 | + | Bool("cef_ui_blocked", classification.CEFUIBlocked). | |
| 192 | + | Bool("cef_io_alive", classification.CEFIOAlive). | |
| 193 | + | Uint64("webview_id", uint64(wv.id)). | |
| 194 | + | Int32("browser_id", diag.browser.GetIdentifier()). | |
| 195 | + | Bool("has_uri", diag.uri != ""). | |
| 196 | + | Bool("has_title", diag.title != ""). | |
| 197 | + | Bool("is_loading", diag.isLoading). | |
| 198 | + | Bool("has_pending_uri", diag.pendingURI != ""). | |
| 199 | + | Bool("audio_playing", diag.audioPlaying). | |
| 200 | + | Int64("cef_ui_heartbeat_ack_age_ms", durationMillis(uiHeartbeat.LastAckAge)). | |
| 201 | + | Int64("cef_ui_heartbeat_latency_ms", durationMillis(uiHeartbeat.Latency)). | |
| 202 | + | Int32("cef_ui_heartbeat_post_result", uiHeartbeat.PostResult). | |
| 203 | + | Bool("cef_ui_heartbeat_in_flight", uiHeartbeat.InFlight). | |
| 204 | + | Int64("cef_io_heartbeat_ack_age_ms", durationMillis(ioHeartbeat.LastAckAge)). | |
| 205 | + | Int64("cef_io_heartbeat_latency_ms", durationMillis(ioHeartbeat.Latency)). | |
| 206 | + | Int32("cef_io_heartbeat_post_result", ioHeartbeat.PostResult). | |
| 207 | + | Bool("cef_io_heartbeat_in_flight", ioHeartbeat.InFlight). | |
| 208 | + | Uint64("audio_packets", wv.audioPacketCount.Load()). | |
| 209 | + | Uint64("audio_writes", wv.audioWriteCount.Load()). | |
| 210 | + | Int64("last_accelerated_paint_age_ms", diag.lastPaintAge.Milliseconds()). | |
| 211 | + | Bool("profile_snapshot_available", hasProfile). | |
| 212 | + | Int64("profile_snapshot_age_ms", durationMillis(profileAge)). | |
| 213 | + | Uint64("profile_frames_received", profile.FramesReceived). | |
| 214 | + | Uint64("profile_frames_queued", profile.FramesQueued). | |
| 215 | + | Uint64("profile_frames_rendered", profile.FramesRendered). | |
| 216 | + | Uint32("profile_gc_delta", profile.GC.NumGCDelta). | |
| 217 | + | Str("render_backend", diag.diag.Backend). | |
| 218 | + | Int("accelerated_paints", diag.diag.AcceleratedPaints). | |
| 219 | + | Int("unsupported_paints", diag.diag.UnsupportedPaints). | |
| 220 | + | Int("accelerated_paint_errors", diag.diag.AcceleratedPaintErrors). | |
| 221 | + | Int("import_failures", diag.diag.ImportFailures). | |
| 222 | + | Int("render_failures", diag.diag.RenderFailures). | |
| 223 | + | Int("textures_built", diag.diag.TexturesBuilt). | |
| 224 | + | Int("texture_build_failures", diag.diag.TextureBuildFailures). | |
| 225 | + | Int("fd_dup_failures", diag.diag.FDDupFailures). | |
| 226 | + | Int("unsupported_formats", diag.diag.UnsupportedFormats). | |
| 227 | + | Int("paintable_swaps", diag.diag.PaintableSwaps). | |
| 228 | + | Bool("pending_frame", diag.diag.PendingFrame). | |
| 229 | + | Bool("pending_scheduled", diag.diag.PendingScheduled). | |
| 230 | + | Int64("pending_age_ms", durationMillis(diag.diag.PendingAge)). | |
| 231 | + | Uint("pending_source_id", diag.diag.PendingSourceID). | |
| 232 | + | Int("pending_reschedules", diag.diag.PendingReschedules). | |
| 233 | + | Int("pending_schedule_failures", diag.diag.PendingScheduleFailures). | |
| 234 | + | Int("pending_idle_callbacks", diag.diag.PendingIdleCallbacks). | |
| 235 | + | Str("latest_render_event_kind", diag.latestEvent.Kind). | |
| 236 | + | Bool("latest_render_event_has_message", diag.latestEvent.Message != ""). | |
| 237 | + | Msg("cef: accelerated rendering appears stalled") | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | func latestDiagnosticEventTime(diag cef2gtk.Diagnostics, kind string) time.Time { | |
| 241 | + | return latestDiagnosticEventOfKind(diag, kind).Time | |
| 242 | + | } | |
| 243 | + | ||
| 244 | + | func latestDiagnosticEventOfKind(diag cef2gtk.Diagnostics, kind string) cef2gtk.DiagnosticEvent { | |
| 245 | + | var latest cef2gtk.DiagnosticEvent | |
| 246 | + | for _, event := range diag.Events { | |
| 247 | + | if event.Kind != kind { | |
| 248 | + | continue | |
| 249 | + | } | |
| 250 | + | if latest.Time.IsZero() || event.Time.After(latest.Time) { | |
| 251 | + | latest = event | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | return latest | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | func latestDiagnosticEvent(diag cef2gtk.Diagnostics) cef2gtk.DiagnosticEvent { | |
| 258 | + | var latest cef2gtk.DiagnosticEvent | |
| 259 | + | for _, event := range diag.Events { | |
| 260 | + | if latest.Time.IsZero() || event.Time.After(latest.Time) { | |
| 261 | + | latest = event | |
| 262 | + | } | |
| 263 | + | } | |
| 264 | + | return latest | |
| 265 | + | } | |
A internal/infrastructure/cef/render_stall_watchdog_test.go +81 -0
| @@ -0,0 +1,81 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | "time" | |
| 7 | + | ||
| 8 | + | cef2gtk "github.com/bnema/purego-cef2gtk" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func TestRenderStallWatchdogStartStopIdempotent(t *testing.T) { | |
| 12 | + | wv := &WebView{ctx: context.Background(), viewBridge: &Cef2gtkAdapter{}} | |
| 13 | + | ||
| 14 | + | wv.startRenderStallWatchdog() | |
| 15 | + | firstStop := wv.renderStallStop | |
| 16 | + | firstDone := wv.renderStallDone | |
| 17 | + | if firstStop == nil || firstDone == nil { | |
| 18 | + | t.Fatal("startRenderStallWatchdog did not initialize stop/done channels") | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | wv.startRenderStallWatchdog() | |
| 22 | + | if wv.renderStallStop != firstStop || wv.renderStallDone != firstDone { | |
| 23 | + | t.Fatal("second start should not replace active watchdog channels") | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | wv.stopRenderStallWatchdog() | |
| 27 | + | if wv.renderStallStop != nil || wv.renderStallDone != nil { | |
| 28 | + | t.Fatal("stopRenderStallWatchdog should clear watchdog channels") | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // A second stop should be a no-op, not a double close panic. | |
| 32 | + | wv.stopRenderStallWatchdog() | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestClassifyRenderStallDetectsCEFUIBlockedWithIOAlive(t *testing.T) { | |
| 36 | + | classification := classifyRenderStall( | |
| 37 | + | cefHeartbeatSnapshot{PostResult: 1, InFlight: true, LastAckAge: 5 * time.Second}, | |
| 38 | + | cefHeartbeatSnapshot{PostResult: 1, InFlight: false, LastAckAge: 100 * time.Millisecond}, | |
| 39 | + | ) | |
| 40 | + | if classification.Category != "cef-ui-task-runner-blocked-io-alive" { | |
| 41 | + | t.Fatalf("category = %q", classification.Category) | |
| 42 | + | } | |
| 43 | + | if !classification.CEFUIBlocked { | |
| 44 | + | t.Fatal("CEF UI should be classified as blocked") | |
| 45 | + | } | |
| 46 | + | if !classification.CEFIOAlive { | |
| 47 | + | t.Fatal("CEF IO should be classified as alive") | |
| 48 | + | } | |
| 49 | + | } | |
| 50 | + | ||
| 51 | + | func TestClassifyRenderStallKeepsOSRCategoryWhenUIHeartbeatHealthy(t *testing.T) { | |
| 52 | + | classification := classifyRenderStall( | |
| 53 | + | cefHeartbeatSnapshot{PostResult: 1, InFlight: false, LastAckAge: 100 * time.Millisecond}, | |
| 54 | + | cefHeartbeatSnapshot{PostResult: 1, InFlight: false, LastAckAge: 100 * time.Millisecond}, | |
| 55 | + | ) | |
| 56 | + | if classification.Category != "osr-paint-stalled" { | |
| 57 | + | t.Fatalf("category = %q", classification.Category) | |
| 58 | + | } | |
| 59 | + | if classification.CEFUIBlocked { | |
| 60 | + | t.Fatal("CEF UI should not be classified as blocked") | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func TestLatestDiagnosticEventHelpers(t *testing.T) { | |
| 65 | + | now := time.Now() | |
| 66 | + | diag := cef2gtk.Diagnostics{Events: []cef2gtk.DiagnosticEvent{ | |
| 67 | + | {Time: now.Add(-3 * time.Second), Kind: "accelerated-paint"}, | |
| 68 | + | {Time: now.Add(-2 * time.Second), Kind: "render-failure", Message: "old"}, | |
| 69 | + | {Time: now.Add(-1 * time.Second), Kind: "render-failure", Message: "new"}, | |
| 70 | + | }} | |
| 71 | + | ||
| 72 | + | if got := latestDiagnosticEventOfKind(diag, "render-failure"); got.Message != "new" { | |
| 73 | + | t.Fatalf("latest render-failure message = %q, want new", got.Message) | |
| 74 | + | } | |
| 75 | + | if got := latestDiagnosticEvent(diag); got.Kind != "render-failure" || got.Message != "new" { | |
| 76 | + | t.Fatalf("latest event = (%q, %q), want (render-failure, new)", got.Kind, got.Message) | |
| 77 | + | } | |
| 78 | + | if got := latestDiagnosticEventTime(diag, "accelerated-paint"); !got.Equal(now.Add(-3 * time.Second)) { | |
| 79 | + | t.Fatalf("latest accelerated paint time = %v, want %v", got, now.Add(-3*time.Second)) | |
| 80 | + | } | |
| 81 | + | } | |
D internal/infrastructure/cef/renderer.go +0 -9
| @@ -1,9 +0,0 @@ | |||
| 1 | - | package cef | |
| 2 | - | ||
| 3 | - | import gtkmenu "github.com/bnema/dumber/internal/infrastructure/gtkmenu" | |
| 4 | - | ||
| 5 | - | type Renderer = gtkmenu.Renderer | |
| 6 | - | ||
| 7 | - | func NewRenderer(dispatch func(func())) *Renderer { | |
| 8 | - | return gtkmenu.NewRenderer(dispatch) | |
| 9 | - | } | |
D internal/infrastructure/cef/resize_reconciler.go +0 -271
| @@ -1,271 +0,0 @@ | |||
| 1 | - | package cef | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "sync" | |
| 6 | - | "time" | |
| 7 | - | ||
| 8 | - | "github.com/bnema/dumber/internal/application/port" | |
| 9 | - | purecef "github.com/bnema/purego-cef/cef" | |
| 10 | - | "github.com/bnema/puregotk/v4/glib" | |
| 11 | - | ||
| 12 | - | "github.com/bnema/dumber/internal/logging" | |
| 13 | - | ) | |
| 14 | - | ||
| 15 | - | const ( | |
| 16 | - | resizeReconcileRetryDelay = 8 * time.Millisecond | |
| 17 | - | resizeReconcileWindow = 64 * time.Millisecond | |
| 18 | - | ) | |
| 19 | - | ||
| 20 | - | type resizeReconcileTimer interface { | |
| 21 | - | stop() | |
| 22 | - | } | |
| 23 | - | ||
| 24 | - | type resizeReconcileScheduler interface { | |
| 25 | - | afterFunc(delay time.Duration, fn func()) resizeReconcileTimer | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | type glibResizeTimer struct{ id uint } | |
| 29 | - | ||
| 30 | - | func (t *glibResizeTimer) stop() { | |
| 31 | - | if t == nil || t.id == 0 { | |
| 32 | - | return | |
| 33 | - | } | |
| 34 | - | glib.SourceRemove(t.id) | |
| 35 | - | t.id = 0 | |
| 36 | - | } | |
| 37 | - | ||
| 38 | - | type glibResizeScheduler struct{} | |
| 39 | - | ||
| 40 | - | func (glibResizeScheduler) afterFunc(delay time.Duration, fn func()) resizeReconcileTimer { | |
| 41 | - | cb := glib.SourceFunc(func(_ uintptr) bool { | |
| 42 | - | if fn != nil { | |
| 43 | - | fn() | |
| 44 | - | } | |
| 45 | - | return false | |
| 46 | - | }) | |
| 47 | - | return &glibResizeTimer{id: glib.TimeoutAdd(uint(delay.Milliseconds()), &cb, 0)} | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | type resizeReconciler struct { | |
| 51 | - | mu sync.Mutex | |
| 52 | - | webviewID port.WebViewID | |
| 53 | - | ctx context.Context | |
| 54 | - | scheduler resizeReconcileScheduler | |
| 55 | - | now func() time.Time | |
| 56 | - | session *resizeReconcileSession | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | type resizeReconcileSession struct { | |
| 60 | - | seq uint64 | |
| 61 | - | startedAt time.Time | |
| 62 | - | retryCount int | |
| 63 | - | timer resizeReconcileTimer | |
| 64 | - | hostGetter func() resizeNotifiableBrowserHost | |
| 65 | - | destroyed func() bool | |
| 66 | - | } | |
| 67 | - | ||
| 68 | - | func newResizeReconciler(ctx context.Context, webviewID port.WebViewID) *resizeReconciler { | |
| 69 | - | return &resizeReconciler{ | |
| 70 | - | webviewID: webviewID, | |
| 71 | - | ctx: ctx, | |
| 72 | - | scheduler: glibResizeScheduler{}, | |
| 73 | - | now: time.Now, | |
| 74 | - | } | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | func (r *resizeReconciler) clock() time.Time { | |
| 78 | - | if r != nil && r.now != nil { | |
| 79 | - | return r.now() | |
| 80 | - | } | |
| 81 | - | return time.Now() | |
| 82 | - | } | |
| 83 | - | ||
| 84 | - | func (r *resizeReconciler) schedulerOrDefault() resizeReconcileScheduler { | |
| 85 | - | if r != nil && r.scheduler != nil { | |
| 86 | - | return r.scheduler | |
| 87 | - | } | |
| 88 | - | return glibResizeScheduler{} | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | func (r *resizeReconciler) start(seq uint64, hostGetter func() resizeNotifiableBrowserHost, destroyed func() bool) { | |
| 92 | - | if r == nil || seq == 0 || hostGetter == nil { | |
| 93 | - | return | |
| 94 | - | } | |
| 95 | - | if destroyed != nil && destroyed() { | |
| 96 | - | return | |
| 97 | - | } | |
| 98 | - | ||
| 99 | - | now := r.clock() | |
| 100 | - | var superseded *resizeReconcileSession | |
| 101 | - | ||
| 102 | - | r.mu.Lock() | |
| 103 | - | if r.session != nil { | |
| 104 | - | superseded = r.session | |
| 105 | - | if superseded.timer != nil { | |
| 106 | - | superseded.timer.stop() | |
| 107 | - | } | |
| 108 | - | r.session = nil | |
| 109 | - | } | |
| 110 | - | session := &resizeReconcileSession{ | |
| 111 | - | seq: seq, | |
| 112 | - | startedAt: now, | |
| 113 | - | hostGetter: hostGetter, | |
| 114 | - | destroyed: destroyed, | |
| 115 | - | } | |
| 116 | - | r.session = session | |
| 117 | - | r.mu.Unlock() | |
| 118 | - | ||
| 119 | - | if superseded != nil { | |
| 120 | - | r.logStopped("superseded", superseded.seq, superseded.retryCount, now.Sub(superseded.startedAt).Milliseconds()) | |
| 121 | - | } | |
| 122 | - | r.logStarted(seq) | |
| 123 | - | r.schedule(session) | |
| 124 | - | } | |
| 125 | - | ||
| 126 | - | func (r *resizeReconciler) notePaint(seq uint64, sizeMatches bool) { | |
| 127 | - | if r == nil || seq == 0 || !sizeMatches { | |
| 128 | - | return | |
| 129 | - | } | |
| 130 | - | now := r.clock() | |
| 131 | - | ||
| 132 | - | r.mu.Lock() | |
| 133 | - | session := r.session | |
| 134 | - | if session == nil || session.seq != seq { | |
| 135 | - | r.mu.Unlock() | |
| 136 | - | return | |
| 137 | - | } | |
| 138 | - | if session.timer != nil { | |
| 139 | - | session.timer.stop() | |
| 140 | - | session.timer = nil | |
| 141 | - | } | |
| 142 | - | r.session = nil | |
| 143 | - | retryCount := session.retryCount | |
| 144 | - | elapsed := now.Sub(session.startedAt).Milliseconds() | |
| 145 | - | r.mu.Unlock() | |
| 146 | - | ||
| 147 | - | r.logStopped("paint-arrived", seq, retryCount, elapsed) | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | func (r *resizeReconciler) stop() { | |
| 151 | - | if r == nil { | |
| 152 | - | return | |
| 153 | - | } | |
| 154 | - | r.mu.Lock() | |
| 155 | - | session := r.session | |
| 156 | - | r.session = nil | |
| 157 | - | r.mu.Unlock() | |
| 158 | - | if session != nil && session.timer != nil { | |
| 159 | - | session.timer.stop() | |
| 160 | - | } | |
| 161 | - | } | |
| 162 | - | ||
| 163 | - | func (r *resizeReconciler) schedule(session *resizeReconcileSession) { | |
| 164 | - | if r == nil || session == nil { | |
| 165 | - | return | |
| 166 | - | } | |
| 167 | - | timer := r.schedulerOrDefault().afterFunc(resizeReconcileRetryDelay, func() { | |
| 168 | - | r.runTick(session) | |
| 169 | - | }) | |
| 170 | - | r.mu.Lock() | |
| 171 | - | if r.session == session { | |
| 172 | - | session.timer = timer | |
| 173 | - | } else if timer != nil { | |
| 174 | - | timer.stop() | |
| 175 | - | } | |
| 176 | - | r.mu.Unlock() | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | func (r *resizeReconciler) runTick(session *resizeReconcileSession) { | |
| 180 | - | if r == nil || session == nil { | |
| 181 | - | return | |
| 182 | - | } | |
| 183 | - | now := r.clock() | |
| 184 | - | ||
| 185 | - | r.mu.Lock() | |
| 186 | - | if r.session != session { | |
| 187 | - | r.mu.Unlock() | |
| 188 | - | return | |
| 189 | - | } | |
| 190 | - | if session.destroyed != nil && session.destroyed() { | |
| 191 | - | r.session = nil | |
| 192 | - | r.mu.Unlock() | |
| 193 | - | return | |
| 194 | - | } | |
| 195 | - | host := session.hostGetter() | |
| 196 | - | if host == nil { | |
| 197 | - | r.session = nil | |
| 198 | - | r.mu.Unlock() | |
| 199 | - | return | |
| 200 | - | } | |
| 201 | - | elapsed := now.Sub(session.startedAt) | |
| 202 | - | if elapsed > resizeReconcileWindow { | |
| 203 | - | r.session = nil | |
| 204 | - | retryCount := session.retryCount | |
| 205 | - | r.mu.Unlock() | |
| 206 | - | r.logStopped("expired", session.seq, retryCount, elapsed.Milliseconds()) | |
| 207 | - | return | |
| 208 | - | } | |
| 209 | - | session.retryCount++ | |
| 210 | - | retryCount := session.retryCount | |
| 211 | - | seq := session.seq | |
| 212 | - | r.mu.Unlock() | |
| 213 | - | ||
| 214 | - | host.Invalidate(purecef.PaintElementTypePetView) | |
| 215 | - | r.logRetry(seq, retryCount, elapsed.Milliseconds()) | |
| 216 | - | ||
| 217 | - | elapsedAfterInvalidate := r.clock().Sub(session.startedAt) | |
| 218 | - | r.mu.Lock() | |
| 219 | - | if r.session != session { | |
| 220 | - | r.mu.Unlock() | |
| 221 | - | return | |
| 222 | - | } | |
| 223 | - | if elapsedAfterInvalidate >= resizeReconcileWindow { | |
| 224 | - | r.session = nil | |
| 225 | - | r.mu.Unlock() | |
| 226 | - | r.logStopped("expired", seq, retryCount, elapsedAfterInvalidate.Milliseconds()) | |
| 227 | - | return | |
| 228 | - | } | |
| 229 | - | newTimer := r.schedulerOrDefault().afterFunc(resizeReconcileRetryDelay, func() { | |
| 230 | - | r.runTick(session) | |
| 231 | - | }) | |
| 232 | - | session.timer = newTimer | |
| 233 | - | r.mu.Unlock() | |
| 234 | - | } | |
| 235 | - | ||
| 236 | - | func (r *resizeReconciler) logStarted(seq uint64) { | |
| 237 | - | if r == nil || r.ctx == nil { | |
| 238 | - | return | |
| 239 | - | } | |
| 240 | - | logging.FromContext(r.ctx).Debug(). | |
| 241 | - | Uint64("webview_id", uint64(r.webviewID)). | |
| 242 | - | Uint64("resize_seq", seq). | |
| 243 | - | Int("retry_count", 0). | |
| 244 | - | Int64("elapsed_ms", 0). | |
| 245 | - | Msg("cef: resize reconciliation started") | |
| 246 | - | } | |
| 247 | - | ||
| 248 | - | func (r *resizeReconciler) logRetry(seq uint64, retryCount int, elapsedMs int64) { | |
| 249 | - | if r == nil || r.ctx == nil { | |
| 250 | - | return | |
| 251 | - | } | |
| 252 | - | logging.FromContext(r.ctx).Debug(). | |
| 253 | - | Uint64("webview_id", uint64(r.webviewID)). | |
| 254 | - | Uint64("resize_seq", seq). | |
| 255 | - | Int("retry_count", retryCount). | |
| 256 | - | Int64("elapsed_ms", elapsedMs). | |
| 257 | - | Msg("cef: resize reconciliation retry") | |
| 258 | - | } | |
| 259 | - | ||
| 260 | - | func (r *resizeReconciler) logStopped(reason string, seq uint64, retryCount int, elapsedMs int64) { | |
| 261 | - | if r == nil || r.ctx == nil { | |
| 262 | - | return | |
| 263 | - | } | |
| 264 | - | logging.FromContext(r.ctx).Debug(). | |
| 265 | - | Uint64("webview_id", uint64(r.webviewID)). | |
| 266 | - | Uint64("resize_seq", seq). | |
| 267 | - | Int("retry_count", retryCount). | |
| 268 | - | Int64("elapsed_ms", elapsedMs). | |
| 269 | - | Str("reason", reason). | |
| 270 | - | Msg("cef: resize reconciliation stopped") | |
| 271 | - | } | |
D internal/infrastructure/cef/resize_reconciler_test.go +0 -192
| @@ -1,192 +0,0 @@ | |||
| 1 | - | package cef | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "context" | |
| 5 | - | "sync" | |
| 6 | - | "testing" | |
| 7 | - | "time" | |
| 8 | - | ||
| 9 | - | purecef "github.com/bnema/purego-cef/cef" | |
| 10 | - | "github.com/stretchr/testify/require" | |
| 11 | - | ) | |
| 12 | - | ||
| 13 | - | type resizeHostRecorder struct { | |
| 14 | - | mu sync.Mutex | |
| 15 | - | invalidations []purecef.PaintElementType | |
| 16 | - | } | |
| 17 | - | ||
| 18 | - | func (h *resizeHostRecorder) WasResized() {} | |
| 19 | - | ||
| 20 | - | func (h *resizeHostRecorder) Invalidate(elementType purecef.PaintElementType) { | |
| 21 | - | h.mu.Lock() | |
| 22 | - | defer h.mu.Unlock() | |
| 23 | - | h.invalidations = append(h.invalidations, elementType) | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | func (h *resizeHostRecorder) invalidationCount() int { | |
| 27 | - | h.mu.Lock() | |
| 28 | - | defer h.mu.Unlock() | |
| 29 | - | return len(h.invalidations) | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | type fakeResizeTimer struct { | |
| 33 | - | id uint | |
| 34 | - | cancel func() | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | func (t *fakeResizeTimer) stop() { | |
| 38 | - | if t != nil && t.cancel != nil { | |
| 39 | - | t.cancel() | |
| 40 | - | } | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | type fakeResizeScheduler struct { | |
| 44 | - | mu sync.Mutex | |
| 45 | - | nextID uint | |
| 46 | - | timers map[uint]func() | |
| 47 | - | delays []time.Duration | |
| 48 | - | stopped map[uint]bool | |
| 49 | - | } | |
| 50 | - | ||
| 51 | - | func newFakeResizeScheduler() *fakeResizeScheduler { | |
| 52 | - | return &fakeResizeScheduler{ | |
| 53 | - | timers: make(map[uint]func()), | |
| 54 | - | stopped: make(map[uint]bool), | |
| 55 | - | } | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | func (s *fakeResizeScheduler) afterFunc(delay time.Duration, fn func()) resizeReconcileTimer { | |
| 59 | - | s.mu.Lock() | |
| 60 | - | defer s.mu.Unlock() | |
| 61 | - | s.nextID++ | |
| 62 | - | id := s.nextID | |
| 63 | - | s.timers[id] = fn | |
| 64 | - | s.delays = append(s.delays, delay) | |
| 65 | - | return &fakeResizeTimer{ | |
| 66 | - | id: id, | |
| 67 | - | cancel: func() { | |
| 68 | - | s.mu.Lock() | |
| 69 | - | defer s.mu.Unlock() | |
| 70 | - | s.stopped[id] = true | |
| 71 | - | }, | |
| 72 | - | } | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | func (s *fakeResizeScheduler) fireLast() { | |
| 76 | - | s.mu.Lock() | |
| 77 | - | id := s.nextID | |
| 78 | - | fn := s.timers[id] | |
| 79 | - | stopped := s.stopped[id] | |
| 80 | - | s.mu.Unlock() | |
| 81 | - | if fn == nil || stopped { | |
| 82 | - | return | |
| 83 | - | } | |
| 84 | - | fn() | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | func (s *fakeResizeScheduler) delayCount() int { | |
| 88 | - | s.mu.Lock() | |
| 89 | - | defer s.mu.Unlock() | |
| 90 | - | return len(s.delays) | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | func TestResizeReconciler_SchedulesRetriesWhenNoPaintObserved(t *testing.T) { | |
| 94 | - | clock := time.Unix(0, 0) | |
| 95 | - | scheduler := newFakeResizeScheduler() | |
| 96 | - | host := &resizeHostRecorder{} | |
| 97 | - | rr := &resizeReconciler{ | |
| 98 | - | ctx: context.Background(), | |
| 99 | - | scheduler: scheduler, | |
| 100 | - | now: func() time.Time { return clock }, | |
| 101 | - | } | |
| 102 | - | ||
| 103 | - | rr.start(7, func() resizeNotifiableBrowserHost { return host }, func() bool { return false }) | |
| 104 | - | require.Equal(t, 1, scheduler.delayCount()) | |
| 105 | - | require.Equal(t, 0, host.invalidationCount()) | |
| 106 | - | ||
| 107 | - | clock = clock.Add(8 * time.Millisecond) | |
| 108 | - | scheduler.fireLast() | |
| 109 | - | require.Equal(t, 1, host.invalidationCount()) | |
| 110 | - | require.Equal(t, 2, scheduler.delayCount()) | |
| 111 | - | } | |
| 112 | - | ||
| 113 | - | func TestResizeReconciler_StopsAfterMatchingPaintArrives(t *testing.T) { | |
| 114 | - | clock := time.Unix(0, 0) | |
| 115 | - | scheduler := newFakeResizeScheduler() | |
| 116 | - | host := &resizeHostRecorder{} | |
| 117 | - | rr := &resizeReconciler{ | |
| 118 | - | ctx: context.Background(), | |
| 119 | - | scheduler: scheduler, | |
| 120 | - | now: func() time.Time { return clock }, | |
| 121 | - | } | |
| 122 | - | ||
| 123 | - | rr.start(9, func() resizeNotifiableBrowserHost { return host }, func() bool { return false }) | |
| 124 | - | rr.notePaint(9, true) | |
| 125 | - | ||
| 126 | - | clock = clock.Add(8 * time.Millisecond) | |
| 127 | - | scheduler.fireLast() | |
| 128 | - | require.Zero(t, host.invalidationCount()) | |
| 129 | - | require.Equal(t, 1, scheduler.delayCount()) | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | func TestResizeReconciler_StaleSizePaintDoesNotStopRetries(t *testing.T) { | |
| 133 | - | clock := time.Unix(0, 0) | |
| 134 | - | scheduler := newFakeResizeScheduler() | |
| 135 | - | host := &resizeHostRecorder{} | |
| 136 | - | rr := &resizeReconciler{ | |
| 137 | - | ctx: context.Background(), | |
| 138 | - | scheduler: scheduler, | |
| 139 | - | now: func() time.Time { return clock }, | |
| 140 | - | } | |
| 141 | - | ||
| 142 | - | rr.start(10, func() resizeNotifiableBrowserHost { return host }, func() bool { return false }) | |
| 143 | - | rr.notePaint(10, false) | |
| 144 | - | ||
| 145 | - | clock = clock.Add(8 * time.Millisecond) | |
| 146 | - | scheduler.fireLast() | |
| 147 | - | require.Equal(t, 1, host.invalidationCount()) | |
| 148 | - | require.Equal(t, 2, scheduler.delayCount()) | |
| 149 | - | } | |
| 150 | - | ||
| 151 | - | func TestResizeReconciler_NewerResizeSupersedesOlderSession(t *testing.T) { | |
| 152 | - | clock := time.Unix(0, 0) | |
| 153 | - | scheduler := newFakeResizeScheduler() | |
| 154 | - | host := &resizeHostRecorder{} | |
| 155 | - | rr := &resizeReconciler{ | |
| 156 | - | ctx: context.Background(), | |
| 157 | - | scheduler: scheduler, | |
| 158 | - | now: func() time.Time { return clock }, | |
| 159 | - | } | |
| 160 | - | ||
| 161 | - | rr.start(1, func() resizeNotifiableBrowserHost { return host }, func() bool { return false }) | |
| 162 | - | firstDelayCount := scheduler.delayCount() | |
| 163 | - | rr.start(2, func() resizeNotifiableBrowserHost { return host }, func() bool { return false }) | |
| 164 | - | require.Equal(t, firstDelayCount+1, scheduler.delayCount()) | |
| 165 | - | ||
| 166 | - | clock = clock.Add(8 * time.Millisecond) | |
| 167 | - | scheduler.fireLast() | |
| 168 | - | require.Equal(t, 1, host.invalidationCount()) | |
| 169 | - | } | |
| 170 | - | ||
| 171 | - | func TestResizeReconciler_ExpiresAfter64ms(t *testing.T) { | |
| 172 | - | clock := time.Unix(0, 0) | |
| 173 | - | scheduler := newFakeResizeScheduler() | |
| 174 | - | host := &resizeHostRecorder{} | |
| 175 | - | rr := &resizeReconciler{ | |
| 176 | - | ctx: context.Background(), | |
| 177 | - | scheduler: scheduler, | |
| 178 | - | now: func() time.Time { return clock }, | |
| 179 | - | } | |
| 180 | - | ||
| 181 | - | rr.start(11, func() resizeNotifiableBrowserHost { return host }, func() bool { return false }) | |
| 182 | - | for i := 0; i < 8; i++ { | |
| 183 | - | clock = clock.Add(8 * time.Millisecond) | |
| 184 | - | scheduler.fireLast() | |
| 185 | - | } | |
| 186 | - | ||
| 187 | - | require.Equal(t, 8, host.invalidationCount()) | |
| 188 | - | require.Equal(t, 8, scheduler.delayCount()) | |
| 189 | - | clock = clock.Add(8 * time.Millisecond) | |
| 190 | - | scheduler.fireLast() | |
| 191 | - | require.Equal(t, 8, host.invalidationCount()) | |
| 192 | - | } | |
M internal/infrastructure/cef/runtime_config.go +1 -0
| @@ -34,6 +34,7 @@ type EngineDependencies struct { | |||
| 34 | 34 | DefaultConfigPayload func() ([]byte, error) | |
| 35 | 35 | ContextMenuBuilder port.ContextMenuBuilder | |
| 36 | 36 | ContextMenuExecutorFactory port.ContextMenuActionExecutorFactory | |
| 37 | + | ContextMenuRenderer ContextMenuRenderer | |
| 37 | 38 | Clipboard port.Clipboard | |
| 38 | 39 | ImageDataResolver port.ImageDataResolver | |
| 39 | 40 | } | |
M internal/infrastructure/cef/webview.go +144 -51
| @@ -14,6 +14,7 @@ import ( | |||
| 14 | 14 | "time" | |
| 15 | 15 | ||
| 16 | 16 | purecef "github.com/bnema/purego-cef/cef" | |
| 17 | + | cef2gtk "github.com/bnema/purego-cef2gtk" | |
| 17 | 18 | "github.com/bnema/puregotk/v4/glib" | |
| 18 | 19 | "github.com/bnema/puregotk/v4/gtk" | |
| 19 | 20 | ||
| @@ -64,20 +65,30 @@ var ( | |||
| 64 | 65 | cefScheduleAfter = func(delay time.Duration, fn func()) { time.AfterFunc(delay, fn) } | |
| 65 | 66 | ) | |
| 66 | 67 | ||
| 67 | - | // WebView implements port.WebView using a CEF off-screen browser rendered | |
| 68 | - | // through a renderPipeline and driven by an inputBridge. | |
| 68 | + | // WebView implements port.WebView using a CEF off-screen browser rendered and | |
| 69 | + | // driven through purego-cef2gtk. Dumber owns browser state and callbacks; the | |
| 70 | + | // bridge owns GTK rendering and input forwarding. | |
| 69 | 71 | type WebView struct { | |
| 70 | - | id port.WebViewID | |
| 71 | - | ctx context.Context | |
| 72 | - | engine *Engine | |
| 73 | - | browser purecef.Browser | |
| 74 | - | host purecef.BrowserHost | |
| 75 | - | client purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it | |
| 76 | - | pipeline *renderPipeline | |
| 77 | - | input *inputBridge | |
| 78 | - | handlers *handlerSet | |
| 79 | - | findCtrl *cefFindController | |
| 80 | - | resizeReconciler *resizeReconciler | |
| 72 | + | id port.WebViewID | |
| 73 | + | ctx context.Context | |
| 74 | + | engine *Engine | |
| 75 | + | browser purecef.Browser | |
| 76 | + | host purecef.BrowserHost | |
| 77 | + | client purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it | |
| 78 | + | viewBridge *Cef2gtkAdapter | |
| 79 | + | handlers *handlerSet | |
| 80 | + | findCtrl *cefFindController | |
| 81 | + | profileCleanup func() | |
| 82 | + | profileMu sync.Mutex | |
| 83 | + | renderStallMu sync.Mutex | |
| 84 | + | renderStallStop chan struct{} | |
| 85 | + | renderStallDone chan struct{} | |
| 86 | + | renderStallRecoveryLastAt time.Time | |
| 87 | + | latestProfileSnapshot cef2gtk.ProfileSnapshot | |
| 88 | + | previousProfileSnapshot cef2gtk.ProfileSnapshot | |
| 89 | + | latestProfileSnapshotAt time.Time | |
| 90 | + | ||
| 91 | + | removeSizeObserver func() | |
| 81 | 92 | ||
| 82 | 93 | // beginFrameTick drives CEF external BeginFrame requests while the GTK | |
| 83 | 94 | // widget is visible. Access is guarded by mu. | |
| @@ -136,6 +147,7 @@ type WebView struct { | |||
| 136 | 147 | isLoading bool | |
| 137 | 148 | selectedText string | |
| 138 | 149 | focusedEditable bool | |
| 150 | + | inputAttached bool | |
| 139 | 151 | bridgeNonce string | |
| 140 | 152 | selectionDebounceTimer stoppableTimer | |
| 141 | 153 | selectionDebounceSeq uint64 | |
| @@ -512,9 +524,9 @@ func (wv *WebView) SetOnReadyToShow(fn func()) { | |||
| 512 | 524 | } | |
| 513 | 525 | wv.mu.Lock() | |
| 514 | 526 | wv.popupReadyToShow = fn | |
| 515 | - | alreadyReady := wv.browser != nil && !wv.popupReadyShown && fn != nil | |
| 527 | + | shouldTryReady := wv.browser != nil && fn != nil | |
| 516 | 528 | wv.mu.Unlock() | |
| 517 | - | if alreadyReady { | |
| 529 | + | if shouldTryReady { | |
| 518 | 530 | wv.fireReadyToShow() | |
| 519 | 531 | } | |
| 520 | 532 | } | |
| @@ -595,7 +607,7 @@ func (wv *WebView) fireReadyToShow() { | |||
| 595 | 607 | return | |
| 596 | 608 | } | |
| 597 | 609 | wv.mu.Lock() | |
| 598 | - | if wv.popupReadyShown || wv.popupReadyToShow == nil { | |
| 610 | + | if wv.popupReadyShown || wv.popupReadyToShow == nil || !wv.inputAttached { | |
| 599 | 611 | wv.mu.Unlock() | |
| 600 | 612 | return | |
| 601 | 613 | } | |
| @@ -605,6 +617,16 @@ func (wv *WebView) fireReadyToShow() { | |||
| 605 | 617 | wv.runOnGTK(fn) | |
| 606 | 618 | } | |
| 607 | 619 | ||
| 620 | + | func (wv *WebView) markInputAttached() { | |
| 621 | + | if wv == nil || wv.destroyed.Load() { | |
| 622 | + | return | |
| 623 | + | } | |
| 624 | + | wv.mu.Lock() | |
| 625 | + | wv.inputAttached = true | |
| 626 | + | wv.mu.Unlock() | |
| 627 | + | wv.fireReadyToShow() | |
| 628 | + | } | |
| 629 | + | ||
| 608 | 630 | func (wv *WebView) markNativePopupCandidate(parent *WebView) { | |
| 609 | 631 | if wv == nil { | |
| 610 | 632 | return | |
| @@ -999,6 +1021,7 @@ func (wv *WebView) Destroy() { | |||
| 999 | 1021 | wv.openerMessageCallbacks = nil | |
| 1000 | 1022 | wv.openerNavigationCallbacks = nil | |
| 1001 | 1023 | wv.popupReadyToShow = nil | |
| 1024 | + | wv.inputAttached = false | |
| 1002 | 1025 | wv.pendingNativePopups = nil | |
| 1003 | 1026 | wv.nativePopupParent = nil | |
| 1004 | 1027 | wv.nativePopupCandidate = false | |
| @@ -1008,10 +1031,8 @@ func (wv *WebView) Destroy() { | |||
| 1008 | 1031 | wv.popupOpenerBridgeParentURI = "" | |
| 1009 | 1032 | wv.mu.Unlock() | |
| 1010 | 1033 | wv.stopNativePopupFallbackTimer() | |
| 1034 | + | wv.stopRenderStallWatchdog() | |
| 1011 | 1035 | wv.cancelSelectionDebounce() | |
| 1012 | - | if wv.resizeReconciler != nil { | |
| 1013 | - | wv.resizeReconciler.stop() | |
| 1014 | - | } | |
| 1015 | 1036 | wv.closeAudioStream() | |
| 1016 | 1037 | wv.scheduleStopBeginFrameLoop() | |
| 1017 | 1038 | wv.mu.RLock() | |
| @@ -1021,22 +1042,66 @@ func (wv *WebView) Destroy() { | |||
| 1021 | 1042 | host.CloseBrowser(1) | |
| 1022 | 1043 | } else { | |
| 1023 | 1044 | wv.runCloseCallbacks() | |
| 1045 | + | wv.destroyViewBridgeOnGTKSync() | |
| 1046 | + | } | |
| 1047 | + | if wv.profileCleanup != nil { | |
| 1048 | + | wv.profileCleanup() | |
| 1049 | + | wv.profileCleanup = nil | |
| 1050 | + | } | |
| 1051 | + | } | |
| 1052 | + | ||
| 1053 | + | // destroyViewBridgeOnGTKSync, destroyViewBridgeOnGTKAsync, and | |
| 1054 | + | // destroyViewBridgeOnGTKThread may race with one another during teardown. The | |
| 1055 | + | // early viewBridge nil checks avoid scheduling unnecessary GTK work; the final | |
| 1056 | + | // nil guard on the GTK thread is authoritative and makes duplicate scheduling | |
| 1057 | + | // harmless. | |
| 1058 | + | func (wv *WebView) destroyViewBridgeOnGTKSync() { | |
| 1059 | + | if wv == nil || wv.viewBridge == nil { | |
| 1060 | + | return | |
| 1024 | 1061 | } | |
| 1025 | - | if wv.pipeline != nil { | |
| 1026 | - | wv.pipeline.destroy() | |
| 1062 | + | wv.runOnGTKSync(func() { | |
| 1063 | + | wv.destroyViewBridgeOnGTKThread() | |
| 1064 | + | }) | |
| 1065 | + | } | |
| 1066 | + | ||
| 1067 | + | func (wv *WebView) destroyViewBridgeOnGTKAsync() { | |
| 1068 | + | if wv == nil || wv.viewBridge == nil { | |
| 1069 | + | return | |
| 1027 | 1070 | } | |
| 1071 | + | wv.runOnGTK(func() { | |
| 1072 | + | wv.destroyViewBridgeOnGTKThread() | |
| 1073 | + | }) | |
| 1074 | + | } | |
| 1075 | + | ||
| 1076 | + | func (wv *WebView) destroyViewBridgeOnGTKThread() { | |
| 1077 | + | if wv == nil || wv.viewBridge == nil { | |
| 1078 | + | return | |
| 1079 | + | } | |
| 1080 | + | if wv.removeSizeObserver != nil { | |
| 1081 | + | wv.removeSizeObserver() | |
| 1082 | + | wv.removeSizeObserver = nil | |
| 1083 | + | } | |
| 1084 | + | _ = wv.viewBridge.DetachInput() | |
| 1085 | + | _ = wv.viewBridge.Destroy() | |
| 1086 | + | wv.viewBridge = nil | |
| 1028 | 1087 | } | |
| 1029 | 1088 | ||
| 1030 | 1089 | // --------------------------------------------------------------------------- | |
| 1031 | 1090 | // NativeWidgetProvider | |
| 1032 | 1091 | // --------------------------------------------------------------------------- | |
| 1033 | 1092 | ||
| 1034 | - | // NativeWidget returns the uintptr for embedding the GLArea into GTK. | |
| 1093 | + | // NativeWidget returns the uintptr for embedding the bridge GTK widget. | |
| 1035 | 1094 | func (wv *WebView) NativeWidget() uintptr { | |
| 1036 | - | if wv.pipeline == nil || wv.pipeline.glArea == nil { | |
| 1095 | + | if wv.viewBridge == nil { | |
| 1037 | 1096 | return 0 | |
| 1038 | 1097 | } | |
| 1039 | - | return wv.pipeline.glArea.GoPointer() | |
| 1098 | + | var ptr uintptr | |
| 1099 | + | wv.runOnGTKSync(func() { | |
| 1100 | + | if wv.viewBridge != nil { | |
| 1101 | + | ptr = wv.viewBridge.NativeWidget() | |
| 1102 | + | } | |
| 1103 | + | }) | |
| 1104 | + | return ptr | |
| 1040 | 1105 | } | |
| 1041 | 1106 | ||
| 1042 | 1107 | // --------------------------------------------------------------------------- | |
| @@ -1263,6 +1328,35 @@ func (wv *WebView) selectedTextSnapshot() string { | |||
| 1263 | 1328 | return wv.selectedText | |
| 1264 | 1329 | } | |
| 1265 | 1330 | ||
| 1331 | + | func (wv *WebView) viewBridgeScale() int32 { | |
| 1332 | + | if wv == nil || wv.viewBridge == nil { | |
| 1333 | + | return 1 | |
| 1334 | + | } | |
| 1335 | + | scale := int32(math.Round(float64(wv.viewBridge.DeviceScaleFactor()))) | |
| 1336 | + | if scale < 1 { | |
| 1337 | + | return 1 | |
| 1338 | + | } | |
| 1339 | + | return scale | |
| 1340 | + | } | |
| 1341 | + | ||
| 1342 | + | func (wv *WebView) handleMiddleClickFromBridge() bool { | |
| 1343 | + | if wv == nil { | |
| 1344 | + | return false | |
| 1345 | + | } | |
| 1346 | + | wv.mu.RLock() | |
| 1347 | + | hoverURI := wv.lastHoverURI | |
| 1348 | + | cb := wv.callbacks | |
| 1349 | + | wv.mu.RUnlock() | |
| 1350 | + | if hoverURI == "" || cb == nil || cb.OnLinkMiddleClick == nil { | |
| 1351 | + | return false | |
| 1352 | + | } | |
| 1353 | + | uri := hoverURI | |
| 1354 | + | wv.runOnGTK(func() { | |
| 1355 | + | cb.OnLinkMiddleClick(uri) | |
| 1356 | + | }) | |
| 1357 | + | return true | |
| 1358 | + | } | |
| 1359 | + | ||
| 1266 | 1360 | func (wv *WebView) scheduleLoadWatchdogs(loadSeq uint64) { | |
| 1267 | 1361 | for _, delay := range cefLoadWatchdogDelays { | |
| 1268 | 1362 | d := delay | |
| @@ -1294,9 +1388,18 @@ func (wv *WebView) logLoadWatchdog(loadSeq uint64, delay time.Duration) { | |||
| 1294 | 1388 | return | |
| 1295 | 1389 | } | |
| 1296 | 1390 | ||
| 1297 | - | snap := renderPipelineSnapshot{} | |
| 1298 | - | if wv.pipeline != nil { | |
| 1299 | - | snap = wv.pipeline.diagnosticSnapshot() | |
| 1391 | + | var acceleratedPaints, unsupportedPaints, acceleratedPaintErrors, importFailures, renderFailures int | |
| 1392 | + | var surfaceWidth, surfaceHeight int32 = 1, 1 | |
| 1393 | + | var surfaceScale float32 = 1 | |
| 1394 | + | if wv.viewBridge != nil { | |
| 1395 | + | diag := wv.viewBridge.Diagnostics() | |
| 1396 | + | acceleratedPaints = diag.AcceleratedPaints | |
| 1397 | + | unsupportedPaints = diag.UnsupportedPaints | |
| 1398 | + | acceleratedPaintErrors = diag.AcceleratedPaintErrors | |
| 1399 | + | importFailures = diag.ImportFailures | |
| 1400 | + | renderFailures = diag.RenderFailures | |
| 1401 | + | surfaceWidth, surfaceHeight = wv.viewBridge.Size() | |
| 1402 | + | surfaceScale = wv.viewBridge.DeviceScaleFactor() | |
| 1300 | 1403 | } | |
| 1301 | 1404 | ||
| 1302 | 1405 | now := time.Now() | |
| @@ -1311,24 +1414,14 @@ func (wv *WebView) logLoadWatchdog(loadSeq uint64, delay time.Duration) { | |||
| 1311 | 1414 | Str("title", title). | |
| 1312 | 1415 | Float64("progress", progress). | |
| 1313 | 1416 | Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)). | |
| 1314 | - | Int32("surface_width", snap.Width). | |
| 1315 | - | Int32("surface_height", snap.Height). | |
| 1316 | - | Int32("surface_scale", snap.Scale). | |
| 1317 | - | Bool("gl_ready", snap.GLReady). | |
| 1318 | - | Bool("needs_upload", snap.NeedsUpload). | |
| 1319 | - | Bool("size_changed", snap.SizeChanged). | |
| 1320 | - | Bool("force_full_upload", snap.ForceFullUpload). | |
| 1321 | - | Uint64("view_rect_seq", snap.ViewRectSeq). | |
| 1322 | - | Uint64("screen_info_seq", snap.ScreenInfoSeq). | |
| 1323 | - | Uint64("paint_seq", snap.PaintSeq). | |
| 1324 | - | Uint64("resize_seq", snap.ResizeSeq). | |
| 1325 | - | Uint64("last_queued_paint_seq", snap.LastQueuedPaintSeq). | |
| 1326 | - | Uint64("gl_render_seq", snap.GLRenderSeq). | |
| 1327 | - | Uint64("paint_total", snap.PaintCount). | |
| 1328 | - | Uint64("queue_total", snap.QueueRenderCount). | |
| 1329 | - | Uint64("render_total", snap.RenderCount). | |
| 1330 | - | Uint64("upload_total", snap.UploadCount). | |
| 1331 | - | Uint64("full_upload_total", snap.FullUploadCount). | |
| 1417 | + | Int32("surface_width", surfaceWidth). | |
| 1418 | + | Int32("surface_height", surfaceHeight). | |
| 1419 | + | Float32("surface_scale", surfaceScale). | |
| 1420 | + | Int("accelerated_paints", acceleratedPaints). | |
| 1421 | + | Int("unsupported_paints", unsupportedPaints). | |
| 1422 | + | Int("accelerated_paint_errors", acceleratedPaintErrors). | |
| 1423 | + | Int("import_failures", importFailures). | |
| 1424 | + | Int("render_failures", renderFailures). | |
| 1332 | 1425 | Msg("cef: loading watchdog") | |
| 1333 | 1426 | } | |
| 1334 | 1427 | ||
| @@ -1524,7 +1617,7 @@ func (wv *WebView) scheduleStopBeginFrameLoop() { | |||
| 1524 | 1617 | } | |
| 1525 | 1618 | ||
| 1526 | 1619 | func (wv *WebView) startBeginFrameLoop() { | |
| 1527 | - | if wv.destroyed.Load() || wv.pipeline == nil || wv.pipeline.glArea == nil { | |
| 1620 | + | if wv.destroyed.Load() || wv.viewBridge == nil || wv.viewBridge.Widget() == nil { | |
| 1528 | 1621 | return | |
| 1529 | 1622 | } | |
| 1530 | 1623 | ||
| @@ -1534,7 +1627,7 @@ func (wv *WebView) startBeginFrameLoop() { | |||
| 1534 | 1627 | return | |
| 1535 | 1628 | } | |
| 1536 | 1629 | ||
| 1537 | - | glArea := wv.pipeline.glArea | |
| 1630 | + | widget := wv.viewBridge.Widget() | |
| 1538 | 1631 | cb := new(gtk.TickCallback) | |
| 1539 | 1632 | *cb = func(_, _, _ uintptr) bool { | |
| 1540 | 1633 | if wv.destroyed.Load() { | |
| @@ -1550,7 +1643,7 @@ func (wv *WebView) startBeginFrameLoop() { | |||
| 1550 | 1643 | return true | |
| 1551 | 1644 | } | |
| 1552 | 1645 | wv.beginFrameTick = cb | |
| 1553 | - | wv.beginFrameTickID = glArea.AddTickCallback(cb, 0, nil) | |
| 1646 | + | wv.beginFrameTickID = widget.AddTickCallback(cb, 0, nil) | |
| 1554 | 1647 | host := wv.host | |
| 1555 | 1648 | wv.mu.Unlock() | |
| 1556 | 1649 | ||
| @@ -1560,7 +1653,7 @@ func (wv *WebView) startBeginFrameLoop() { | |||
| 1560 | 1653 | } | |
| 1561 | 1654 | ||
| 1562 | 1655 | func (wv *WebView) stopBeginFrameLoop() { | |
| 1563 | - | if wv.pipeline == nil || wv.pipeline.glArea == nil { | |
| 1656 | + | if wv.viewBridge == nil || wv.viewBridge.Widget() == nil { | |
| 1564 | 1657 | return | |
| 1565 | 1658 | } | |
| 1566 | 1659 | ||
| @@ -1568,11 +1661,11 @@ func (wv *WebView) stopBeginFrameLoop() { | |||
| 1568 | 1661 | tickID := wv.beginFrameTickID | |
| 1569 | 1662 | wv.beginFrameTickID = 0 | |
| 1570 | 1663 | wv.beginFrameTick = nil | |
| 1571 | - | glArea := wv.pipeline.glArea | |
| 1664 | + | widget := wv.viewBridge.Widget() | |
| 1572 | 1665 | wv.mu.Unlock() | |
| 1573 | 1666 | ||
| 1574 | 1667 | if tickID != 0 { | |
| 1575 | - | glArea.RemoveTickCallback(tickID) | |
| 1668 | + | widget.RemoveTickCallback(tickID) | |
| 1576 | 1669 | } | |
| 1577 | 1670 | } | |
| 1578 | 1671 | ||
M internal/infrastructure/cef/webview_audio_test.go +3 -5
| @@ -32,7 +32,7 @@ func TestEngine_WiresAudioOutputFactory(t *testing.T) { | |||
| 32 | 32 | assert.Equal(t, audioFactory, opts.audioOutputFactory) | |
| 33 | 33 | ||
| 34 | 34 | // Verify the factory is passed through | |
| 35 | - | factory := newWebViewFactory(&Engine{}, &glLoader{}, opts) | |
| 35 | + | factory := newWebViewFactory(&Engine{}, opts) | |
| 36 | 36 | require.NotNil(t, factory.audioOutputFactory) | |
| 37 | 37 | } | |
| 38 | 38 | ||
| @@ -47,7 +47,7 @@ func TestWebViewFactory_AudioFactoryIsOptional(t *testing.T) { | |||
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | 49 | // Act | |
| 50 | - | factory := newWebViewFactory(&Engine{}, &glLoader{}, opts) | |
| 50 | + | factory := newWebViewFactory(&Engine{}, opts) | |
| 51 | 51 | ||
| 52 | 52 | // Assert | |
| 53 | 53 | require.NotNil(t, factory) | |
| @@ -423,7 +423,6 @@ func TestWebView_Destroy_ClosesAudioStream(t *testing.T) { | |||
| 423 | 423 | wv := &WebView{ | |
| 424 | 424 | ctx: context.Background(), | |
| 425 | 425 | activeAudioStream: mockStream, | |
| 426 | - | pipeline: &renderPipeline{}, | |
| 427 | 426 | } | |
| 428 | 427 | wv.audioPlaying.Store(true) | |
| 429 | 428 | ||
| @@ -439,8 +438,7 @@ func TestWebView_Destroy_ClosesAudioStream(t *testing.T) { | |||
| 439 | 438 | // handles the case where no audio stream is active. | |
| 440 | 439 | func TestWebView_Destroy_NoAudioStream_DoesNotPanic(t *testing.T) { | |
| 441 | 440 | wv := &WebView{ | |
| 442 | - | ctx: context.Background(), | |
| 443 | - | pipeline: &renderPipeline{}, | |
| 441 | + | ctx: context.Background(), | |
| 444 | 442 | } | |
| 445 | 443 | ||
| 446 | 444 | assert.NotPanics(t, func() { | |
M internal/infrastructure/cef/webview_handlers.go +99 -312
| @@ -3,8 +3,10 @@ package cef | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "strings" | |
| 6 | + | "sync" | |
| 6 | 7 | ||
| 7 | 8 | purecef "github.com/bnema/purego-cef/cef" | |
| 9 | + | cef2gtk "github.com/bnema/purego-cef2gtk" | |
| 8 | 10 | ||
| 9 | 11 | "github.com/bnema/dumber/internal/application/port" | |
| 10 | 12 | downloadutil "github.com/bnema/dumber/internal/domain/download" | |
| @@ -21,13 +23,14 @@ const ( | |||
| 21 | 23 | // owning WebView. A single struct is used so that the Client's Get*Handler | |
| 22 | 24 | // methods can return the same receiver, avoiding extra allocations. | |
| 23 | 25 | type handlerSet struct { | |
| 24 | - | wv *WebView | |
| 26 | + | wv *WebView | |
| 27 | + | renderHandlerOnce sync.Once | |
| 28 | + | renderHandler purecef.RenderHandler | |
| 25 | 29 | } | |
| 26 | 30 | ||
| 27 | 31 | // Compile-time interface checks. | |
| 28 | 32 | var ( | |
| 29 | 33 | _ purecef.Client = (*handlerSet)(nil) | |
| 30 | - | _ purecef.RenderHandler = (*handlerSet)(nil) | |
| 31 | 34 | _ purecef.DisplayHandler = (*handlerSet)(nil) | |
| 32 | 35 | _ purecef.LoadHandler = (*handlerSet)(nil) | |
| 33 | 36 | _ purecef.LifeSpanHandler = (*handlerSet)(nil) | |
| @@ -69,8 +72,16 @@ func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return n | |||
| 69 | 72 | func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h } | |
| 70 | 73 | func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h } | |
| 71 | 74 | func (h *handlerSet) GetPrintHandler() purecef.PrintHandler { return nil } | |
| 72 | - | func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { return h } | |
| 73 | - | func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h } | |
| 75 | + | func (h *handlerSet) GetRenderHandler() purecef.RenderHandler { | |
| 76 | + | if h == nil { | |
| 77 | + | return nil | |
| 78 | + | } | |
| 79 | + | h.renderHandlerOnce.Do(func() { | |
| 80 | + | h.renderHandler = newDumberRenderHandler(h.wv) | |
| 81 | + | }) | |
| 82 | + | return h.renderHandler | |
| 83 | + | } | |
| 84 | + | func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h } | |
| 74 | 85 | ||
| 75 | 86 | func (h *handlerSet) OnProcessMessageReceived( | |
| 76 | 87 | browser purecef.Browser, | |
| @@ -136,291 +147,11 @@ func (h *handlerSet) OnProcessMessageReceived( | |||
| 136 | 147 | } | |
| 137 | 148 | ||
| 138 | 149 | // =========================================================================== | |
| 139 | - | // RenderHandler (17 methods) | |
| 150 | + | // RenderHandler methods live in render_handler_adapter.go. handlerSet keeps | |
| 151 | + | // Dumber's non-render CEF handlers and returns a delegating render handler from | |
| 152 | + | // GetRenderHandler. | |
| 140 | 153 | // =========================================================================== | |
| 141 | 154 | ||
| 142 | - | func (h *handlerSet) GetAccessibilityHandler() purecef.AccessibilityHandler { return nil } | |
| 143 | - | ||
| 144 | - | func (h *handlerSet) GetRootScreenRect(_ purecef.Browser, _ *purecef.Rect) int32 { return 0 } | |
| 145 | - | ||
| 146 | - | // GetViewRect fills the rect struct with the pipeline dimensions. | |
| 147 | - | func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) { | |
| 148 | - | if rect == nil { | |
| 149 | - | return | |
| 150 | - | } | |
| 151 | - | callSeq := h.wv.pipeline.nextViewRectSeq() | |
| 152 | - | w, ht, s := h.wv.pipeline.viewRectSize() | |
| 153 | - | ||
| 154 | - | // CEF expects view geometry in DIP coordinates while OnPaint dimensions are | |
| 155 | - | // in device pixels. The render pipeline tracks device pixels, so convert | |
| 156 | - | // back to DIP before answering GetViewRect/GetScreenInfo. | |
| 157 | - | w /= s | |
| 158 | - | ht /= s | |
| 159 | - | ||
| 160 | - | // CEF requires a non-empty rect. Return a 1x1 fallback if the GL area has | |
| 161 | - | // not been realized yet. | |
| 162 | - | if w <= 0 { | |
| 163 | - | w = 1 | |
| 164 | - | } | |
| 165 | - | if ht <= 0 { | |
| 166 | - | ht = 1 | |
| 167 | - | } | |
| 168 | - | ||
| 169 | - | rect.X = 0 | |
| 170 | - | rect.Y = 0 | |
| 171 | - | rect.Width = w | |
| 172 | - | rect.Height = ht | |
| 173 | - | if h.wv != nil && h.wv.ctx != nil { | |
| 174 | - | logging.FromContext(h.wv.ctx).Debug(). | |
| 175 | - | Uint64("webview_id", uint64(h.wv.id)). | |
| 176 | - | Uint64("view_rect_seq", callSeq). | |
| 177 | - | Int32("width", rect.Width). | |
| 178 | - | Int32("height", rect.Height). | |
| 179 | - | Int32("scale", s). | |
| 180 | - | Msg("cef: GetViewRect") | |
| 181 | - | } | |
| 182 | - | } | |
| 183 | - | ||
| 184 | - | func (h *handlerSet) GetScreenPoint(_ purecef.Browser, _, _ int32, _, _ *int32) int32 { | |
| 185 | - | return 0 | |
| 186 | - | } | |
| 187 | - | ||
| 188 | - | func (h *handlerSet) GetScreenInfo(_ purecef.Browser, info *purecef.ScreenInfo) int32 { | |
| 189 | - | if info == nil { | |
| 190 | - | return 0 | |
| 191 | - | } | |
| 192 | - | callSeq := h.wv.pipeline.nextScreenInfoSeq() | |
| 193 | - | w, ht, s := h.wv.pipeline.viewRectSize() | |
| 194 | - | w /= s | |
| 195 | - | ht /= s | |
| 196 | - | if w <= 0 { | |
| 197 | - | w = 1 | |
| 198 | - | } | |
| 199 | - | if ht <= 0 { | |
| 200 | - | ht = 1 | |
| 201 | - | } | |
| 202 | - | ||
| 203 | - | const ( | |
| 204 | - | screenDepth = 24 | |
| 205 | - | screenDepthPerComponent = 8 | |
| 206 | - | ) | |
| 207 | - | r := purecef.Rect{X: 0, Y: 0, Width: w, Height: ht} | |
| 208 | - | si := purecef.NewScreenInfo() | |
| 209 | - | si.DeviceScaleFactor = float32(s) | |
| 210 | - | si.Depth = screenDepth | |
| 211 | - | si.DepthPerComponent = screenDepthPerComponent | |
| 212 | - | si.Rect = r | |
| 213 | - | si.AvailableRect = r | |
| 214 | - | *info = si | |
| 215 | - | if h.wv != nil && h.wv.ctx != nil { | |
| 216 | - | logging.FromContext(h.wv.ctx).Debug(). | |
| 217 | - | Uint64("webview_id", uint64(h.wv.id)). | |
| 218 | - | Uint64("screen_info_seq", callSeq). | |
| 219 | - | Int32("width", w). | |
| 220 | - | Int32("height", ht). | |
| 221 | - | Int32("scale", s). | |
| 222 | - | Msg("cef: GetScreenInfo") | |
| 223 | - | } | |
| 224 | - | return 1 | |
| 225 | - | } | |
| 226 | - | ||
| 227 | - | func (h *handlerSet) OnPopupShow(_ purecef.Browser, show int32) { | |
| 228 | - | if h.wv == nil || h.wv.pipeline == nil { | |
| 229 | - | return | |
| 230 | - | } | |
| 231 | - | h.wv.runOnGTK(func() { | |
| 232 | - | h.wv.pipeline.setPopupVisible(show != 0) | |
| 233 | - | }) | |
| 234 | - | } | |
| 235 | - | ||
| 236 | - | func (h *handlerSet) OnPopupSize(_ purecef.Browser, popupRect *purecef.Rect) { | |
| 237 | - | if h.wv == nil || h.wv.pipeline == nil || popupRect == nil { | |
| 238 | - | return | |
| 239 | - | } | |
| 240 | - | popup := rect{ | |
| 241 | - | X: popupRect.X, | |
| 242 | - | Y: popupRect.Y, | |
| 243 | - | Width: popupRect.Width, | |
| 244 | - | Height: popupRect.Height, | |
| 245 | - | } | |
| 246 | - | h.wv.runOnGTK(func() { | |
| 247 | - | h.wv.pipeline.setPopupRect(popup) | |
| 248 | - | }) | |
| 249 | - | } | |
| 250 | - | ||
| 251 | - | // OnPaint receives the BGRA pixel buffer from CEF and forwards dirty rects | |
| 252 | - | // to the render pipeline for GPU upload. Main-view paints are copied directly | |
| 253 | - | // into the persistent staging buffer on the CEF UI thread; only the GTK | |
| 254 | - | // QueueRender hop remains. Popups still copy their transient buffer before | |
| 255 | - | // crossing threads because they are small and infrequent. | |
| 256 | - | func (h *handlerSet) OnPaint( | |
| 257 | - | _ purecef.Browser, elementType purecef.PaintElementType, | |
| 258 | - | dirtyRects []purecef.Rect, buffer []byte, width, height int32, | |
| 259 | - | ) { | |
| 260 | - | if len(buffer) == 0 || width <= 0 || height <= 0 { | |
| 261 | - | return | |
| 262 | - | } | |
| 263 | - | paintSeq := h.wv.pipeline.nextPaintSeq() | |
| 264 | - | resizeSeq, resizeAgeMs := uint64(0), int64(0) | |
| 265 | - | if elementType != purecef.PaintElementTypePetPopup { | |
| 266 | - | resizeSeq, resizeAgeMs = h.wv.pipeline.latestResizeDiagnostics() | |
| 267 | - | } | |
| 268 | - | if h.wv != nil && h.wv.ctx != nil { | |
| 269 | - | log := logging.FromContext(h.wv.ctx).Trace(). | |
| 270 | - | Uint64("paint_seq", paintSeq). | |
| 271 | - | Int32("width", width). | |
| 272 | - | Int32("height", height). | |
| 273 | - | Int("dirty_rect_count", len(dirtyRects)). | |
| 274 | - | Int("buffer_len", len(buffer)) | |
| 275 | - | if elementType != purecef.PaintElementTypePetPopup { | |
| 276 | - | log = log. | |
| 277 | - | Uint64("resize_seq", resizeSeq). | |
| 278 | - | Int64("time_since_resize_ms", resizeAgeMs) | |
| 279 | - | } | |
| 280 | - | log.Msg("cef: OnPaint begin") | |
| 281 | - | } | |
| 282 | - | if elementType != purecef.PaintElementTypePetPopup && | |
| 283 | - | !h.shouldAcceptMainViewPaint(width, height, dirtyRects, paintSeq, resizeSeq, resizeAgeMs) { | |
| 284 | - | return | |
| 285 | - | } | |
| 286 | - | rects := make([]rect, len(dirtyRects)) | |
| 287 | - | for i, dr := range dirtyRects { | |
| 288 | - | rects[i] = rect{X: dr.X, Y: dr.Y, Width: dr.Width, Height: dr.Height} | |
| 289 | - | } | |
| 290 | - | ||
| 291 | - | if elementType == purecef.PaintElementTypePetPopup { | |
| 292 | - | pixels := make([]byte, len(buffer)) | |
| 293 | - | copy(pixels, buffer) | |
| 294 | - | h.wv.runOnGTK(func() { | |
| 295 | - | h.wv.pipeline.handlePopupPaint(pixels, width, height, paintSeq) | |
| 296 | - | }) | |
| 297 | - | if h.wv != nil && h.wv.ctx != nil { | |
| 298 | - | logging.FromContext(h.wv.ctx).Trace(). | |
| 299 | - | Uint64("paint_seq", paintSeq). | |
| 300 | - | Msg("cef: OnPaint queued popup to GTK") | |
| 301 | - | } | |
| 302 | - | return | |
| 303 | - | } | |
| 304 | - | ||
| 305 | - | h.wv.pipeline.handlePaint(buffer, width, height, rects, paintSeq) | |
| 306 | - | h.wv.runOnGTK(func() { | |
| 307 | - | h.wv.pipeline.queuePaintRender() | |
| 308 | - | }) | |
| 309 | - | if h.wv != nil && h.wv.ctx != nil { | |
| 310 | - | logging.FromContext(h.wv.ctx).Trace(). | |
| 311 | - | Uint64("paint_seq", paintSeq). | |
| 312 | - | Msg("cef: OnPaint queued to GTK") | |
| 313 | - | } | |
| 314 | - | } | |
| 315 | - | ||
| 316 | - | func (h *handlerSet) shouldAcceptMainViewPaint( | |
| 317 | - | width, height int32, | |
| 318 | - | dirtyRects []purecef.Rect, | |
| 319 | - | paintSeq uint64, | |
| 320 | - | resizeSeq uint64, | |
| 321 | - | resizeAgeMs int64, | |
| 322 | - | ) bool { | |
| 323 | - | expectedWidth, expectedHeight, _ := h.wv.pipeline.viewRectSize() | |
| 324 | - | sizeMatchesCurrentView := width == expectedWidth && height == expectedHeight | |
| 325 | - | ||
| 326 | - | if h.wv.resizeReconciler != nil { | |
| 327 | - | h.wv.resizeReconciler.notePaint(resizeSeq, sizeMatchesCurrentView) | |
| 328 | - | } | |
| 329 | - | ||
| 330 | - | if h.wv.ctx != nil { | |
| 331 | - | if sizeMatchesCurrentView { | |
| 332 | - | _, _, shouldLog := h.wv.pipeline.markFirstPaintAfterResize() | |
| 333 | - | if shouldLog { | |
| 334 | - | logging.Trace().Mark("cef_first_content_paint") | |
| 335 | - | logging.FromContext(h.wv.ctx).Debug(). | |
| 336 | - | Uint64("webview_id", uint64(h.wv.id)). | |
| 337 | - | Uint64("paint_seq", paintSeq). | |
| 338 | - | Uint64("resize_seq", resizeSeq). | |
| 339 | - | Int64("time_since_resize_ms", resizeAgeMs). | |
| 340 | - | Int32("width", width). | |
| 341 | - | Int32("height", height). | |
| 342 | - | Int("dirty_rect_count", len(dirtyRects)). | |
| 343 | - | Msg("cef: first paint after resize") | |
| 344 | - | } | |
| 345 | - | } else if h.wv.pipeline.markStalePaintAfterResize(resizeSeq) { | |
| 346 | - | logging.FromContext(h.wv.ctx).Debug(). | |
| 347 | - | Uint64("webview_id", uint64(h.wv.id)). | |
| 348 | - | Uint64("paint_seq", paintSeq). | |
| 349 | - | Uint64("resize_seq", resizeSeq). | |
| 350 | - | Int64("time_since_resize_ms", resizeAgeMs). | |
| 351 | - | Int32("width", width). | |
| 352 | - | Int32("height", height). | |
| 353 | - | Int32("expected_width", expectedWidth). | |
| 354 | - | Int32("expected_height", expectedHeight). | |
| 355 | - | Int("dirty_rect_count", len(dirtyRects)). | |
| 356 | - | Msg("cef: stale-size paint after resize") | |
| 357 | - | } | |
| 358 | - | } | |
| 359 | - | ||
| 360 | - | return sizeMatchesCurrentView | |
| 361 | - | } | |
| 362 | - | ||
| 363 | - | func (h *handlerSet) OnAcceleratedPaint( | |
| 364 | - | _ purecef.Browser, | |
| 365 | - | _ purecef.PaintElementType, | |
| 366 | - | _ []purecef.Rect, | |
| 367 | - | _ *purecef.AcceleratedPaintInfo, | |
| 368 | - | ) { | |
| 369 | - | count := h.wv.pipeline.recordAcceleratedPaint() | |
| 370 | - | if count <= 5 || count%100 == 0 { | |
| 371 | - | logging.FromContext(h.wv.ctx).Info(). | |
| 372 | - | Uint64("count", count). | |
| 373 | - | Msg("cef: OnAcceleratedPaint") | |
| 374 | - | } | |
| 375 | - | } | |
| 376 | - | ||
| 377 | - | func (h *handlerSet) GetTouchHandleSize(_ purecef.Browser, _ purecef.HorizontalAlignment, _ *purecef.Size) { | |
| 378 | - | } | |
| 379 | - | ||
| 380 | - | func (h *handlerSet) OnTouchHandleStateChanged(_ purecef.Browser, _ *purecef.TouchHandleState) {} | |
| 381 | - | ||
| 382 | - | func (h *handlerSet) StartDragging( | |
| 383 | - | _ purecef.Browser, | |
| 384 | - | _ purecef.DragData, | |
| 385 | - | _ purecef.DragOperationsMask, | |
| 386 | - | _, _ int32, | |
| 387 | - | ) int32 { | |
| 388 | - | return 0 | |
| 389 | - | } | |
| 390 | - | ||
| 391 | - | func (h *handlerSet) UpdateDragCursor(_ purecef.Browser, _ purecef.DragOperationsMask) {} | |
| 392 | - | ||
| 393 | - | func (h *handlerSet) OnScrollOffsetChanged(_ purecef.Browser, _, _ float64) {} | |
| 394 | - | ||
| 395 | - | func (h *handlerSet) OnImeCompositionRangeChanged(_ purecef.Browser, _ *purecef.Range, _ []purecef.Rect) { | |
| 396 | - | } | |
| 397 | - | ||
| 398 | - | func (h *handlerSet) OnTextSelectionChanged(_ purecef.Browser, selectedText string, _ *purecef.Range) { | |
| 399 | - | if h == nil || h.wv == nil { | |
| 400 | - | return | |
| 401 | - | } | |
| 402 | - | previous, changed := h.wv.setSelectedText(selectedText) | |
| 403 | - | if !changed { | |
| 404 | - | return | |
| 405 | - | } | |
| 406 | - | if h.wv.ctx != nil && selectedText == "" { | |
| 407 | - | if previous != "" { | |
| 408 | - | logging.FromContext(h.wv.ctx).Debug(). | |
| 409 | - | Int("prev_text_len", len(previous)). | |
| 410 | - | Msg("cef: text selection cleared") | |
| 411 | - | } | |
| 412 | - | } else if h.wv.ctx != nil { | |
| 413 | - | logging.FromContext(h.wv.ctx).Debug(). | |
| 414 | - | Int("text_len", len(selectedText)). | |
| 415 | - | Msg("cef: text selection changed") | |
| 416 | - | } | |
| 417 | - | if h.wv.engine != nil { | |
| 418 | - | h.wv.scheduleSelectionUpdate(selectedText) | |
| 419 | - | } | |
| 420 | - | } | |
| 421 | - | ||
| 422 | - | func (h *handlerSet) OnVirtualKeyboardRequested(_ purecef.Browser, _ purecef.TextInputMode) {} | |
| 423 | - | ||
| 424 | 155 | // =========================================================================== | |
| 425 | 156 | // DisplayHandler (13 methods) | |
| 426 | 157 | // =========================================================================== | |
| @@ -528,9 +259,14 @@ func (h *handlerSet) OnCursorChange( | |||
| 528 | 259 | cursorType purecef.CursorType, | |
| 529 | 260 | _ *purecef.CursorInfo, | |
| 530 | 261 | ) int32 { | |
| 262 | + | if h == nil || h.wv == nil { | |
| 263 | + | return 0 | |
| 264 | + | } | |
| 531 | 265 | name := cefCursorToGDKName(cursorType) | |
| 532 | 266 | h.wv.runOnGTK(func() { | |
| 533 | - | h.wv.pipeline.glArea.SetCursorFromName(&name) | |
| 267 | + | if h.wv.viewBridge != nil { | |
| 268 | + | h.wv.viewBridge.SetCursorFromName(name) | |
| 269 | + | } | |
| 534 | 270 | }) | |
| 535 | 271 | return 1 // handled | |
| 536 | 272 | } | |
| @@ -565,13 +301,17 @@ func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangobac | |||
| 565 | 301 | Msg("cef: OnLoadingStateChange") | |
| 566 | 302 | } | |
| 567 | 303 | ||
| 568 | - | if !loading && h.wv.input != nil && h.wv.input.hasGTKFocus() { | |
| 569 | - | h.wv.mu.RLock() | |
| 570 | - | host := h.wv.host | |
| 571 | - | h.wv.mu.RUnlock() | |
| 572 | - | if host != nil { | |
| 573 | - | syncWindowlessBrowserFocus(host) | |
| 574 | - | } | |
| 304 | + | if !loading && h.wv.viewBridge != nil { | |
| 305 | + | h.wv.runOnGTK(func() { | |
| 306 | + | if h.wv.viewBridge != nil && h.wv.viewBridge.HasFocus() { | |
| 307 | + | h.wv.mu.RLock() | |
| 308 | + | host := h.wv.host | |
| 309 | + | h.wv.mu.RUnlock() | |
| 310 | + | if host != nil { | |
| 311 | + | syncWindowlessBrowserFocus(host) | |
| 312 | + | } | |
| 313 | + | } | |
| 314 | + | }) | |
| 575 | 315 | } | |
| 576 | 316 | ||
| 577 | 317 | h.wv.mu.RLock() | |
| @@ -783,13 +523,13 @@ func (h *handlerSet) attachAfterCreatedBrowser( | |||
| 783 | 523 | ) afterCreatedState { | |
| 784 | 524 | state := afterCreatedState{} | |
| 785 | 525 | h.wv.mu.Lock() | |
| 786 | - | defer h.wv.mu.Unlock() | |
| 787 | 526 | ||
| 788 | 527 | if existing := h.wv.browser; existing != nil { | |
| 789 | 528 | existingID := existing.GetIdentifier() | |
| 790 | 529 | if existingID != 0 && existingID != browserID { | |
| 791 | 530 | state.closeDuplicate = true | |
| 792 | 531 | state.duplicateBrowserID = existingID | |
| 532 | + | h.wv.mu.Unlock() | |
| 793 | 533 | return state | |
| 794 | 534 | } | |
| 795 | 535 | } | |
| @@ -797,7 +537,9 @@ func (h *handlerSet) attachAfterCreatedBrowser( | |||
| 797 | 537 | h.wv.browser = browser | |
| 798 | 538 | h.wv.host = host | |
| 799 | 539 | h.wv.pendingCreate = nil | |
| 800 | - | h.wv.input.setHost(host) | |
| 540 | + | bridge := h.wv.viewBridge | |
| 541 | + | h.wv.inputAttached = bridge == nil | |
| 542 | + | state.inputAttached = h.wv.inputAttached | |
| 801 | 543 | if h.wv.findCtrl != nil { | |
| 802 | 544 | h.wv.findCtrl.setHost(host) | |
| 803 | 545 | } | |
| @@ -806,23 +548,58 @@ func (h *handlerSet) attachAfterCreatedBrowser( | |||
| 806 | 548 | h.wv.nativePopupParent = nil | |
| 807 | 549 | h.wv.nativePopupID = 0 | |
| 808 | 550 | h.wv.nativePopupFallbackStarted = false | |
| 551 | + | state.hasPendingNavigation = strings.TrimSpace(h.wv.pendingURI) != "" | |
| 552 | + | h.wv.mu.Unlock() | |
| 553 | + | ||
| 554 | + | if bridge != nil { | |
| 555 | + | wv := h.wv | |
| 556 | + | wv.runOnGTK(func() { | |
| 557 | + | if wv.destroyed.Load() || wv.viewBridge == nil { | |
| 558 | + | wv.handleInputAttachFailure(ErrAdapterDestroyed, host) | |
| 559 | + | return | |
| 560 | + | } | |
| 561 | + | if err := wv.viewBridge.AttachInput(host, cef2gtk.InputOptions{ | |
| 562 | + | Scale: wv.viewBridgeScale(), | |
| 563 | + | OnMiddleClick: func(_, _ float64) bool { | |
| 564 | + | return wv.handleMiddleClickFromBridge() | |
| 565 | + | }, | |
| 566 | + | SelectionText: wv.selectedTextSnapshot, | |
| 567 | + | OnClipboardShortcut: func(action, text string) { | |
| 568 | + | if wv.engine != nil { | |
| 569 | + | wv.engine.handleExplicitClipboardBridgeText(wv.id, action, text) | |
| 570 | + | } | |
| 571 | + | }, | |
| 572 | + | }); err != nil { | |
| 573 | + | wv.handleInputAttachFailure(err, host) | |
| 574 | + | return | |
| 575 | + | } | |
| 576 | + | if wv.viewBridge != nil && wv.viewBridge.HasFocus() { | |
| 577 | + | syncWindowlessBrowserFocus(host) | |
| 578 | + | } else { | |
| 579 | + | host.Invalidate(purecef.PaintElementTypePetView) | |
| 580 | + | } | |
| 581 | + | wv.markInputAttached() | |
| 582 | + | }) | |
| 583 | + | } | |
| 809 | 584 | ||
| 810 | 585 | // Mark browser as visible — CEF OSR starts in hidden state and suppresses | |
| 811 | 586 | // painting/caret updates until explicitly told the browser is shown. | |
| 812 | 587 | host.WasHidden(0) | |
| 813 | - | state.shouldSyncFocus = h.afterCreatedShouldSyncFocus() | |
| 814 | - | state.hasPendingNavigation = strings.TrimSpace(h.wv.pendingURI) != "" | |
| 815 | 588 | return state | |
| 816 | 589 | } | |
| 817 | 590 | ||
| 818 | - | func (h *handlerSet) afterCreatedShouldSyncFocus() bool { | |
| 819 | - | if h.wv.input == nil { | |
| 820 | - | return false | |
| 591 | + | func (wv *WebView) handleInputAttachFailure(err error, host purecef.BrowserHost) { | |
| 592 | + | if wv == nil || err == nil { | |
| 593 | + | return | |
| 821 | 594 | } | |
| 822 | - | if h.wv.input.hasGTKFocus() { | |
| 823 | - | return true | |
| 595 | + | if wv.ctx != nil { | |
| 596 | + | logging.FromContext(wv.ctx).Warn().Err(err).Msg("cef: failed to attach input to cef2gtk bridge") | |
| 597 | + | } | |
| 598 | + | if host != nil && !wv.destroyed.Load() { | |
| 599 | + | host.CloseBrowser(1) | |
| 600 | + | return | |
| 824 | 601 | } | |
| 825 | - | return h.wv.input.glArea != nil && h.wv.input.glArea.HasFocus() | |
| 602 | + | wv.runCloseCallbacks() | |
| 826 | 603 | } | |
| 827 | 604 | ||
| 828 | 605 | func (h *handlerSet) finishAfterCreated( | |
| @@ -836,12 +613,10 @@ func (h *handlerSet) finishAfterCreated( | |||
| 836 | 613 | h.wv.engine.bindBrowserWebView(browser, h.wv) | |
| 837 | 614 | } | |
| 838 | 615 | h.wv.stopNativePopupFallbackTimer() | |
| 839 | - | if state.shouldSyncFocus { | |
| 840 | - | syncWindowlessBrowserFocus(host) | |
| 841 | - | } else { | |
| 616 | + | if state.inputAttached { | |
| 842 | 617 | // Request an initial OSR frame even before the first real navigation | |
| 843 | 618 | // commits. This restores the about:blank warm-up paint that the stable | |
| 844 | - | // startup path relied on and prevents the render pipeline from staying idle. | |
| 619 | + | // startup path relied on and prevents the rendering bridge from staying idle. | |
| 845 | 620 | host.Invalidate(purecef.PaintElementTypePetView) | |
| 846 | 621 | } | |
| 847 | 622 | if state.hasPendingNavigation { | |
| @@ -855,7 +630,7 @@ func (h *handlerSet) finishAfterCreated( | |||
| 855 | 630 | } | |
| 856 | 631 | ||
| 857 | 632 | type afterCreatedState struct { | |
| 858 | - | shouldSyncFocus bool | |
| 633 | + | inputAttached bool | |
| 859 | 634 | hasPendingNavigation bool | |
| 860 | 635 | nativePopupParent *WebView | |
| 861 | 636 | nativePopupID int32 | |
| @@ -880,12 +655,24 @@ func (h *handlerSet) OnBeforeClose(browser purecef.Browser) { | |||
| 880 | 655 | h.wv.mu.Lock() | |
| 881 | 656 | h.wv.browser = nil | |
| 882 | 657 | h.wv.host = nil | |
| 883 | - | h.wv.input.setHost(nil) | |
| 658 | + | h.wv.inputAttached = false | |
| 884 | 659 | if h.wv.findCtrl != nil { | |
| 885 | 660 | h.wv.findCtrl.setHost(nil) | |
| 886 | 661 | } | |
| 887 | 662 | h.wv.mu.Unlock() | |
| 888 | - | h.wv.resizeReconciler.stop() | |
| 663 | + | if h.wv.destroyed.Load() { | |
| 664 | + | h.wv.destroyViewBridgeOnGTKAsync() | |
| 665 | + | } else if h.wv.viewBridge != nil { | |
| 666 | + | wv := h.wv | |
| 667 | + | wv.runOnGTK(func() { | |
| 668 | + | if wv.viewBridge == nil { | |
| 669 | + | return | |
| 670 | + | } | |
| 671 | + | if err := wv.viewBridge.SetInputHost(nil); err != nil && wv.ctx != nil { | |
| 672 | + | logging.FromContext(wv.ctx).Warn().Err(err).Msg("cef: failed to clear cef2gtk input host") | |
| 673 | + | } | |
| 674 | + | }) | |
| 675 | + | } | |
| 889 | 676 | h.wv.scheduleStopBeginFrameLoop() | |
| 890 | 677 | ||
| 891 | 678 | h.wv.mu.RLock() | |
M internal/infrastructure/cef/webview_handlers_test.go +19 -71
| @@ -60,13 +60,6 @@ func (s *controlledSelectionScheduler) fire(index int) { | |||
| 60 | 60 | } | |
| 61 | 61 | } | |
| 62 | 62 | ||
| 63 | - | func newTestPipeline(w, h, s int32) *renderPipeline { | |
| 64 | - | rp := &renderPipeline{scale: s} | |
| 65 | - | rp.widthAtomic.Store(w) | |
| 66 | - | rp.heightAtomic.Store(h) | |
| 67 | - | return rp | |
| 68 | - | } | |
| 69 | - | ||
| 70 | 63 | type stubFrame struct { | |
| 71 | 64 | main bool | |
| 72 | 65 | url string | |
| @@ -309,7 +302,7 @@ func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *test | |||
| 309 | 302 | clipboardTextOrchestrator: orchestrator, | |
| 310 | 303 | }, | |
| 311 | 304 | } | |
| 312 | - | h := &handlerSet{wv: wv} | |
| 305 | + | h := &selectionRenderHandlerForTest{wv: wv} | |
| 313 | 306 | ||
| 314 | 307 | h.OnTextSelectionChanged(nil, "selected text", nil) | |
| 315 | 308 | ||
| @@ -339,7 +332,7 @@ func TestOnTextSelectionChanged_DebouncesAndCollapsesRapidUpdates(t *testing.T) | |||
| 339 | 332 | clipboardTextOrchestrator: orchestrator, | |
| 340 | 333 | }, | |
| 341 | 334 | } | |
| 342 | - | h := &handlerSet{wv: wv} | |
| 335 | + | h := &selectionRenderHandlerForTest{wv: wv} | |
| 343 | 336 | ||
| 344 | 337 | h.OnTextSelectionChanged(nil, "first selection", nil) | |
| 345 | 338 | h.OnTextSelectionChanged(nil, "second selection", nil) | |
| @@ -382,7 +375,8 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu | |||
| 382 | 375 | clipboardTextOrchestrator: orchestrator, | |
| 383 | 376 | }, | |
| 384 | 377 | } | |
| 385 | - | h := &handlerSet{wv: wv} | |
| 378 | + | h := &selectionRenderHandlerForTest{wv: wv} | |
| 379 | + | clientHandlers := &handlerSet{wv: wv} | |
| 386 | 380 | frame := cefmocks.NewMockFrame(t) | |
| 387 | 381 | oldFactory := newRendererBridgeProcessMessage | |
| 388 | 382 | t.Cleanup(func() { newRendererBridgeProcessMessage = oldFactory }) | |
| @@ -395,7 +389,7 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu | |||
| 395 | 389 | args := message.GetArgumentList() | |
| 396 | 390 | require.Equal(t, "editable_focus_changed", args.GetString(0)) | |
| 397 | 391 | require.Equal(t, "1", args.GetString(1)) | |
| 398 | - | h.OnProcessMessageReceived(nil, nil, 0, message) | |
| 392 | + | clientHandlers.OnProcessMessageReceived(nil, nil, 0, message) | |
| 399 | 393 | }).Once() | |
| 400 | 394 | (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: true}) | |
| 401 | 395 | ||
| @@ -416,7 +410,7 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu | |||
| 416 | 410 | args := message.GetArgumentList() | |
| 417 | 411 | require.Equal(t, "editable_focus_changed", args.GetString(0)) | |
| 418 | 412 | require.Equal(t, "0", args.GetString(1)) | |
| 419 | - | h.OnProcessMessageReceived(nil, nil, 0, message) | |
| 413 | + | clientHandlers.OnProcessMessageReceived(nil, nil, 0, message) | |
| 420 | 414 | }).Once() | |
| 421 | 415 | (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: false}) | |
| 422 | 416 | ||
| @@ -455,7 +449,7 @@ func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *te | |||
| 455 | 449 | clipboardTextOrchestrator: orchestrator, | |
| 456 | 450 | }, | |
| 457 | 451 | } | |
| 458 | - | h := &handlerSet{wv: wv} | |
| 452 | + | h := &selectionRenderHandlerForTest{wv: wv} | |
| 459 | 453 | ||
| 460 | 454 | h.OnTextSelectionChanged(nil, "selected text", nil) | |
| 461 | 455 | require.Len(t, scheduler.callbacks, 1) | |
| @@ -500,49 +494,21 @@ func (n stubEditableDomnode) SetElementAttribute(string, string) int32 { return | |||
| 500 | 494 | func (n stubEditableDomnode) GetElementInnerText() string { return "" } | |
| 501 | 495 | func (n stubEditableDomnode) GetElementBounds() uintptr { return 0 } | |
| 502 | 496 | ||
| 503 | - | func TestGetViewRectUsesDIPCoordinates(t *testing.T) { | |
| 504 | - | rect := &purecef.Rect{} | |
| 505 | - | h := &handlerSet{ | |
| 506 | - | wv: &WebView{ | |
| 507 | - | ctx: context.Background(), | |
| 508 | - | pipeline: newTestPipeline(800, 600, 2), | |
| 509 | - | }, | |
| 510 | - | } | |
| 511 | - | ||
| 512 | - | h.GetViewRect(nil, rect) | |
| 513 | - | ||
| 514 | - | require.Equal(t, int32(0), rect.X) | |
| 515 | - | require.Equal(t, int32(0), rect.Y) | |
| 516 | - | require.Equal(t, int32(400), rect.Width) | |
| 517 | - | require.Equal(t, int32(300), rect.Height) | |
| 518 | - | } | |
| 519 | - | ||
| 520 | - | func TestGetScreenInfoUsesDIPRectAndScale(t *testing.T) { | |
| 521 | - | si := purecef.NewScreenInfo() | |
| 522 | - | info := &si | |
| 523 | - | h := &handlerSet{ | |
| 524 | - | wv: &WebView{ | |
| 525 | - | ctx: context.Background(), | |
| 526 | - | pipeline: newTestPipeline(1500, 900, 3), | |
| 527 | - | }, | |
| 528 | - | } | |
| 529 | - | ||
| 530 | - | ok := h.GetScreenInfo(nil, info) | |
| 531 | - | ||
| 532 | - | require.Equal(t, int32(1), ok) | |
| 533 | - | require.InEpsilon(t, float32(3), info.DeviceScaleFactor, 0.001) | |
| 534 | - | require.Equal(t, int32(500), info.Rect.Width) | |
| 535 | - | require.Equal(t, int32(300), info.Rect.Height) | |
| 536 | - | require.Equal(t, int32(500), info.AvailableRect.Width) | |
| 537 | - | require.Equal(t, int32(300), info.AvailableRect.Height) | |
| 538 | - | } | |
| 539 | - | ||
| 540 | 497 | func TestOptionalHandlersAreAlwaysEnabled(t *testing.T) { | |
| 541 | 498 | h := &handlerSet{} | |
| 542 | 499 | ||
| 543 | 500 | // AudioHandler is always enabled (required for media decoding). | |
| 544 | 501 | require.Same(t, h, h.GetAudioHandler()) | |
| 545 | 502 | require.Same(t, h, h.GetContextMenuHandler()) | |
| 503 | + | require.Nil(t, h.GetRenderHandler()) | |
| 504 | + | } | |
| 505 | + | ||
| 506 | + | func TestRenderTextSelectionHookPreservesWebViewState(t *testing.T) { | |
| 507 | + | wv := &WebView{ctx: context.Background()} | |
| 508 | + | ||
| 509 | + | handleRenderTextSelectionChanged(wv, "selected via hook") | |
| 510 | + | ||
| 511 | + | require.Equal(t, "selected via hook", wv.selectedTextSnapshot()) | |
| 546 | 512 | } | |
| 547 | 513 | ||
| 548 | 514 | func TestGetDownloadHandler_EnabledWhenEngineConfigured(t *testing.T) { | |
| @@ -559,26 +525,8 @@ func TestGetDownloadHandler_EnabledWhenEngineConfigured(t *testing.T) { | |||
| 559 | 525 | require.Same(t, h, h.GetDownloadHandler()) | |
| 560 | 526 | } | |
| 561 | 527 | ||
| 562 | - | func TestOnPaint_DropsStaleMainViewPaint(t *testing.T) { | |
| 563 | - | wv := &WebView{ | |
| 564 | - | id: 42, | |
| 565 | - | pipeline: &renderPipeline{ | |
| 566 | - | ctx: context.Background(), | |
| 567 | - | scale: 1, | |
| 568 | - | }, | |
| 569 | - | } | |
| 570 | - | wv.pipeline.widthAtomic.Store(1269) | |
| 571 | - | wv.pipeline.heightAtomic.Store(1035) | |
| 572 | - | ||
| 573 | - | h := &handlerSet{wv: wv} | |
| 574 | - | buffer := make([]byte, 1269*2106*4) | |
| 575 | - | ||
| 576 | - | h.OnPaint(nil, purecef.PaintElementTypePetView, []purecef.Rect{{X: 0, Y: 0, Width: 1269, Height: 2106}}, buffer, 1269, 2106) | |
| 528 | + | type selectionRenderHandlerForTest struct{ wv *WebView } | |
| 577 | 529 | ||
| 578 | - | require.Equal(t, int32(1269), wv.pipeline.widthAtomic.Load()) | |
| 579 | - | require.Equal(t, int32(1035), wv.pipeline.heightAtomic.Load()) | |
| 580 | - | require.Nil(t, wv.pipeline.staging) | |
| 581 | - | require.Zero(t, wv.pipeline.lastQueuedPaintSeq.Load()) | |
| 582 | - | require.False(t, wv.pipeline.needsUpload) | |
| 583 | - | require.Empty(t, wv.pipeline.dirtyRects) | |
| 530 | + | func (h *selectionRenderHandlerForTest) OnTextSelectionChanged(_ purecef.Browser, selectedText string, _ *purecef.Range) { | |
| 531 | + | handleRenderTextSelectionChanged(h.wv, selectedText) | |
| 584 | 532 | } | |
A internal/infrastructure/cef/webview_lifecycle_test.go +61 -0
| @@ -0,0 +1,61 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | purecef "github.com/bnema/purego-cef/cef" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestDestroy_WithHostDefersBridgeDestroyUntilBeforeClose(t *testing.T) { | |
| 11 | + | bridge := &Cef2gtkAdapter{} | |
| 12 | + | host := &stubBrowserHost{} | |
| 13 | + | wv := &WebView{ | |
| 14 | + | viewBridge: bridge, | |
| 15 | + | host: host, | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | wv.Destroy() | |
| 19 | + | ||
| 20 | + | require.True(t, wv.IsDestroyed()) | |
| 21 | + | require.True(t, host.closeBrowserCalled) | |
| 22 | + | require.False(t, bridge.IsDestroyed(), "Destroy should request CEF close before destroying the GTK bridge") | |
| 23 | + | ||
| 24 | + | wv.destroyViewBridgeOnGTKSync() | |
| 25 | + | ||
| 26 | + | require.True(t, bridge.IsDestroyed()) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestDestroy_WithoutHostDestroysBridgeImmediately(t *testing.T) { | |
| 30 | + | bridge := &Cef2gtkAdapter{} | |
| 31 | + | wv := &WebView{viewBridge: bridge} | |
| 32 | + | ||
| 33 | + | wv.Destroy() | |
| 34 | + | ||
| 35 | + | require.True(t, bridge.IsDestroyed()) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestHandleInputAttachFailureClosesBrowserForAnyAttachError(t *testing.T) { | |
| 39 | + | host := &stubBrowserHost{} | |
| 40 | + | wv := &WebView{} | |
| 41 | + | ||
| 42 | + | wv.handleInputAttachFailure(assertAnError{}, host) | |
| 43 | + | ||
| 44 | + | require.True(t, host.closeBrowserCalled) | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | type assertAnError struct{} | |
| 48 | + | ||
| 49 | + | func (assertAnError) Error() string { return "attach failed" } | |
| 50 | + | ||
| 51 | + | // stubBrowserHost intentionally only stubs CloseBrowser. The embedded | |
| 52 | + | // purecef.BrowserHost may be nil; calling any other BrowserHost method will | |
| 53 | + | // panic and should be avoided in these lifecycle tests. | |
| 54 | + | type stubBrowserHost struct { | |
| 55 | + | purecef.BrowserHost | |
| 56 | + | closeBrowserCalled bool | |
| 57 | + | } | |
| 58 | + | ||
| 59 | + | func (s *stubBrowserHost) CloseBrowser(int32) { | |
| 60 | + | s.closeBrowserCalled = true | |
| 61 | + | } | |
M internal/infrastructure/config/loader.go +0 -2
| @@ -610,8 +610,6 @@ func (m *Manager) setEngineDefaults(defaults *Config) { | |||
| 610 | 610 | m.viper.SetDefault("engine.webkit.network_process_memory_strict_threshold", wk.NetworkProcessMemoryStrictThreshold) | |
| 611 | 611 | } | |
| 612 | 612 | ||
| 613 | - | ||
| 614 | - | ||
| 615 | 613 | // New returns a new default configuration instance. | |
| 616 | 614 | // This is a convenience function for getting default config without the full manager. | |
| 617 | 615 | func New() *Config { | |
A internal/logging/port_logger.go +41 -0
| @@ -0,0 +1,41 @@ | |||
| 1 | + | package logging | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | ||
| 6 | + | "github.com/rs/zerolog" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/application/port" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | // NewPortLogger adapts the zerolog logger stored in ctx to the application | |
| 12 | + | // logging boundary. | |
| 13 | + | func NewPortLogger(ctx context.Context) port.Logger { | |
| 14 | + | if ctx == nil { | |
| 15 | + | ctx = context.Background() | |
| 16 | + | } | |
| 17 | + | return portLogger{ctx: ctx} | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | type portLogger struct { | |
| 21 | + | ctx context.Context | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | func (l portLogger) Debug(msg string, fields ...port.LogField) { | |
| 25 | + | addLogFields(FromContext(l.ctx).Debug(), fields).Msg(msg) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | func (l portLogger) Info(msg string, fields ...port.LogField) { | |
| 29 | + | addLogFields(FromContext(l.ctx).Info(), fields).Msg(msg) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func (l portLogger) Warn(msg string, fields ...port.LogField) { | |
| 33 | + | addLogFields(FromContext(l.ctx).Warn(), fields).Msg(msg) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func addLogFields(event *zerolog.Event, fields []port.LogField) *zerolog.Event { | |
| 37 | + | for _, field := range fields { | |
| 38 | + | event = event.Interface(field.Key, field.Value) | |
| 39 | + | } | |
| 40 | + | return event | |
| 41 | + | } | |
M internal/logging/session.go +26 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package logging | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "crypto/rand" | |
| 5 | 6 | "encoding/hex" | |
| 6 | 7 | "errors" | |
| @@ -55,6 +56,31 @@ func SessionFilename(sessionID string) string { | |||
| 55 | 56 | return "session_" + sessionID + ".log" | |
| 56 | 57 | } | |
| 57 | 58 | ||
| 59 | + | type sessionMetadataKey struct{} | |
| 60 | + | ||
| 61 | + | // SessionMetadata describes the active browser session log identity carried by context. | |
| 62 | + | type SessionMetadata struct { | |
| 63 | + | ID string | |
| 64 | + | LogPath string | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // WithSessionMetadata attaches active session metadata to ctx. | |
| 68 | + | func WithSessionMetadata(ctx context.Context, id, logPath string) context.Context { | |
| 69 | + | if ctx == nil { | |
| 70 | + | ctx = context.Background() | |
| 71 | + | } | |
| 72 | + | return context.WithValue(ctx, sessionMetadataKey{}, SessionMetadata{ID: id, LogPath: logPath}) | |
| 73 | + | } | |
| 74 | + | ||
| 75 | + | // SessionMetadataFromContext returns active session metadata from ctx, if present. | |
| 76 | + | func SessionMetadataFromContext(ctx context.Context) (SessionMetadata, bool) { | |
| 77 | + | if ctx == nil { | |
| 78 | + | return SessionMetadata{}, false | |
| 79 | + | } | |
| 80 | + | metadata, ok := ctx.Value(sessionMetadataKey{}).(SessionMetadata) | |
| 81 | + | return metadata, ok | |
| 82 | + | } | |
| 83 | + | ||
| 58 | 84 | type sessionLogFile struct { | |
| 59 | 85 | path string | |
| 60 | 86 | filename string | |
M internal/ui/app.go +16 -2
| @@ -2581,9 +2581,23 @@ func (a *App) MainWindow() *window.MainWindow { | |||
| 2581 | 2581 | ||
| 2582 | 2582 | // Quit requests the application to quit. | |
| 2583 | 2583 | func (a *App) Quit() { | |
| 2584 | - | if a.gtkApp != nil { | |
| 2585 | - | a.gtkApp.Quit() | |
| 2584 | + | if a == nil || a.gtkApp == nil { | |
| 2585 | + | return | |
| 2586 | + | } | |
| 2587 | + | quit := func() { | |
| 2588 | + | if a.gtkApp != nil { | |
| 2589 | + | a.gtkApp.Quit() | |
| 2590 | + | } | |
| 2591 | + | } | |
| 2592 | + | glibCtx := glib.MainContextDefault() | |
| 2593 | + | if glibCtx != nil && glibCtx.IsOwner() { | |
| 2594 | + | quit() | |
| 2595 | + | return | |
| 2586 | 2596 | } | |
| 2597 | + | cb := glib.SourceOnceFunc(func(_ uintptr) { | |
| 2598 | + | quit() | |
| 2599 | + | }) | |
| 2600 | + | glib.IdleAddOnce(&cb, 0) | |
| 2587 | 2601 | } | |
| 2588 | 2602 | ||
| 2589 | 2603 | // RunWithArgs is a convenience function that creates and runs an App. | |
M internal/ui/input/global_shortcuts.go +75 -15
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "fmt" | |
| 7 | 7 | "strings" | |
| 8 | + | "time" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| 10 | 11 | "github.com/bnema/dumber/internal/logging" | |
| @@ -17,12 +18,15 @@ import ( | |||
| 17 | 18 | // even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL | |
| 18 | 19 | // to intercept shortcuts before they reach the WebView. | |
| 19 | 20 | type GlobalShortcutHandler struct { | |
| 20 | - | controller *gtk.ShortcutController | |
| 21 | - | window *gtk.ApplicationWindow | |
| 22 | - | kbHandler *KeyboardHandler | |
| 23 | - | onAction ActionHandler | |
| 24 | - | ctx context.Context | |
| 25 | - | registered map[KeyBinding]Action | |
| 21 | + | controller *gtk.ShortcutController | |
| 22 | + | window *gtk.ApplicationWindow | |
| 23 | + | kbHandler *KeyboardHandler | |
| 24 | + | onAction ActionHandler | |
| 25 | + | ctx context.Context | |
| 26 | + | registered map[KeyBinding]Action | |
| 27 | + | lastDispatchAt map[Action]time.Time | |
| 28 | + | // generation is mutated only from the GTK main thread alongside controller replacement. | |
| 29 | + | generation uint64 | |
| 26 | 30 | ||
| 27 | 31 | // Keep references to callbacks to prevent GC from collecting them | |
| 28 | 32 | callbacks []gtk.ShortcutFunc | |
| @@ -42,13 +46,14 @@ func NewGlobalShortcutHandler( | |||
| 42 | 46 | log.Debug().Msg("creating global shortcut handler") | |
| 43 | 47 | ||
| 44 | 48 | h := &GlobalShortcutHandler{ | |
| 45 | - | controller: gtk.NewShortcutController(), | |
| 46 | - | window: window, | |
| 47 | - | kbHandler: kbHandler, | |
| 48 | - | onAction: onAction, | |
| 49 | - | ctx: ctx, | |
| 50 | - | callbacks: make([]gtk.ShortcutFunc, 0), | |
| 51 | - | registered: make(map[KeyBinding]Action), | |
| 49 | + | controller: gtk.NewShortcutController(), | |
| 50 | + | window: window, | |
| 51 | + | kbHandler: kbHandler, | |
| 52 | + | onAction: onAction, | |
| 53 | + | ctx: ctx, | |
| 54 | + | callbacks: make([]gtk.ShortcutFunc, 0), | |
| 55 | + | registered: make(map[KeyBinding]Action), | |
| 56 | + | lastDispatchAt: make(map[Action]time.Time), | |
| 52 | 57 | } | |
| 53 | 58 | ||
| 54 | 59 | if h.controller == nil { | |
| @@ -181,11 +186,27 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi | |||
| 181 | 186 | return false | |
| 182 | 187 | } | |
| 183 | 188 | ||
| 184 | - | // Create callback action | |
| 185 | - | // We need to capture the action in the closure | |
| 189 | + | // Create callback action. | |
| 190 | + | // Capture the generation so callbacks from removed/reloaded controllers cannot | |
| 191 | + | // dispatch one-shot actions after a shortcut reload. | |
| 186 | 192 | actionToDispatch := action | |
| 193 | + | generation := h.generation | |
| 187 | 194 | callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool { | |
| 188 | 195 | log := logging.FromContext(h.ctx) | |
| 196 | + | if h.isStaleGeneration(generation) { | |
| 197 | + | log.Trace(). | |
| 198 | + | Str("action", string(actionToDispatch)). | |
| 199 | + | Uint64("callback_generation", generation). | |
| 200 | + | Uint64("current_generation", h.generation). | |
| 201 | + | Msg("stale global shortcut callback ignored") | |
| 202 | + | return true | |
| 203 | + | } | |
| 204 | + | if h.suppressRepeatedShortcut(actionToDispatch, time.Now()) { | |
| 205 | + | log.Trace(). | |
| 206 | + | Str("action", string(actionToDispatch)). | |
| 207 | + | Msg("global shortcut repeat suppressed") | |
| 208 | + | return true | |
| 209 | + | } | |
| 189 | 210 | log.Debug(). | |
| 190 | 211 | Str("action", string(actionToDispatch)). | |
| 191 | 212 | Msg("global shortcut triggered") | |
| @@ -266,6 +287,8 @@ func globalShortcutActionMap() map[string]Action { | |||
| 266 | 287 | // are removed. | |
| 267 | 288 | func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) { | |
| 268 | 289 | log := logging.FromContext(ctx) | |
| 290 | + | h.ctx = ctx | |
| 291 | + | h.generation++ | |
| 269 | 292 | ||
| 270 | 293 | if h.window == nil || h.controller == nil { | |
| 271 | 294 | return | |
| @@ -283,6 +306,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace * | |||
| 283 | 306 | h.controller.SetScope(gtk.ShortcutScopeGlobalValue) | |
| 284 | 307 | h.callbacks = make([]gtk.ShortcutFunc, 0) | |
| 285 | 308 | h.registered = make(map[KeyBinding]Action) | |
| 309 | + | h.lastDispatchAt = make(map[Action]time.Time) | |
| 286 | 310 | ||
| 287 | 311 | // Re-register hardcoded shortcuts (Alt+1-9, Alt+0, Alt+Tab, Ctrl+Shift+S) | |
| 288 | 312 | tabActions := []Action{ | |
| @@ -348,9 +372,45 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace * | |||
| 348 | 372 | // Note: GTK handles cleanup when the widget is destroyed, | |
| 349 | 373 | // but we clear our references here. | |
| 350 | 374 | func (h *GlobalShortcutHandler) Detach() { | |
| 375 | + | h.generation++ | |
| 376 | + | if h.window != nil && h.controller != nil { | |
| 377 | + | h.window.RemoveController(&h.controller.EventController) | |
| 378 | + | } | |
| 351 | 379 | h.controller = nil | |
| 352 | 380 | h.callbacks = nil | |
| 353 | 381 | h.registered = nil | |
| 382 | + | h.lastDispatchAt = nil | |
| 383 | + | } | |
| 384 | + | ||
| 385 | + | const globalShortcutRepeatSuppressWindow = 250 * time.Millisecond | |
| 386 | + | ||
| 387 | + | func (h *GlobalShortcutHandler) isStaleGeneration(generation uint64) bool { | |
| 388 | + | return h == nil || generation != h.generation | |
| 389 | + | } | |
| 390 | + | ||
| 391 | + | func (h *GlobalShortcutHandler) suppressRepeatedShortcut(action Action, now time.Time) bool { | |
| 392 | + | if h == nil || !isRepeatedGlobalShortcutSuppressed(action) { | |
| 393 | + | return false | |
| 394 | + | } | |
| 395 | + | if h.lastDispatchAt == nil { | |
| 396 | + | // Detached/reloading handlers clear dispatch state; consume one-shot shortcuts | |
| 397 | + | // rather than letting a stale GTK callback repeat destructive actions. | |
| 398 | + | return true | |
| 399 | + | } | |
| 400 | + | if last, ok := h.lastDispatchAt[action]; ok && now.Sub(last) < globalShortcutRepeatSuppressWindow { | |
| 401 | + | return true | |
| 402 | + | } | |
| 403 | + | h.lastDispatchAt[action] = now | |
| 404 | + | return false | |
| 405 | + | } | |
| 406 | + | ||
| 407 | + | func isRepeatedGlobalShortcutSuppressed(action Action) bool { | |
| 408 | + | switch action { | |
| 409 | + | case ActionZoomReset, ActionClosePane, ActionCloseTab, ActionQuit, ActionOpenSessionManager: | |
| 410 | + | return true | |
| 411 | + | default: | |
| 412 | + | return false | |
| 413 | + | } | |
| 354 | 414 | } | |
| 355 | 415 | ||
| 356 | 416 | func formatBinding(binding KeyBinding) string { | |
A internal/ui/input/global_shortcuts_test.go +61 -0
| @@ -0,0 +1,61 @@ | |||
| 1 | + | package input | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | "time" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestGlobalShortcutHandlerSuppressesRepeatedOneShotActions(t *testing.T) { | |
| 9 | + | h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)} | |
| 10 | + | now := time.Unix(100, 0) | |
| 11 | + | ||
| 12 | + | if h.suppressRepeatedShortcut(ActionZoomReset, now) { | |
| 13 | + | t.Fatal("first zoom reset dispatch was suppressed") | |
| 14 | + | } | |
| 15 | + | if !h.suppressRepeatedShortcut(ActionZoomReset, now.Add(globalShortcutRepeatSuppressWindow/2)) { | |
| 16 | + | t.Fatal("repeated zoom reset dispatch inside suppression window was not suppressed") | |
| 17 | + | } | |
| 18 | + | if h.suppressRepeatedShortcut(ActionZoomReset, now.Add(globalShortcutRepeatSuppressWindow)) { | |
| 19 | + | t.Fatal("zoom reset dispatch at suppression boundary was suppressed") | |
| 20 | + | } | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | func TestGlobalShortcutHandlerAllowsRepeatingNavigationActions(t *testing.T) { | |
| 24 | + | h := &GlobalShortcutHandler{lastDispatchAt: make(map[Action]time.Time)} | |
| 25 | + | now := time.Unix(100, 0) | |
| 26 | + | ||
| 27 | + | if h.suppressRepeatedShortcut(ActionFocusRight, now) { | |
| 28 | + | t.Fatal("first focus-right dispatch was suppressed") | |
| 29 | + | } | |
| 30 | + | if h.suppressRepeatedShortcut(ActionFocusRight, now.Add(time.Millisecond)) { | |
| 31 | + | t.Fatal("focus-right repeat was suppressed") | |
| 32 | + | } | |
| 33 | + | } | |
| 34 | + | ||
| 35 | + | func TestGlobalShortcutHandlerSuppressesOneShotActionsAfterDetach(t *testing.T) { | |
| 36 | + | h := &GlobalShortcutHandler{ | |
| 37 | + | registered: make(map[KeyBinding]Action), | |
| 38 | + | lastDispatchAt: make(map[Action]time.Time), | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | if h.suppressRepeatedShortcut(ActionZoomReset, time.Unix(100, 0)) { | |
| 42 | + | t.Fatal("one-shot shortcut was suppressed before detach") | |
| 43 | + | } | |
| 44 | + | h.Detach() | |
| 45 | + | if !h.suppressRepeatedShortcut(ActionZoomReset, time.Unix(100, 0)) { | |
| 46 | + | t.Fatal("one-shot shortcut was not suppressed with detached handler state") | |
| 47 | + | } | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | func TestGlobalShortcutHandlerGenerationMarksOldCallbacksStale(t *testing.T) { | |
| 51 | + | h := &GlobalShortcutHandler{} | |
| 52 | + | generation := h.generation | |
| 53 | + | ||
| 54 | + | if h.isStaleGeneration(generation) { | |
| 55 | + | t.Fatal("current generation was marked stale") | |
| 56 | + | } | |
| 57 | + | h.generation++ | |
| 58 | + | if !h.isStaleGeneration(generation) { | |
| 59 | + | t.Fatal("old generation was not marked stale") | |
| 60 | + | } | |
| 61 | + | } | |
A package-lock.json +6 -0 generated file: collapsed
A scripts/cef-gpu-diagnostics.sh +212 -0
| @@ -0,0 +1,212 @@ | |||
| 1 | + | #!/usr/bin/env bash | |
| 2 | + | set -euo pipefail | |
| 3 | + | ||
| 4 | + | usage() { | |
| 5 | + | cat <<'EOF' | |
| 6 | + | Usage: scripts/cef-gpu-diagnostics.sh [options] | |
| 7 | + | ||
| 8 | + | Capture a short, repeatable Dumber/CEF GPU-video diagnostic bundle while a | |
| 9 | + | YouTube video is playing. | |
| 10 | + | ||
| 11 | + | Options: | |
| 12 | + | -p, --pid PID Dumber browser/root PID. Defaults to oldest/root dumber process. | |
| 13 | + | -o, --out DIR Output directory. Default: .dev/dumber/gpu-diagnostics/<timestamp> | |
| 14 | + | -n, --samples N amdgpu_top JSON samples. Default: 10 | |
| 15 | + | -i, --interval-ms MS amdgpu_top sample interval. Default: 1000 | |
| 16 | + | --session-log PATH Copy a Dumber session log into the bundle. | |
| 17 | + | --codec-stats TEXT Record manually copied YouTube "Stats for nerds" text. | |
| 18 | + | --codec-stats-file PATH Record YouTube "Stats for nerds" text from a file. | |
| 19 | + | --devtools-port PORT Save http://127.0.0.1:PORT/json/version and /json tabs if enabled. | |
| 20 | + | -h, --help Show this help. | |
| 21 | + | ||
| 22 | + | Recommended run shape: | |
| 23 | + | 1. Start Dumber with CEF/VAAPI/profiling enabled. | |
| 24 | + | 2. Open YouTube Stats for nerds and copy the Codecs line (or full panel text). | |
| 25 | + | 3. Run this script while playback is active. | |
| 26 | + | ||
| 27 | + | The bundle includes process/thread CPU, process command lines, /proc DRM fdinfo, | |
| 28 | + | amdgpu_top JSON capture, kernel DRM node metadata, optional DevTools tab metadata, | |
| 29 | + | and optional YouTube codec stats text. | |
| 30 | + | EOF | |
| 31 | + | } | |
| 32 | + | ||
| 33 | + | pid="" | |
| 34 | + | out_dir="" | |
| 35 | + | samples=10 | |
| 36 | + | interval_ms=1000 | |
| 37 | + | session_log="" | |
| 38 | + | codec_stats="" | |
| 39 | + | codec_stats_file="" | |
| 40 | + | devtools_port="" | |
| 41 | + | ||
| 42 | + | require_value() { | |
| 43 | + | local opt="$1" | |
| 44 | + | local value="${2:-}" | |
| 45 | + | if [[ -z "$value" || "$value" == -* ]]; then | |
| 46 | + | echo "$opt requires a value" >&2 | |
| 47 | + | usage >&2 | |
| 48 | + | exit 2 | |
| 49 | + | fi | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | validate_devtools_port() { | |
| 53 | + | local value="$1" | |
| 54 | + | if [[ ! "$value" =~ ^[1-9][0-9]*$ ]] || (( value < 1 || value > 65535 )); then | |
| 55 | + | echo "--devtools-port must be an integer between 1 and 65535" >&2 | |
| 56 | + | exit 2 | |
| 57 | + | fi | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | while [[ $# -gt 0 ]]; do | |
| 61 | + | case "$1" in | |
| 62 | + | -p|--pid) require_value "$1" "${2:-}"; pid="$2"; shift 2 ;; | |
| 63 | + | -o|--out) require_value "$1" "${2:-}"; out_dir="$2"; shift 2 ;; | |
| 64 | + | -n|--samples) require_value "$1" "${2:-}"; samples="$2"; shift 2 ;; | |
| 65 | + | -i|--interval-ms) require_value "$1" "${2:-}"; interval_ms="$2"; shift 2 ;; | |
| 66 | + | --session-log) require_value "$1" "${2:-}"; session_log="$2"; shift 2 ;; | |
| 67 | + | --codec-stats) require_value "$1" "${2:-}"; codec_stats="$2"; shift 2 ;; | |
| 68 | + | --codec-stats-file) require_value "$1" "${2:-}"; codec_stats_file="$2"; shift 2 ;; | |
| 69 | + | --devtools-port) require_value "$1" "${2:-}"; validate_devtools_port "$2"; devtools_port="$2"; shift 2 ;; | |
| 70 | + | -h|--help) usage; exit 0 ;; | |
| 71 | + | *) echo "unknown argument: $1" >&2; usage >&2; exit 2 ;; | |
| 72 | + | esac | |
| 73 | + | done | |
| 74 | + | ||
| 75 | + | if [[ -z "$pid" ]]; then | |
| 76 | + | pid="$(pgrep -o -x dumber || true)" | |
| 77 | + | fi | |
| 78 | + | if [[ -n "$pid" && ! "$pid" =~ ^[1-9][0-9]*$ ]]; then | |
| 79 | + | echo "--pid must be a positive integer" >&2 | |
| 80 | + | exit 2 | |
| 81 | + | fi | |
| 82 | + | if [[ -z "$pid" || ! -d "/proc/$pid" ]]; then | |
| 83 | + | echo "could not find a live dumber PID; pass --pid" >&2 | |
| 84 | + | exit 1 | |
| 85 | + | fi | |
| 86 | + | if ! [[ "$samples" =~ ^[1-9][0-9]*$ && "$interval_ms" =~ ^[1-9][0-9]*$ ]]; then | |
| 87 | + | echo "--samples and --interval-ms must be positive integers" >&2 | |
| 88 | + | exit 2 | |
| 89 | + | fi | |
| 90 | + | ||
| 91 | + | if [[ -z "$out_dir" ]]; then | |
| 92 | + | out_dir=".dev/dumber/gpu-diagnostics/$(date +%Y%m%d_%H%M%S)_pid${pid}" | |
| 93 | + | fi | |
| 94 | + | mkdir -p "$out_dir" | |
| 95 | + | ||
| 96 | + | log() { printf '[cef-gpu-diagnostics] %s\n' "$*" >&2; } | |
| 97 | + | ||
| 98 | + | collect_children() { | |
| 99 | + | local root="$1" | |
| 100 | + | local child | |
| 101 | + | printf '%s\n' "$root" | |
| 102 | + | for child in $(pgrep -P "$root" || true); do | |
| 103 | + | collect_children "$child" | |
| 104 | + | done | |
| 105 | + | } | |
| 106 | + | ||
| 107 | + | mapfile -t pids < <(collect_children "$pid" | awk '!seen[$0]++') | |
| 108 | + | printf '%s\n' "${pids[@]}" > "$out_dir/pids.txt" | |
| 109 | + | ||
| 110 | + | { | |
| 111 | + | echo "timestamp=$(date --iso-8601=seconds)" | |
| 112 | + | echo "root_pid=$pid" | |
| 113 | + | echo "samples=$samples" | |
| 114 | + | echo "interval_ms=$interval_ms" | |
| 115 | + | echo "uname=$(uname -a)" | |
| 116 | + | echo "wayland_display=${WAYLAND_DISPLAY:-}" | |
| 117 | + | echo "display=${DISPLAY:-}" | |
| 118 | + | command -v amdgpu_top >/dev/null && amdgpu_top --version 2>/dev/null || true | |
| 119 | + | } > "$out_dir/metadata.txt" | |
| 120 | + | ||
| 121 | + | log "writing process command lines and thread CPU" | |
| 122 | + | : > "$out_dir/cmdlines.txt" | |
| 123 | + | : > "$out_dir/thread_cpu.txt" | |
| 124 | + | for p in "${pids[@]}"; do | |
| 125 | + | [[ -d "/proc/$p" ]] || continue | |
| 126 | + | { | |
| 127 | + | echo "--- pid=$p comm=$(cat "/proc/$p/comm" 2>/dev/null || true)" | |
| 128 | + | tr '\0' ' ' < "/proc/$p/cmdline" || true | |
| 129 | + | echo | |
| 130 | + | } >> "$out_dir/cmdlines.txt" | |
| 131 | + | ps -L -p "$p" -o pid,tid,ppid,pcpu,pmem,stat,comm,args --sort=-pcpu >> "$out_dir/thread_cpu.txt" 2>/dev/null || true | |
| 132 | + | done | |
| 133 | + | ||
| 134 | + | log "capturing DRM fdinfo" | |
| 135 | + | mkdir -p "$out_dir/fdinfo" | |
| 136 | + | for p in "${pids[@]}"; do | |
| 137 | + | [[ -d "/proc/$p/fdinfo" ]] || continue | |
| 138 | + | out="$out_dir/fdinfo/pid_${p}.txt" | |
| 139 | + | : > "$out" | |
| 140 | + | for fd in "/proc/${p}/fdinfo"/*; do | |
| 141 | + | [[ -r "$fd" ]] || continue | |
| 142 | + | if grep -Eq 'drm-|amdgpu|engine|vram|gtt|cycles' "$fd" 2>/dev/null; then | |
| 143 | + | echo "--- $fd" >> "$out" | |
| 144 | + | cat "$fd" >> "$out" 2>/dev/null || true | |
| 145 | + | fi | |
| 146 | + | done | |
| 147 | + | [[ -s "$out" ]] || rm -f "$out" | |
| 148 | + | done | |
| 149 | + | ||
| 150 | + | log "capturing DRM device metadata" | |
| 151 | + | mkdir -p "$out_dir/dri" | |
| 152 | + | for card in /sys/class/drm/card*; do | |
| 153 | + | [[ -e "$card" && ! "$card" =~ - ]] || continue | |
| 154 | + | name="$(basename "$card")" | |
| 155 | + | { | |
| 156 | + | echo "--- $card" | |
| 157 | + | readlink -f "$card/device" || true | |
| 158 | + | for f in vendor device revision subsystem_vendor subsystem_device; do | |
| 159 | + | [[ -r "$card/device/$f" ]] && printf '%s=' "$f" && cat "$card/device/$f" | |
| 160 | + | done | |
| 161 | + | } > "$out_dir/dri/${name}.txt" | |
| 162 | + | done | |
| 163 | + | ||
| 164 | + | if command -v amdgpu_top >/dev/null; then | |
| 165 | + | log "capturing amdgpu_top JSON (${samples} samples @ ${interval_ms}ms)" | |
| 166 | + | amdgpu_top -J -s "$interval_ms" -n "$samples" -p -gm > "$out_dir/amdgpu_top.json" 2> "$out_dir/amdgpu_top.stderr" || true | |
| 167 | + | if command -v jq >/dev/null; then | |
| 168 | + | jq '{period, devices: [.devices[]? | {name: .Info.DeviceName, render: .Info.DevicePath.render, activity: .gpu_activity, total_fdinfo: .["Total fdinfo"], processes: (.fdinfo // {})}]}' \ | |
| 169 | + | "$out_dir/amdgpu_top.json" > "$out_dir/amdgpu_top.summary.json" 2>/dev/null || true | |
| 170 | + | fi | |
| 171 | + | else | |
| 172 | + | log "amdgpu_top not found; skipping GPU samples" | |
| 173 | + | fi | |
| 174 | + | ||
| 175 | + | if [[ -n "$session_log" ]]; then | |
| 176 | + | if [[ ! -r "$session_log" ]]; then | |
| 177 | + | log "--session-log is not readable: $session_log" | |
| 178 | + | exit 2 | |
| 179 | + | fi | |
| 180 | + | cp -- "$session_log" "$out_dir/$(basename -- "$session_log")" | |
| 181 | + | fi | |
| 182 | + | if [[ -n "$codec_stats_file" ]]; then | |
| 183 | + | if [[ ! -r "$codec_stats_file" ]]; then | |
| 184 | + | log "--codec-stats-file is not readable: $codec_stats_file" | |
| 185 | + | exit 2 | |
| 186 | + | fi | |
| 187 | + | cp -- "$codec_stats_file" "$out_dir/youtube_stats_for_nerds.txt" | |
| 188 | + | elif [[ -n "$codec_stats" ]]; then | |
| 189 | + | printf '%s\n' "$codec_stats" > "$out_dir/youtube_stats_for_nerds.txt" | |
| 190 | + | else | |
| 191 | + | cat > "$out_dir/youtube_stats_for_nerds.README.txt" <<'EOF' | |
| 192 | + | No YouTube codec stats were supplied. Re-run with --codec-stats or | |
| 193 | + | --codec-stats-file after copying the YouTube "Stats for nerds" panel, | |
| 194 | + | especially the Codecs line (e.g. vp09/av01/h264) and viewport/resolution. | |
| 195 | + | EOF | |
| 196 | + | fi | |
| 197 | + | ||
| 198 | + | if [[ -n "$devtools_port" ]]; then | |
| 199 | + | validate_devtools_port "$devtools_port" | |
| 200 | + | log "capturing DevTools metadata from port $devtools_port" | |
| 201 | + | if ! curl --connect-timeout 1 --max-time 3 -fsS "http://127.0.0.1:${devtools_port}/json/version" > "$out_dir/devtools_version.json" 2> "$out_dir/devtools.stderr"; then | |
| 202 | + | log "failed to fetch DevTools version metadata from port $devtools_port" | |
| 203 | + | exit 1 | |
| 204 | + | fi | |
| 205 | + | if ! curl --connect-timeout 1 --max-time 3 -fsS "http://127.0.0.1:${devtools_port}/json" > "$out_dir/devtools_tabs.json" 2>> "$out_dir/devtools.stderr"; then | |
| 206 | + | log "failed to fetch DevTools tab metadata from port $devtools_port" | |
| 207 | + | exit 1 | |
| 208 | + | fi | |
| 209 | + | fi | |
| 210 | + | ||
| 211 | + | log "bundle complete: $out_dir" | |
| 212 | + | printf '%s\n' "$out_dir" | |