fix(cef): resolve random SIGSEGV at startup, switch to modernc sqlite
Root cause: purego callbacks create mixed Go/C stack frames that Go's runtime cannot traverse when compiler optimizations are enabled. This causes SIGSEGV in runtime.scanstack during GC, stack growth, or goroutine scheduling. Fix: build with -gcflags 'all=-N -l' to disable optimizations and inlining globally. The -N flag is critical — it prevents register-only variables and stack slot reuse that corrupt the runtime's stack walker. -l alone is insufficient. Other changes included from ongoing CEF work: - Switch ncruces/go-sqlite3 (WASM) to modernc.org/sqlite (pure Go) - Remove purego local fork replace directive (upstream is unmodified) - Browser creation posted to CEF UI thread via PostTask - Proper engine shutdown with webview cleanup and timeouts - Lock-free render pipeline (atomic width/height, sequence counters) - Dirty rect clamping and glUnmapBuffer error handling - Thread-safe pendingCreate accessor
- commit
- 5ec3eb128563d080ccd5795b991c954a922b23a7
- author
- brice · · 2026-03-22 15:14 UTC
- committer
- brice · · 2026-03-28 04:38 UTC
- parent
- 6b1adf4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
14 files · +554 -66
M Makefile +10 -2
| @@ -24,6 +24,14 @@ BUILD_DATE=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ") | |||
| 24 | 24 | # Linker flags | |
| 25 | 25 | LDFLAGS=-ldflags "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE)" | |
| 26 | 26 | ||
| 27 | + | # Disable optimizations and inlining for ALL packages. purego callbacks | |
| 28 | + | # create mixed Go/C stack frames; the Go runtime (stack growth, GC, | |
| 29 | + | # scheduler) cannot reliably traverse optimised frames across these | |
| 30 | + | # boundaries. -N prevents register-only variables and stack slot reuse | |
| 31 | + | # that corrupt the runtime's stack walker. -l prevents inlining across | |
| 32 | + | # package boundaries. Both are required — -l alone is insufficient. | |
| 33 | + | GCFLAGS=-gcflags 'all=-N -l' | |
| 34 | + | ||
| 27 | 35 | # Default target | |
| 28 | 36 | help: ## Show this help message | |
| 29 | 37 | @echo "Available targets:" | |
| @@ -33,7 +41,7 @@ help: ## Show this help message | |||
| 33 | 41 | build: build-frontend ## Build the application (pure Go, no CGO) | |
| 34 | 42 | @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..." | |
| 35 | 43 | @mkdir -p $(DIST_DIR) | |
| 36 | - | CGO_ENABLED=0 go build -p $(NPROCS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | |
| 44 | + | CGO_ENABLED=0 go build -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | |
| 37 | 45 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | |
| 38 | 46 | ||
| 39 | 47 | build-frontend: ## Build homepage and error pages | |
| @@ -44,7 +52,7 @@ build-frontend: ## Build homepage and error pages | |||
| 44 | 52 | build-quick: ## Build without frontend (faster for backend development) | |
| 45 | 53 | @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..." | |
| 46 | 54 | @mkdir -p $(DIST_DIR) | |
| 47 | - | CGO_ENABLED=0 go build -p $(NPROCS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | |
| 55 | + | CGO_ENABLED=0 go build -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | |
| 48 | 56 | CGO_ENABLED=0 go build -p $(NPROCS) -ldflags "-s -w" -o $(DIST_DIR)/cef-helper ./cmd/cef-helper | |
| 49 | 57 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | |
| 50 | 58 | ||
| @@ -24,6 +24,14 @@ BUILD_DATE=$(shell date -u +"%Y-%m-%dT%H:%M:%SZ") | |||||
| 24 | # Linker flags | 24 | # Linker flags | ||
| 25 | LDFLAGS=-ldflags "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE)" | 25 | LDFLAGS=-ldflags "-s -w -X main.version=$(VERSION) -X main.commit=$(COMMIT) -X main.buildDate=$(BUILD_DATE)" | ||
| 26 | 26 | ||||
| 27 | + | # Disable optimizations and inlining for ALL packages. purego callbacks | |||
| 28 | + | # create mixed Go/C stack frames; the Go runtime (stack growth, GC, | |||
| 29 | + | # scheduler) cannot reliably traverse optimised frames across these | |||
| 30 | + | # boundaries. -N prevents register-only variables and stack slot reuse | |||
| 31 | + | # that corrupt the runtime's stack walker. -l prevents inlining across | |||
| 32 | + | # package boundaries. Both are required — -l alone is insufficient. | |||
| 33 | + | GCFLAGS=-gcflags 'all=-N -l' | |||
| 34 | + | ||||
| 27 | # Default target | 35 | # Default target | ||
| 28 | help: ## Show this help message | 36 | help: ## Show this help message | ||
| 29 | @echo "Available targets:" | 37 | @echo "Available targets:" | ||
| @@ -33,7 +41,7 @@ help: ## Show this help message | |||||
| 33 | build: build-frontend ## Build the application (pure Go, no CGO) | 41 | build: build-frontend ## Build the application (pure Go, no CGO) | ||
| 34 | @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..." | 42 | @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..." | ||
| 35 | @mkdir -p $(DIST_DIR) | 43 | @mkdir -p $(DIST_DIR) | ||
| 36 | - | CGO_ENABLED=0 go build -p $(NPROCS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | 44 | + | CGO_ENABLED=0 go build -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) |
| 37 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | 45 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | ||
| 38 | 46 | ||||
| 39 | build-frontend: ## Build homepage and error pages | 47 | build-frontend: ## Build homepage and error pages | ||
| @@ -44,7 +52,7 @@ build-frontend: ## Build homepage and error pages | |||||
| 44 | build-quick: ## Build without frontend (faster for backend development) | 52 | build-quick: ## Build without frontend (faster for backend development) | ||
| 45 | @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..." | 53 | @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..." | ||
| 46 | @mkdir -p $(DIST_DIR) | 54 | @mkdir -p $(DIST_DIR) | ||
| 47 | - | CGO_ENABLED=0 go build -p $(NPROCS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | 55 | + | CGO_ENABLED=0 go build -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) |
| 48 | CGO_ENABLED=0 go build -p $(NPROCS) -ldflags "-s -w" -o $(DIST_DIR)/cef-helper ./cmd/cef-helper | 56 | CGO_ENABLED=0 go build -p $(NPROCS) -ldflags "-s -w" -o $(DIST_DIR)/cef-helper ./cmd/cef-helper | ||
| 49 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | 57 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | ||
| 50 | 58 | ||||
M go.mod +8 -0
| @@ -22,6 +22,7 @@ require ( | |||
| 22 | 22 | golang.org/x/image v0.38.0 | |
| 23 | 23 | golang.org/x/sys v0.42.0 | |
| 24 | 24 | gopkg.in/yaml.v3 v3.0.1 | |
| 25 | + | modernc.org/sqlite v1.47.0 | |
| 25 | 26 | ) | |
| 26 | 27 | ||
| 27 | 28 | replace github.com/ebitengine/purego => github.com/bnema/purego v0.0.0-20260323095903-91aa177a2704 | |
| @@ -40,8 +41,10 @@ require ( | |||
| 40 | 41 | github.com/clipperhouse/uax29/v2 v2.5.0 // indirect | |
| 41 | 42 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect | |
| 42 | 43 | github.com/davecgh/go-spew v1.1.1 // indirect | |
| 44 | + | github.com/dustin/go-humanize v1.0.1 // indirect | |
| 43 | 45 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | |
| 44 | 46 | github.com/go-viper/mapstructure/v2 v2.5.0 // indirect | |
| 47 | + | github.com/google/uuid v1.6.0 // indirect | |
| 45 | 48 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | |
| 46 | 49 | github.com/lucasb-eyer/go-colorful v1.3.0 // indirect | |
| 47 | 50 | github.com/mattn/go-colorable v0.1.14 // indirect | |
| @@ -52,7 +55,9 @@ require ( | |||
| 52 | 55 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | |
| 53 | 56 | github.com/muesli/cancelreader v0.2.2 // indirect | |
| 54 | 57 | github.com/muesli/termenv v0.16.0 // indirect | |
| 58 | + | github.com/ncruces/go-strftime v1.0.0 // indirect | |
| 55 | 59 | github.com/pmezard/go-difflib v1.0.0 // indirect | |
| 60 | + | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect | |
| 56 | 61 | github.com/rivo/uniseg v0.4.7 // indirect | |
| 57 | 62 | github.com/russross/blackfriday/v2 v2.1.0 // indirect | |
| 58 | 63 | github.com/sagikazarmark/locafero v0.12.0 // indirect | |
| @@ -69,4 +74,7 @@ require ( | |||
| 69 | 74 | golang.org/x/sync v0.20.0 // indirect | |
| 70 | 75 | golang.org/x/text v0.35.0 // indirect | |
| 71 | 76 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect | |
| 77 | + | modernc.org/libc v1.70.0 // indirect | |
| 78 | + | modernc.org/mathutil v1.7.1 // indirect | |
| 79 | + | modernc.org/memory v1.11.0 // indirect | |
| 72 | 80 | ) | |
| @@ -22,6 +22,7 @@ require ( | |||||
| 22 | golang.org/x/image v0.38.0 | 22 | golang.org/x/image v0.38.0 | ||
| 23 | golang.org/x/sys v0.42.0 | 23 | golang.org/x/sys v0.42.0 | ||
| 24 | gopkg.in/yaml.v3 v3.0.1 | 24 | gopkg.in/yaml.v3 v3.0.1 | ||
| 25 | + | modernc.org/sqlite v1.47.0 | |||
| 25 | ) | 26 | ) | ||
| 26 | 27 | ||||
| 27 | replace github.com/ebitengine/purego => github.com/bnema/purego v0.0.0-20260323095903-91aa177a2704 | 28 | replace github.com/ebitengine/purego => github.com/bnema/purego v0.0.0-20260323095903-91aa177a2704 | ||
| @@ -40,8 +41,10 @@ require ( | |||||
| 40 | github.com/clipperhouse/uax29/v2 v2.5.0 // indirect | 41 | github.com/clipperhouse/uax29/v2 v2.5.0 // indirect | ||
| 41 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect | 42 | github.com/cpuguy83/go-md2man/v2 v2.0.7 // indirect | ||
| 42 | github.com/davecgh/go-spew v1.1.1 // indirect | 43 | github.com/davecgh/go-spew v1.1.1 // indirect | ||
| 44 | + | github.com/dustin/go-humanize v1.0.1 // indirect | |||
| 43 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | 45 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect | ||
| 44 | github.com/go-viper/mapstructure/v2 v2.5.0 // indirect | 46 | github.com/go-viper/mapstructure/v2 v2.5.0 // indirect | ||
| 47 | + | github.com/google/uuid v1.6.0 // indirect | |||
| 45 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | 48 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | ||
| 46 | github.com/lucasb-eyer/go-colorful v1.3.0 // indirect | 49 | github.com/lucasb-eyer/go-colorful v1.3.0 // indirect | ||
| 47 | github.com/mattn/go-colorable v0.1.14 // indirect | 50 | github.com/mattn/go-colorable v0.1.14 // indirect | ||
| @@ -52,7 +55,9 @@ require ( | |||||
| 52 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | 55 | github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect | ||
| 53 | github.com/muesli/cancelreader v0.2.2 // indirect | 56 | github.com/muesli/cancelreader v0.2.2 // indirect | ||
| 54 | github.com/muesli/termenv v0.16.0 // indirect | 57 | github.com/muesli/termenv v0.16.0 // indirect | ||
| 58 | + | github.com/ncruces/go-strftime v1.0.0 // indirect | |||
| 55 | github.com/pmezard/go-difflib v1.0.0 // indirect | 59 | github.com/pmezard/go-difflib v1.0.0 // indirect | ||
| 60 | + | github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect | |||
| 56 | github.com/rivo/uniseg v0.4.7 // indirect | 61 | github.com/rivo/uniseg v0.4.7 // indirect | ||
| 57 | github.com/russross/blackfriday/v2 v2.1.0 // indirect | 62 | github.com/russross/blackfriday/v2 v2.1.0 // indirect | ||
| 58 | github.com/sagikazarmark/locafero v0.12.0 // indirect | 63 | github.com/sagikazarmark/locafero v0.12.0 // indirect | ||
| @@ -69,4 +74,7 @@ require ( | |||||
| 69 | golang.org/x/sync v0.20.0 // indirect | 74 | golang.org/x/sync v0.20.0 // indirect | ||
| 70 | golang.org/x/text v0.35.0 // indirect | 75 | golang.org/x/text v0.35.0 // indirect | ||
| 71 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect | 76 | gopkg.in/check.v1 v1.0.0-20190902080502-41f04d3bba15 // indirect | ||
| 77 | + | modernc.org/libc v1.70.0 // indirect | |||
| 78 | + | modernc.org/mathutil v1.7.1 // indirect | |||
| 79 | + | modernc.org/memory v1.11.0 // indirect | |||
| 72 | ) | 80 | ) | ||
M go.sum +32 -10 generated file: collapsed
M internal/bootstrap/gui.go +1 -1
| @@ -148,7 +148,7 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |||
| 148 | 148 | } | |
| 149 | 149 | ||
| 150 | 150 | // RunDeferredInit runs deferred initialization checks off the critical path. | |
| 151 | - | // This includes SQLite WASM precompile, runtime requirements, and media checks. | |
| 151 | + | // This includes runtime requirements and media checks. | |
| 152 | 152 | func RunDeferredInit(input DeferredInitInput) DeferredInitResult { | |
| 153 | 153 | var ( | |
| 154 | 154 | runtimeErr error | |
| @@ -148,7 +148,7 @@ func RunParallelInit(input ParallelInitInput) (*ParallelInitResult, error) { | |||||
| 148 | } | 148 | } | ||
| 149 | 149 | ||||
| 150 | // RunDeferredInit runs deferred initialization checks off the critical path. | 150 | // RunDeferredInit runs deferred initialization checks off the critical path. | ||
| 151 | - | // This includes SQLite WASM precompile, runtime requirements, and media checks. | 151 | + | // This includes runtime requirements and media checks. |
| 152 | func RunDeferredInit(input DeferredInitInput) DeferredInitResult { | 152 | func RunDeferredInit(input DeferredInitInput) DeferredInitResult { | ||
| 153 | var ( | 153 | var ( | ||
| 154 | runtimeErr error | 154 | runtimeErr error | ||
M internal/infrastructure/cef/engine.go +74 -0
| @@ -122,12 +122,86 @@ func (e *Engine) InternalSchemePath() string { | |||
| 122 | 122 | ||
| 123 | 123 | // Close releases all resources held by the engine. | |
| 124 | 124 | func (e *Engine) Close() error { | |
| 125 | + | log := logging.FromContext(e.ctx) | |
| 126 | + | activeBefore := e.activeWebViewCount() | |
| 127 | + | log.Debug(). | |
| 128 | + | Int("active_webviews", activeBefore). | |
| 129 | + | Msg("cef: engine close started") | |
| 130 | + | ||
| 125 | 131 | e.stopMessagePump() | |
| 132 | + | e.closeActiveWebViews() | |
| 126 | 133 | e.pool.Close() | |
| 134 | + | ||
| 135 | + | if activeAfter := e.activeWebViewCount(); activeAfter > 0 { | |
| 136 | + | log.Warn(). | |
| 137 | + | Int("remaining_active_webviews", activeAfter). | |
| 138 | + | Msg("cef: shutting down with active webviews still registered") | |
| 139 | + | } | |
| 140 | + | ||
| 127 | 141 | purecef.Shutdown() | |
| 142 | + | log.Debug().Msg("cef: engine close completed") | |
| 128 | 143 | return nil | |
| 129 | 144 | } | |
| 130 | 145 | ||
| 146 | + | const ( | |
| 147 | + | cefShutdownWaitStep = 10 * time.Millisecond | |
| 148 | + | cefShutdownWaitTimeout = 2 * time.Second | |
| 149 | + | ) | |
| 150 | + | ||
| 151 | + | func (e *Engine) activeWebViewCount() int { | |
| 152 | + | count := 0 | |
| 153 | + | e.activeWebViews.Range(func(_, _ any) bool { | |
| 154 | + | count++ | |
| 155 | + | return true | |
| 156 | + | }) | |
| 157 | + | return count | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | func (e *Engine) closeActiveWebViews() { | |
| 161 | + | if e == nil { | |
| 162 | + | return | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | log := logging.FromContext(e.ctx) | |
| 166 | + | webViews := make([]*WebView, 0, e.activeWebViewCount()) | |
| 167 | + | e.activeWebViews.Range(func(_, value any) bool { | |
| 168 | + | wv, ok := value.(*WebView) | |
| 169 | + | if ok && wv != nil { | |
| 170 | + | webViews = append(webViews, wv) | |
| 171 | + | } | |
| 172 | + | return true | |
| 173 | + | }) | |
| 174 | + | ||
| 175 | + | if len(webViews) == 0 { | |
| 176 | + | return | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | log.Debug(). | |
| 180 | + | Int("count", len(webViews)). | |
| 181 | + | Msg("cef: closing active webviews before shutdown") | |
| 182 | + | ||
| 183 | + | for _, wv := range webViews { | |
| 184 | + | wv.Destroy() | |
| 185 | + | } | |
| 186 | + | ||
| 187 | + | deadline := time.Now().Add(cefShutdownWaitTimeout) | |
| 188 | + | for { | |
| 189 | + | remaining := e.activeWebViewCount() | |
| 190 | + | if remaining == 0 { | |
| 191 | + | log.Debug().Msg("cef: all active webviews closed before shutdown") | |
| 192 | + | return | |
| 193 | + | } | |
| 194 | + | if time.Now().After(deadline) { | |
| 195 | + | log.Warn(). | |
| 196 | + | Int("remaining", remaining). | |
| 197 | + | Str("timeout", cefShutdownWaitTimeout.String()). | |
| 198 | + | Msg("cef: timed out waiting for OnBeforeClose before shutdown") | |
| 199 | + | return | |
| 200 | + | } | |
| 201 | + | time.Sleep(cefShutdownWaitStep) | |
| 202 | + | } | |
| 203 | + | } | |
| 204 | + | ||
| 131 | 205 | // RegisterHandlers registers all WebUI message bridge handlers with the message router. | |
| 132 | 206 | // Handler registration follows the same pattern as WebKit's handlers/register.go: | |
| 133 | 207 | // each handler is registered by message type with the router, which dispatches | |
| @@ -122,12 +122,86 @@ func (e *Engine) InternalSchemePath() string { | |||||
| 122 | 122 | ||||
| 123 | // Close releases all resources held by the engine. | 123 | // Close releases all resources held by the engine. | ||
| 124 | func (e *Engine) Close() error { | 124 | func (e *Engine) Close() error { | ||
| 125 | + | log := logging.FromContext(e.ctx) | |||
| 126 | + | activeBefore := e.activeWebViewCount() | |||
| 127 | + | log.Debug(). | |||
| 128 | + | Int("active_webviews", activeBefore). | |||
| 129 | + | Msg("cef: engine close started") | |||
| 130 | + | ||||
| 125 | e.stopMessagePump() | 131 | e.stopMessagePump() | ||
| 132 | + | e.closeActiveWebViews() | |||
| 126 | e.pool.Close() | 133 | e.pool.Close() | ||
| 134 | + | ||||
| 135 | + | if activeAfter := e.activeWebViewCount(); activeAfter > 0 { | |||
| 136 | + | log.Warn(). | |||
| 137 | + | Int("remaining_active_webviews", activeAfter). | |||
| 138 | + | Msg("cef: shutting down with active webviews still registered") | |||
| 139 | + | } | |||
| 140 | + | ||||
| 127 | purecef.Shutdown() | 141 | purecef.Shutdown() | ||
| 142 | + | log.Debug().Msg("cef: engine close completed") | |||
| 128 | return nil | 143 | return nil | ||
| 129 | } | 144 | } | ||
| 130 | 145 | ||||
| 146 | + | const ( | |||
| 147 | + | cefShutdownWaitStep = 10 * time.Millisecond | |||
| 148 | + | cefShutdownWaitTimeout = 2 * time.Second | |||
| 149 | + | ) | |||
| 150 | + | ||||
| 151 | + | func (e *Engine) activeWebViewCount() int { | |||
| 152 | + | count := 0 | |||
| 153 | + | e.activeWebViews.Range(func(_, _ any) bool { | |||
| 154 | + | count++ | |||
| 155 | + | return true | |||
| 156 | + | }) | |||
| 157 | + | return count | |||
| 158 | + | } | |||
| 159 | + | ||||
| 160 | + | func (e *Engine) closeActiveWebViews() { | |||
| 161 | + | if e == nil { | |||
| 162 | + | return | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | log := logging.FromContext(e.ctx) | |||
| 166 | + | webViews := make([]*WebView, 0, e.activeWebViewCount()) | |||
| 167 | + | e.activeWebViews.Range(func(_, value any) bool { | |||
| 168 | + | wv, ok := value.(*WebView) | |||
| 169 | + | if ok && wv != nil { | |||
| 170 | + | webViews = append(webViews, wv) | |||
| 171 | + | } | |||
| 172 | + | return true | |||
| 173 | + | }) | |||
| 174 | + | ||||
| 175 | + | if len(webViews) == 0 { | |||
| 176 | + | return | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | log.Debug(). | |||
| 180 | + | Int("count", len(webViews)). | |||
| 181 | + | Msg("cef: closing active webviews before shutdown") | |||
| 182 | + | ||||
| 183 | + | for _, wv := range webViews { | |||
| 184 | + | wv.Destroy() | |||
| 185 | + | } | |||
| 186 | + | ||||
| 187 | + | deadline := time.Now().Add(cefShutdownWaitTimeout) | |||
| 188 | + | for { | |||
| 189 | + | remaining := e.activeWebViewCount() | |||
| 190 | + | if remaining == 0 { | |||
| 191 | + | log.Debug().Msg("cef: all active webviews closed before shutdown") | |||
| 192 | + | return | |||
| 193 | + | } | |||
| 194 | + | if time.Now().After(deadline) { | |||
| 195 | + | log.Warn(). | |||
| 196 | + | Int("remaining", remaining). | |||
| 197 | + | Str("timeout", cefShutdownWaitTimeout.String()). | |||
| 198 | + | Msg("cef: timed out waiting for OnBeforeClose before shutdown") | |||
| 199 | + | return | |||
| 200 | + | } | |||
| 201 | + | time.Sleep(cefShutdownWaitStep) | |||
| 202 | + | } | |||
| 203 | + | } | |||
| 204 | + | ||||
| 131 | // RegisterHandlers registers all WebUI message bridge handlers with the message router. | 205 | // RegisterHandlers registers all WebUI message bridge handlers with the message router. | ||
| 132 | // Handler registration follows the same pattern as WebKit's handlers/register.go: | 206 | // Handler registration follows the same pattern as WebKit's handlers/register.go: | ||
| 133 | // each handler is registered by message type with the router, which dispatches | 207 | // each handler is registered by message type with the router, which dispatches | ||
M internal/infrastructure/cef/factory.go +24 -4
| @@ -131,9 +131,17 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |||
| 131 | 131 | // When the GL area gets its first non-zero size, create the browser. | |
| 132 | 132 | pipeline.onFirstResize = func(w, h int32) { | |
| 133 | 133 | log := logging.FromContext(ctx) | |
| 134 | - | log.Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, creating browser") | |
| 135 | - | if pc := wv.pendingCreate; pc != nil { | |
| 136 | - | wv.pendingCreate = nil | |
| 134 | + | log.Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, scheduling browser creation") | |
| 135 | + | pc := wv.takePendingCreate() | |
| 136 | + | if pc == nil { | |
| 137 | + | return | |
| 138 | + | } | |
| 139 | + | ||
| 140 | + | task := purecef.NewTask(cefTaskFunc(func() { | |
| 141 | + | if wv.destroyed.Load() { | |
| 142 | + | log.Debug().Msg("cef: skipping browser creation for destroyed webview") | |
| 143 | + | return | |
| 144 | + | } | |
| 137 | 145 | result := purecef.BrowserHostCreateBrowser( | |
| 138 | 146 | pc.windowInfo, | |
| 139 | 147 | pc.client, | |
| @@ -152,7 +160,19 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |||
| 152 | 160 | Int32("shared_texture", pc.windowInfo.SharedTextureEnabled). | |
| 153 | 161 | Int32("external_begin_frame", pc.windowInfo.ExternalBeginFrameEnabled). | |
| 154 | 162 | Bool("client_nil", pc.client == nil). | |
| 155 | - | Msg("cef: BrowserHostCreateBrowser call completed") | |
| 163 | + | Msg("cef: BrowserHostCreateBrowser call completed on CEF UI thread") | |
| 164 | + | })) | |
| 165 | + | ||
| 166 | + | postResult := purecef.PostTask(purecef.ThreadIDTidUi, task) | |
| 167 | + | if postResult != 1 { | |
| 168 | + | wv.mu.Lock() | |
| 169 | + | if wv.pendingCreate == nil { | |
| 170 | + | wv.pendingCreate = pc | |
| 171 | + | } | |
| 172 | + | wv.mu.Unlock() | |
| 173 | + | log.Error(). | |
| 174 | + | Int32("result", postResult). | |
| 175 | + | Msg("cef: failed to post initial browser creation to CEF UI thread") | |
| 156 | 176 | } | |
| 157 | 177 | } | |
| 158 | 178 | ||
| @@ -131,9 +131,17 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |||||
| 131 | // When the GL area gets its first non-zero size, create the browser. | 131 | // When the GL area gets its first non-zero size, create the browser. | ||
| 132 | pipeline.onFirstResize = func(w, h int32) { | 132 | pipeline.onFirstResize = func(w, h int32) { | ||
| 133 | log := logging.FromContext(ctx) | 133 | log := logging.FromContext(ctx) | ||
| 134 | - | log.Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, creating browser") | 134 | + | log.Debug().Int32("w", w).Int32("h", h).Msg("cef: onFirstResize fired, scheduling browser creation") |
| 135 | - | if pc := wv.pendingCreate; pc != nil { | 135 | + | pc := wv.takePendingCreate() |
| 136 | - | wv.pendingCreate = nil | 136 | + | if pc == nil { |
| 137 | + | return | |||
| 138 | + | } | |||
| 139 | + | ||||
| 140 | + | task := purecef.NewTask(cefTaskFunc(func() { | |||
| 141 | + | if wv.destroyed.Load() { | |||
| 142 | + | log.Debug().Msg("cef: skipping browser creation for destroyed webview") | |||
| 143 | + | return | |||
| 144 | + | } | |||
| 137 | result := purecef.BrowserHostCreateBrowser( | 145 | result := purecef.BrowserHostCreateBrowser( | ||
| 138 | pc.windowInfo, | 146 | pc.windowInfo, | ||
| 139 | pc.client, | 147 | pc.client, | ||
| @@ -152,7 +160,19 @@ func (f *WebViewFactory) Create(ctx context.Context) (port.WebView, error) { | |||||
| 152 | Int32("shared_texture", pc.windowInfo.SharedTextureEnabled). | 160 | Int32("shared_texture", pc.windowInfo.SharedTextureEnabled). | ||
| 153 | Int32("external_begin_frame", pc.windowInfo.ExternalBeginFrameEnabled). | 161 | Int32("external_begin_frame", pc.windowInfo.ExternalBeginFrameEnabled). | ||
| 154 | Bool("client_nil", pc.client == nil). | 162 | Bool("client_nil", pc.client == nil). | ||
| 155 | - | Msg("cef: BrowserHostCreateBrowser call completed") | 163 | + | Msg("cef: BrowserHostCreateBrowser call completed on CEF UI thread") |
| 164 | + | })) | |||
| 165 | + | ||||
| 166 | + | postResult := purecef.PostTask(purecef.ThreadIDTidUi, task) | |||
| 167 | + | if postResult != 1 { | |||
| 168 | + | wv.mu.Lock() | |||
| 169 | + | if wv.pendingCreate == nil { | |||
| 170 | + | wv.pendingCreate = pc | |||
| 171 | + | } | |||
| 172 | + | wv.mu.Unlock() | |||
| 173 | + | log.Error(). | |||
| 174 | + | Int32("result", postResult). | |||
| 175 | + | Msg("cef: failed to post initial browser creation to CEF UI thread") | |||
| 156 | } | 176 | } | ||
| 157 | } | 177 | } | ||
| 158 | 178 | ||||
M internal/infrastructure/cef/render_pipeline.go +164 -11
| @@ -3,6 +3,7 @@ package cef | |||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | 5 | "sync" | |
| 6 | + | "sync/atomic" | |
| 6 | 7 | "time" | |
| 7 | 8 | "unsafe" | |
| 8 | 9 | ||
| @@ -40,6 +41,10 @@ type renderPipeline struct { | |||
| 40 | 41 | height int32 | |
| 41 | 42 | scale int32 | |
| 42 | 43 | ||
| 44 | + | // geometry mirrors the current surface size for lock-free CEF callbacks. | |
| 45 | + | widthAtomic atomic.Int32 | |
| 46 | + | heightAtomic atomic.Int32 | |
| 47 | + | ||
| 43 | 48 | // Staging: handlePaint copies dirty rects here, render signal uploads. | |
| 44 | 49 | mu sync.Mutex | |
| 45 | 50 | staging []byte | |
| @@ -51,6 +56,11 @@ type renderPipeline struct { | |||
| 51 | 56 | glReady bool | |
| 52 | 57 | ||
| 53 | 58 | // Diagnostics (main thread only). | |
| 59 | + | viewRectSeq atomic.Uint64 | |
| 60 | + | screenInfoSeq atomic.Uint64 | |
| 61 | + | paintSeq atomic.Uint64 | |
| 62 | + | lastQueuedPaintSeq atomic.Uint64 | |
| 63 | + | glRenderSeq atomic.Uint64 | |
| 54 | 64 | paintCount uint64 | |
| 55 | 65 | acceleratedPaintCount uint64 | |
| 56 | 66 | queueRenderCount uint64 | |
| @@ -151,30 +161,50 @@ func newRenderPipeline(ctx context.Context, gl *glLoader, scale int32) *renderPi | |||
| 151 | 161 | // handlePaint is called from CEF's OnPaint callback (on GTK main thread via | |
| 152 | 162 | // IdleAdd). It copies dirty rect regions from the CEF buffer into the staging | |
| 153 | 163 | // buffer and queues a GL redraw. | |
| 154 | - | func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects []rect) { | |
| 164 | + | func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects []rect, paintSeq uint64) { | |
| 155 | 165 | if len(buffer) == 0 || width <= 0 || height <= 0 { | |
| 156 | 166 | return | |
| 157 | 167 | } | |
| 158 | 168 | ||
| 159 | 169 | startedAt := time.Now() | |
| 170 | + | rp.lastQueuedPaintSeq.Store(paintSeq) | |
| 171 | + | if rp.ctx != nil { | |
| 172 | + | logging.FromContext(rp.ctx).Debug(). | |
| 173 | + | Uint64("paint_seq", paintSeq). | |
| 174 | + | Int32("width", width). | |
| 175 | + | Int32("height", height). | |
| 176 | + | Int("rect_count", len(rects)). | |
| 177 | + | Int("buffer_len", len(buffer)). | |
| 178 | + | Msg("cef: handlePaint begin") | |
| 179 | + | } | |
| 160 | 180 | rp.mu.Lock() | |
| 161 | 181 | ||
| 162 | 182 | bufSize := int(int64(width) * int64(height) * 4) | |
| 163 | 183 | copiedBytes := uint64(0) | |
| 184 | + | sizeChanged := false | |
| 164 | 185 | ||
| 165 | 186 | // Detect size change, or first paint (staging not yet allocated). | |
| 166 | 187 | if width != rp.width || height != rp.height || rp.staging == nil { | |
| 167 | 188 | rp.width = width | |
| 168 | 189 | rp.height = height | |
| 190 | + | rp.widthAtomic.Store(width) | |
| 191 | + | rp.heightAtomic.Store(height) | |
| 169 | 192 | rp.staging = make([]byte, bufSize) | |
| 170 | 193 | rp.sizeChanged = true | |
| 194 | + | sizeChanged = true | |
| 171 | 195 | // On size change, copy the entire buffer. | |
| 172 | 196 | copy(rp.staging, buffer) | |
| 173 | 197 | copiedBytes = uint64(bufSize) | |
| 174 | 198 | } else { | |
| 175 | 199 | // Copy only dirty rect rows. | |
| 176 | 200 | stride := int(width) * 4 | |
| 177 | - | for _, r := range rects { | |
| 201 | + | sanitized := make([]rect, 0, len(rects)) | |
| 202 | + | for _, rawRect := range rects { | |
| 203 | + | r, ok := clampDirtyRect(rawRect, width, height) | |
| 204 | + | if !ok { | |
| 205 | + | continue | |
| 206 | + | } | |
| 207 | + | sanitized = append(sanitized, r) | |
| 178 | 208 | for row := r.Y; row < r.Y+r.Height; row++ { | |
| 179 | 209 | srcOff := int(row)*stride + int(r.X)*4 | |
| 180 | 210 | dstOff := srcOff | |
| @@ -183,6 +213,7 @@ func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects | |||
| 183 | 213 | copiedBytes += uint64(rowBytes) | |
| 184 | 214 | } | |
| 185 | 215 | } | |
| 216 | + | rects = sanitized | |
| 186 | 217 | } | |
| 187 | 218 | ||
| 188 | 219 | // Accumulate dirty rects across multiple OnPaint calls between renders. | |
| @@ -205,12 +236,28 @@ func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects | |||
| 205 | 236 | } | |
| 206 | 237 | ||
| 207 | 238 | rp.glArea.QueueRender() | |
| 239 | + | if rp.ctx != nil { | |
| 240 | + | logging.FromContext(rp.ctx).Debug(). | |
| 241 | + | Uint64("paint_seq", paintSeq). | |
| 242 | + | Uint64("copied_bytes", copiedBytes). | |
| 243 | + | Bool("size_changed", sizeChanged). | |
| 244 | + | Dur("copy_duration", copyDuration). | |
| 245 | + | Msg("cef: handlePaint end") | |
| 246 | + | } | |
| 208 | 247 | rp.maybeLogDiagnostics() | |
| 209 | 248 | } | |
| 210 | 249 | ||
| 211 | 250 | // onGLRender is the GTK "render" signal handler. GL context is current. | |
| 212 | 251 | func (rp *renderPipeline) onGLRender() bool { | |
| 252 | + | renderSeq := rp.glRenderSeq.Add(1) | |
| 253 | + | paintSeq := rp.lastQueuedPaintSeq.Load() | |
| 213 | 254 | renderStartedAt := time.Now() | |
| 255 | + | if rp.ctx != nil { | |
| 256 | + | logging.FromContext(rp.ctx).Debug(). | |
| 257 | + | Uint64("render_seq", renderSeq). | |
| 258 | + | Uint64("paint_seq", paintSeq). | |
| 259 | + | Msg("cef: onGLRender begin") | |
| 260 | + | } | |
| 214 | 261 | rp.mu.Lock() | |
| 215 | 262 | ||
| 216 | 263 | needsUpload := rp.needsUpload | |
| @@ -246,7 +293,7 @@ func (rp *renderPipeline) onGLRender() bool { | |||
| 246 | 293 | ||
| 247 | 294 | if needsUpload { | |
| 248 | 295 | uploadStartedAt := time.Now() | |
| 249 | - | uploadedBytes := rp.uploadToPBO(dirtyRects, sizeChanged) | |
| 296 | + | uploadedBytes := rp.uploadToPBO(dirtyRects, sizeChanged, renderSeq, paintSeq) | |
| 250 | 297 | uploadDuration := time.Since(uploadStartedAt) | |
| 251 | 298 | rp.uploadCount++ | |
| 252 | 299 | if sizeChanged { | |
| @@ -277,19 +324,35 @@ func (rp *renderPipeline) onGLRender() bool { | |||
| 277 | 324 | rp.maxRenderNs = renderDuration.Nanoseconds() | |
| 278 | 325 | } | |
| 279 | 326 | rp.maybeLogDiagnostics() | |
| 327 | + | if rp.ctx != nil { | |
| 328 | + | logging.FromContext(rp.ctx).Debug(). | |
| 329 | + | Uint64("render_seq", renderSeq). | |
| 330 | + | Uint64("paint_seq", paintSeq). | |
| 331 | + | Dur("render_duration", renderDuration). | |
| 332 | + | Bool("needs_upload", needsUpload). | |
| 333 | + | Bool("size_changed", sizeChanged). | |
| 334 | + | Msg("cef: onGLRender end") | |
| 335 | + | } | |
| 280 | 336 | ||
| 281 | 337 | return true | |
| 282 | 338 | } | |
| 283 | 339 | ||
| 284 | 340 | // uploadToPBO uploads dirty regions from the staging buffer into a PBO, then | |
| 285 | 341 | // transfers to the GL texture via async DMA. | |
| 286 | - | func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool) uint64 { | |
| 342 | + | func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, renderSeq, paintSeq uint64) uint64 { | |
| 287 | 343 | gl := rp.gl | |
| 288 | 344 | ||
| 289 | - | rp.mu.Lock() | |
| 290 | - | w := rp.width | |
| 291 | - | h := rp.height | |
| 292 | - | rp.mu.Unlock() | |
| 345 | + | w, h, _ := rp.viewRectSize() | |
| 346 | + | if rp.ctx != nil { | |
| 347 | + | logging.FromContext(rp.ctx).Debug(). | |
| 348 | + | Uint64("render_seq", renderSeq). | |
| 349 | + | Uint64("paint_seq", paintSeq). | |
| 350 | + | Int32("width", w). | |
| 351 | + | Int32("height", h). | |
| 352 | + | Int("dirty_rect_count", len(dirtyRects)). | |
| 353 | + | Bool("full_upload", fullUpload). | |
| 354 | + | Msg("cef: uploadToPBO begin") | |
| 355 | + | } | |
| 293 | 356 | ||
| 294 | 357 | bufSize := int64(w) * int64(h) * 4 | |
| 295 | 358 | stride := int(w) * 4 | |
| @@ -313,7 +376,11 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool) uint64 | |||
| 313 | 376 | copy(mappedSlice, rp.staging) | |
| 314 | 377 | uploadedBytes = uint64(bufSize) | |
| 315 | 378 | } else { | |
| 316 | - | for _, r := range dirtyRects { | |
| 379 | + | for _, rawRect := range dirtyRects { | |
| 380 | + | r, ok := clampDirtyRect(rawRect, w, h) | |
| 381 | + | if !ok { | |
| 382 | + | continue | |
| 383 | + | } | |
| 317 | 384 | for row := r.Y; row < r.Y+r.Height; row++ { | |
| 318 | 385 | off := int(row)*stride + int(r.X)*4 | |
| 319 | 386 | rowBytes := int(r.Width) * 4 | |
| @@ -324,7 +391,15 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool) uint64 | |||
| 324 | 391 | } | |
| 325 | 392 | rp.mu.Unlock() | |
| 326 | 393 | ||
| 327 | - | gl.unmapBuffer(glPixelUnpackBuffer) | |
| 394 | + | if !gl.unmapBuffer(glPixelUnpackBuffer) { | |
| 395 | + | logging.FromContext(rp.ctx).Error(). | |
| 396 | + | Int32("width", w). | |
| 397 | + | Int32("height", h). | |
| 398 | + | Bool("full_upload", fullUpload). | |
| 399 | + | Msg("cef: glUnmapBuffer reported corrupted PBO contents; dropping frame") | |
| 400 | + | gl.bindBuffer(glPixelUnpackBuffer, 0) | |
| 401 | + | return 0 | |
| 402 | + | } | |
| 328 | 403 | ||
| 329 | 404 | // Transfer PBO → texture. With PBO bound, the last arg is a byte offset. | |
| 330 | 405 | gl.bindTexture(glTexture2D, rp.texture) | |
| @@ -335,7 +410,11 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool) uint64 | |||
| 335 | 410 | // stride, allowing one glTexSubImage2D per dirty rect instead of | |
| 336 | 411 | // one per row. | |
| 337 | 412 | gl.pixelStorei(glUnpackRowLength, w) | |
| 338 | - | for _, r := range dirtyRects { | |
| 413 | + | for _, rawRect := range dirtyRects { | |
| 414 | + | r, ok := clampDirtyRect(rawRect, w, h) | |
| 415 | + | if !ok { | |
| 416 | + | continue | |
| 417 | + | } | |
| 339 | 418 | offset := uintptr(int(r.Y)*stride + int(r.X)*4) | |
| 340 | 419 | gl.texSubImage2D(glTexture2D, 0, r.X, r.Y, r.Width, r.Height, | |
| 341 | 420 | glBGRA, glUnsignedByte, pboOffset(offset)) | |
| @@ -348,6 +427,14 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool) uint64 | |||
| 348 | 427 | rp.pboIndex = 1 - rp.pboIndex | |
| 349 | 428 | ||
| 350 | 429 | gl.bindBuffer(glPixelUnpackBuffer, 0) | |
| 430 | + | if rp.ctx != nil { | |
| 431 | + | logging.FromContext(rp.ctx).Debug(). | |
| 432 | + | Uint64("render_seq", renderSeq). | |
| 433 | + | Uint64("paint_seq", paintSeq). | |
| 434 | + | Uint64("uploaded_bytes", uploadedBytes). | |
| 435 | + | Int("next_pbo_index", rp.pboIndex). | |
| 436 | + | Msg("cef: uploadToPBO end") | |
| 437 | + | } | |
| 351 | 438 | return uploadedBytes | |
| 352 | 439 | } | |
| 353 | 440 | ||
| @@ -553,6 +640,8 @@ func (rp *renderPipeline) onResize(width, height int32) { | |||
| 553 | 640 | ||
| 554 | 641 | rp.width = scaled(width) | |
| 555 | 642 | rp.height = scaled(height) | |
| 643 | + | rp.widthAtomic.Store(rp.width) | |
| 644 | + | rp.heightAtomic.Store(rp.height) | |
| 556 | 645 | rp.sizeChanged = true | |
| 557 | 646 | ||
| 558 | 647 | firstCB := rp.onFirstResize | |
| @@ -571,6 +660,70 @@ func (rp *renderPipeline) onResize(width, height int32) { | |||
| 571 | 660 | } | |
| 572 | 661 | } | |
| 573 | 662 | ||
| 663 | + | func (rp *renderPipeline) viewRectSize() (width, height, scale int32) { | |
| 664 | + | width = rp.widthAtomic.Load() | |
| 665 | + | height = rp.heightAtomic.Load() | |
| 666 | + | if width <= 0 || height <= 0 { | |
| 667 | + | rp.mu.Lock() | |
| 668 | + | width = rp.width | |
| 669 | + | height = rp.height | |
| 670 | + | rp.mu.Unlock() | |
| 671 | + | } | |
| 672 | + | scale = rp.scale | |
| 673 | + | if scale <= 0 { | |
| 674 | + | scale = 1 | |
| 675 | + | } | |
| 676 | + | return width, height, scale | |
| 677 | + | } | |
| 678 | + | ||
| 679 | + | func (rp *renderPipeline) nextViewRectSeq() uint64 { | |
| 680 | + | return rp.viewRectSeq.Add(1) | |
| 681 | + | } | |
| 682 | + | ||
| 683 | + | func (rp *renderPipeline) nextScreenInfoSeq() uint64 { | |
| 684 | + | return rp.screenInfoSeq.Add(1) | |
| 685 | + | } | |
| 686 | + | ||
| 687 | + | func (rp *renderPipeline) nextPaintSeq() uint64 { | |
| 688 | + | return rp.paintSeq.Add(1) | |
| 689 | + | } | |
| 690 | + | ||
| 691 | + | func clampDirtyRect(r rect, width, height int32) (rect, bool) { | |
| 692 | + | if width <= 0 || height <= 0 || r.Width <= 0 || r.Height <= 0 { | |
| 693 | + | return rect{}, false | |
| 694 | + | } | |
| 695 | + | ||
| 696 | + | x0 := maxInt32(r.X, 0) | |
| 697 | + | y0 := maxInt32(r.Y, 0) | |
| 698 | + | x1 := minInt32(r.X+r.Width, width) | |
| 699 | + | y1 := minInt32(r.Y+r.Height, height) | |
| 700 | + | ||
| 701 | + | if x1 <= x0 || y1 <= y0 { | |
| 702 | + | return rect{}, false | |
| 703 | + | } | |
| 704 | + | ||
| 705 | + | return rect{ | |
| 706 | + | X: x0, | |
| 707 | + | Y: y0, | |
| 708 | + | Width: x1 - x0, | |
| 709 | + | Height: y1 - y0, | |
| 710 | + | }, true | |
| 711 | + | } | |
| 712 | + | ||
| 713 | + | func minInt32(a, b int32) int32 { | |
| 714 | + | if a < b { | |
| 715 | + | return a | |
| 716 | + | } | |
| 717 | + | return b | |
| 718 | + | } | |
| 719 | + | ||
| 720 | + | func maxInt32(a, b int32) int32 { | |
| 721 | + | if a > b { | |
| 722 | + | return a | |
| 723 | + | } | |
| 724 | + | return b | |
| 725 | + | } | |
| 726 | + | ||
| 574 | 727 | // destroy releases all GL resources. | |
| 575 | 728 | func (rp *renderPipeline) destroy() { | |
| 576 | 729 | if !rp.glReady { | |
| @@ -3,6 +3,7 @@ package cef | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "context" | 4 | "context" | ||
| 5 | "sync" | 5 | "sync" | ||
| 6 | + | "sync/atomic" | |||
| 6 | "time" | 7 | "time" | ||
| 7 | "unsafe" | 8 | "unsafe" | ||
| 8 | 9 | ||||
| @@ -40,6 +41,10 @@ type renderPipeline struct { | |||||
| 40 | height int32 | 41 | height int32 | ||
| 41 | scale int32 | 42 | scale int32 | ||
| 42 | 43 | ||||
| 44 | + | // geometry mirrors the current surface size for lock-free CEF callbacks. | |||
| 45 | + | widthAtomic atomic.Int32 | |||
| 46 | + | heightAtomic atomic.Int32 | |||
| 47 | + | ||||
| 43 | // Staging: handlePaint copies dirty rects here, render signal uploads. | 48 | // Staging: handlePaint copies dirty rects here, render signal uploads. | ||
| 44 | mu sync.Mutex | 49 | mu sync.Mutex | ||
| 45 | staging []byte | 50 | staging []byte | ||
| @@ -51,6 +56,11 @@ type renderPipeline struct { | |||||
| 51 | glReady bool | 56 | glReady bool | ||
| 52 | 57 | ||||
| 53 | // Diagnostics (main thread only). | 58 | // Diagnostics (main thread only). | ||
| 59 | + | viewRectSeq atomic.Uint64 | |||
| 60 | + | screenInfoSeq atomic.Uint64 | |||
| 61 | + | paintSeq atomic.Uint64 | |||
| 62 | + | lastQueuedPaintSeq atomic.Uint64 | |||
| 63 | + | glRenderSeq atomic.Uint64 | |||
| 54 | paintCount uint64 | 64 | paintCount uint64 | ||
| 55 | acceleratedPaintCount uint64 | 65 | acceleratedPaintCount uint64 | ||
| 56 | queueRenderCount uint64 | 66 | queueRenderCount uint64 | ||
| @@ -151,30 +161,50 @@ func newRenderPipeline(ctx context.Context, gl *glLoader, scale int32) *renderPi | |||||
| 151 | // handlePaint is called from CEF's OnPaint callback (on GTK main thread via | 161 | // handlePaint is called from CEF's OnPaint callback (on GTK main thread via | ||
| 152 | // IdleAdd). It copies dirty rect regions from the CEF buffer into the staging | 162 | // IdleAdd). It copies dirty rect regions from the CEF buffer into the staging | ||
| 153 | // buffer and queues a GL redraw. | 163 | // buffer and queues a GL redraw. | ||
| 154 | - | func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects []rect) { | 164 | + | func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects []rect, paintSeq uint64) { |
| 155 | if len(buffer) == 0 || width <= 0 || height <= 0 { | 165 | if len(buffer) == 0 || width <= 0 || height <= 0 { | ||
| 156 | return | 166 | return | ||
| 157 | } | 167 | } | ||
| 158 | 168 | ||||
| 159 | startedAt := time.Now() | 169 | startedAt := time.Now() | ||
| 170 | + | rp.lastQueuedPaintSeq.Store(paintSeq) | |||
| 171 | + | if rp.ctx != nil { | |||
| 172 | + | logging.FromContext(rp.ctx).Debug(). | |||
| 173 | + | Uint64("paint_seq", paintSeq). | |||
| 174 | + | Int32("width", width). | |||
| 175 | + | Int32("height", height). | |||
| 176 | + | Int("rect_count", len(rects)). | |||
| 177 | + | Int("buffer_len", len(buffer)). | |||
| 178 | + | Msg("cef: handlePaint begin") | |||
| 179 | + | } | |||
| 160 | rp.mu.Lock() | 180 | rp.mu.Lock() | ||
| 161 | 181 | ||||
| 162 | bufSize := int(int64(width) * int64(height) * 4) | 182 | bufSize := int(int64(width) * int64(height) * 4) | ||
| 163 | copiedBytes := uint64(0) | 183 | copiedBytes := uint64(0) | ||
| 184 | + | sizeChanged := false | |||
| 164 | 185 | ||||
| 165 | // Detect size change, or first paint (staging not yet allocated). | 186 | // Detect size change, or first paint (staging not yet allocated). | ||
| 166 | if width != rp.width || height != rp.height || rp.staging == nil { | 187 | if width != rp.width || height != rp.height || rp.staging == nil { | ||
| 167 | rp.width = width | 188 | rp.width = width | ||
| 168 | rp.height = height | 189 | rp.height = height | ||
| 190 | + | rp.widthAtomic.Store(width) | |||
| 191 | + | rp.heightAtomic.Store(height) | |||
| 169 | rp.staging = make([]byte, bufSize) | 192 | rp.staging = make([]byte, bufSize) | ||
| 170 | rp.sizeChanged = true | 193 | rp.sizeChanged = true | ||
| 194 | + | sizeChanged = true | |||
| 171 | // On size change, copy the entire buffer. | 195 | // On size change, copy the entire buffer. | ||
| 172 | copy(rp.staging, buffer) | 196 | copy(rp.staging, buffer) | ||
| 173 | copiedBytes = uint64(bufSize) | 197 | copiedBytes = uint64(bufSize) | ||
| 174 | } else { | 198 | } else { | ||
| 175 | // Copy only dirty rect rows. | 199 | // Copy only dirty rect rows. | ||
| 176 | stride := int(width) * 4 | 200 | stride := int(width) * 4 | ||
| 177 | - | for _, r := range rects { | 201 | + | sanitized := make([]rect, 0, len(rects)) |
| 202 | + | for _, rawRect := range rects { | |||
| 203 | + | r, ok := clampDirtyRect(rawRect, width, height) | |||
| 204 | + | if !ok { | |||
| 205 | + | continue | |||
| 206 | + | } | |||
| 207 | + | sanitized = append(sanitized, r) | |||
| 178 | for row := r.Y; row < r.Y+r.Height; row++ { | 208 | for row := r.Y; row < r.Y+r.Height; row++ { | ||
| 179 | srcOff := int(row)*stride + int(r.X)*4 | 209 | srcOff := int(row)*stride + int(r.X)*4 | ||
| 180 | dstOff := srcOff | 210 | dstOff := srcOff | ||
| @@ -183,6 +213,7 @@ func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects | |||||
| 183 | copiedBytes += uint64(rowBytes) | 213 | copiedBytes += uint64(rowBytes) | ||
| 184 | } | 214 | } | ||
| 185 | } | 215 | } | ||
| 216 | + | rects = sanitized | |||
| 186 | } | 217 | } | ||
| 187 | 218 | ||||
| 188 | // Accumulate dirty rects across multiple OnPaint calls between renders. | 219 | // Accumulate dirty rects across multiple OnPaint calls between renders. | ||
| @@ -205,12 +236,28 @@ func (rp *renderPipeline) handlePaint(buffer []byte, width, height int32, rects | |||||
| 205 | } | 236 | } | ||
| 206 | 237 | ||||
| 207 | rp.glArea.QueueRender() | 238 | rp.glArea.QueueRender() | ||
| 239 | + | if rp.ctx != nil { | |||
| 240 | + | logging.FromContext(rp.ctx).Debug(). | |||
| 241 | + | Uint64("paint_seq", paintSeq). | |||
| 242 | + | Uint64("copied_bytes", copiedBytes). | |||
| 243 | + | Bool("size_changed", sizeChanged). | |||
| 244 | + | Dur("copy_duration", copyDuration). | |||
| 245 | + | Msg("cef: handlePaint end") | |||
| 246 | + | } | |||
| 208 | rp.maybeLogDiagnostics() | 247 | rp.maybeLogDiagnostics() | ||
| 209 | } | 248 | } | ||
| 210 | 249 | ||||
| 211 | // onGLRender is the GTK "render" signal handler. GL context is current. | 250 | // onGLRender is the GTK "render" signal handler. GL context is current. | ||
| 212 | func (rp *renderPipeline) onGLRender() bool { | 251 | func (rp *renderPipeline) onGLRender() bool { | ||
| 252 | + | renderSeq := rp.glRenderSeq.Add(1) | |||
| 253 | + | paintSeq := rp.lastQueuedPaintSeq.Load() | |||
| 213 | renderStartedAt := time.Now() | 254 | renderStartedAt := time.Now() | ||
| 255 | + | if rp.ctx != nil { | |||
| 256 | + | logging.FromContext(rp.ctx).Debug(). | |||
| 257 | + | Uint64("render_seq", renderSeq). | |||
| 258 | + | Uint64("paint_seq", paintSeq). | |||
| 259 | + | Msg("cef: onGLRender begin") | |||
| 260 | + | } | |||
| 214 | rp.mu.Lock() | 261 | rp.mu.Lock() | ||
| 215 | 262 | ||||
| 216 | needsUpload := rp.needsUpload | 263 | needsUpload := rp.needsUpload | ||
| @@ -246,7 +293,7 @@ func (rp *renderPipeline) onGLRender() bool { | |||||
| 246 | 293 | ||||
| 247 | if needsUpload { | 294 | if needsUpload { | ||
| 248 | uploadStartedAt := time.Now() | 295 | uploadStartedAt := time.Now() | ||
| 249 | - | uploadedBytes := rp.uploadToPBO(dirtyRects, sizeChanged) | 296 | + | uploadedBytes := rp.uploadToPBO(dirtyRects, sizeChanged, renderSeq, paintSeq) |
| 250 | uploadDuration := time.Since(uploadStartedAt) | 297 | uploadDuration := time.Since(uploadStartedAt) | ||
| 251 | rp.uploadCount++ | 298 | rp.uploadCount++ | ||
| 252 | if sizeChanged { | 299 | if sizeChanged { | ||
| @@ -277,19 +324,35 @@ func (rp *renderPipeline) onGLRender() bool { | |||||
| 277 | rp.maxRenderNs = renderDuration.Nanoseconds() | 324 | rp.maxRenderNs = renderDuration.Nanoseconds() | ||
| 278 | } | 325 | } | ||
| 279 | rp.maybeLogDiagnostics() | 326 | rp.maybeLogDiagnostics() | ||
| 327 | + | if rp.ctx != nil { | |||
| 328 | + | logging.FromContext(rp.ctx).Debug(). | |||
| 329 | + | Uint64("render_seq", renderSeq). | |||
| 330 | + | Uint64("paint_seq", paintSeq). | |||
| 331 | + | Dur("render_duration", renderDuration). | |||
| 332 | + | Bool("needs_upload", needsUpload). | |||
| 333 | + | Bool("size_changed", sizeChanged). | |||
| 334 | + | Msg("cef: onGLRender end") | |||
| 335 | + | } | |||
| 280 | 336 | ||||
| 281 | return true | 337 | return true | ||
| 282 | } | 338 | } | ||
| 283 | 339 | ||||
| 284 | // uploadToPBO uploads dirty regions from the staging buffer into a PBO, then | 340 | // uploadToPBO uploads dirty regions from the staging buffer into a PBO, then | ||
| 285 | // transfers to the GL texture via async DMA. | 341 | // transfers to the GL texture via async DMA. | ||
| 286 | - | func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool) uint64 { | 342 | + | func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, renderSeq, paintSeq uint64) uint64 { |
| 287 | gl := rp.gl | 343 | gl := rp.gl | ||
| 288 | 344 | ||||
| 289 | - | rp.mu.Lock() | 345 | + | w, h, _ := rp.viewRectSize() |
| 290 | - | w := rp.width | 346 | + | if rp.ctx != nil { |
| 291 | - | h := rp.height | 347 | + | logging.FromContext(rp.ctx).Debug(). |
| 292 | - | rp.mu.Unlock() | 348 | + | Uint64("render_seq", renderSeq). |
| 349 | + | Uint64("paint_seq", paintSeq). | |||
| 350 | + | Int32("width", w). | |||
| 351 | + | Int32("height", h). | |||
| 352 | + | Int("dirty_rect_count", len(dirtyRects)). | |||
| 353 | + | Bool("full_upload", fullUpload). | |||
| 354 | + | Msg("cef: uploadToPBO begin") | |||
| 355 | + | } | |||
| 293 | 356 | ||||
| 294 | bufSize := int64(w) * int64(h) * 4 | 357 | bufSize := int64(w) * int64(h) * 4 | ||
| 295 | stride := int(w) * 4 | 358 | stride := int(w) * 4 | ||
| @@ -313,7 +376,11 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool) uint64 | |||||
| 313 | copy(mappedSlice, rp.staging) | 376 | copy(mappedSlice, rp.staging) | ||
| 314 | uploadedBytes = uint64(bufSize) | 377 | uploadedBytes = uint64(bufSize) | ||
| 315 | } else { | 378 | } else { | ||
| 316 | - | for _, r := range dirtyRects { | 379 | + | for _, rawRect := range dirtyRects { |
| 380 | + | r, ok := clampDirtyRect(rawRect, w, h) | |||
| 381 | + | if !ok { | |||
| 382 | + | continue | |||
| 383 | + | } | |||
| 317 | for row := r.Y; row < r.Y+r.Height; row++ { | 384 | for row := r.Y; row < r.Y+r.Height; row++ { | ||
| 318 | off := int(row)*stride + int(r.X)*4 | 385 | off := int(row)*stride + int(r.X)*4 | ||
| 319 | rowBytes := int(r.Width) * 4 | 386 | rowBytes := int(r.Width) * 4 | ||
| @@ -324,7 +391,15 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool) uint64 | |||||
| 324 | } | 391 | } | ||
| 325 | rp.mu.Unlock() | 392 | rp.mu.Unlock() | ||
| 326 | 393 | ||||
| 327 | - | gl.unmapBuffer(glPixelUnpackBuffer) | 394 | + | if !gl.unmapBuffer(glPixelUnpackBuffer) { |
| 395 | + | logging.FromContext(rp.ctx).Error(). | |||
| 396 | + | Int32("width", w). | |||
| 397 | + | Int32("height", h). | |||
| 398 | + | Bool("full_upload", fullUpload). | |||
| 399 | + | Msg("cef: glUnmapBuffer reported corrupted PBO contents; dropping frame") | |||
| 400 | + | gl.bindBuffer(glPixelUnpackBuffer, 0) | |||
| 401 | + | return 0 | |||
| 402 | + | } | |||
| 328 | 403 | ||||
| 329 | // Transfer PBO → texture. With PBO bound, the last arg is a byte offset. | 404 | // Transfer PBO → texture. With PBO bound, the last arg is a byte offset. | ||
| 330 | gl.bindTexture(glTexture2D, rp.texture) | 405 | gl.bindTexture(glTexture2D, rp.texture) | ||
| @@ -335,7 +410,11 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool) uint64 | |||||
| 335 | // stride, allowing one glTexSubImage2D per dirty rect instead of | 410 | // stride, allowing one glTexSubImage2D per dirty rect instead of | ||
| 336 | // one per row. | 411 | // one per row. | ||
| 337 | gl.pixelStorei(glUnpackRowLength, w) | 412 | gl.pixelStorei(glUnpackRowLength, w) | ||
| 338 | - | for _, r := range dirtyRects { | 413 | + | for _, rawRect := range dirtyRects { |
| 414 | + | r, ok := clampDirtyRect(rawRect, w, h) | |||
| 415 | + | if !ok { | |||
| 416 | + | continue | |||
| 417 | + | } | |||
| 339 | offset := uintptr(int(r.Y)*stride + int(r.X)*4) | 418 | offset := uintptr(int(r.Y)*stride + int(r.X)*4) | ||
| 340 | gl.texSubImage2D(glTexture2D, 0, r.X, r.Y, r.Width, r.Height, | 419 | gl.texSubImage2D(glTexture2D, 0, r.X, r.Y, r.Width, r.Height, | ||
| 341 | glBGRA, glUnsignedByte, pboOffset(offset)) | 420 | glBGRA, glUnsignedByte, pboOffset(offset)) | ||
| @@ -348,6 +427,14 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool) uint64 | |||||
| 348 | rp.pboIndex = 1 - rp.pboIndex | 427 | rp.pboIndex = 1 - rp.pboIndex | ||
| 349 | 428 | ||||
| 350 | gl.bindBuffer(glPixelUnpackBuffer, 0) | 429 | gl.bindBuffer(glPixelUnpackBuffer, 0) | ||
| 430 | + | if rp.ctx != nil { | |||
| 431 | + | logging.FromContext(rp.ctx).Debug(). | |||
| 432 | + | Uint64("render_seq", renderSeq). | |||
| 433 | + | Uint64("paint_seq", paintSeq). | |||
| 434 | + | Uint64("uploaded_bytes", uploadedBytes). | |||
| 435 | + | Int("next_pbo_index", rp.pboIndex). | |||
| 436 | + | Msg("cef: uploadToPBO end") | |||
| 437 | + | } | |||
| 351 | return uploadedBytes | 438 | return uploadedBytes | ||
| 352 | } | 439 | } | ||
| 353 | 440 | ||||
| @@ -553,6 +640,8 @@ func (rp *renderPipeline) onResize(width, height int32) { | |||||
| 553 | 640 | ||||
| 554 | rp.width = scaled(width) | 641 | rp.width = scaled(width) | ||
| 555 | rp.height = scaled(height) | 642 | rp.height = scaled(height) | ||
| 643 | + | rp.widthAtomic.Store(rp.width) | |||
| 644 | + | rp.heightAtomic.Store(rp.height) | |||
| 556 | rp.sizeChanged = true | 645 | rp.sizeChanged = true | ||
| 557 | 646 | ||||
| 558 | firstCB := rp.onFirstResize | 647 | firstCB := rp.onFirstResize | ||
| @@ -571,6 +660,70 @@ func (rp *renderPipeline) onResize(width, height int32) { | |||||
| 571 | } | 660 | } | ||
| 572 | } | 661 | } | ||
| 573 | 662 | ||||
| 663 | + | func (rp *renderPipeline) viewRectSize() (width, height, scale int32) { | |||
| 664 | + | width = rp.widthAtomic.Load() | |||
| 665 | + | height = rp.heightAtomic.Load() | |||
| 666 | + | if width <= 0 || height <= 0 { | |||
| 667 | + | rp.mu.Lock() | |||
| 668 | + | width = rp.width | |||
| 669 | + | height = rp.height | |||
| 670 | + | rp.mu.Unlock() | |||
| 671 | + | } | |||
| 672 | + | scale = rp.scale | |||
| 673 | + | if scale <= 0 { | |||
| 674 | + | scale = 1 | |||
| 675 | + | } | |||
| 676 | + | return width, height, scale | |||
| 677 | + | } | |||
| 678 | + | ||||
| 679 | + | func (rp *renderPipeline) nextViewRectSeq() uint64 { | |||
| 680 | + | return rp.viewRectSeq.Add(1) | |||
| 681 | + | } | |||
| 682 | + | ||||
| 683 | + | func (rp *renderPipeline) nextScreenInfoSeq() uint64 { | |||
| 684 | + | return rp.screenInfoSeq.Add(1) | |||
| 685 | + | } | |||
| 686 | + | ||||
| 687 | + | func (rp *renderPipeline) nextPaintSeq() uint64 { | |||
| 688 | + | return rp.paintSeq.Add(1) | |||
| 689 | + | } | |||
| 690 | + | ||||
| 691 | + | func clampDirtyRect(r rect, width, height int32) (rect, bool) { | |||
| 692 | + | if width <= 0 || height <= 0 || r.Width <= 0 || r.Height <= 0 { | |||
| 693 | + | return rect{}, false | |||
| 694 | + | } | |||
| 695 | + | ||||
| 696 | + | x0 := maxInt32(r.X, 0) | |||
| 697 | + | y0 := maxInt32(r.Y, 0) | |||
| 698 | + | x1 := minInt32(r.X+r.Width, width) | |||
| 699 | + | y1 := minInt32(r.Y+r.Height, height) | |||
| 700 | + | ||||
| 701 | + | if x1 <= x0 || y1 <= y0 { | |||
| 702 | + | return rect{}, false | |||
| 703 | + | } | |||
| 704 | + | ||||
| 705 | + | return rect{ | |||
| 706 | + | X: x0, | |||
| 707 | + | Y: y0, | |||
| 708 | + | Width: x1 - x0, | |||
| 709 | + | Height: y1 - y0, | |||
| 710 | + | }, true | |||
| 711 | + | } | |||
| 712 | + | ||||
| 713 | + | func minInt32(a, b int32) int32 { | |||
| 714 | + | if a < b { | |||
| 715 | + | return a | |||
| 716 | + | } | |||
| 717 | + | return b | |||
| 718 | + | } | |||
| 719 | + | ||||
| 720 | + | func maxInt32(a, b int32) int32 { | |||
| 721 | + | if a > b { | |||
| 722 | + | return a | |||
| 723 | + | } | |||
| 724 | + | return b | |||
| 725 | + | } | |||
| 726 | + | ||||
| 574 | // destroy releases all GL resources. | 727 | // destroy releases all GL resources. | ||
| 575 | func (rp *renderPipeline) destroy() { | 728 | func (rp *renderPipeline) destroy() { | ||
| 576 | if !rp.glReady { | 729 | if !rp.glReady { | ||
M internal/infrastructure/cef/webview.go +16 -8
| @@ -90,6 +90,14 @@ type pendingBrowserCreate struct { | |||
| 90 | 90 | settings *purecef.BrowserSettings | |
| 91 | 91 | } | |
| 92 | 92 | ||
| 93 | + | type cefTaskFunc func() | |
| 94 | + | ||
| 95 | + | func (fn cefTaskFunc) Execute() { | |
| 96 | + | if fn != nil { | |
| 97 | + | fn() | |
| 98 | + | } | |
| 99 | + | } | |
| 100 | + | ||
| 93 | 101 | // --------------------------------------------------------------------------- | |
| 94 | 102 | // Identity | |
| 95 | 103 | // --------------------------------------------------------------------------- | |
| @@ -549,14 +557,6 @@ func (wv *WebView) updateHoverURI(uri string) { | |||
| 549 | 557 | wv.mu.Unlock() | |
| 550 | 558 | } | |
| 551 | 559 | ||
| 552 | - | type cefTaskFunc func() | |
| 553 | - | ||
| 554 | - | func (f cefTaskFunc) Execute() { | |
| 555 | - | if f != nil { | |
| 556 | - | f() | |
| 557 | - | } | |
| 558 | - | } | |
| 559 | - | ||
| 560 | 560 | func (wv *WebView) scheduleZoomRefresh() { | |
| 561 | 561 | for _, delayMs := range [...]int64{16, 48} { | |
| 562 | 562 | purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() { | |
| @@ -679,3 +679,11 @@ func (wv *WebView) runOnGTK(fn func()) { | |||
| 679 | 679 | } | |
| 680 | 680 | glib.IdleAddOnce(cb, 0) | |
| 681 | 681 | } | |
| 682 | + | ||
| 683 | + | func (wv *WebView) takePendingCreate() *pendingBrowserCreate { | |
| 684 | + | wv.mu.Lock() | |
| 685 | + | defer wv.mu.Unlock() | |
| 686 | + | pc := wv.pendingCreate | |
| 687 | + | wv.pendingCreate = nil | |
| 688 | + | return pc | |
| 689 | + | } | |
| @@ -90,6 +90,14 @@ type pendingBrowserCreate struct { | |||||
| 90 | settings *purecef.BrowserSettings | 90 | settings *purecef.BrowserSettings | ||
| 91 | } | 91 | } | ||
| 92 | 92 | ||||
| 93 | + | type cefTaskFunc func() | |||
| 94 | + | ||||
| 95 | + | func (fn cefTaskFunc) Execute() { | |||
| 96 | + | if fn != nil { | |||
| 97 | + | fn() | |||
| 98 | + | } | |||
| 99 | + | } | |||
| 100 | + | ||||
| 93 | // --------------------------------------------------------------------------- | 101 | // --------------------------------------------------------------------------- | ||
| 94 | // Identity | 102 | // Identity | ||
| 95 | // --------------------------------------------------------------------------- | 103 | // --------------------------------------------------------------------------- | ||
| @@ -549,14 +557,6 @@ func (wv *WebView) updateHoverURI(uri string) { | |||||
| 549 | wv.mu.Unlock() | 557 | wv.mu.Unlock() | ||
| 550 | } | 558 | } | ||
| 551 | 559 | ||||
| 552 | - | type cefTaskFunc func() | |||
| 553 | - | ||||
| 554 | - | func (f cefTaskFunc) Execute() { | |||
| 555 | - | if f != nil { | |||
| 556 | - | f() | |||
| 557 | - | } | |||
| 558 | - | } | |||
| 559 | - | ||||
| 560 | func (wv *WebView) scheduleZoomRefresh() { | 560 | func (wv *WebView) scheduleZoomRefresh() { | ||
| 561 | for _, delayMs := range [...]int64{16, 48} { | 561 | for _, delayMs := range [...]int64{16, 48} { | ||
| 562 | purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() { | 562 | purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() { | ||
| @@ -679,3 +679,11 @@ func (wv *WebView) runOnGTK(fn func()) { | |||||
| 679 | } | 679 | } | ||
| 680 | glib.IdleAddOnce(cb, 0) | 680 | glib.IdleAddOnce(cb, 0) | ||
| 681 | } | 681 | } | ||
| 682 | + | ||||
| 683 | + | func (wv *WebView) takePendingCreate() *pendingBrowserCreate { | |||
| 684 | + | wv.mu.Lock() | |||
| 685 | + | defer wv.mu.Unlock() | |||
| 686 | + | pc := wv.pendingCreate | |||
| 687 | + | wv.pendingCreate = nil | |||
| 688 | + | return pc | |||
| 689 | + | } | |||
M internal/infrastructure/cef/webview_handlers.go +47 -19
| @@ -79,18 +79,12 @@ func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) { | |||
| 79 | 79 | if rect == nil { | |
| 80 | 80 | return | |
| 81 | 81 | } | |
| 82 | - | h.wv.pipeline.mu.Lock() | |
| 83 | - | w := h.wv.pipeline.width | |
| 84 | - | ht := h.wv.pipeline.height | |
| 85 | - | s := h.wv.pipeline.scale | |
| 86 | - | h.wv.pipeline.mu.Unlock() | |
| 82 | + | callSeq := h.wv.pipeline.nextViewRectSeq() | |
| 83 | + | w, ht, s := h.wv.pipeline.viewRectSize() | |
| 87 | 84 | ||
| 88 | 85 | // CEF expects view geometry in DIP coordinates while OnPaint dimensions are | |
| 89 | 86 | // in device pixels. The render pipeline tracks device pixels, so convert | |
| 90 | 87 | // back to DIP before answering GetViewRect/GetScreenInfo. | |
| 91 | - | if s <= 0 { | |
| 92 | - | s = 1 | |
| 93 | - | } | |
| 94 | 88 | w /= s | |
| 95 | 89 | ht /= s | |
| 96 | 90 | ||
| @@ -107,6 +101,14 @@ func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) { | |||
| 107 | 101 | rect.Y = 0 | |
| 108 | 102 | rect.Width = w | |
| 109 | 103 | rect.Height = ht | |
| 104 | + | if h.wv != nil && h.wv.ctx != nil { | |
| 105 | + | logging.FromContext(h.wv.ctx).Debug(). | |
| 106 | + | Uint64("view_rect_seq", callSeq). | |
| 107 | + | Int32("width", rect.Width). | |
| 108 | + | Int32("height", rect.Height). | |
| 109 | + | Int32("scale", s). | |
| 110 | + | Msg("cef: GetViewRect") | |
| 111 | + | } | |
| 110 | 112 | } | |
| 111 | 113 | ||
| 112 | 114 | func (h *handlerSet) GetScreenPoint(_ purecef.Browser, _, _ int32, _, _ *int32) int32 { | |
| @@ -117,14 +119,8 @@ func (h *handlerSet) GetScreenInfo(_ purecef.Browser, info *purecef.ScreenInfo) | |||
| 117 | 119 | if info == nil { | |
| 118 | 120 | return 0 | |
| 119 | 121 | } | |
| 120 | - | h.wv.pipeline.mu.Lock() | |
| 121 | - | w := h.wv.pipeline.width | |
| 122 | - | ht := h.wv.pipeline.height | |
| 123 | - | s := h.wv.pipeline.scale | |
| 124 | - | h.wv.pipeline.mu.Unlock() | |
| 125 | - | if s <= 0 { | |
| 126 | - | s = 1 | |
| 127 | - | } | |
| 122 | + | callSeq := h.wv.pipeline.nextScreenInfoSeq() | |
| 123 | + | w, ht, s := h.wv.pipeline.viewRectSize() | |
| 128 | 124 | w /= s | |
| 129 | 125 | ht /= s | |
| 130 | 126 | if w <= 0 { | |
| @@ -140,6 +136,14 @@ func (h *handlerSet) GetScreenInfo(_ purecef.Browser, info *purecef.ScreenInfo) | |||
| 140 | 136 | ) | |
| 141 | 137 | r := purecef.Rect{X: 0, Y: 0, Width: w, Height: ht} | |
| 142 | 138 | *info = purecef.NewScreenInfo(float32(s), screenDepth, screenDepthPerComponent, false, r, r) | |
| 139 | + | if h.wv != nil && h.wv.ctx != nil { | |
| 140 | + | logging.FromContext(h.wv.ctx).Debug(). | |
| 141 | + | Uint64("screen_info_seq", callSeq). | |
| 142 | + | Int32("width", w). | |
| 143 | + | Int32("height", ht). | |
| 144 | + | Int32("scale", s). | |
| 145 | + | Msg("cef: GetScreenInfo") | |
| 146 | + | } | |
| 143 | 147 | return 1 | |
| 144 | 148 | } | |
| 145 | 149 | ||
| @@ -157,6 +161,16 @@ func (h *handlerSet) OnPaint( | |||
| 157 | 161 | if len(buffer) == 0 || width <= 0 || height <= 0 { | |
| 158 | 162 | return | |
| 159 | 163 | } | |
| 164 | + | paintSeq := h.wv.pipeline.nextPaintSeq() | |
| 165 | + | if h.wv != nil && h.wv.ctx != nil { | |
| 166 | + | logging.FromContext(h.wv.ctx).Debug(). | |
| 167 | + | Uint64("paint_seq", paintSeq). | |
| 168 | + | Int32("width", width). | |
| 169 | + | Int32("height", height). | |
| 170 | + | Int("dirty_rect_count", len(dirtyRects)). | |
| 171 | + | Int("buffer_len", len(buffer)). | |
| 172 | + | Msg("cef: OnPaint begin") | |
| 173 | + | } | |
| 160 | 174 | rects := make([]rect, len(dirtyRects)) | |
| 161 | 175 | for i, dr := range dirtyRects { | |
| 162 | 176 | rects[i] = rect{X: dr.X, Y: dr.Y, Width: dr.Width, Height: dr.Height} | |
| @@ -165,11 +179,21 @@ func (h *handlerSet) OnPaint( | |||
| 165 | 179 | pixels := make([]byte, len(buffer)) | |
| 166 | 180 | copy(pixels, buffer) | |
| 167 | 181 | h.wv.runOnGTK(func() { | |
| 168 | - | h.wv.pipeline.handlePaint(pixels, width, height, rects) | |
| 182 | + | h.wv.pipeline.handlePaint(pixels, width, height, rects, paintSeq) | |
| 169 | 183 | }) | |
| 184 | + | if h.wv != nil && h.wv.ctx != nil { | |
| 185 | + | logging.FromContext(h.wv.ctx).Debug(). | |
| 186 | + | Uint64("paint_seq", paintSeq). | |
| 187 | + | Msg("cef: OnPaint queued to GTK") | |
| 188 | + | } | |
| 170 | 189 | return | |
| 171 | 190 | } | |
| 172 | - | h.wv.pipeline.handlePaint(buffer, width, height, rects) | |
| 191 | + | h.wv.pipeline.handlePaint(buffer, width, height, rects, paintSeq) | |
| 192 | + | if h.wv != nil && h.wv.ctx != nil { | |
| 193 | + | logging.FromContext(h.wv.ctx).Debug(). | |
| 194 | + | Uint64("paint_seq", paintSeq). | |
| 195 | + | Msg("cef: OnPaint handled inline") | |
| 196 | + | } | |
| 173 | 197 | } | |
| 174 | 198 | ||
| 175 | 199 | func (h *handlerSet) OnAcceleratedPaint(_ purecef.Browser, _ purecef.PaintElementType, _ []purecef.Rect, _ *purecef.AcceleratedPaintInfo) { | |
| @@ -342,8 +366,12 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu | |||
| 342 | 366 | h.wv.crashCount.Store(0) | |
| 343 | 367 | ||
| 344 | 368 | // Inject scripts and styles after page load. | |
| 369 | + | // Must run on GTK thread — OnLoadEnd fires on the CEF IO thread, | |
| 370 | + | // and JavaScript injection requires the main thread. | |
| 345 | 371 | if h.wv.engine != nil && h.wv.engine.contentInj != nil { | |
| 346 | - | h.wv.engine.contentInj.onLoadEnd(h.wv) | |
| 372 | + | h.wv.runOnGTK(func() { | |
| 373 | + | h.wv.engine.contentInj.onLoadEnd(h.wv) | |
| 374 | + | }) | |
| 347 | 375 | } | |
| 348 | 376 | ||
| 349 | 377 | h.wv.mu.RLock() | |
| @@ -79,18 +79,12 @@ func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) { | |||||
| 79 | if rect == nil { | 79 | if rect == nil { | ||
| 80 | return | 80 | return | ||
| 81 | } | 81 | } | ||
| 82 | - | h.wv.pipeline.mu.Lock() | 82 | + | callSeq := h.wv.pipeline.nextViewRectSeq() |
| 83 | - | w := h.wv.pipeline.width | 83 | + | w, ht, s := h.wv.pipeline.viewRectSize() |
| 84 | - | ht := h.wv.pipeline.height | |||
| 85 | - | s := h.wv.pipeline.scale | |||
| 86 | - | h.wv.pipeline.mu.Unlock() | |||
| 87 | 84 | ||||
| 88 | // CEF expects view geometry in DIP coordinates while OnPaint dimensions are | 85 | // CEF expects view geometry in DIP coordinates while OnPaint dimensions are | ||
| 89 | // in device pixels. The render pipeline tracks device pixels, so convert | 86 | // in device pixels. The render pipeline tracks device pixels, so convert | ||
| 90 | // back to DIP before answering GetViewRect/GetScreenInfo. | 87 | // back to DIP before answering GetViewRect/GetScreenInfo. | ||
| 91 | - | if s <= 0 { | |||
| 92 | - | s = 1 | |||
| 93 | - | } | |||
| 94 | w /= s | 88 | w /= s | ||
| 95 | ht /= s | 89 | ht /= s | ||
| 96 | 90 | ||||
| @@ -107,6 +101,14 @@ func (h *handlerSet) GetViewRect(_ purecef.Browser, rect *purecef.Rect) { | |||||
| 107 | rect.Y = 0 | 101 | rect.Y = 0 | ||
| 108 | rect.Width = w | 102 | rect.Width = w | ||
| 109 | rect.Height = ht | 103 | rect.Height = ht | ||
| 104 | + | if h.wv != nil && h.wv.ctx != nil { | |||
| 105 | + | logging.FromContext(h.wv.ctx).Debug(). | |||
| 106 | + | Uint64("view_rect_seq", callSeq). | |||
| 107 | + | Int32("width", rect.Width). | |||
| 108 | + | Int32("height", rect.Height). | |||
| 109 | + | Int32("scale", s). | |||
| 110 | + | Msg("cef: GetViewRect") | |||
| 111 | + | } | |||
| 110 | } | 112 | } | ||
| 111 | 113 | ||||
| 112 | func (h *handlerSet) GetScreenPoint(_ purecef.Browser, _, _ int32, _, _ *int32) int32 { | 114 | func (h *handlerSet) GetScreenPoint(_ purecef.Browser, _, _ int32, _, _ *int32) int32 { | ||
| @@ -117,14 +119,8 @@ func (h *handlerSet) GetScreenInfo(_ purecef.Browser, info *purecef.ScreenInfo) | |||||
| 117 | if info == nil { | 119 | if info == nil { | ||
| 118 | return 0 | 120 | return 0 | ||
| 119 | } | 121 | } | ||
| 120 | - | h.wv.pipeline.mu.Lock() | 122 | + | callSeq := h.wv.pipeline.nextScreenInfoSeq() |
| 121 | - | w := h.wv.pipeline.width | 123 | + | w, ht, s := h.wv.pipeline.viewRectSize() |
| 122 | - | ht := h.wv.pipeline.height | |||
| 123 | - | s := h.wv.pipeline.scale | |||
| 124 | - | h.wv.pipeline.mu.Unlock() | |||
| 125 | - | if s <= 0 { | |||
| 126 | - | s = 1 | |||
| 127 | - | } | |||
| 128 | w /= s | 124 | w /= s | ||
| 129 | ht /= s | 125 | ht /= s | ||
| 130 | if w <= 0 { | 126 | if w <= 0 { | ||
| @@ -140,6 +136,14 @@ func (h *handlerSet) GetScreenInfo(_ purecef.Browser, info *purecef.ScreenInfo) | |||||
| 140 | ) | 136 | ) | ||
| 141 | r := purecef.Rect{X: 0, Y: 0, Width: w, Height: ht} | 137 | r := purecef.Rect{X: 0, Y: 0, Width: w, Height: ht} | ||
| 142 | *info = purecef.NewScreenInfo(float32(s), screenDepth, screenDepthPerComponent, false, r, r) | 138 | *info = purecef.NewScreenInfo(float32(s), screenDepth, screenDepthPerComponent, false, r, r) | ||
| 139 | + | if h.wv != nil && h.wv.ctx != nil { | |||
| 140 | + | logging.FromContext(h.wv.ctx).Debug(). | |||
| 141 | + | Uint64("screen_info_seq", callSeq). | |||
| 142 | + | Int32("width", w). | |||
| 143 | + | Int32("height", ht). | |||
| 144 | + | Int32("scale", s). | |||
| 145 | + | Msg("cef: GetScreenInfo") | |||
| 146 | + | } | |||
| 143 | return 1 | 147 | return 1 | ||
| 144 | } | 148 | } | ||
| 145 | 149 | ||||
| @@ -157,6 +161,16 @@ func (h *handlerSet) OnPaint( | |||||
| 157 | if len(buffer) == 0 || width <= 0 || height <= 0 { | 161 | if len(buffer) == 0 || width <= 0 || height <= 0 { | ||
| 158 | return | 162 | return | ||
| 159 | } | 163 | } | ||
| 164 | + | paintSeq := h.wv.pipeline.nextPaintSeq() | |||
| 165 | + | if h.wv != nil && h.wv.ctx != nil { | |||
| 166 | + | logging.FromContext(h.wv.ctx).Debug(). | |||
| 167 | + | Uint64("paint_seq", paintSeq). | |||
| 168 | + | Int32("width", width). | |||
| 169 | + | Int32("height", height). | |||
| 170 | + | Int("dirty_rect_count", len(dirtyRects)). | |||
| 171 | + | Int("buffer_len", len(buffer)). | |||
| 172 | + | Msg("cef: OnPaint begin") | |||
| 173 | + | } | |||
| 160 | rects := make([]rect, len(dirtyRects)) | 174 | rects := make([]rect, len(dirtyRects)) | ||
| 161 | for i, dr := range dirtyRects { | 175 | for i, dr := range dirtyRects { | ||
| 162 | rects[i] = rect{X: dr.X, Y: dr.Y, Width: dr.Width, Height: dr.Height} | 176 | rects[i] = rect{X: dr.X, Y: dr.Y, Width: dr.Width, Height: dr.Height} | ||
| @@ -165,11 +179,21 @@ func (h *handlerSet) OnPaint( | |||||
| 165 | pixels := make([]byte, len(buffer)) | 179 | pixels := make([]byte, len(buffer)) | ||
| 166 | copy(pixels, buffer) | 180 | copy(pixels, buffer) | ||
| 167 | h.wv.runOnGTK(func() { | 181 | h.wv.runOnGTK(func() { | ||
| 168 | - | h.wv.pipeline.handlePaint(pixels, width, height, rects) | 182 | + | h.wv.pipeline.handlePaint(pixels, width, height, rects, paintSeq) |
| 169 | }) | 183 | }) | ||
| 184 | + | if h.wv != nil && h.wv.ctx != nil { | |||
| 185 | + | logging.FromContext(h.wv.ctx).Debug(). | |||
| 186 | + | Uint64("paint_seq", paintSeq). | |||
| 187 | + | Msg("cef: OnPaint queued to GTK") | |||
| 188 | + | } | |||
| 170 | return | 189 | return | ||
| 171 | } | 190 | } | ||
| 172 | - | h.wv.pipeline.handlePaint(buffer, width, height, rects) | 191 | + | h.wv.pipeline.handlePaint(buffer, width, height, rects, paintSeq) |
| 192 | + | if h.wv != nil && h.wv.ctx != nil { | |||
| 193 | + | logging.FromContext(h.wv.ctx).Debug(). | |||
| 194 | + | Uint64("paint_seq", paintSeq). | |||
| 195 | + | Msg("cef: OnPaint handled inline") | |||
| 196 | + | } | |||
| 173 | } | 197 | } | ||
| 174 | 198 | ||||
| 175 | func (h *handlerSet) OnAcceleratedPaint(_ purecef.Browser, _ purecef.PaintElementType, _ []purecef.Rect, _ *purecef.AcceleratedPaintInfo) { | 199 | func (h *handlerSet) OnAcceleratedPaint(_ purecef.Browser, _ purecef.PaintElementType, _ []purecef.Rect, _ *purecef.AcceleratedPaintInfo) { | ||
| @@ -342,8 +366,12 @@ func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatu | |||||
| 342 | h.wv.crashCount.Store(0) | 366 | h.wv.crashCount.Store(0) | ||
| 343 | 367 | ||||
| 344 | // Inject scripts and styles after page load. | 368 | // Inject scripts and styles after page load. | ||
| 369 | + | // Must run on GTK thread — OnLoadEnd fires on the CEF IO thread, | |||
| 370 | + | // and JavaScript injection requires the main thread. | |||
| 345 | if h.wv.engine != nil && h.wv.engine.contentInj != nil { | 371 | if h.wv.engine != nil && h.wv.engine.contentInj != nil { | ||
| 346 | - | h.wv.engine.contentInj.onLoadEnd(h.wv) | 372 | + | h.wv.runOnGTK(func() { |
| 373 | + | h.wv.engine.contentInj.onLoadEnd(h.wv) | |||
| 374 | + | }) | |||
| 347 | } | 375 | } | ||
| 348 | 376 | ||||
| 349 | h.wv.mu.RLock() | 377 | h.wv.mu.RLock() | ||
M internal/infrastructure/favicon/cache.go +16 -0
| @@ -1,11 +1,13 @@ | |||
| 1 | 1 | package favicon | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "os" | |
| 5 | 6 | "path/filepath" | |
| 6 | 7 | "sync" | |
| 7 | 8 | ||
| 8 | 9 | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 10 | + | "github.com/bnema/dumber/internal/logging" | |
| 9 | 11 | ) | |
| 10 | 12 | ||
| 11 | 13 | const ( | |
| @@ -55,12 +57,17 @@ func (c *Cache) Get(domain string) ([]byte, bool) { | |||
| 55 | 57 | if domain == "" { | |
| 56 | 58 | return nil, false | |
| 57 | 59 | } | |
| 60 | + | log := logging.FromContext(context.Background()) | |
| 58 | 61 | ||
| 59 | 62 | // Check memory cache first | |
| 60 | 63 | c.mu.RLock() | |
| 61 | 64 | data, ok := c.memCache[domain] | |
| 62 | 65 | c.mu.RUnlock() | |
| 63 | 66 | if ok { | |
| 67 | + | log.Debug(). | |
| 68 | + | Str("domain", domain). | |
| 69 | + | Int("bytes", len(data)). | |
| 70 | + | Msg("favicon: Cache.Get memory hit") | |
| 64 | 71 | return data, true | |
| 65 | 72 | } | |
| 66 | 73 | ||
| @@ -71,9 +78,14 @@ func (c *Cache) Get(domain string) ([]byte, bool) { | |||
| 71 | 78 | c.mu.Lock() | |
| 72 | 79 | c.memCache[domain] = data | |
| 73 | 80 | c.mu.Unlock() | |
| 81 | + | log.Debug(). | |
| 82 | + | Str("domain", domain). | |
| 83 | + | Int("bytes", len(data)). | |
| 84 | + | Msg("favicon: Cache.Get disk hit") | |
| 74 | 85 | return data, true | |
| 75 | 86 | } | |
| 76 | 87 | ||
| 88 | + | log.Debug().Str("domain", domain).Msg("favicon: Cache.Get miss") | |
| 77 | 89 | return nil, false | |
| 78 | 90 | } | |
| 79 | 91 | ||
| @@ -83,6 +95,10 @@ func (c *Cache) Set(domain string, data []byte) { | |||
| 83 | 95 | if domain == "" || len(data) == 0 { | |
| 84 | 96 | return | |
| 85 | 97 | } | |
| 98 | + | logging.FromContext(context.Background()).Debug(). | |
| 99 | + | Str("domain", domain). | |
| 100 | + | Int("bytes", len(data)). | |
| 101 | + | Msg("favicon: Cache.Set") | |
| 86 | 102 | ||
| 87 | 103 | // Write to memory cache | |
| 88 | 104 | c.mu.Lock() | |
| @@ -1,11 +1,13 @@ | |||||
| 1 | package favicon | 1 | package favicon | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "os" | 5 | "os" | ||
| 5 | "path/filepath" | 6 | "path/filepath" | ||
| 6 | "sync" | 7 | "sync" | ||
| 7 | 8 | ||||
| 8 | domainurl "github.com/bnema/dumber/internal/domain/url" | 9 | domainurl "github.com/bnema/dumber/internal/domain/url" | ||
| 10 | + | "github.com/bnema/dumber/internal/logging" | |||
| 9 | ) | 11 | ) | ||
| 10 | 12 | ||||
| 11 | const ( | 13 | const ( | ||
| @@ -55,12 +57,17 @@ func (c *Cache) Get(domain string) ([]byte, bool) { | |||||
| 55 | if domain == "" { | 57 | if domain == "" { | ||
| 56 | return nil, false | 58 | return nil, false | ||
| 57 | } | 59 | } | ||
| 60 | + | log := logging.FromContext(context.Background()) | |||
| 58 | 61 | ||||
| 59 | // Check memory cache first | 62 | // Check memory cache first | ||
| 60 | c.mu.RLock() | 63 | c.mu.RLock() | ||
| 61 | data, ok := c.memCache[domain] | 64 | data, ok := c.memCache[domain] | ||
| 62 | c.mu.RUnlock() | 65 | c.mu.RUnlock() | ||
| 63 | if ok { | 66 | if ok { | ||
| 67 | + | log.Debug(). | |||
| 68 | + | Str("domain", domain). | |||
| 69 | + | Int("bytes", len(data)). | |||
| 70 | + | Msg("favicon: Cache.Get memory hit") | |||
| 64 | return data, true | 71 | return data, true | ||
| 65 | } | 72 | } | ||
| 66 | 73 | ||||
| @@ -71,9 +78,14 @@ func (c *Cache) Get(domain string) ([]byte, bool) { | |||||
| 71 | c.mu.Lock() | 78 | c.mu.Lock() | ||
| 72 | c.memCache[domain] = data | 79 | c.memCache[domain] = data | ||
| 73 | c.mu.Unlock() | 80 | c.mu.Unlock() | ||
| 81 | + | log.Debug(). | |||
| 82 | + | Str("domain", domain). | |||
| 83 | + | Int("bytes", len(data)). | |||
| 84 | + | Msg("favicon: Cache.Get disk hit") | |||
| 74 | return data, true | 85 | return data, true | ||
| 75 | } | 86 | } | ||
| 76 | 87 | ||||
| 88 | + | log.Debug().Str("domain", domain).Msg("favicon: Cache.Get miss") | |||
| 77 | return nil, false | 89 | return nil, false | ||
| 78 | } | 90 | } | ||
| 79 | 91 | ||||
| @@ -83,6 +95,10 @@ func (c *Cache) Set(domain string, data []byte) { | |||||
| 83 | if domain == "" || len(data) == 0 { | 95 | if domain == "" || len(data) == 0 { | ||
| 84 | return | 96 | return | ||
| 85 | } | 97 | } | ||
| 98 | + | logging.FromContext(context.Background()).Debug(). | |||
| 99 | + | Str("domain", domain). | |||
| 100 | + | Int("bytes", len(data)). | |||
| 101 | + | Msg("favicon: Cache.Set") | |||
| 86 | 102 | ||||
| 87 | // Write to memory cache | 103 | // Write to memory cache | ||
| 88 | c.mu.Lock() | 104 | c.mu.Lock() | ||
M internal/infrastructure/favicon/service.go +42 -5
| @@ -37,20 +37,28 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) { | |||
| 37 | 37 | if domain == "" { | |
| 38 | 38 | return nil, nil | |
| 39 | 39 | } | |
| 40 | + | log := logging.FromContext(ctx) | |
| 41 | + | log.Debug().Str("domain", domain).Msg("favicon: Service.Get begin") | |
| 40 | 42 | ||
| 41 | 43 | // Internal domain returns the app logo | |
| 42 | 44 | if domain == InternalDomain { | |
| 45 | + | log.Debug().Str("domain", domain).Msg("favicon: Service.Get internal domain") | |
| 43 | 46 | return assets.LogoSVG, nil | |
| 44 | 47 | } | |
| 45 | 48 | ||
| 46 | 49 | // Check cache first (memory + disk) | |
| 47 | 50 | if data, ok := s.cache.Get(domain); ok { | |
| 51 | + | log.Debug(). | |
| 52 | + | Str("domain", domain). | |
| 53 | + | Int("bytes", len(data)). | |
| 54 | + | Msg("favicon: Service.Get cache hit") | |
| 48 | 55 | return data, nil | |
| 49 | 56 | } | |
| 50 | 57 | ||
| 51 | 58 | // Fetch from external API | |
| 52 | 59 | data, err := s.fetcher.Fetch(ctx, domain) | |
| 53 | 60 | if err != nil { | |
| 61 | + | log.Debug().Err(err).Str("domain", domain).Msg("favicon: Service.Get fetch error") | |
| 54 | 62 | return nil, err | |
| 55 | 63 | } | |
| 56 | 64 | ||
| @@ -58,6 +66,10 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) { | |||
| 58 | 66 | if len(data) > 0 { | |
| 59 | 67 | s.cache.Set(domain, data) | |
| 60 | 68 | } | |
| 69 | + | log.Debug(). | |
| 70 | + | Str("domain", domain). | |
| 71 | + | Int("bytes", len(data)). | |
| 72 | + | Msg("favicon: Service.Get end") | |
| 61 | 73 | ||
| 62 | 74 | return data, nil | |
| 63 | 75 | } | |
| @@ -75,7 +87,13 @@ func GetLogoBytes() []byte { | |||
| 75 | 87 | ||
| 76 | 88 | // GetCached returns favicon bytes only if already cached (no external fetch). | |
| 77 | 89 | func (s *Service) GetCached(domain string) ([]byte, bool) { | |
| 78 | - | return s.cache.Get(domain) | |
| 90 | + | data, ok := s.cache.Get(domain) | |
| 91 | + | logging.FromContext(context.Background()).Debug(). | |
| 92 | + | Str("domain", domain). | |
| 93 | + | Bool("hit", ok). | |
| 94 | + | Int("bytes", len(data)). | |
| 95 | + | Msg("favicon: Service.GetCached") | |
| 96 | + | return data, ok | |
| 79 | 97 | } | |
| 80 | 98 | ||
| 81 | 99 | // Store saves favicon bytes for a domain to cache. | |
| @@ -115,14 +133,15 @@ func (s *Service) EnsureDiskCache(ctx context.Context, domain string) { | |||
| 115 | 133 | if domain == "" { | |
| 116 | 134 | return | |
| 117 | 135 | } | |
| 136 | + | log := logging.FromContext(ctx) | |
| 137 | + | log.Debug().Str("domain", domain).Msg("favicon: EnsureDiskCache begin") | |
| 118 | 138 | ||
| 119 | 139 | // Already on disk? | |
| 120 | 140 | if s.cache.HasOnDisk(domain) { | |
| 141 | + | log.Debug().Str("domain", domain).Msg("favicon: EnsureDiskCache already on disk") | |
| 121 | 142 | return | |
| 122 | 143 | } | |
| 123 | 144 | ||
| 124 | - | log := logging.FromContext(ctx) | |
| 125 | - | ||
| 126 | 145 | // Fetch and store | |
| 127 | 146 | data, err := s.fetcher.Fetch(ctx, domain) | |
| 128 | 147 | if err != nil { | |
| @@ -133,6 +152,10 @@ func (s *Service) EnsureDiskCache(ctx context.Context, domain string) { | |||
| 133 | 152 | if len(data) > 0 { | |
| 134 | 153 | s.cache.Set(domain, data) | |
| 135 | 154 | } | |
| 155 | + | log.Debug(). | |
| 156 | + | Str("domain", domain). | |
| 157 | + | Int("bytes", len(data)). | |
| 158 | + | Msg("favicon: EnsureDiskCache end") | |
| 136 | 159 | } | |
| 137 | 160 | ||
| 138 | 161 | // DiskPathPNGSized returns the filesystem path for a sized PNG favicon. | |
| @@ -151,9 +174,18 @@ func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) e | |||
| 151 | 174 | if domain == "" { | |
| 152 | 175 | return nil | |
| 153 | 176 | } | |
| 177 | + | log := logging.FromContext(ctx) | |
| 178 | + | log.Debug(). | |
| 179 | + | Str("domain", domain). | |
| 180 | + | Int("size", size). | |
| 181 | + | Msg("favicon: EnsureSizedPNG begin") | |
| 154 | 182 | ||
| 155 | 183 | // Already exists? | |
| 156 | 184 | if s.HasPNGSizedOnDisk(domain, size) { | |
| 185 | + | log.Debug(). | |
| 186 | + | Str("domain", domain). | |
| 187 | + | Int("size", size). | |
| 188 | + | Msg("favicon: EnsureSizedPNG already exists") | |
| 157 | 189 | return nil | |
| 158 | 190 | } | |
| 159 | 191 | ||
| @@ -168,10 +200,15 @@ func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) e | |||
| 168 | 200 | return fmt.Errorf("cannot determine destination path for domain %s", domain) | |
| 169 | 201 | } | |
| 170 | 202 | ||
| 171 | - | log := logging.FromContext(ctx) | |
| 172 | 203 | log.Debug().Str("domain", domain).Int("size", size).Msg("creating sized favicon") | |
| 173 | 204 | ||
| 174 | - | return ResizePNG(srcPath, dstPath, size) | |
| 205 | + | err := ResizePNG(srcPath, dstPath, size) | |
| 206 | + | log.Debug(). | |
| 207 | + | Err(err). | |
| 208 | + | Str("domain", domain). | |
| 209 | + | Int("size", size). | |
| 210 | + | Msg("favicon: EnsureSizedPNG end") | |
| 211 | + | return err | |
| 175 | 212 | } | |
| 176 | 213 | ||
| 177 | 214 | // EnsureInternalFaviconPNG ensures the internal app logo PNG exists on disk. | |
| @@ -37,20 +37,28 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) { | |||||
| 37 | if domain == "" { | 37 | if domain == "" { | ||
| 38 | return nil, nil | 38 | return nil, nil | ||
| 39 | } | 39 | } | ||
| 40 | + | log := logging.FromContext(ctx) | |||
| 41 | + | log.Debug().Str("domain", domain).Msg("favicon: Service.Get begin") | |||
| 40 | 42 | ||||
| 41 | // Internal domain returns the app logo | 43 | // Internal domain returns the app logo | ||
| 42 | if domain == InternalDomain { | 44 | if domain == InternalDomain { | ||
| 45 | + | log.Debug().Str("domain", domain).Msg("favicon: Service.Get internal domain") | |||
| 43 | return assets.LogoSVG, nil | 46 | return assets.LogoSVG, nil | ||
| 44 | } | 47 | } | ||
| 45 | 48 | ||||
| 46 | // Check cache first (memory + disk) | 49 | // Check cache first (memory + disk) | ||
| 47 | if data, ok := s.cache.Get(domain); ok { | 50 | if data, ok := s.cache.Get(domain); ok { | ||
| 51 | + | log.Debug(). | |||
| 52 | + | Str("domain", domain). | |||
| 53 | + | Int("bytes", len(data)). | |||
| 54 | + | Msg("favicon: Service.Get cache hit") | |||
| 48 | return data, nil | 55 | return data, nil | ||
| 49 | } | 56 | } | ||
| 50 | 57 | ||||
| 51 | // Fetch from external API | 58 | // Fetch from external API | ||
| 52 | data, err := s.fetcher.Fetch(ctx, domain) | 59 | data, err := s.fetcher.Fetch(ctx, domain) | ||
| 53 | if err != nil { | 60 | if err != nil { | ||
| 61 | + | log.Debug().Err(err).Str("domain", domain).Msg("favicon: Service.Get fetch error") | |||
| 54 | return nil, err | 62 | return nil, err | ||
| 55 | } | 63 | } | ||
| 56 | 64 | ||||
| @@ -58,6 +66,10 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) { | |||||
| 58 | if len(data) > 0 { | 66 | if len(data) > 0 { | ||
| 59 | s.cache.Set(domain, data) | 67 | s.cache.Set(domain, data) | ||
| 60 | } | 68 | } | ||
| 69 | + | log.Debug(). | |||
| 70 | + | Str("domain", domain). | |||
| 71 | + | Int("bytes", len(data)). | |||
| 72 | + | Msg("favicon: Service.Get end") | |||
| 61 | 73 | ||||
| 62 | return data, nil | 74 | return data, nil | ||
| 63 | } | 75 | } | ||
| @@ -75,7 +87,13 @@ func GetLogoBytes() []byte { | |||||
| 75 | 87 | ||||
| 76 | // GetCached returns favicon bytes only if already cached (no external fetch). | 88 | // GetCached returns favicon bytes only if already cached (no external fetch). | ||
| 77 | func (s *Service) GetCached(domain string) ([]byte, bool) { | 89 | func (s *Service) GetCached(domain string) ([]byte, bool) { | ||
| 78 | - | return s.cache.Get(domain) | 90 | + | data, ok := s.cache.Get(domain) |
| 91 | + | logging.FromContext(context.Background()).Debug(). | |||
| 92 | + | Str("domain", domain). | |||
| 93 | + | Bool("hit", ok). | |||
| 94 | + | Int("bytes", len(data)). | |||
| 95 | + | Msg("favicon: Service.GetCached") | |||
| 96 | + | return data, ok | |||
| 79 | } | 97 | } | ||
| 80 | 98 | ||||
| 81 | // Store saves favicon bytes for a domain to cache. | 99 | // Store saves favicon bytes for a domain to cache. | ||
| @@ -115,14 +133,15 @@ func (s *Service) EnsureDiskCache(ctx context.Context, domain string) { | |||||
| 115 | if domain == "" { | 133 | if domain == "" { | ||
| 116 | return | 134 | return | ||
| 117 | } | 135 | } | ||
| 136 | + | log := logging.FromContext(ctx) | |||
| 137 | + | log.Debug().Str("domain", domain).Msg("favicon: EnsureDiskCache begin") | |||
| 118 | 138 | ||||
| 119 | // Already on disk? | 139 | // Already on disk? | ||
| 120 | if s.cache.HasOnDisk(domain) { | 140 | if s.cache.HasOnDisk(domain) { | ||
| 141 | + | log.Debug().Str("domain", domain).Msg("favicon: EnsureDiskCache already on disk") | |||
| 121 | return | 142 | return | ||
| 122 | } | 143 | } | ||
| 123 | 144 | ||||
| 124 | - | log := logging.FromContext(ctx) | |||
| 125 | - | ||||
| 126 | // Fetch and store | 145 | // Fetch and store | ||
| 127 | data, err := s.fetcher.Fetch(ctx, domain) | 146 | data, err := s.fetcher.Fetch(ctx, domain) | ||
| 128 | if err != nil { | 147 | if err != nil { | ||
| @@ -133,6 +152,10 @@ func (s *Service) EnsureDiskCache(ctx context.Context, domain string) { | |||||
| 133 | if len(data) > 0 { | 152 | if len(data) > 0 { | ||
| 134 | s.cache.Set(domain, data) | 153 | s.cache.Set(domain, data) | ||
| 135 | } | 154 | } | ||
| 155 | + | log.Debug(). | |||
| 156 | + | Str("domain", domain). | |||
| 157 | + | Int("bytes", len(data)). | |||
| 158 | + | Msg("favicon: EnsureDiskCache end") | |||
| 136 | } | 159 | } | ||
| 137 | 160 | ||||
| 138 | // DiskPathPNGSized returns the filesystem path for a sized PNG favicon. | 161 | // DiskPathPNGSized returns the filesystem path for a sized PNG favicon. | ||
| @@ -151,9 +174,18 @@ func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) e | |||||
| 151 | if domain == "" { | 174 | if domain == "" { | ||
| 152 | return nil | 175 | return nil | ||
| 153 | } | 176 | } | ||
| 177 | + | log := logging.FromContext(ctx) | |||
| 178 | + | log.Debug(). | |||
| 179 | + | Str("domain", domain). | |||
| 180 | + | Int("size", size). | |||
| 181 | + | Msg("favicon: EnsureSizedPNG begin") | |||
| 154 | 182 | ||||
| 155 | // Already exists? | 183 | // Already exists? | ||
| 156 | if s.HasPNGSizedOnDisk(domain, size) { | 184 | if s.HasPNGSizedOnDisk(domain, size) { | ||
| 185 | + | log.Debug(). | |||
| 186 | + | Str("domain", domain). | |||
| 187 | + | Int("size", size). | |||
| 188 | + | Msg("favicon: EnsureSizedPNG already exists") | |||
| 157 | return nil | 189 | return nil | ||
| 158 | } | 190 | } | ||
| 159 | 191 | ||||
| @@ -168,10 +200,15 @@ func (s *Service) EnsureSizedPNG(ctx context.Context, domain string, size int) e | |||||
| 168 | return fmt.Errorf("cannot determine destination path for domain %s", domain) | 200 | return fmt.Errorf("cannot determine destination path for domain %s", domain) | ||
| 169 | } | 201 | } | ||
| 170 | 202 | ||||
| 171 | - | log := logging.FromContext(ctx) | |||
| 172 | log.Debug().Str("domain", domain).Int("size", size).Msg("creating sized favicon") | 203 | log.Debug().Str("domain", domain).Int("size", size).Msg("creating sized favicon") | ||
| 173 | 204 | ||||
| 174 | - | return ResizePNG(srcPath, dstPath, size) | 205 | + | err := ResizePNG(srcPath, dstPath, size) |
| 206 | + | log.Debug(). | |||
| 207 | + | Err(err). | |||
| 208 | + | Str("domain", domain). | |||
| 209 | + | Int("size", size). | |||
| 210 | + | Msg("favicon: EnsureSizedPNG end") | |||
| 211 | + | return err | |||
| 175 | } | 212 | } | ||
| 176 | 213 | ||||
| 177 | // EnsureInternalFaviconPNG ensures the internal app logo PNG exists on disk. | 214 | // EnsureInternalFaviconPNG ensures the internal app logo PNG exists on disk. | ||
M internal/infrastructure/persistence/sqlite/connection.go +1 -1
| @@ -27,7 +27,7 @@ func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) { | |||
| 27 | 27 | } | |
| 28 | 28 | ||
| 29 | 29 | // Open database connection | |
| 30 | - | db, err := sql.Open("sqlite3", dbPath) | |
| 30 | + | db, err := sql.Open("sqlite", dbPath) | |
| 31 | 31 | if err != nil { | |
| 32 | 32 | return nil, fmt.Errorf("failed to open database: %w", err) | |
| 33 | 33 | } | |
| @@ -27,7 +27,7 @@ func NewConnection(ctx context.Context, dbPath string) (*sql.DB, error) { | |||||
| 27 | } | 27 | } | ||
| 28 | 28 | ||||
| 29 | // Open database connection | 29 | // Open database connection | ||
| 30 | - | db, err := sql.Open("sqlite3", dbPath) | 30 | + | db, err := sql.Open("sqlite", dbPath) |
| 31 | if err != nil { | 31 | if err != nil { | ||
| 32 | return nil, fmt.Errorf("failed to open database: %w", err) | 32 | return nil, fmt.Errorf("failed to open database: %w", err) | ||
| 33 | } | 33 | } | ||
M internal/ui/adapter/favicon.go +112 -5
| @@ -3,6 +3,7 @@ package adapter | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | + | "os" | |
| 6 | 7 | "sync" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/dumber/internal/application/port" | |
| @@ -28,10 +29,17 @@ type FaviconAdapter struct { | |||
| 28 | 29 | internalDomain string | |
| 29 | 30 | normalizedIconSize int | |
| 30 | 31 | getLogoBytes func() []byte | |
| 32 | + | disabled bool | |
| 31 | 33 | } | |
| 32 | 34 | ||
| 35 | + | const disableFaviconsEnv = "DUMBER_DISABLE_FAVICONS" | |
| 36 | + | ||
| 33 | 37 | type warningLogFunc func(log *zerolog.Logger, err error) | |
| 34 | 38 | ||
| 39 | + | type textureResult struct { | |
| 40 | + | texture *gdk.Texture | |
| 41 | + | } | |
| 42 | + | ||
| 35 | 43 | // FaviconAdapterConfig holds optional configuration for FaviconAdapter. | |
| 36 | 44 | // Zero values disable the corresponding features (internal-URL logo, sized-PNG creation). | |
| 37 | 45 | type FaviconAdapterConfig struct { | |
| @@ -48,6 +56,7 @@ type FaviconAdapterConfig struct { | |||
| 48 | 56 | // NewFaviconAdapter creates a new FaviconAdapter. | |
| 49 | 57 | // The faviconDB can be nil if a favicon database is not available. | |
| 50 | 58 | func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDatabase, cfg FaviconAdapterConfig) *FaviconAdapter { | |
| 59 | + | disabled := os.Getenv(disableFaviconsEnv) == "1" | |
| 51 | 60 | return &FaviconAdapter{ | |
| 52 | 61 | service: service, | |
| 53 | 62 | faviconDB: faviconDB, | |
| @@ -57,6 +66,7 @@ func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDataba | |||
| 57 | 66 | internalDomain: cfg.InternalDomain, | |
| 58 | 67 | normalizedIconSize: cfg.NormalizedIconSize, | |
| 59 | 68 | getLogoBytes: cfg.GetLogoBytes, | |
| 69 | + | disabled: disabled, | |
| 60 | 70 | } | |
| 61 | 71 | } | |
| 62 | 72 | ||
| @@ -92,18 +102,33 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||
| 92 | 102 | } | |
| 93 | 103 | ||
| 94 | 104 | log := logging.FromContext(ctx) | |
| 105 | + | if a.disabled { | |
| 106 | + | log.Warn(). | |
| 107 | + | Str("env", disableFaviconsEnv). | |
| 108 | + | Msg("favicon: UI favicon pipeline temporarily disabled for crash isolation") | |
| 109 | + | callback(nil) | |
| 110 | + | return | |
| 111 | + | } | |
| 95 | 112 | ||
| 96 | 113 | // Handle internal dumb:// scheme URLs - use app logo | |
| 97 | 114 | if a.isInternalURL != nil && a.isInternalURL(pageURL) { | |
| 98 | - | texture := a.getOrCreateLogoTexture() | |
| 115 | + | texture := a.textureFromBytesOnGTK(a.getLogoBytes()) | |
| 116 | + | if texture != nil { | |
| 117 | + | a.setTexture(a.internalDomain, texture) | |
| 118 | + | } | |
| 99 | 119 | callback(texture) | |
| 100 | 120 | return | |
| 101 | 121 | } | |
| 102 | 122 | ||
| 103 | 123 | domain := domainurl.ExtractDomain(pageURL) | |
| 124 | + | log.Debug(). | |
| 125 | + | Str("page_url", pageURL). | |
| 126 | + | Str("domain", domain). | |
| 127 | + | Msg("favicon: GetOrFetch begin") | |
| 104 | 128 | ||
| 105 | 129 | // Check texture cache first | |
| 106 | 130 | if texture := a.GetTexture(domain); texture != nil { | |
| 131 | + | log.Debug().Str("domain", domain).Msg("favicon: texture cache hit") | |
| 107 | 132 | // Ensure sized PNG exists for CLI tools (async, idempotent) | |
| 108 | 133 | go a.ensureSizedPNG(ctx, domain) | |
| 109 | 134 | callback(texture) | |
| @@ -112,7 +137,11 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||
| 112 | 137 | ||
| 113 | 138 | // Check service cache (memory + disk) and convert to texture | |
| 114 | 139 | if data, ok := a.service.GetCached(domain); ok && len(data) > 0 { | |
| 115 | - | texture := bytesToTexture(data) | |
| 140 | + | log.Debug(). | |
| 141 | + | Str("domain", domain). | |
| 142 | + | Int("bytes", len(data)). | |
| 143 | + | Msg("favicon: service cache hit") | |
| 144 | + | texture := a.textureFromBytesOnGTK(data) | |
| 116 | 145 | if texture != nil { | |
| 117 | 146 | a.setTexture(domain, texture) | |
| 118 | 147 | // Ensure sized PNG exists for CLI tools (async, idempotent) | |
| @@ -124,7 +153,15 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||
| 124 | 153 | ||
| 125 | 154 | // Helper to invoke callback on main thread | |
| 126 | 155 | invokeCallback := func(texture *gdk.Texture) { | |
| 156 | + | log.Debug(). | |
| 157 | + | Str("domain", domain). | |
| 158 | + | Bool("texture_nil", texture == nil). | |
| 159 | + | Msg("favicon: scheduling callback on GTK") | |
| 127 | 160 | cb := glib.SourceFunc(func(uintptr) bool { | |
| 161 | + | log.Debug(). | |
| 162 | + | Str("domain", domain). | |
| 163 | + | Bool("texture_nil", texture == nil). | |
| 164 | + | Msg("favicon: callback executing on GTK") | |
| 128 | 165 | callback(texture) | |
| 129 | 166 | return false | |
| 130 | 167 | }) | |
| @@ -134,16 +171,30 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||
| 134 | 171 | // Helper to fetch via service and invoke callback | |
| 135 | 172 | fetchViaService := func() { | |
| 136 | 173 | go func() { | |
| 174 | + | log.Debug().Str("domain", domain).Msg("favicon: fetchViaService begin") | |
| 137 | 175 | data, err := a.service.Get(ctx, domain) | |
| 138 | 176 | if err != nil || len(data) == 0 { | |
| 177 | + | log.Debug(). | |
| 178 | + | Err(err). | |
| 179 | + | Str("domain", domain). | |
| 180 | + | Int("bytes", len(data)). | |
| 181 | + | Msg("favicon: fetchViaService empty/error result") | |
| 139 | 182 | invokeCallback(nil) | |
| 140 | 183 | return | |
| 141 | 184 | } | |
| 142 | 185 | ||
| 143 | - | texture := bytesToTexture(data) | |
| 186 | + | log.Debug(). | |
| 187 | + | Str("domain", domain). | |
| 188 | + | Int("bytes", len(data)). | |
| 189 | + | Msg("favicon: fetchViaService fetched data") | |
| 190 | + | texture := a.textureFromBytesOnGTK(data) | |
| 144 | 191 | if texture != nil { | |
| 145 | 192 | a.setTexture(domain, texture) | |
| 146 | 193 | } | |
| 194 | + | log.Debug(). | |
| 195 | + | Str("domain", domain). | |
| 196 | + | Bool("texture_nil", texture == nil). | |
| 197 | + | Msg("favicon: fetchViaService end") | |
| 147 | 198 | invokeCallback(texture) | |
| 148 | 199 | }() | |
| 149 | 200 | } | |
| @@ -151,6 +202,10 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||
| 151 | 202 | // Try engine FaviconDatabase if available | |
| 152 | 203 | if a.faviconDB != nil { | |
| 153 | 204 | a.faviconDB.GetFaviconAsync(pageURL, func(tex port.Texture) { | |
| 205 | + | log.Debug(). | |
| 206 | + | Str("domain", domain). | |
| 207 | + | Bool("texture_nil", tex == nil). | |
| 208 | + | Msg("favicon: engine db callback") | |
| 154 | 209 | if tex == nil { | |
| 155 | 210 | log.Debug().Str("domain", domain).Msg("favicon not in engine db, fetching via service") | |
| 156 | 211 | fetchViaService() | |
| @@ -170,12 +225,19 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||
| 170 | 225 | } | |
| 171 | 226 | ||
| 172 | 227 | // No WebKit DB, fetch via service directly | |
| 228 | + | log.Debug().Str("domain", domain).Msg("favicon: no engine db, fetching via service") | |
| 173 | 229 | fetchViaService() | |
| 174 | 230 | } | |
| 175 | 231 | ||
| 176 | 232 | // StoreFromWebKit stores a favicon texture received from WebKit signals. | |
| 177 | 233 | // Also ensures the favicon is persisted to disk cache via the service. | |
| 178 | 234 | func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, texture *gdk.Texture) { | |
| 235 | + | if a.disabled { | |
| 236 | + | logging.FromContext(ctx).Debug(). | |
| 237 | + | Str("page_url", pageURL). | |
| 238 | + | Msg("favicon: StoreFromWebKit skipped because favicon pipeline is disabled") | |
| 239 | + | return | |
| 240 | + | } | |
| 179 | 241 | if texture == nil { | |
| 180 | 242 | return | |
| 181 | 243 | } | |
| @@ -272,25 +334,43 @@ func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, t | |||
| 272 | 334 | // PreloadFromCache attempts to load a favicon from cache without fetching. | |
| 273 | 335 | // Returns the texture if found in memory or disk cache, nil otherwise. | |
| 274 | 336 | func (a *FaviconAdapter) PreloadFromCache(pageURL string) *gdk.Texture { | |
| 337 | + | if a.disabled { | |
| 338 | + | logging.FromContext(context.Background()).Debug(). | |
| 339 | + | Str("page_url", pageURL). | |
| 340 | + | Str("env", disableFaviconsEnv). | |
| 341 | + | Msg("favicon: PreloadFromCache skipped because favicon pipeline is disabled") | |
| 342 | + | return nil | |
| 343 | + | } | |
| 275 | 344 | domain := domainurl.ExtractDomain(pageURL) | |
| 276 | 345 | if domain == "" { | |
| 277 | 346 | return nil | |
| 278 | 347 | } | |
| 348 | + | log := logging.FromContext(context.Background()) | |
| 349 | + | log.Debug(). | |
| 350 | + | Str("page_url", pageURL). | |
| 351 | + | Str("domain", domain). | |
| 352 | + | Msg("favicon: PreloadFromCache begin") | |
| 279 | 353 | ||
| 280 | 354 | // Check texture cache | |
| 281 | 355 | if texture := a.GetTexture(domain); texture != nil { | |
| 356 | + | log.Debug().Str("domain", domain).Msg("favicon: PreloadFromCache texture hit") | |
| 282 | 357 | return texture | |
| 283 | 358 | } | |
| 284 | 359 | ||
| 285 | 360 | // Check service cache (memory + disk) | |
| 286 | 361 | if data, ok := a.service.GetCached(domain); ok && len(data) > 0 { | |
| 287 | - | texture := bytesToTexture(data) | |
| 362 | + | log.Debug(). | |
| 363 | + | Str("domain", domain). | |
| 364 | + | Int("bytes", len(data)). | |
| 365 | + | Msg("favicon: PreloadFromCache service hit") | |
| 366 | + | texture := a.textureFromBytesOnGTK(data) | |
| 288 | 367 | if texture != nil { | |
| 289 | 368 | a.setTexture(domain, texture) | |
| 290 | 369 | return texture | |
| 291 | 370 | } | |
| 292 | 371 | } | |
| 293 | 372 | ||
| 373 | + | log.Debug().Str("domain", domain).Msg("favicon: PreloadFromCache miss") | |
| 294 | 374 | return nil | |
| 295 | 375 | } | |
| 296 | 376 | ||
| @@ -414,13 +494,40 @@ func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture { | |||
| 414 | 494 | ||
| 415 | 495 | // Create texture from logo bytes | |
| 416 | 496 | logoBytes := a.getLogoBytes() | |
| 417 | - | texture := bytesToTexture(logoBytes) | |
| 497 | + | texture := a.textureFromBytesOnGTK(logoBytes) | |
| 418 | 498 | if texture != nil { | |
| 419 | 499 | a.setTexture(a.internalDomain, texture) | |
| 420 | 500 | } | |
| 421 | 501 | return texture | |
| 422 | 502 | } | |
| 423 | 503 | ||
| 504 | + | func (a *FaviconAdapter) textureFromBytesOnGTK(data []byte) *gdk.Texture { | |
| 505 | + | if len(data) == 0 { | |
| 506 | + | return nil | |
| 507 | + | } | |
| 508 | + | log := logging.FromContext(context.Background()) | |
| 509 | + | log.Debug(). | |
| 510 | + | Int("bytes", len(data)). | |
| 511 | + | Msg("favicon: textureFromBytesOnGTK begin") | |
| 512 | + | ||
| 513 | + | ctx := glib.MainContextDefault() | |
| 514 | + | if ctx != nil && ctx.IsOwner() { | |
| 515 | + | texture := bytesToTexture(data) | |
| 516 | + | log.Debug().Bool("texture_nil", texture == nil).Msg("favicon: textureFromBytesOnGTK inline") | |
| 517 | + | return texture | |
| 518 | + | } | |
| 519 | + | ||
| 520 | + | resultCh := make(chan textureResult, 1) | |
| 521 | + | cb := glib.SourceOnceFunc(func(_ uintptr) { | |
| 522 | + | log.Debug().Int("bytes", len(data)).Msg("favicon: textureFromBytesOnGTK executing on GTK") | |
| 523 | + | resultCh <- textureResult{texture: bytesToTexture(data)} | |
| 524 | + | }) | |
| 525 | + | glib.IdleAddOnce(&cb, 0) | |
| 526 | + | result := <-resultCh | |
| 527 | + | log.Debug().Bool("texture_nil", result.texture == nil).Msg("favicon: textureFromBytesOnGTK end") | |
| 528 | + | return result.texture | |
| 529 | + | } | |
| 530 | + | ||
| 424 | 531 | // bytesToTexture converts raw favicon bytes to a GDK texture. | |
| 425 | 532 | func bytesToTexture(data []byte) *gdk.Texture { | |
| 426 | 533 | if len(data) == 0 { | |
| @@ -3,6 +3,7 @@ package adapter | |||||
| 3 | 3 | ||||
| 4 | import ( | 4 | import ( | ||
| 5 | "context" | 5 | "context" | ||
| 6 | + | "os" | |||
| 6 | "sync" | 7 | "sync" | ||
| 7 | 8 | ||||
| 8 | "github.com/bnema/dumber/internal/application/port" | 9 | "github.com/bnema/dumber/internal/application/port" | ||
| @@ -28,10 +29,17 @@ type FaviconAdapter struct { | |||||
| 28 | internalDomain string | 29 | internalDomain string | ||
| 29 | normalizedIconSize int | 30 | normalizedIconSize int | ||
| 30 | getLogoBytes func() []byte | 31 | getLogoBytes func() []byte | ||
| 32 | + | disabled bool | |||
| 31 | } | 33 | } | ||
| 32 | 34 | ||||
| 35 | + | const disableFaviconsEnv = "DUMBER_DISABLE_FAVICONS" | |||
| 36 | + | ||||
| 33 | type warningLogFunc func(log *zerolog.Logger, err error) | 37 | type warningLogFunc func(log *zerolog.Logger, err error) | ||
| 34 | 38 | ||||
| 39 | + | type textureResult struct { | |||
| 40 | + | texture *gdk.Texture | |||
| 41 | + | } | |||
| 42 | + | ||||
| 35 | // FaviconAdapterConfig holds optional configuration for FaviconAdapter. | 43 | // FaviconAdapterConfig holds optional configuration for FaviconAdapter. | ||
| 36 | // Zero values disable the corresponding features (internal-URL logo, sized-PNG creation). | 44 | // Zero values disable the corresponding features (internal-URL logo, sized-PNG creation). | ||
| 37 | type FaviconAdapterConfig struct { | 45 | type FaviconAdapterConfig struct { | ||
| @@ -48,6 +56,7 @@ type FaviconAdapterConfig struct { | |||||
| 48 | // NewFaviconAdapter creates a new FaviconAdapter. | 56 | // NewFaviconAdapter creates a new FaviconAdapter. | ||
| 49 | // The faviconDB can be nil if a favicon database is not available. | 57 | // The faviconDB can be nil if a favicon database is not available. | ||
| 50 | func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDatabase, cfg FaviconAdapterConfig) *FaviconAdapter { | 58 | func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDatabase, cfg FaviconAdapterConfig) *FaviconAdapter { | ||
| 59 | + | disabled := os.Getenv(disableFaviconsEnv) == "1" | |||
| 51 | return &FaviconAdapter{ | 60 | return &FaviconAdapter{ | ||
| 52 | service: service, | 61 | service: service, | ||
| 53 | faviconDB: faviconDB, | 62 | faviconDB: faviconDB, | ||
| @@ -57,6 +66,7 @@ func NewFaviconAdapter(service port.FaviconService, faviconDB port.FaviconDataba | |||||
| 57 | internalDomain: cfg.InternalDomain, | 66 | internalDomain: cfg.InternalDomain, | ||
| 58 | normalizedIconSize: cfg.NormalizedIconSize, | 67 | normalizedIconSize: cfg.NormalizedIconSize, | ||
| 59 | getLogoBytes: cfg.GetLogoBytes, | 68 | getLogoBytes: cfg.GetLogoBytes, | ||
| 69 | + | disabled: disabled, | |||
| 60 | } | 70 | } | ||
| 61 | } | 71 | } | ||
| 62 | 72 | ||||
| @@ -92,18 +102,33 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||||
| 92 | } | 102 | } | ||
| 93 | 103 | ||||
| 94 | log := logging.FromContext(ctx) | 104 | log := logging.FromContext(ctx) | ||
| 105 | + | if a.disabled { | |||
| 106 | + | log.Warn(). | |||
| 107 | + | Str("env", disableFaviconsEnv). | |||
| 108 | + | Msg("favicon: UI favicon pipeline temporarily disabled for crash isolation") | |||
| 109 | + | callback(nil) | |||
| 110 | + | return | |||
| 111 | + | } | |||
| 95 | 112 | ||||
| 96 | // Handle internal dumb:// scheme URLs - use app logo | 113 | // Handle internal dumb:// scheme URLs - use app logo | ||
| 97 | if a.isInternalURL != nil && a.isInternalURL(pageURL) { | 114 | if a.isInternalURL != nil && a.isInternalURL(pageURL) { | ||
| 98 | - | texture := a.getOrCreateLogoTexture() | 115 | + | texture := a.textureFromBytesOnGTK(a.getLogoBytes()) |
| 116 | + | if texture != nil { | |||
| 117 | + | a.setTexture(a.internalDomain, texture) | |||
| 118 | + | } | |||
| 99 | callback(texture) | 119 | callback(texture) | ||
| 100 | return | 120 | return | ||
| 101 | } | 121 | } | ||
| 102 | 122 | ||||
| 103 | domain := domainurl.ExtractDomain(pageURL) | 123 | domain := domainurl.ExtractDomain(pageURL) | ||
| 124 | + | log.Debug(). | |||
| 125 | + | Str("page_url", pageURL). | |||
| 126 | + | Str("domain", domain). | |||
| 127 | + | Msg("favicon: GetOrFetch begin") | |||
| 104 | 128 | ||||
| 105 | // Check texture cache first | 129 | // Check texture cache first | ||
| 106 | if texture := a.GetTexture(domain); texture != nil { | 130 | if texture := a.GetTexture(domain); texture != nil { | ||
| 131 | + | log.Debug().Str("domain", domain).Msg("favicon: texture cache hit") | |||
| 107 | // Ensure sized PNG exists for CLI tools (async, idempotent) | 132 | // Ensure sized PNG exists for CLI tools (async, idempotent) | ||
| 108 | go a.ensureSizedPNG(ctx, domain) | 133 | go a.ensureSizedPNG(ctx, domain) | ||
| 109 | callback(texture) | 134 | callback(texture) | ||
| @@ -112,7 +137,11 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||||
| 112 | 137 | ||||
| 113 | // Check service cache (memory + disk) and convert to texture | 138 | // Check service cache (memory + disk) and convert to texture | ||
| 114 | if data, ok := a.service.GetCached(domain); ok && len(data) > 0 { | 139 | if data, ok := a.service.GetCached(domain); ok && len(data) > 0 { | ||
| 115 | - | texture := bytesToTexture(data) | 140 | + | log.Debug(). |
| 141 | + | Str("domain", domain). | |||
| 142 | + | Int("bytes", len(data)). | |||
| 143 | + | Msg("favicon: service cache hit") | |||
| 144 | + | texture := a.textureFromBytesOnGTK(data) | |||
| 116 | if texture != nil { | 145 | if texture != nil { | ||
| 117 | a.setTexture(domain, texture) | 146 | a.setTexture(domain, texture) | ||
| 118 | // Ensure sized PNG exists for CLI tools (async, idempotent) | 147 | // Ensure sized PNG exists for CLI tools (async, idempotent) | ||
| @@ -124,7 +153,15 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||||
| 124 | 153 | ||||
| 125 | // Helper to invoke callback on main thread | 154 | // Helper to invoke callback on main thread | ||
| 126 | invokeCallback := func(texture *gdk.Texture) { | 155 | invokeCallback := func(texture *gdk.Texture) { | ||
| 156 | + | log.Debug(). | |||
| 157 | + | Str("domain", domain). | |||
| 158 | + | Bool("texture_nil", texture == nil). | |||
| 159 | + | Msg("favicon: scheduling callback on GTK") | |||
| 127 | cb := glib.SourceFunc(func(uintptr) bool { | 160 | cb := glib.SourceFunc(func(uintptr) bool { | ||
| 161 | + | log.Debug(). | |||
| 162 | + | Str("domain", domain). | |||
| 163 | + | Bool("texture_nil", texture == nil). | |||
| 164 | + | Msg("favicon: callback executing on GTK") | |||
| 128 | callback(texture) | 165 | callback(texture) | ||
| 129 | return false | 166 | return false | ||
| 130 | }) | 167 | }) | ||
| @@ -134,16 +171,30 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||||
| 134 | // Helper to fetch via service and invoke callback | 171 | // Helper to fetch via service and invoke callback | ||
| 135 | fetchViaService := func() { | 172 | fetchViaService := func() { | ||
| 136 | go func() { | 173 | go func() { | ||
| 174 | + | log.Debug().Str("domain", domain).Msg("favicon: fetchViaService begin") | |||
| 137 | data, err := a.service.Get(ctx, domain) | 175 | data, err := a.service.Get(ctx, domain) | ||
| 138 | if err != nil || len(data) == 0 { | 176 | if err != nil || len(data) == 0 { | ||
| 177 | + | log.Debug(). | |||
| 178 | + | Err(err). | |||
| 179 | + | Str("domain", domain). | |||
| 180 | + | Int("bytes", len(data)). | |||
| 181 | + | Msg("favicon: fetchViaService empty/error result") | |||
| 139 | invokeCallback(nil) | 182 | invokeCallback(nil) | ||
| 140 | return | 183 | return | ||
| 141 | } | 184 | } | ||
| 142 | 185 | ||||
| 143 | - | texture := bytesToTexture(data) | 186 | + | log.Debug(). |
| 187 | + | Str("domain", domain). | |||
| 188 | + | Int("bytes", len(data)). | |||
| 189 | + | Msg("favicon: fetchViaService fetched data") | |||
| 190 | + | texture := a.textureFromBytesOnGTK(data) | |||
| 144 | if texture != nil { | 191 | if texture != nil { | ||
| 145 | a.setTexture(domain, texture) | 192 | a.setTexture(domain, texture) | ||
| 146 | } | 193 | } | ||
| 194 | + | log.Debug(). | |||
| 195 | + | Str("domain", domain). | |||
| 196 | + | Bool("texture_nil", texture == nil). | |||
| 197 | + | Msg("favicon: fetchViaService end") | |||
| 147 | invokeCallback(texture) | 198 | invokeCallback(texture) | ||
| 148 | }() | 199 | }() | ||
| 149 | } | 200 | } | ||
| @@ -151,6 +202,10 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||||
| 151 | // Try engine FaviconDatabase if available | 202 | // Try engine FaviconDatabase if available | ||
| 152 | if a.faviconDB != nil { | 203 | if a.faviconDB != nil { | ||
| 153 | a.faviconDB.GetFaviconAsync(pageURL, func(tex port.Texture) { | 204 | a.faviconDB.GetFaviconAsync(pageURL, func(tex port.Texture) { | ||
| 205 | + | log.Debug(). | |||
| 206 | + | Str("domain", domain). | |||
| 207 | + | Bool("texture_nil", tex == nil). | |||
| 208 | + | Msg("favicon: engine db callback") | |||
| 154 | if tex == nil { | 209 | if tex == nil { | ||
| 155 | log.Debug().Str("domain", domain).Msg("favicon not in engine db, fetching via service") | 210 | log.Debug().Str("domain", domain).Msg("favicon not in engine db, fetching via service") | ||
| 156 | fetchViaService() | 211 | fetchViaService() | ||
| @@ -170,12 +225,19 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||||
| 170 | } | 225 | } | ||
| 171 | 226 | ||||
| 172 | // No WebKit DB, fetch via service directly | 227 | // No WebKit DB, fetch via service directly | ||
| 228 | + | log.Debug().Str("domain", domain).Msg("favicon: no engine db, fetching via service") | |||
| 173 | fetchViaService() | 229 | fetchViaService() | ||
| 174 | } | 230 | } | ||
| 175 | 231 | ||||
| 176 | // StoreFromWebKit stores a favicon texture received from WebKit signals. | 232 | // StoreFromWebKit stores a favicon texture received from WebKit signals. | ||
| 177 | // Also ensures the favicon is persisted to disk cache via the service. | 233 | // Also ensures the favicon is persisted to disk cache via the service. | ||
| 178 | func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, texture *gdk.Texture) { | 234 | func (a *FaviconAdapter) StoreFromWebKit(ctx context.Context, pageURL string, texture *gdk.Texture) { | ||
| 235 | + | if a.disabled { | |||
| 236 | + | logging.FromContext(ctx).Debug(). | |||
| 237 | + | Str("page_url", pageURL). | |||
| 238 | + | Msg("favicon: StoreFromWebKit skipped because favicon pipeline is disabled") | |||
| 239 | + | return | |||
| 240 | + | } | |||
| 179 | if texture == nil { | 241 | if texture == nil { | ||
| 180 | return | 242 | return | ||
| 181 | } | 243 | } | ||
| @@ -272,25 +334,43 @@ func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, t | |||||
| 272 | // PreloadFromCache attempts to load a favicon from cache without fetching. | 334 | // PreloadFromCache attempts to load a favicon from cache without fetching. | ||
| 273 | // Returns the texture if found in memory or disk cache, nil otherwise. | 335 | // Returns the texture if found in memory or disk cache, nil otherwise. | ||
| 274 | func (a *FaviconAdapter) PreloadFromCache(pageURL string) *gdk.Texture { | 336 | func (a *FaviconAdapter) PreloadFromCache(pageURL string) *gdk.Texture { | ||
| 337 | + | if a.disabled { | |||
| 338 | + | logging.FromContext(context.Background()).Debug(). | |||
| 339 | + | Str("page_url", pageURL). | |||
| 340 | + | Str("env", disableFaviconsEnv). | |||
| 341 | + | Msg("favicon: PreloadFromCache skipped because favicon pipeline is disabled") | |||
| 342 | + | return nil | |||
| 343 | + | } | |||
| 275 | domain := domainurl.ExtractDomain(pageURL) | 344 | domain := domainurl.ExtractDomain(pageURL) | ||
| 276 | if domain == "" { | 345 | if domain == "" { | ||
| 277 | return nil | 346 | return nil | ||
| 278 | } | 347 | } | ||
| 348 | + | log := logging.FromContext(context.Background()) | |||
| 349 | + | log.Debug(). | |||
| 350 | + | Str("page_url", pageURL). | |||
| 351 | + | Str("domain", domain). | |||
| 352 | + | Msg("favicon: PreloadFromCache begin") | |||
| 279 | 353 | ||||
| 280 | // Check texture cache | 354 | // Check texture cache | ||
| 281 | if texture := a.GetTexture(domain); texture != nil { | 355 | if texture := a.GetTexture(domain); texture != nil { | ||
| 356 | + | log.Debug().Str("domain", domain).Msg("favicon: PreloadFromCache texture hit") | |||
| 282 | return texture | 357 | return texture | ||
| 283 | } | 358 | } | ||
| 284 | 359 | ||||
| 285 | // Check service cache (memory + disk) | 360 | // Check service cache (memory + disk) | ||
| 286 | if data, ok := a.service.GetCached(domain); ok && len(data) > 0 { | 361 | if data, ok := a.service.GetCached(domain); ok && len(data) > 0 { | ||
| 287 | - | texture := bytesToTexture(data) | 362 | + | log.Debug(). |
| 363 | + | Str("domain", domain). | |||
| 364 | + | Int("bytes", len(data)). | |||
| 365 | + | Msg("favicon: PreloadFromCache service hit") | |||
| 366 | + | texture := a.textureFromBytesOnGTK(data) | |||
| 288 | if texture != nil { | 367 | if texture != nil { | ||
| 289 | a.setTexture(domain, texture) | 368 | a.setTexture(domain, texture) | ||
| 290 | return texture | 369 | return texture | ||
| 291 | } | 370 | } | ||
| 292 | } | 371 | } | ||
| 293 | 372 | ||||
| 373 | + | log.Debug().Str("domain", domain).Msg("favicon: PreloadFromCache miss") | |||
| 294 | return nil | 374 | return nil | ||
| 295 | } | 375 | } | ||
| 296 | 376 | ||||
| @@ -414,13 +494,40 @@ func (a *FaviconAdapter) getOrCreateLogoTexture() *gdk.Texture { | |||||
| 414 | 494 | ||||
| 415 | // Create texture from logo bytes | 495 | // Create texture from logo bytes | ||
| 416 | logoBytes := a.getLogoBytes() | 496 | logoBytes := a.getLogoBytes() | ||
| 417 | - | texture := bytesToTexture(logoBytes) | 497 | + | texture := a.textureFromBytesOnGTK(logoBytes) |
| 418 | if texture != nil { | 498 | if texture != nil { | ||
| 419 | a.setTexture(a.internalDomain, texture) | 499 | a.setTexture(a.internalDomain, texture) | ||
| 420 | } | 500 | } | ||
| 421 | return texture | 501 | return texture | ||
| 422 | } | 502 | } | ||
| 423 | 503 | ||||
| 504 | + | func (a *FaviconAdapter) textureFromBytesOnGTK(data []byte) *gdk.Texture { | |||
| 505 | + | if len(data) == 0 { | |||
| 506 | + | return nil | |||
| 507 | + | } | |||
| 508 | + | log := logging.FromContext(context.Background()) | |||
| 509 | + | log.Debug(). | |||
| 510 | + | Int("bytes", len(data)). | |||
| 511 | + | Msg("favicon: textureFromBytesOnGTK begin") | |||
| 512 | + | ||||
| 513 | + | ctx := glib.MainContextDefault() | |||
| 514 | + | if ctx != nil && ctx.IsOwner() { | |||
| 515 | + | texture := bytesToTexture(data) | |||
| 516 | + | log.Debug().Bool("texture_nil", texture == nil).Msg("favicon: textureFromBytesOnGTK inline") | |||
| 517 | + | return texture | |||
| 518 | + | } | |||
| 519 | + | ||||
| 520 | + | resultCh := make(chan textureResult, 1) | |||
| 521 | + | cb := glib.SourceOnceFunc(func(_ uintptr) { | |||
| 522 | + | log.Debug().Int("bytes", len(data)).Msg("favicon: textureFromBytesOnGTK executing on GTK") | |||
| 523 | + | resultCh <- textureResult{texture: bytesToTexture(data)} | |||
| 524 | + | }) | |||
| 525 | + | glib.IdleAddOnce(&cb, 0) | |||
| 526 | + | result := <-resultCh | |||
| 527 | + | log.Debug().Bool("texture_nil", result.texture == nil).Msg("favicon: textureFromBytesOnGTK end") | |||
| 528 | + | return result.texture | |||
| 529 | + | } | |||
| 530 | + | ||||
| 424 | // bytesToTexture converts raw favicon bytes to a GDK texture. | 531 | // bytesToTexture converts raw favicon bytes to a GDK texture. | ||
| 425 | func bytesToTexture(data []byte) *gdk.Texture { | 532 | func bytesToTexture(data []byte) *gdk.Texture { | ||
| 426 | if len(data) == 0 { | 533 | if len(data) == 0 { | ||
M internal/ui/app.go +7 -0
| @@ -385,12 +385,19 @@ func (a *App) prewarmWebViewPoolAsync(ctx context.Context) { | |||
| 385 | 385 | } | |
| 386 | 386 | ||
| 387 | 387 | func (a *App) createMainWindow(ctx context.Context) error { | |
| 388 | + | log := logging.FromContext(ctx) | |
| 388 | 389 | mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config.Workspace.TabBarPosition) | |
| 389 | 390 | if err != nil { | |
| 390 | 391 | return err | |
| 391 | 392 | } | |
| 392 | 393 | a.mainWindow = mainWindow | |
| 393 | 394 | ||
| 395 | + | closeRequestCb := func(_ gtk.Window) bool { | |
| 396 | + | log.Info().Msg("main window close requested") | |
| 397 | + | return false | |
| 398 | + | } | |
| 399 | + | a.mainWindow.Window().ConnectCloseRequest(&closeRequestCb) | |
| 400 | + | ||
| 394 | 401 | // Create permission popup and dialog presenter | |
| 395 | 402 | if a.deps != nil && a.deps.PermissionUC != nil { | |
| 396 | 403 | uiScale := 1.0 | |
| @@ -385,12 +385,19 @@ func (a *App) prewarmWebViewPoolAsync(ctx context.Context) { | |||||
| 385 | } | 385 | } | ||
| 386 | 386 | ||||
| 387 | func (a *App) createMainWindow(ctx context.Context) error { | 387 | func (a *App) createMainWindow(ctx context.Context) error { | ||
| 388 | + | log := logging.FromContext(ctx) | |||
| 388 | mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config.Workspace.TabBarPosition) | 389 | mainWindow, err := window.New(ctx, a.gtkApp, a.deps.Config.Workspace.TabBarPosition) | ||
| 389 | if err != nil { | 390 | if err != nil { | ||
| 390 | return err | 391 | return err | ||
| 391 | } | 392 | } | ||
| 392 | a.mainWindow = mainWindow | 393 | a.mainWindow = mainWindow | ||
| 393 | 394 | ||||
| 395 | + | closeRequestCb := func(_ gtk.Window) bool { | |||
| 396 | + | log.Info().Msg("main window close requested") | |||
| 397 | + | return false | |||
| 398 | + | } | |||
| 399 | + | a.mainWindow.Window().ConnectCloseRequest(&closeRequestCb) | |||
| 400 | + | ||||
| 394 | // Create permission popup and dialog presenter | 401 | // Create permission popup and dialog presenter | ||
| 395 | if a.deps != nil && a.deps.PermissionUC != nil { | 402 | if a.deps != nil && a.deps.PermissionUC != nil { | ||
| 396 | uiScale := 1.0 | 403 | uiScale := 1.0 | ||