detached 6066b2a

Merge pull request #241 from bnema/feature/use-purego-cef2gtk

Use new package purego-cef2gtk
commit
6066b2a792c5504e8f790bfed10c09e7c096beb1
author
bnema · · 2026-05-01 16:55 UTC
committer
GitHub · · 2026-05-01 16:55 UTC
merge of
bda4c7f + af7d4c5 · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
58 files · +3549 -3799
M .gitignore +2 -1
@@ -84,10 +84,11 @@ CLAUDE.local.md
8484 memory/
8585 notes/
8686 docs/plans/
87-scripts/
87+scripts/*
8888 !scripts/generate-types.js
8989 !scripts/verify_purego_only.sh
9090 !scripts/stress_omnibox_callbacks.go
91+!scripts/cef-gpu-diagnostics.sh
9192 specs/
9293 templates/
9394 .serena/
M .golangci.yml +3 -1
@@ -40,7 +40,9 @@ linters:
4040 statements: 50
4141 goconst:
4242 min-len: 2
43- min-occurrences: 3
43+ min-occurrences: 20
44+ ignore-string-values:
45+ - "^(bool|int|string|error|engine\\.webkit|>=0)$"
4446 gocritic:
4547 enabled-tags:
4648 - diagnostic
M Makefile +5 -5
@@ -42,7 +42,7 @@ help: ## Show this help message
4242 build: build-systemviews ## Build the application (pure Go, no CGO)
4343 @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..."
4444 @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)
4646 @rm -f $(DIST_DIR)/cef-helper
4747 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
4848
@@ -63,7 +63,7 @@ build-systemviews: generate-systemviews ## Build the WASM systemviews runtime
6363 build-quick: ## Build quickly for backend development
6464 @echo "Building $(BINARY_NAME) $(VERSION) (quick)..."
6565 @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)
6767 @rm -f $(DIST_DIR)/cef-helper
6868 @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)"
6969
@@ -102,15 +102,15 @@ mocks: ## Generate mock implementations with mockery
102102 # Testing
103103 test: ## Run tests
104104 @echo "Running tests..."
105- go test -v ./...
105+ GOFLAGS=-mod=mod go test -v ./...
106106
107107 test-race: ## Run tests with race detection
108108 @echo "Running tests with race detection..."
109- go test -race -v ./...
109+ GOFLAGS=-mod=mod go test -race -v ./...
110110
111111 test-cover: ## Run tests with coverage
112112 @echo "Running tests with coverage..."
113- go test -coverprofile=coverage.out ./...
113+ GOFLAGS=-mod=mod go test -coverprofile=coverage.out ./...
114114 go tool cover -html=coverage.out -o coverage.html
115115 @echo "Coverage report: coverage.html"
116116
M README.md +2 -0
@@ -101,6 +101,8 @@ CEF_DIR=/custom/path dumber browse
101101
102102 Then set `engine.type = "cef"` in your config.
103103
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+
104106 ## Keyboard-Driven
105107
106108 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 (
2424 "github.com/bnema/dumber/internal/infrastructure/config"
2525 "github.com/bnema/dumber/internal/infrastructure/deps"
2626 "github.com/bnema/dumber/internal/infrastructure/desktop"
27+ renderenv "github.com/bnema/dumber/internal/infrastructure/env"
2728 "github.com/bnema/dumber/internal/infrastructure/favicon"
2829 "github.com/bnema/dumber/internal/infrastructure/filesystem"
2930 "github.com/bnema/dumber/internal/infrastructure/idle"
@@ -199,6 +200,7 @@ func runGUI(cfg *config.Config) int {
199200 timer.Mark("config")
200201
201202 ctx := initStartupContextWithTrace(cfg)
203+ applyCEFRenderStackDefault(ctx, cfg)
202204 timer.Mark("logger")
203205 bootstrapLog := logging.FromContext(ctx)
204206
@@ -273,6 +275,7 @@ func runStandaloneOmnibox() int {
273275 cfg := initConfig()
274276 configureBrowserLaunchRelay(cfg)
275277 ctx := initStartupContextWithTrace(cfg)
278+ applyCEFRenderStackDefault(ctx, cfg)
276279
277280 initResult, err := runParallelInitPhase(ctx, cfg)
278281 if err != nil {
@@ -320,6 +323,20 @@ func runStandaloneOmnibox() int {
320323 return ui.RunStandaloneOmnibox(ctx, runtimeCfg)
321324 }
322325
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+
323340 func preInitializeAdwaitaForCEF(cfg *config.Config, initResult *bootstrap.ParallelInitResult, initAdwaita func()) {
324341 if cfg == nil || initResult == nil || initAdwaita == nil {
325342 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
690690 DUMBER_DATABASE_PATH=/custom/path/db.sqlite
691691
692692 # 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
694696 DUMBER_RENDERING_MODE=cpu
697+# WebKit-specific rendering overrides:
695698 # DUMBER_RENDERING_DISABLE_DMABUF_RENDERER=true
696699 # DUMBER_RENDERING_GSK_RENDERER=opengl
697700 DUMBER_DEFAULT_WEBPAGE_ZOOM=1.5
M docs/reference/configuration.md +1 -0
@@ -59,6 +59,7 @@
5959 | `rendering.show_fps` | bool | `false` | |
6060 | `rendering.sample_memory` | bool | `false` | |
6161 | `rendering.debug_frames` | bool | `false` | |
62+| `DUMBER_RENDER_STACK` env | string | `vulkan-dmabuf` | `vulkan-dmabuf`, `auto`, `legacy-gl` |
6263 | `default_ui_scale` | float | `1.0` | > 0 |
6364 | `default_webpage_zoom` | float | `1.2` | > 0 |
6465 | `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
7171 sudo pacman -S vulkan-tools # for vulkaninfo
7272 ```
7373
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+
7478 ## Debian/Ubuntu
7579
7680 ### Core GTK4 and WebKit
M go.mod +3 -1
@@ -7,9 +7,10 @@ require (
77 github.com/andybalholm/brotli v1.2.1
88 github.com/bnema/purego v0.11.0-bnema.2
99 github.com/bnema/purego-cef v0.12.0
10+ github.com/bnema/purego-cef2gtk v0.1.0
1011 github.com/bnema/purego-pipewire v0.1.2
1112 github.com/bnema/purego-sqlite v0.1.2
12- github.com/bnema/puregotk v0.5.1
13+ github.com/bnema/puregotk v0.5.2
1314 github.com/charmbracelet/bubbles v1.0.0
1415 github.com/charmbracelet/bubbletea v1.3.10
1516 github.com/charmbracelet/lipgloss v1.1.0
@@ -57,6 +58,7 @@ require (
5758 github.com/natefinch/atomic v1.0.1 // indirect
5859 github.com/pmezard/go-difflib v1.0.0 // indirect
5960 github.com/rivo/uniseg v0.4.7 // indirect
61+ github.com/rogpeppe/go-internal v1.14.1 // indirect
6062 github.com/russross/blackfriday/v2 v2.1.0 // indirect
6163 github.com/sagikazarmark/locafero v0.12.0 // indirect
6264 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 (
99 // LoggerFromContext resolves the current request/session logger from context.
1010 // Use cases depend on this boundary instead of importing infrastructure logging.
1111 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 (
1313 clipboardinfra "github.com/bnema/dumber/internal/infrastructure/clipboard"
1414 "github.com/bnema/dumber/internal/infrastructure/config"
1515 "github.com/bnema/dumber/internal/infrastructure/env"
16+ "github.com/bnema/dumber/internal/infrastructure/gtkmenu"
1617 "github.com/bnema/dumber/internal/infrastructure/handlers"
1718 "github.com/bnema/dumber/internal/infrastructure/runtimeprofile"
1819 "github.com/bnema/dumber/internal/infrastructure/webkit"
@@ -90,13 +91,15 @@ func BuildEngine(input EngineInput) (port.Engine, error) {
9091 DefaultConfigPayload: defaultConfigPayload,
9192 ContextMenuBuilder: contextMenuBuilder,
9293 ContextMenuExecutorFactory: contextMenuExecutorFactory,
94+ ContextMenuRenderer: gtkmenu.NewRenderer(nil),
9395 Clipboard: clipboardinfra.New(),
9496 ImageDataResolver: webkit.NewContextMenuResolver(),
9597 }
9698 audioFactory := audiofactory.NewAudioOutputFactory()
9799 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,
100103 }, cefCfg, audioFactory, deps)
101104 default:
102105 return nil, fmt.Errorf("unknown engine type: %q", engineType)
M internal/bootstrap/session.go +5 -1
@@ -118,7 +118,11 @@ func StartBrowserSession(
118118 logger.Info().Str("log_file", logPath).Msg("session log file")
119119 }
120120
121- sessionCtx := corelogging.WithContext(context.Background(), logger)
121+ sessionCtx := corelogging.WithSessionMetadata(
122+ corelogging.WithContext(context.Background(), logger),
123+ string(session.ID),
124+ logPath,
125+ )
122126
123127 bs := &BrowserSession{
124128 Session: session,
M internal/infrastructure/cef/app.go +122 -2
@@ -1,17 +1,21 @@
11 package cef
22
33 import (
4+ "os"
45 "strings"
56
67 purecef "github.com/bnema/purego-cef/cef"
8+ cef2gtk "github.com/bnema/purego-cef2gtk"
79
810 "github.com/bnema/dumber/internal/logging"
911 )
1012
1113 const (
1214 dumbSchemeName = "dumb"
15+ chromiumEnableFeaturesSwitch = "enable-features"
1316 chromiumDisableFeaturesSwitch = "disable-features"
1417 chromiumDisableBlinkFeaturesSwitch = "disable-blink-features"
18+ chromiumRenderNodeOverrideSwitch = "render-node-override"
1519 )
1620
1721 // Chromium's core Blink runtime flag for the Web Authentication API is
@@ -41,6 +45,11 @@ func configureCommandLine(commandLine purecef.CommandLine) {
4145 return
4246 }
4347
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+
4453 // Enable Chromium's built-in smooth scrolling animation — without this,
4554 // mouse wheel scroll jumps in discrete steps with no momentum/easing.
4655 commandLine.AppendSwitch("enable-smooth-scrolling")
@@ -50,9 +59,116 @@ func configureCommandLine(commandLine purecef.CommandLine) {
5059 // Chromium blocks them, showing an infinite spinner.
5160 commandLine.AppendSwitchWithValue("autoplay-policy", "no-user-gesture-required")
5261
62+ configureHardwareVideoDecode(commandLine)
63+ configureEnvChromiumFlags(commandLine)
64+ configureRenderNodeOverride(commandLine)
5365 configureWebAuthnFeaturePolicy(commandLine)
5466 }
5567
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+
56172 func configureWebAuthnFeaturePolicy(commandLine purecef.CommandLine) {
57173 if commandLine == nil {
58174 return
@@ -191,6 +307,7 @@ func (a *dumberApp) OnBeforeCommandLineProcessing(processType string, commandLin
191307 }
192308 }
193309
310+ renderNodeOverride := commandLine.GetSwitchValue(chromiumRenderNodeOverrideSwitch)
194311 cmdline := commandLine.GetCommandLineString()
195312 if len(cmdline) > maxCmdLineLogLen {
196313 runes := []rune(cmdline)
@@ -200,6 +317,7 @@ func (a *dumberApp) OnBeforeCommandLineProcessing(processType string, commandLin
200317 }
201318 log.Debug().
202319 Str("process_type", processType).
320+ Str("render_node_override", renderNodeOverride).
203321 Str("command_line", cmdline).
204322 Msg("cef: OnBeforeCommandLineProcessing")
205323 }
@@ -240,15 +358,17 @@ func (h *dumberBPH) OnBeforeChildProcessLaunch(commandLine purecef.CommandLine)
240358 commandLineString := ""
241359 useAngle := ""
242360 ozonePlatform := ""
361+ renderNodeOverride := ""
243362 if commandLine != nil {
244- configureWebAuthnFeaturePolicy(commandLine)
363+ configureCommandLine(commandLine)
245364 appendSwitchIfMissing(commandLine, "no-zygote")
246365 processType = commandLine.GetSwitchValue("type")
247366 commandLineString = commandLine.GetCommandLineString()
248367 useAngle = commandLine.GetSwitchValue("use-angle")
249368 ozonePlatform = commandLine.GetSwitchValue("ozone-platform")
369+ renderNodeOverride = commandLine.GetSwitchValue(chromiumRenderNodeOverrideSwitch)
250370 }
251- h.engine.recordChildProcessLaunch(processType, useAngle, ozonePlatform, commandLineString)
371+ h.engine.recordChildProcessLaunch(processType, useAngle, ozonePlatform, renderNodeOverride, commandLineString)
252372 }
253373
254374 func appendSwitchIfMissing(commandLine purecef.CommandLine, name string) {
M internal/infrastructure/cef/app_test.go +177 -0
@@ -261,6 +261,126 @@ func TestConfigureCommandLine_WebAuthnUnsafeOverrideRemovesSwitchWhenOnlyWebAuth
261261 }
262262 }
263263
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+
264384 func TestAppendUniqueCommaSeparatedSwitchValues(t *testing.T) {
265385 t.Run("trims whitespace and skips empty existing values", func(t *testing.T) {
266386 commandLine := newMutableCommandLineStub()
@@ -313,6 +433,63 @@ func TestDumberBPH_OnBeforeChildProcessLaunch_AppendsNoZygote(t *testing.T) {
313433 }
314434 }
315435
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+
316493 func TestDumberBPH_OnBeforeChildProcessLaunch_DisablesWebAuthnByDefault(t *testing.T) {
317494 t.Setenv(cefEnableWebAuthnUnsafeEnvVar, "")
318495
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 (
1212 "github.com/bnema/puregotk/v4/gtk"
1313 )
1414
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+
1522 // ===========================================================================
1623 // ContextMenuHandler (implemented on handlerSet)
1724 // ===========================================================================
@@ -19,6 +26,7 @@ import (
1926 func (h *handlerSet) OnBeforeContextMenu(_ purecef.Browser, _ purecef.Frame, _ purecef.ContextMenuParams, _ purecef.MenuModel) {
2027 }
2128
29+//nolint:gocyclo // CEF callback coordinates menu extraction, rendering, and native fallback in one synchronous call.
2230 func (h *handlerSet) RunContextMenu(
2331 _ purecef.Browser, _ purecef.Frame,
2432 params purecef.ContextMenuParams, model purecef.MenuModel,
@@ -67,31 +75,42 @@ func (h *handlerSet) RunContextMenu(
6775 commandIDByAction[item.Action] = cmdID
6876 }
6977 }
70- if h.wv == nil || h.wv.pipeline == nil || h.wv.pipeline.glArea == nil {
78+ if h.wv == nil || h.wv.viewBridge == nil {
7179 return 0
7280 }
7381
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())
7783 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 {
9287 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+ })
95114 return 1
96115 }
97116
@@ -119,23 +138,23 @@ func contextMenuAnchorPosition(params purecef.ContextMenuParams, scale int32) (i
119138
120139 func logContextMenuPopupRequest(
121140 h *handlerSet,
122- glArea *gtk.GLArea,
141+ widget *gtk.Widget,
123142 params purecef.ContextMenuParams,
124143 x, y int32,
125144 ) {
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 {
127146 return
128147 }
129148 rawX, rawY := contextMenuRawPosition(params)
130- parent := glArea.GetParent()
149+ parent := widget.GetParent()
131150 logging.FromContext(h.wv.ctx).Debug().
132151 Int32("raw_x", rawX).
133152 Int32("raw_y", rawY).
134153 Int32("popup_x", x).
135154 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()).
139158 Int("parent_width", cefWidgetAllocatedWidth(parent)).
140159 Int("parent_height", cefWidgetAllocatedHeight(parent)).
141160 Msg("cef: context menu popup request")
M internal/infrastructure/cef/engine.go +49 -9
@@ -4,6 +4,7 @@ import (
44 "context"
55 "crypto/subtle"
66 "fmt"
7+ "os"
78 "sync"
89 "sync/atomic"
910 "time"
@@ -23,10 +24,11 @@ var _ port.AlreadyRunningAppRelaunchHandlerSetter = (*Engine)(nil)
2324 // Engine implements port.Engine for the CEF browser backend.
2425 // It manages the CEF lifecycle and provides access to all engine subsystems.
2526 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
3032
3133 messageRouter *MessageRouter
3234 schemeHandler *dumbSchemeHandler
@@ -38,6 +40,7 @@ type Engine struct {
3840 defaultConfigPayload func() ([]byte, error)
3941 ctxMenuBuilder port.ContextMenuBuilder
4042 ctxMenuExecutorFactory port.ContextMenuActionExecutorFactory
43+ ctxMenuRenderer ContextMenuRenderer
4144 clipboard port.Clipboard
4245 clipboardTextOrchestrator port.ClipboardTextOrchestrator
4346 onClipboardCopied func(textLen int)
@@ -56,6 +59,12 @@ type Engine struct {
5659 // during shutdown, replacing the busy-wait poll in closeActiveWebViews.
5760 shutdownNotify chan struct{}
5861
62+ // Cross-layer render diagnostics.
63+ cefHeartbeatStop chan struct{}
64+ cefHeartbeatDone chan struct{}
65+ cefUIHeartbeat cefThreadHeartbeatState
66+ cefIOHeartbeat cefThreadHeartbeatState
67+
5968 // Diagnostic counters.
6069 contextInitializedCount atomic.Uint64
6170 childLaunchRenderer atomic.Uint64
@@ -69,13 +78,19 @@ type Engine struct {
6978 browserCreateLastWidth atomic.Int32
7079 browserCreateLastHeight atomic.Int32
7180 browserCreateComplete atomic.Bool
72-
7381 }
7482
7583 func (e *Engine) Factory() port.WebViewFactory {
7684 return &webViewFactoryAdapter{factory: e.factory}
7785 }
7886
87+func (e *Engine) contextMenuRenderer() ContextMenuRenderer {
88+ if e == nil {
89+ return nil
90+ }
91+ return e.ctxMenuRenderer
92+}
93+
7994 func (e *Engine) Pool() port.WebViewPool {
8095 return &webViewPoolAdapter{pool: e.pool, ctx: e.ctx}
8196 }
@@ -197,6 +212,7 @@ func (e *Engine) Close() error {
197212
198213 e.closeActiveWebViews()
199214 e.pool.Close()
215+ e.stopCEFHeartbeat()
200216
201217 if activeAfter := e.activeWebViewCount(); activeAfter > 0 {
202218 log.Warn().
@@ -209,7 +225,11 @@ func (e *Engine) Close() error {
209225 return nil
210226 }
211227
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
213233
214234 func (e *Engine) activeWebViewCount() int {
215235 return int(e.activeCount.Load())
@@ -246,6 +266,7 @@ func (e *Engine) closeActiveWebViews() {
246266 // Check immediately in case all webviews closed synchronously.
247267 if e.activeWebViewCount() == 0 {
248268 log.Debug().Msg("cef: all active webviews closed before shutdown")
269+ e.destroyClosedWebViewBridges(webViews)
249270 return
250271 }
251272
@@ -259,6 +280,15 @@ func (e *Engine) closeActiveWebViews() {
259280 Str("timeout", cefShutdownWaitTimeout.String()).
260281 Msg("cef: timed out waiting for OnBeforeClose before shutdown")
261282 }
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+ }
262292 }
263293
264294 // RegisterHandlers registers all WebUI message bridge handlers with the message router.
@@ -369,6 +399,16 @@ func (e *Engine) notifyClipboardCopied(text string) {
369399 // SetHandlerContext sets the base context for message handler dispatch.
370400 func (e *Engine) SetHandlerContext(ctx context.Context) {
371401 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+ }
372412 if e.messageRouter != nil {
373413 e.messageRouter.SetBaseContext(ctx)
374414 }
@@ -381,7 +421,7 @@ func (e *Engine) recordContextInitialized() {
381421 Msg("cef: OnContextInitialized")
382422 }
383423
384-func (e *Engine) recordChildProcessLaunch(processType, useAngle, ozonePlatform, commandLine string) {
424+func (e *Engine) recordChildProcessLaunch(processType, useAngle, ozonePlatform, renderNodeOverride, commandLine string) {
385425 switch processType {
386426 case "renderer":
387427 e.childLaunchRenderer.Add(1)
@@ -395,7 +435,8 @@ func (e *Engine) recordChildProcessLaunch(processType, useAngle, ozonePlatform,
395435 Str("process_type", processType).
396436 Str("use_angle", useAngle).
397437 Str("ozone_platform", ozonePlatform).
398- Str("command_line", commandLine).
438+ Str("render_node_override", renderNodeOverride).
439+ Strs("command_line_flags", safeChromiumCmdlineFlags(commandLine)).
399440 Uint64("renderer_launches", e.childLaunchRenderer.Load()).
400441 Uint64("gpu_launches", e.childLaunchGPU.Load()).
401442 Uint64("other_launches", e.childLaunchOther.Load()).
@@ -475,4 +516,3 @@ func (e *Engine) currentDownloadHandler() *downloadHandler {
475516 defer e.downloadMu.RUnlock()
476517 return e.downloadHandler
477518 }
478-
M internal/infrastructure/cef/engine_init.go +49 -13
@@ -17,17 +17,18 @@ import (
1717
1818 "github.com/bnema/dumber/assets"
1919 "github.com/bnema/dumber/internal/application/port"
20- "github.com/bnema/dumber/internal/infrastructure/config"
2120 "github.com/bnema/dumber/internal/logging"
2221 )
2322
2423 const puregoCEFInitTraceEnvVar = "PUREGO_CEF_INIT_TRACE"
2524 const CEFRootCachePathEnvVar = "DUMBER_CEF_ROOT_CACHE_PATH"
25+const defaultCEFWindowlessFrameRate = 60
2626
2727 // RuntimePaths contains the concrete filesystem paths the CEF adapter needs.
2828 type RuntimePaths struct {
29- StateRoot string
30- LogFile string
29+ StateRoot string
30+ LogFile string
31+ ProfileLogDir string
3132 }
3233
3334 // NewEngine initializes the CEF runtime and returns a ready-to-use Engine.
@@ -42,7 +43,7 @@ func NewEngine(
4243 logger := logging.FromContext(ctx)
4344 stateRoot := resolvedStateRoot(paths.StateRoot, opts)
4445 cleanStaleSingletonLocks(logger, stateRoot)
45- windowlessFrameRate := config.CEFEngineConfig{WindowlessFrameRate: cfg.WindowlessFrameRate}.CEFWindowlessFrameRate()
46+ windowlessFrameRate := normalizedWindowlessFrameRate(cfg.WindowlessFrameRate)
4647
4748 settings, err := prepareCEFSettings(opts, paths, cfg, logger)
4849 if err != nil {
@@ -56,12 +57,15 @@ func NewEngine(
5657
5758 eng := &Engine{
5859 ctx: ctx,
60+ profileLogDir: paths.ProfileLogDir,
61+ runtimeCEFDir: settings.CEFDir,
5962 registerHandlers: deps.RegisterHandlers,
6063 registerAccentHandlers: deps.RegisterAccentHandlers,
6164 currentConfigPayload: deps.CurrentConfigPayload,
6265 defaultConfigPayload: deps.DefaultConfigPayload,
6366 ctxMenuBuilder: deps.ContextMenuBuilder,
6467 ctxMenuExecutorFactory: deps.ContextMenuExecutorFactory,
68+ ctxMenuRenderer: deps.ContextMenuRenderer,
6569 clipboard: deps.Clipboard,
6670 resolver: deps.ImageDataResolver,
6771 }
@@ -90,6 +94,13 @@ func NewEngine(
9094 )
9195 }
9296
97+func normalizedWindowlessFrameRate(frameRate int32) int32 {
98+ if frameRate > 0 {
99+ return frameRate
100+ }
101+ return defaultCEFWindowlessFrameRate
102+}
103+
93104 func resolvedStateRoot(defaultStateRoot string, opts port.EngineOptions) string {
94105 if root := os.Getenv(CEFRootCachePathEnvVar); root != "" {
95106 return root
@@ -151,28 +162,52 @@ func prepareCEFSettings(
151162 // and browser-process callbacks (OnBeforeCommandLineProcessing, etc.).
152163 func initializeCEF(eng *Engine, settings purecef.Settings, logger *zerolog.Logger) error {
153164 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")
154169 logger.Debug().Msg("cef: calling InitWithApp")
155170 if err := purecef.InitWithApp(settings, app); err != nil {
156171 return fmt.Errorf("cef.InitWithApp: %w", err)
157172 }
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+ }
158178 logger.Debug().Msg("cef: InitWithApp returned OK")
159179 return nil
160180 }
161181
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.
163205 func wireEngine(
164206 ctx context.Context, eng *Engine,
165207 windowlessFrameRate int32, audioFactory port.AudioOutputFactory, logger *zerolog.Logger,
166208 currentConfigPayload func() ([]byte, error), defaultConfigPayload func() ([]byte, error),
167209 ) (*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{
176211 scale: detectHiDPIScale(logger),
177212 windowlessFrameRate: windowlessFrameRate,
178213 audioOutputFactory: audioFactory,
@@ -193,6 +228,7 @@ func wireEngine(
193228 }
194229 eng.messageRouter = messageRouter
195230 eng.schemeHandler = schemeHandler
231+ eng.startCEFHeartbeat()
196232
197233 registerEngineSchemeFactories(logger, purecef.NewSchemeHandlerFactory(schemeHandler))
198234 return eng, nil
M internal/infrastructure/cef/engine_init_test.go +12 -0
@@ -46,6 +46,18 @@ func TestPrepareCEFSettings_UsesResolvedProfilePaths(t *testing.T) {
4646 require.Empty(t, settings.BrowserSubprocessPath)
4747 }
4848
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+
4961 func TestPrepareCEFSettings_RejectsNonDefaultCookiePolicy(t *testing.T) {
5062 logger := zerolog.Nop()
5163 profile := testCEFDevProfile(t)
M internal/infrastructure/cef/env.go +13 -0
@@ -8,6 +8,11 @@ import (
88 const (
99 cefExternalBeginFrameEnvVar = "DUMBER_CEF_EXTERNAL_BEGIN_FRAME"
1010 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"
1116 )
1217
1318 // envBoolEnabled returns true when the given environment variable is set
@@ -28,3 +33,11 @@ func externalBeginFrameEnabled() bool {
2833 func cefWebAuthnUnsafeEnabled() bool {
2934 return envBoolEnabled(cefEnableWebAuthnUnsafeEnvVar)
3035 }
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 (
77 "time"
88
99 purecef "github.com/bnema/purego-cef/cef"
10+ cef2gtk "github.com/bnema/purego-cef2gtk"
1011
1112 "github.com/bnema/dumber/internal/application/port"
1213 "github.com/bnema/dumber/internal/logging"
@@ -16,11 +17,10 @@ import (
1617 var _ port.WebViewFactory = (*WebViewFactory)(nil)
1718
1819 // 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).
2122 type WebViewFactory struct {
2223 engine *Engine
23- gl *glLoader
2424 nextID atomic.Uint64
2525 scale int32
2626 windowlessFrameRate int32
@@ -47,8 +47,8 @@ const (
4747 )
4848
4949 // 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 {
5252 if opts.scale < 1 {
5353 opts.scale = 1
5454 }
@@ -57,10 +57,9 @@ func newWebViewFactory(engine *Engine, gl *glLoader, opts webViewFactoryOptions)
5757 }
5858 return &WebViewFactory{
5959 engine: engine,
60- gl: gl,
6160 scale: opts.scale,
6261 windowlessFrameRate: opts.windowlessFrameRate,
63- audioOutputFactory: opts.audioOutputFactory,
62+ audioOutputFactory: opts.audioOutputFactory,
6463 }
6564 }
6665
@@ -75,11 +74,14 @@ func (f *WebViewFactory) setDefaultBackgroundColor(r, g, b, a float64) {
7574 // asynchronously; the returned WebView is usable immediately but navigation
7675 // will fail with errNoBrowser until OnAfterCreated fires.
7776 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+ }
7981
80- // Configure WindowInfo for off-screen rendering (OSR).
82+ // Delegate windowless/shared-texture setup to the GTK bridge.
8183 windowInfo := purecef.NewWindowInfo()
82- windowInfo.WindowlessRenderingEnabled = 1
84+ cef2gtk.ConfigureWindowInfo(&windowInfo, cef2gtk.WindowInfoOptions{})
8385 if externalBeginFrameEnabled() {
8486 windowInfo.ExternalBeginFrameEnabled = 1
8587 }
@@ -92,125 +94,110 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) {
9294 settings.BackgroundColor = bg
9395 }
9496
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.
9698 // CEF requires GetViewRect to return a non-empty rect, but the GL area
9799 // 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)
99101
100102 return wv, nil
101103 }
102104
103-func (f *WebViewFactory) newWebView(ctx context.Context) *WebView {
105+func (f *WebViewFactory) newWebView(ctx context.Context) (*WebView, error) {
104106 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+ }
106111
107112 wv := &WebView{
108113 id: id,
109114 ctx: ctx,
110115 engine: f.engine,
111- pipeline: pipeline,
116+ viewBridge: viewBridge,
112117 audioOutputFactory: f.audioOutputFactory,
113- resizeReconciler: newResizeReconciler(ctx, id),
114118 windowlessFrameRate: f.windowlessFrameRate,
115119 backgroundColor: f.bgColor.Load(),
116120 }
117121
118- handlers := &handlerSet{
119- wv: wv,
120- }
122+ handlers := &handlerSet{wv: wv}
121123 wv.handlers = handlers
122124 wv.findCtrl = newFindController()
123125
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-
149126 // Build a CEF client backed by our handlerSet.
150127 // Store on WebView to prevent GC collection before CEF AddRef's it.
151128 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
153136 }
154137
155138 func (f *WebViewFactory) configureInitialBrowserCreation(
156- ctx context.Context, wv *WebView, pipeline *renderPipeline, client purecef.RawClient,
139+ ctx context.Context, wv *WebView, client purecef.RawClient,
157140 windowInfo *purecef.WindowInfo, settings *purecef.BrowserSettings,
141+ onFirstResize func(w, h int32),
158142 ) {
159143 wv.pendingCreate = &pendingBrowserCreate{
160144 windowInfo: windowInfo,
161145 client: client,
162146 settings: settings,
163147 }
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+ }
168153 }
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().
178193 Uint64("webview_id", uint64(wv.id)).
179194 Int32("resize_width", w).
180195 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+ })
214201 }
215202
216203 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
319306 return nil, fmt.Errorf("parent webview %d is destroyed", parentID)
320307 }
321308
322- popupWV := f.newWebView(ctx)
309+ popupWV, err := f.newWebView(ctx)
310+ if err != nil {
311+ return nil, err
312+ }
323313 popupWV.markNativePopupCandidate(parent)
324314
325315 windowInfo := purecef.NewWindowInfo()
326- windowInfo.WindowlessRenderingEnabled = 1
316+ cef2gtk.ConfigureWindowInfo(&windowInfo, cef2gtk.WindowInfoOptions{})
327317 if externalBeginFrameEnabled() {
328318 windowInfo.ExternalBeginFrameEnabled = 1
329319 }
@@ -335,8 +325,7 @@ func (f *WebViewFactory) CreateRelated(ctx context.Context, parentID port.WebVie
335325 settings.BackgroundColor = bg
336326 }
337327
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) {
340329 log := logging.FromContext(ctx)
341330 if popupWV.awaitsNativePopupAttachment() {
342331 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
357346 }
358347 log.Debug().Int32("w", w).Int32("h", h).Msg("cef: popup shell first resize, creating browser directly")
359348 f.postPendingBrowserCreate(ctx, popupWV, w, h)
360- }
349+ })
361350 return popupWV, nil
362351 }
M internal/infrastructure/cef/factory_resize_test.go +13 -0
@@ -6,6 +6,7 @@ import (
66 "time"
77
88 purecef "github.com/bnema/purego-cef/cef"
9+ cef2gtk "github.com/bnema/purego-cef2gtk"
910 "github.com/stretchr/testify/require"
1011 )
1112
@@ -29,6 +30,15 @@ func TestNotifyBrowserResize_CallsWasResizedThenInvalidate(t *testing.T) {
2930 require.Equal(t, []string{"WasResized", "Invalidate"}, host.calls)
3031 }
3132
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+
3242 func TestPostPendingBrowserCreate_RetriesWhenPostingTaskFails(t *testing.T) {
3343 oldNewTask := cefNewTask
3444 oldPostTask := cefPostTask
@@ -75,10 +85,13 @@ func TestPostPendingBrowserCreate_RetriesWhenPostingTaskFails(t *testing.T) {
7585 require.NotNil(t, windowInfo)
7686 require.NotNil(t, settings)
7787 require.Equal(t, "about:blank", url)
88+ require.Equal(t, int32(1), windowInfo.WindowlessRenderingEnabled)
89+ require.Equal(t, int32(1), windowInfo.SharedTextureEnabled)
7890 return 1
7991 }
8092
8193 windowInfo := purecef.NewWindowInfo()
94+ cef2gtk.ConfigureWindowInfo(&windowInfo, cef2gtk.WindowInfoOptions{})
8295 settings := purecef.NewBrowserSettings()
8396 wv := &WebView{
8497 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 {
3434 DefaultConfigPayload func() ([]byte, error)
3535 ContextMenuBuilder port.ContextMenuBuilder
3636 ContextMenuExecutorFactory port.ContextMenuActionExecutorFactory
37+ ContextMenuRenderer ContextMenuRenderer
3738 Clipboard port.Clipboard
3839 ImageDataResolver port.ImageDataResolver
3940 }
M internal/infrastructure/cef/webview.go +144 -51
@@ -14,6 +14,7 @@ import (
1414 "time"
1515
1616 purecef "github.com/bnema/purego-cef/cef"
17+ cef2gtk "github.com/bnema/purego-cef2gtk"
1718 "github.com/bnema/puregotk/v4/glib"
1819 "github.com/bnema/puregotk/v4/gtk"
1920
@@ -64,20 +65,30 @@ var (
6465 cefScheduleAfter = func(delay time.Duration, fn func()) { time.AfterFunc(delay, fn) }
6566 )
6667
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.
6971 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()
8192
8293 // beginFrameTick drives CEF external BeginFrame requests while the GTK
8394 // widget is visible. Access is guarded by mu.
@@ -136,6 +147,7 @@ type WebView struct {
136147 isLoading bool
137148 selectedText string
138149 focusedEditable bool
150+ inputAttached bool
139151 bridgeNonce string
140152 selectionDebounceTimer stoppableTimer
141153 selectionDebounceSeq uint64
@@ -512,9 +524,9 @@ func (wv *WebView) SetOnReadyToShow(fn func()) {
512524 }
513525 wv.mu.Lock()
514526 wv.popupReadyToShow = fn
515- alreadyReady := wv.browser != nil && !wv.popupReadyShown && fn != nil
527+ shouldTryReady := wv.browser != nil && fn != nil
516528 wv.mu.Unlock()
517- if alreadyReady {
529+ if shouldTryReady {
518530 wv.fireReadyToShow()
519531 }
520532 }
@@ -595,7 +607,7 @@ func (wv *WebView) fireReadyToShow() {
595607 return
596608 }
597609 wv.mu.Lock()
598- if wv.popupReadyShown || wv.popupReadyToShow == nil {
610+ if wv.popupReadyShown || wv.popupReadyToShow == nil || !wv.inputAttached {
599611 wv.mu.Unlock()
600612 return
601613 }
@@ -605,6 +617,16 @@ func (wv *WebView) fireReadyToShow() {
605617 wv.runOnGTK(fn)
606618 }
607619
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+
608630 func (wv *WebView) markNativePopupCandidate(parent *WebView) {
609631 if wv == nil {
610632 return
@@ -999,6 +1021,7 @@ func (wv *WebView) Destroy() {
9991021 wv.openerMessageCallbacks = nil
10001022 wv.openerNavigationCallbacks = nil
10011023 wv.popupReadyToShow = nil
1024+ wv.inputAttached = false
10021025 wv.pendingNativePopups = nil
10031026 wv.nativePopupParent = nil
10041027 wv.nativePopupCandidate = false
@@ -1008,10 +1031,8 @@ func (wv *WebView) Destroy() {
10081031 wv.popupOpenerBridgeParentURI = ""
10091032 wv.mu.Unlock()
10101033 wv.stopNativePopupFallbackTimer()
1034+ wv.stopRenderStallWatchdog()
10111035 wv.cancelSelectionDebounce()
1012- if wv.resizeReconciler != nil {
1013- wv.resizeReconciler.stop()
1014- }
10151036 wv.closeAudioStream()
10161037 wv.scheduleStopBeginFrameLoop()
10171038 wv.mu.RLock()
@@ -1021,22 +1042,66 @@ func (wv *WebView) Destroy() {
10211042 host.CloseBrowser(1)
10221043 } else {
10231044 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
10241061 }
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
10271070 }
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
10281087 }
10291088
10301089 // ---------------------------------------------------------------------------
10311090 // NativeWidgetProvider
10321091 // ---------------------------------------------------------------------------
10331092
1034-// NativeWidget returns the uintptr for embedding the GLArea into GTK.
1093+// NativeWidget returns the uintptr for embedding the bridge GTK widget.
10351094 func (wv *WebView) NativeWidget() uintptr {
1036- if wv.pipeline == nil || wv.pipeline.glArea == nil {
1095+ if wv.viewBridge == nil {
10371096 return 0
10381097 }
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
10401105 }
10411106
10421107 // ---------------------------------------------------------------------------
@@ -1263,6 +1328,35 @@ func (wv *WebView) selectedTextSnapshot() string {
12631328 return wv.selectedText
12641329 }
12651330
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+
12661360 func (wv *WebView) scheduleLoadWatchdogs(loadSeq uint64) {
12671361 for _, delay := range cefLoadWatchdogDelays {
12681362 d := delay
@@ -1294,9 +1388,18 @@ func (wv *WebView) logLoadWatchdog(loadSeq uint64, delay time.Duration) {
12941388 return
12951389 }
12961390
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()
13001403 }
13011404
13021405 now := time.Now()
@@ -1311,24 +1414,14 @@ func (wv *WebView) logLoadWatchdog(loadSeq uint64, delay time.Duration) {
13111414 Str("title", title).
13121415 Float64("progress", progress).
13131416 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).
13321425 Msg("cef: loading watchdog")
13331426 }
13341427
@@ -1524,7 +1617,7 @@ func (wv *WebView) scheduleStopBeginFrameLoop() {
15241617 }
15251618
15261619 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 {
15281621 return
15291622 }
15301623
@@ -1534,7 +1627,7 @@ func (wv *WebView) startBeginFrameLoop() {
15341627 return
15351628 }
15361629
1537- glArea := wv.pipeline.glArea
1630+ widget := wv.viewBridge.Widget()
15381631 cb := new(gtk.TickCallback)
15391632 *cb = func(_, _, _ uintptr) bool {
15401633 if wv.destroyed.Load() {
@@ -1550,7 +1643,7 @@ func (wv *WebView) startBeginFrameLoop() {
15501643 return true
15511644 }
15521645 wv.beginFrameTick = cb
1553- wv.beginFrameTickID = glArea.AddTickCallback(cb, 0, nil)
1646+ wv.beginFrameTickID = widget.AddTickCallback(cb, 0, nil)
15541647 host := wv.host
15551648 wv.mu.Unlock()
15561649
@@ -1560,7 +1653,7 @@ func (wv *WebView) startBeginFrameLoop() {
15601653 }
15611654
15621655 func (wv *WebView) stopBeginFrameLoop() {
1563- if wv.pipeline == nil || wv.pipeline.glArea == nil {
1656+ if wv.viewBridge == nil || wv.viewBridge.Widget() == nil {
15641657 return
15651658 }
15661659
@@ -1568,11 +1661,11 @@ func (wv *WebView) stopBeginFrameLoop() {
15681661 tickID := wv.beginFrameTickID
15691662 wv.beginFrameTickID = 0
15701663 wv.beginFrameTick = nil
1571- glArea := wv.pipeline.glArea
1664+ widget := wv.viewBridge.Widget()
15721665 wv.mu.Unlock()
15731666
15741667 if tickID != 0 {
1575- glArea.RemoveTickCallback(tickID)
1668+ widget.RemoveTickCallback(tickID)
15761669 }
15771670 }
15781671
M internal/infrastructure/cef/webview_audio_test.go +3 -5
@@ -32,7 +32,7 @@ func TestEngine_WiresAudioOutputFactory(t *testing.T) {
3232 assert.Equal(t, audioFactory, opts.audioOutputFactory)
3333
3434 // Verify the factory is passed through
35- factory := newWebViewFactory(&Engine{}, &glLoader{}, opts)
35+ factory := newWebViewFactory(&Engine{}, opts)
3636 require.NotNil(t, factory.audioOutputFactory)
3737 }
3838
@@ -47,7 +47,7 @@ func TestWebViewFactory_AudioFactoryIsOptional(t *testing.T) {
4747 }
4848
4949 // Act
50- factory := newWebViewFactory(&Engine{}, &glLoader{}, opts)
50+ factory := newWebViewFactory(&Engine{}, opts)
5151
5252 // Assert
5353 require.NotNil(t, factory)
@@ -423,7 +423,6 @@ func TestWebView_Destroy_ClosesAudioStream(t *testing.T) {
423423 wv := &WebView{
424424 ctx: context.Background(),
425425 activeAudioStream: mockStream,
426- pipeline: &renderPipeline{},
427426 }
428427 wv.audioPlaying.Store(true)
429428
@@ -439,8 +438,7 @@ func TestWebView_Destroy_ClosesAudioStream(t *testing.T) {
439438 // handles the case where no audio stream is active.
440439 func TestWebView_Destroy_NoAudioStream_DoesNotPanic(t *testing.T) {
441440 wv := &WebView{
442- ctx: context.Background(),
443- pipeline: &renderPipeline{},
441+ ctx: context.Background(),
444442 }
445443
446444 assert.NotPanics(t, func() {
M internal/infrastructure/cef/webview_handlers.go +99 -312
@@ -3,8 +3,10 @@ package cef
33 import (
44 "context"
55 "strings"
6+ "sync"
67
78 purecef "github.com/bnema/purego-cef/cef"
9+ cef2gtk "github.com/bnema/purego-cef2gtk"
810
911 "github.com/bnema/dumber/internal/application/port"
1012 downloadutil "github.com/bnema/dumber/internal/domain/download"
@@ -21,13 +23,14 @@ const (
2123 // owning WebView. A single struct is used so that the Client's Get*Handler
2224 // methods can return the same receiver, avoiding extra allocations.
2325 type handlerSet struct {
24- wv *WebView
26+ wv *WebView
27+ renderHandlerOnce sync.Once
28+ renderHandler purecef.RenderHandler
2529 }
2630
2731 // Compile-time interface checks.
2832 var (
2933 _ purecef.Client = (*handlerSet)(nil)
30- _ purecef.RenderHandler = (*handlerSet)(nil)
3134 _ purecef.DisplayHandler = (*handlerSet)(nil)
3235 _ purecef.LoadHandler = (*handlerSet)(nil)
3336 _ purecef.LifeSpanHandler = (*handlerSet)(nil)
@@ -69,8 +72,16 @@ func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler { return n
6972 func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler { return h }
7073 func (h *handlerSet) GetLoadHandler() purecef.LoadHandler { return h }
7174 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 }
7485
7586 func (h *handlerSet) OnProcessMessageReceived(
7687 browser purecef.Browser,
@@ -136,291 +147,11 @@ func (h *handlerSet) OnProcessMessageReceived(
136147 }
137148
138149 // ===========================================================================
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.
140153 // ===========================================================================
141154
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-
424155 // ===========================================================================
425156 // DisplayHandler (13 methods)
426157 // ===========================================================================
@@ -528,9 +259,14 @@ func (h *handlerSet) OnCursorChange(
528259 cursorType purecef.CursorType,
529260 _ *purecef.CursorInfo,
530261 ) int32 {
262+ if h == nil || h.wv == nil {
263+ return 0
264+ }
531265 name := cefCursorToGDKName(cursorType)
532266 h.wv.runOnGTK(func() {
533- h.wv.pipeline.glArea.SetCursorFromName(&name)
267+ if h.wv.viewBridge != nil {
268+ h.wv.viewBridge.SetCursorFromName(name)
269+ }
534270 })
535271 return 1 // handled
536272 }
@@ -565,13 +301,17 @@ func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangobac
565301 Msg("cef: OnLoadingStateChange")
566302 }
567303
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+ })
575315 }
576316
577317 h.wv.mu.RLock()
@@ -783,13 +523,13 @@ func (h *handlerSet) attachAfterCreatedBrowser(
783523 ) afterCreatedState {
784524 state := afterCreatedState{}
785525 h.wv.mu.Lock()
786- defer h.wv.mu.Unlock()
787526
788527 if existing := h.wv.browser; existing != nil {
789528 existingID := existing.GetIdentifier()
790529 if existingID != 0 && existingID != browserID {
791530 state.closeDuplicate = true
792531 state.duplicateBrowserID = existingID
532+ h.wv.mu.Unlock()
793533 return state
794534 }
795535 }
@@ -797,7 +537,9 @@ func (h *handlerSet) attachAfterCreatedBrowser(
797537 h.wv.browser = browser
798538 h.wv.host = host
799539 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
801543 if h.wv.findCtrl != nil {
802544 h.wv.findCtrl.setHost(host)
803545 }
@@ -806,23 +548,58 @@ func (h *handlerSet) attachAfterCreatedBrowser(
806548 h.wv.nativePopupParent = nil
807549 h.wv.nativePopupID = 0
808550 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+ }
809584
810585 // Mark browser as visible — CEF OSR starts in hidden state and suppresses
811586 // painting/caret updates until explicitly told the browser is shown.
812587 host.WasHidden(0)
813- state.shouldSyncFocus = h.afterCreatedShouldSyncFocus()
814- state.hasPendingNavigation = strings.TrimSpace(h.wv.pendingURI) != ""
815588 return state
816589 }
817590
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
821594 }
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
824601 }
825- return h.wv.input.glArea != nil && h.wv.input.glArea.HasFocus()
602+ wv.runCloseCallbacks()
826603 }
827604
828605 func (h *handlerSet) finishAfterCreated(
@@ -836,12 +613,10 @@ func (h *handlerSet) finishAfterCreated(
836613 h.wv.engine.bindBrowserWebView(browser, h.wv)
837614 }
838615 h.wv.stopNativePopupFallbackTimer()
839- if state.shouldSyncFocus {
840- syncWindowlessBrowserFocus(host)
841- } else {
616+ if state.inputAttached {
842617 // Request an initial OSR frame even before the first real navigation
843618 // 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.
845620 host.Invalidate(purecef.PaintElementTypePetView)
846621 }
847622 if state.hasPendingNavigation {
@@ -855,7 +630,7 @@ func (h *handlerSet) finishAfterCreated(
855630 }
856631
857632 type afterCreatedState struct {
858- shouldSyncFocus bool
633+ inputAttached bool
859634 hasPendingNavigation bool
860635 nativePopupParent *WebView
861636 nativePopupID int32
@@ -880,12 +655,24 @@ func (h *handlerSet) OnBeforeClose(browser purecef.Browser) {
880655 h.wv.mu.Lock()
881656 h.wv.browser = nil
882657 h.wv.host = nil
883- h.wv.input.setHost(nil)
658+ h.wv.inputAttached = false
884659 if h.wv.findCtrl != nil {
885660 h.wv.findCtrl.setHost(nil)
886661 }
887662 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+ }
889676 h.wv.scheduleStopBeginFrameLoop()
890677
891678 h.wv.mu.RLock()
M internal/infrastructure/cef/webview_handlers_test.go +19 -71
@@ -60,13 +60,6 @@ func (s *controlledSelectionScheduler) fire(index int) {
6060 }
6161 }
6262
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-
7063 type stubFrame struct {
7164 main bool
7265 url string
@@ -309,7 +302,7 @@ func TestOnTextSelectionChanged_ForwardsSelectionToClipboardOrchestrator(t *test
309302 clipboardTextOrchestrator: orchestrator,
310303 },
311304 }
312- h := &handlerSet{wv: wv}
305+ h := &selectionRenderHandlerForTest{wv: wv}
313306
314307 h.OnTextSelectionChanged(nil, "selected text", nil)
315308
@@ -339,7 +332,7 @@ func TestOnTextSelectionChanged_DebouncesAndCollapsesRapidUpdates(t *testing.T)
339332 clipboardTextOrchestrator: orchestrator,
340333 },
341334 }
342- h := &handlerSet{wv: wv}
335+ h := &selectionRenderHandlerForTest{wv: wv}
343336
344337 h.OnTextSelectionChanged(nil, "first selection", nil)
345338 h.OnTextSelectionChanged(nil, "second selection", nil)
@@ -382,7 +375,8 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu
382375 clipboardTextOrchestrator: orchestrator,
383376 },
384377 }
385- h := &handlerSet{wv: wv}
378+ h := &selectionRenderHandlerForTest{wv: wv}
379+ clientHandlers := &handlerSet{wv: wv}
386380 frame := cefmocks.NewMockFrame(t)
387381 oldFactory := newRendererBridgeProcessMessage
388382 t.Cleanup(func() { newRendererBridgeProcessMessage = oldFactory })
@@ -395,7 +389,7 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu
395389 args := message.GetArgumentList()
396390 require.Equal(t, "editable_focus_changed", args.GetString(0))
397391 require.Equal(t, "1", args.GetString(1))
398- h.OnProcessMessageReceived(nil, nil, 0, message)
392+ clientHandlers.OnProcessMessageReceived(nil, nil, 0, message)
399393 }).Once()
400394 (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: true})
401395
@@ -416,7 +410,7 @@ func TestOnTextSelectionChanged_SuppressesAutoCopyWhenFocusedNodeEditableAndResu
416410 args := message.GetArgumentList()
417411 require.Equal(t, "editable_focus_changed", args.GetString(0))
418412 require.Equal(t, "0", args.GetString(1))
419- h.OnProcessMessageReceived(nil, nil, 0, message)
413+ clientHandlers.OnProcessMessageReceived(nil, nil, 0, message)
420414 }).Once()
421415 (&rendererBridgeProcessHandler{}).OnFocusedNodeChanged(nil, frame, stubEditableDomnode{editable: false})
422416
@@ -455,7 +449,7 @@ func TestOnTextSelectionChanged_DoesNotEmitLateDebouncedUpdateAfterDestroy(t *te
455449 clipboardTextOrchestrator: orchestrator,
456450 },
457451 }
458- h := &handlerSet{wv: wv}
452+ h := &selectionRenderHandlerForTest{wv: wv}
459453
460454 h.OnTextSelectionChanged(nil, "selected text", nil)
461455 require.Len(t, scheduler.callbacks, 1)
@@ -500,49 +494,21 @@ func (n stubEditableDomnode) SetElementAttribute(string, string) int32 { return
500494 func (n stubEditableDomnode) GetElementInnerText() string { return "" }
501495 func (n stubEditableDomnode) GetElementBounds() uintptr { return 0 }
502496
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-
540497 func TestOptionalHandlersAreAlwaysEnabled(t *testing.T) {
541498 h := &handlerSet{}
542499
543500 // AudioHandler is always enabled (required for media decoding).
544501 require.Same(t, h, h.GetAudioHandler())
545502 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())
546512 }
547513
548514 func TestGetDownloadHandler_EnabledWhenEngineConfigured(t *testing.T) {
@@ -559,26 +525,8 @@ func TestGetDownloadHandler_EnabledWhenEngineConfigured(t *testing.T) {
559525 require.Same(t, h, h.GetDownloadHandler())
560526 }
561527
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 }
577529
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)
584532 }
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) {
610610 m.viper.SetDefault("engine.webkit.network_process_memory_strict_threshold", wk.NetworkProcessMemoryStrictThreshold)
611611 }
612612
613-
614-
615613 // New returns a new default configuration instance.
616614 // This is a convenience function for getting default config without the full manager.
617615 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 @@
11 package logging
22
33 import (
4+ "context"
45 "crypto/rand"
56 "encoding/hex"
67 "errors"
@@ -55,6 +56,31 @@ func SessionFilename(sessionID string) string {
5556 return "session_" + sessionID + ".log"
5657 }
5758
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+
5884 type sessionLogFile struct {
5985 path string
6086 filename string
M internal/ui/app.go +16 -2
@@ -2581,9 +2581,23 @@ func (a *App) MainWindow() *window.MainWindow {
25812581
25822582 // Quit requests the application to quit.
25832583 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
25862596 }
2597+ cb := glib.SourceOnceFunc(func(_ uintptr) {
2598+ quit()
2599+ })
2600+ glib.IdleAddOnce(&cb, 0)
25872601 }
25882602
25892603 // 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 (
55 "context"
66 "fmt"
77 "strings"
8+ "time"
89
910 "github.com/bnema/dumber/internal/domain/entity"
1011 "github.com/bnema/dumber/internal/logging"
@@ -17,12 +18,15 @@ import (
1718 // even when WebView has focus. It uses GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL
1819 // to intercept shortcuts before they reach the WebView.
1920 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
2630
2731 // Keep references to callbacks to prevent GC from collecting them
2832 callbacks []gtk.ShortcutFunc
@@ -42,13 +46,14 @@ func NewGlobalShortcutHandler(
4246 log.Debug().Msg("creating global shortcut handler")
4347
4448 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),
5257 }
5358
5459 if h.controller == nil {
@@ -181,11 +186,27 @@ func (h *GlobalShortcutHandler) registerShortcut(keyval uint, modifiers gdk.Modi
181186 return false
182187 }
183188
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.
186192 actionToDispatch := action
193+ generation := h.generation
187194 callback := gtk.ShortcutFunc(func(_ uintptr, _ *glib.Variant, _ uintptr) bool {
188195 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+ }
189210 log.Debug().
190211 Str("action", string(actionToDispatch)).
191212 Msg("global shortcut triggered")
@@ -266,6 +287,8 @@ func globalShortcutActionMap() map[string]Action {
266287 // are removed.
267288 func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
268289 log := logging.FromContext(ctx)
290+ h.ctx = ctx
291+ h.generation++
269292
270293 if h.window == nil || h.controller == nil {
271294 return
@@ -283,6 +306,7 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *
283306 h.controller.SetScope(gtk.ShortcutScopeGlobalValue)
284307 h.callbacks = make([]gtk.ShortcutFunc, 0)
285308 h.registered = make(map[KeyBinding]Action)
309+ h.lastDispatchAt = make(map[Action]time.Time)
286310
287311 // Re-register hardcoded shortcuts (Alt+1-9, Alt+0, Alt+Tab, Ctrl+Shift+S)
288312 tabActions := []Action{
@@ -348,9 +372,45 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace *
348372 // Note: GTK handles cleanup when the widget is destroyed,
349373 // but we clear our references here.
350374 func (h *GlobalShortcutHandler) Detach() {
375+ h.generation++
376+ if h.window != nil && h.controller != nil {
377+ h.window.RemoveController(&h.controller.EventController)
378+ }
351379 h.controller = nil
352380 h.callbacks = nil
353381 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+ }
354414 }
355415
356416 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"

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close