fix: address CodeRabbit findings + vendor purego fork with FFI optimizations
CodeRabbit fixes (28 findings): - Guard nil pointer deref in render_pipeline.go - Add os.Exit(1) after MaybeExitSubprocess - Add cef-helper to build target - Use html.EscapeString in scheme_handler - Fix schema range for windowless_frame_rate - Use ResolveEngineType() in validation - Remove unnecessary goroutine in noop favicon - Context propagation in favicon cache/service/adapter - Deadlock timeout in textureFromBytesOnGTK - Log LoadURI errors in popup - Document handler_deps exception - Rune-safe truncation in app.go - Accent handler debug logging - Built-in min/max in render_pipeline - gl_loader Close() method - LoadHTML size warning - Zoom delay documentation - Table-driven zoom tests - Test context initialization Vendor updates: - purego fork: []string marshalling, RWMutex callbacks, sync.Pool args, fast path, SyscallSelf, scalar return optimization, HostLayout - purego-cef: eliminate OnPaint buffer copy
- commit
- 82d9dffd8f0da675e25006a8872ccdcb8c006620
- author
- brice · · 2026-03-23 09:46 UTC
- committer
- brice · · 2026-03-28 04:38 UTC
- parent
- 5ec3eb1
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
27 files · +141 -89
M Makefile +1 -0
| @@ -42,6 +42,7 @@ build: build-frontend ## Build the application (pure Go, no CGO) | |||
| 42 | 42 | @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..." | |
| 43 | 43 | @mkdir -p $(DIST_DIR) | |
| 44 | 44 | CGO_ENABLED=0 go build -p $(NPROCS) $(GCFLAGS) $(LDFLAGS) -o $(DIST_DIR)/$(BINARY_NAME) $(MAIN_PATH) | |
| 45 | + | CGO_ENABLED=0 go build -p $(NPROCS) -ldflags "-s -w" -o $(DIST_DIR)/cef-helper ./cmd/cef-helper | |
| 45 | 46 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | |
| 46 | 47 | ||
| 47 | 48 | build-frontend: ## Build homepage and error pages | |
| @@ -42,6 +42,7 @@ build: build-frontend ## Build the application (pure Go, no CGO) | |||||
| 42 | @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..." | 42 | @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..." | ||
| 43 | @mkdir -p $(DIST_DIR) | 43 | @mkdir -p $(DIST_DIR) | ||
| 44 | CGO_ENABLED=0 go build -p $(NPROCS) $(GCFLAGS) $(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) | ||
| 45 | + | CGO_ENABLED=0 go build -p $(NPROCS) -ldflags "-s -w" -o $(DIST_DIR)/cef-helper ./cmd/cef-helper | |||
| 45 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | 46 | @echo "Build successful! Binary: $(DIST_DIR)/$(BINARY_NAME)" | ||
| 46 | 47 | ||||
| 47 | build-frontend: ## Build homepage and error pages | 48 | build-frontend: ## Build homepage and error pages | ||
M cmd/dumber/main.go +1 -0
| @@ -64,6 +64,7 @@ func main() { | |||
| 64 | 64 | // engine can also be selected via config. | |
| 65 | 65 | if isCEFSubprocess() { | |
| 66 | 66 | cef.MaybeExitSubprocess() | |
| 67 | + | os.Exit(1) | |
| 67 | 68 | } | |
| 68 | 69 | ||
| 69 | 70 | // Run GUI mode for browse command | |
| @@ -64,6 +64,7 @@ func main() { | |||||
| 64 | // engine can also be selected via config. | 64 | // engine can also be selected via config. | ||
| 65 | if isCEFSubprocess() { | 65 | if isCEFSubprocess() { | ||
| 66 | cef.MaybeExitSubprocess() | 66 | cef.MaybeExitSubprocess() | ||
| 67 | + | os.Exit(1) | |||
| 67 | } | 68 | } | ||
| 68 | 69 | ||||
| 69 | // Run GUI mode for browse command | 70 | // Run GUI mode for browse command | ||
M go.mod +2 -0
| @@ -29,6 +29,8 @@ replace github.com/ebitengine/purego => github.com/bnema/purego v0.0.0-202603230 | |||
| 29 | 29 | ||
| 30 | 30 | replace github.com/bnema/purego-cef => ../purego-cef | |
| 31 | 31 | ||
| 32 | + | replace github.com/ebitengine/purego => ../purego | |
| 33 | + | ||
| 32 | 34 | require ( | |
| 33 | 35 | github.com/atotto/clipboard v0.1.4 // indirect | |
| 34 | 36 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | |
| @@ -29,6 +29,8 @@ replace github.com/ebitengine/purego => github.com/bnema/purego v0.0.0-202603230 | |||||
| 29 | 29 | ||||
| 30 | replace github.com/bnema/purego-cef => ../purego-cef | 30 | replace github.com/bnema/purego-cef => ../purego-cef | ||
| 31 | 31 | ||||
| 32 | + | replace github.com/ebitengine/purego => ../purego | |||
| 33 | + | ||||
| 32 | require ( | 34 | require ( | ||
| 33 | github.com/atotto/clipboard v0.1.4 // indirect | 35 | github.com/atotto/clipboard v0.1.4 // indirect | ||
| 34 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | 36 | github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect | ||
M go.sum +0 -2
| @@ -34,8 +34,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c | |||
| 34 | 34 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | |
| 35 | 35 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= | |
| 36 | 36 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= | |
| 37 | - | github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= | |
| 38 | - | github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= | |
| 39 | 37 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= | |
| 40 | 38 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= | |
| 41 | 39 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= | |
| @@ -34,8 +34,6 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c | |||||
| 34 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | 34 | github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= | ||
| 35 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= | 35 | github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= | ||
| 36 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= | 36 | github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= | ||
| 37 | - | github.com/ebitengine/purego v0.10.0 h1:QIw4xfpWT6GWTzaW5XEKy3HXoqrJGx1ijYHzTF0/ISU= | |||
| 38 | - | github.com/ebitengine/purego v0.10.0/go.mod h1:iIjxzd6CiRiOG0UyXP+V1+jWqUXVjPKLAI0mRfJZTmQ= | |||
| 39 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= | 37 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= | ||
| 40 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= | 38 | github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= | ||
| 41 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= | 39 | github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= | ||
M internal/application/port/favicon_service.go +1 -1
| @@ -4,7 +4,7 @@ import "context" | |||
| 4 | 4 | ||
| 5 | 5 | // FaviconService provides favicon retrieval and caching. | |
| 6 | 6 | type FaviconService interface { | |
| 7 | - | GetCached(domain string) ([]byte, bool) | |
| 7 | + | GetCached(ctx context.Context, domain string) ([]byte, bool) | |
| 8 | 8 | Get(ctx context.Context, domain string) ([]byte, error) | |
| 9 | 9 | DiskPathPNG(domain string) string | |
| 10 | 10 | HasPNGOnDisk(domain string) bool | |
| @@ -4,7 +4,7 @@ import "context" | |||||
| 4 | 4 | ||||
| 5 | // FaviconService provides favicon retrieval and caching. | 5 | // FaviconService provides favicon retrieval and caching. | ||
| 6 | type FaviconService interface { | 6 | type FaviconService interface { | ||
| 7 | - | GetCached(domain string) ([]byte, bool) | 7 | + | GetCached(ctx context.Context, domain string) ([]byte, bool) |
| 8 | Get(ctx context.Context, domain string) ([]byte, error) | 8 | Get(ctx context.Context, domain string) ([]byte, error) | ||
| 9 | DiskPathPNG(domain string) string | 9 | DiskPathPNG(domain string) string | ||
| 10 | HasPNGOnDisk(domain string) bool | 10 | HasPNGOnDisk(domain string) bool | ||
M internal/application/port/handler_deps.go +4 -0
| @@ -34,6 +34,10 @@ type AutoCopyConfig interface { | |||
| 34 | 34 | // HandlerDeps holds handler dependencies built at bootstrap time. | |
| 35 | 35 | // Lives in the port layer so both bootstrap and ui can depend on it | |
| 36 | 36 | // without creating a reverse dependency. | |
| 37 | + | // | |
| 38 | + | // This is an intentional exception to the interfaces-only port layer guideline. | |
| 39 | + | // A struct is used here to avoid reverse dependencies between the bootstrap and | |
| 40 | + | // UI layers, which both need to reference this shared dependency container. | |
| 37 | 41 | type HandlerDeps struct { | |
| 38 | 42 | SaveConfig func(context.Context, WebUIConfig) error | |
| 39 | 43 | KeybindingsGetter KeybindingsGetter | |
| @@ -34,6 +34,10 @@ type AutoCopyConfig interface { | |||||
| 34 | // HandlerDeps holds handler dependencies built at bootstrap time. | 34 | // HandlerDeps holds handler dependencies built at bootstrap time. | ||
| 35 | // Lives in the port layer so both bootstrap and ui can depend on it | 35 | // Lives in the port layer so both bootstrap and ui can depend on it | ||
| 36 | // without creating a reverse dependency. | 36 | // without creating a reverse dependency. | ||
| 37 | + | // | |||
| 38 | + | // This is an intentional exception to the interfaces-only port layer guideline. | |||
| 39 | + | // A struct is used here to avoid reverse dependencies between the bootstrap and | |||
| 40 | + | // UI layers, which both need to reference this shared dependency container. | |||
| 37 | type HandlerDeps struct { | 41 | type HandlerDeps struct { | ||
| 38 | SaveConfig func(context.Context, WebUIConfig) error | 42 | SaveConfig func(context.Context, WebUIConfig) error | ||
| 39 | KeybindingsGetter KeybindingsGetter | 43 | KeybindingsGetter KeybindingsGetter | ||
M internal/application/port/mocks/mocks.go +20 -14
| @@ -4425,8 +4425,8 @@ func (_c *MockFaviconService_Get_Call) RunAndReturn(run func(ctx context.Context | |||
| 4425 | 4425 | } | |
| 4426 | 4426 | ||
| 4427 | 4427 | // GetCached provides a mock function for the type MockFaviconService | |
| 4428 | - | func (_mock *MockFaviconService) GetCached(domain string) ([]byte, bool) { | |
| 4429 | - | ret := _mock.Called(domain) | |
| 4428 | + | func (_mock *MockFaviconService) GetCached(ctx context.Context, domain string) ([]byte, bool) { | |
| 4429 | + | ret := _mock.Called(ctx, domain) | |
| 4430 | 4430 | ||
| 4431 | 4431 | if len(ret) == 0 { | |
| 4432 | 4432 | panic("no return value specified for GetCached") | |
| @@ -4434,18 +4434,18 @@ func (_mock *MockFaviconService) GetCached(domain string) ([]byte, bool) { | |||
| 4434 | 4434 | ||
| 4435 | 4435 | var r0 []byte | |
| 4436 | 4436 | var r1 bool | |
| 4437 | - | if returnFunc, ok := ret.Get(0).(func(string) ([]byte, bool)); ok { | |
| 4438 | - | return returnFunc(domain) | |
| 4437 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]byte, bool)); ok { | |
| 4438 | + | return returnFunc(ctx, domain) | |
| 4439 | 4439 | } | |
| 4440 | - | if returnFunc, ok := ret.Get(0).(func(string) []byte); ok { | |
| 4441 | - | r0 = returnFunc(domain) | |
| 4440 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) []byte); ok { | |
| 4441 | + | r0 = returnFunc(ctx, domain) | |
| 4442 | 4442 | } else { | |
| 4443 | 4443 | if ret.Get(0) != nil { | |
| 4444 | 4444 | r0 = ret.Get(0).([]byte) | |
| 4445 | 4445 | } | |
| 4446 | 4446 | } | |
| 4447 | - | if returnFunc, ok := ret.Get(1).(func(string) bool); ok { | |
| 4448 | - | r1 = returnFunc(domain) | |
| 4447 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) bool); ok { | |
| 4448 | + | r1 = returnFunc(ctx, domain) | |
| 4449 | 4449 | } else { | |
| 4450 | 4450 | r1 = ret.Get(1).(bool) | |
| 4451 | 4451 | } | |
| @@ -4458,19 +4458,25 @@ type MockFaviconService_GetCached_Call struct { | |||
| 4458 | 4458 | } | |
| 4459 | 4459 | ||
| 4460 | 4460 | // GetCached is a helper method to define mock.On call | |
| 4461 | + | // - ctx context.Context | |
| 4461 | 4462 | // - domain string | |
| 4462 | - | func (_e *MockFaviconService_Expecter) GetCached(domain interface{}) *MockFaviconService_GetCached_Call { | |
| 4463 | - | return &MockFaviconService_GetCached_Call{Call: _e.mock.On("GetCached", domain)} | |
| 4463 | + | func (_e *MockFaviconService_Expecter) GetCached(ctx interface{}, domain interface{}) *MockFaviconService_GetCached_Call { | |
| 4464 | + | return &MockFaviconService_GetCached_Call{Call: _e.mock.On("GetCached", ctx, domain)} | |
| 4464 | 4465 | } | |
| 4465 | 4466 | ||
| 4466 | - | func (_c *MockFaviconService_GetCached_Call) Run(run func(domain string)) *MockFaviconService_GetCached_Call { | |
| 4467 | + | func (_c *MockFaviconService_GetCached_Call) Run(run func(ctx context.Context, domain string)) *MockFaviconService_GetCached_Call { | |
| 4467 | 4468 | _c.Call.Run(func(args mock.Arguments) { | |
| 4468 | - | var arg0 string | |
| 4469 | + | var arg0 context.Context | |
| 4469 | 4470 | if args[0] != nil { | |
| 4470 | - | arg0 = args[0].(string) | |
| 4471 | + | arg0 = args[0].(context.Context) | |
| 4472 | + | } | |
| 4473 | + | var arg1 string | |
| 4474 | + | if args[1] != nil { | |
| 4475 | + | arg1 = args[1].(string) | |
| 4471 | 4476 | } | |
| 4472 | 4477 | run( | |
| 4473 | 4478 | arg0, | |
| 4479 | + | arg1, | |
| 4474 | 4480 | ) | |
| 4475 | 4481 | }) | |
| 4476 | 4482 | return _c | |
| @@ -4481,7 +4487,7 @@ func (_c *MockFaviconService_GetCached_Call) Return(bytes []byte, b bool) *MockF | |||
| 4481 | 4487 | return _c | |
| 4482 | 4488 | } | |
| 4483 | 4489 | ||
| 4484 | - | func (_c *MockFaviconService_GetCached_Call) RunAndReturn(run func(domain string) ([]byte, bool)) *MockFaviconService_GetCached_Call { | |
| 4490 | + | func (_c *MockFaviconService_GetCached_Call) RunAndReturn(run func(ctx context.Context, domain string) ([]byte, bool)) *MockFaviconService_GetCached_Call { | |
| 4485 | 4491 | _c.Call.Return(run) | |
| 4486 | 4492 | return _c | |
| 4487 | 4493 | } | |
| @@ -4425,8 +4425,8 @@ func (_c *MockFaviconService_Get_Call) RunAndReturn(run func(ctx context.Context | |||||
| 4425 | } | 4425 | } | ||
| 4426 | 4426 | ||||
| 4427 | // GetCached provides a mock function for the type MockFaviconService | 4427 | // GetCached provides a mock function for the type MockFaviconService | ||
| 4428 | - | func (_mock *MockFaviconService) GetCached(domain string) ([]byte, bool) { | 4428 | + | func (_mock *MockFaviconService) GetCached(ctx context.Context, domain string) ([]byte, bool) { |
| 4429 | - | ret := _mock.Called(domain) | 4429 | + | ret := _mock.Called(ctx, domain) |
| 4430 | 4430 | ||||
| 4431 | if len(ret) == 0 { | 4431 | if len(ret) == 0 { | ||
| 4432 | panic("no return value specified for GetCached") | 4432 | panic("no return value specified for GetCached") | ||
| @@ -4434,18 +4434,18 @@ func (_mock *MockFaviconService) GetCached(domain string) ([]byte, bool) { | |||||
| 4434 | 4434 | ||||
| 4435 | var r0 []byte | 4435 | var r0 []byte | ||
| 4436 | var r1 bool | 4436 | var r1 bool | ||
| 4437 | - | if returnFunc, ok := ret.Get(0).(func(string) ([]byte, bool)); ok { | 4437 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) ([]byte, bool)); ok { |
| 4438 | - | return returnFunc(domain) | 4438 | + | return returnFunc(ctx, domain) |
| 4439 | } | 4439 | } | ||
| 4440 | - | if returnFunc, ok := ret.Get(0).(func(string) []byte); ok { | 4440 | + | if returnFunc, ok := ret.Get(0).(func(context.Context, string) []byte); ok { |
| 4441 | - | r0 = returnFunc(domain) | 4441 | + | r0 = returnFunc(ctx, domain) |
| 4442 | } else { | 4442 | } else { | ||
| 4443 | if ret.Get(0) != nil { | 4443 | if ret.Get(0) != nil { | ||
| 4444 | r0 = ret.Get(0).([]byte) | 4444 | r0 = ret.Get(0).([]byte) | ||
| 4445 | } | 4445 | } | ||
| 4446 | } | 4446 | } | ||
| 4447 | - | if returnFunc, ok := ret.Get(1).(func(string) bool); ok { | 4447 | + | if returnFunc, ok := ret.Get(1).(func(context.Context, string) bool); ok { |
| 4448 | - | r1 = returnFunc(domain) | 4448 | + | r1 = returnFunc(ctx, domain) |
| 4449 | } else { | 4449 | } else { | ||
| 4450 | r1 = ret.Get(1).(bool) | 4450 | r1 = ret.Get(1).(bool) | ||
| 4451 | } | 4451 | } | ||
| @@ -4458,19 +4458,25 @@ type MockFaviconService_GetCached_Call struct { | |||||
| 4458 | } | 4458 | } | ||
| 4459 | 4459 | ||||
| 4460 | // GetCached is a helper method to define mock.On call | 4460 | // GetCached is a helper method to define mock.On call | ||
| 4461 | + | // - ctx context.Context | |||
| 4461 | // - domain string | 4462 | // - domain string | ||
| 4462 | - | func (_e *MockFaviconService_Expecter) GetCached(domain interface{}) *MockFaviconService_GetCached_Call { | 4463 | + | func (_e *MockFaviconService_Expecter) GetCached(ctx interface{}, domain interface{}) *MockFaviconService_GetCached_Call { |
| 4463 | - | return &MockFaviconService_GetCached_Call{Call: _e.mock.On("GetCached", domain)} | 4464 | + | return &MockFaviconService_GetCached_Call{Call: _e.mock.On("GetCached", ctx, domain)} |
| 4464 | } | 4465 | } | ||
| 4465 | 4466 | ||||
| 4466 | - | func (_c *MockFaviconService_GetCached_Call) Run(run func(domain string)) *MockFaviconService_GetCached_Call { | 4467 | + | func (_c *MockFaviconService_GetCached_Call) Run(run func(ctx context.Context, domain string)) *MockFaviconService_GetCached_Call { |
| 4467 | _c.Call.Run(func(args mock.Arguments) { | 4468 | _c.Call.Run(func(args mock.Arguments) { | ||
| 4468 | - | var arg0 string | 4469 | + | var arg0 context.Context |
| 4469 | if args[0] != nil { | 4470 | if args[0] != nil { | ||
| 4470 | - | arg0 = args[0].(string) | 4471 | + | arg0 = args[0].(context.Context) |
| 4472 | + | } | |||
| 4473 | + | var arg1 string | |||
| 4474 | + | if args[1] != nil { | |||
| 4475 | + | arg1 = args[1].(string) | |||
| 4471 | } | 4476 | } | ||
| 4472 | run( | 4477 | run( | ||
| 4473 | arg0, | 4478 | arg0, | ||
| 4479 | + | arg1, | |||
| 4474 | ) | 4480 | ) | ||
| 4475 | }) | 4481 | }) | ||
| 4476 | return _c | 4482 | return _c | ||
| @@ -4481,7 +4487,7 @@ func (_c *MockFaviconService_GetCached_Call) Return(bytes []byte, b bool) *MockF | |||||
| 4481 | return _c | 4487 | return _c | ||
| 4482 | } | 4488 | } | ||
| 4483 | 4489 | ||||
| 4484 | - | func (_c *MockFaviconService_GetCached_Call) RunAndReturn(run func(domain string) ([]byte, bool)) *MockFaviconService_GetCached_Call { | 4490 | + | func (_c *MockFaviconService_GetCached_Call) RunAndReturn(run func(ctx context.Context, domain string) ([]byte, bool)) *MockFaviconService_GetCached_Call { |
| 4485 | _c.Call.Return(run) | 4491 | _c.Call.Return(run) | ||
| 4486 | return _c | 4492 | return _c | ||
| 4487 | } | 4493 | } | ||
M internal/application/usecase/navigate_test.go +6 -2
| @@ -140,8 +140,12 @@ func TestHistoryWorker_ReenqueueDuringFlushIsPersistedOnShutdown(t *testing.T) { | |||
| 140 | 140 | uc.RecordHistory(ctx, "pane-1", "https://example.com/article") | |
| 141 | 141 | ||
| 142 | 142 | // The history worker flushes on a timer; there is no exported sync | |
| 143 | - | // mechanism, so sleep 3× the flush interval before shutting down. | |
| 144 | - | time.Sleep(historyWorkerFlushInterval * 3) | |
| 143 | + | // mechanism, so we sleep before shutting down. Using 5× the flush | |
| 144 | + | // interval for headroom — this is a known flaky pattern, but | |
| 145 | + | // restructuring the worker for deterministic sync is out of scope. | |
| 146 | + | // Close() performs a final flush, so items re-enqueued during the | |
| 147 | + | // timed flush are still captured on shutdown. | |
| 148 | + | time.Sleep(historyWorkerFlushInterval * 5) | |
| 145 | 149 | uc.Close() | |
| 146 | 150 | ||
| 147 | 151 | mu.Lock() | |
| @@ -140,8 +140,12 @@ func TestHistoryWorker_ReenqueueDuringFlushIsPersistedOnShutdown(t *testing.T) { | |||||
| 140 | uc.RecordHistory(ctx, "pane-1", "https://example.com/article") | 140 | uc.RecordHistory(ctx, "pane-1", "https://example.com/article") | ||
| 141 | 141 | ||||
| 142 | // The history worker flushes on a timer; there is no exported sync | 142 | // The history worker flushes on a timer; there is no exported sync | ||
| 143 | - | // mechanism, so sleep 3× the flush interval before shutting down. | 143 | + | // mechanism, so we sleep before shutting down. Using 5× the flush |
| 144 | - | time.Sleep(historyWorkerFlushInterval * 3) | 144 | + | // interval for headroom — this is a known flaky pattern, but |
| 145 | + | // restructuring the worker for deterministic sync is out of scope. | |||
| 146 | + | // Close() performs a final flush, so items re-enqueued during the | |||
| 147 | + | // timed flush are still captured on shutdown. | |||
| 148 | + | time.Sleep(historyWorkerFlushInterval * 5) | |||
| 145 | uc.Close() | 149 | uc.Close() | ||
| 146 | 150 | ||||
| 147 | mu.Lock() | 151 | mu.Lock() | ||
M internal/bootstrap/handler_deps.go +2 -1
| @@ -11,7 +11,8 @@ import ( | |||
| 11 | 11 | ||
| 12 | 12 | // BuildHandlerDeps constructs handler dependencies from the config manager. | |
| 13 | 13 | // It combines keybinding use cases and config save into a single struct. | |
| 14 | - | func BuildHandlerDeps(_ context.Context) (*port.HandlerDeps, error) { | |
| 14 | + | // The context is accepted for future use (e.g., passing to constructors that may need cancellation). | |
| 15 | + | func BuildHandlerDeps(ctx context.Context) (*port.HandlerDeps, error) { | |
| 15 | 16 | mgr := config.GetManager() | |
| 16 | 17 | if mgr == nil { | |
| 17 | 18 | return nil, fmt.Errorf("config manager not initialized") | |
| @@ -11,7 +11,8 @@ import ( | |||||
| 11 | 11 | ||||
| 12 | // BuildHandlerDeps constructs handler dependencies from the config manager. | 12 | // BuildHandlerDeps constructs handler dependencies from the config manager. | ||
| 13 | // It combines keybinding use cases and config save into a single struct. | 13 | // It combines keybinding use cases and config save into a single struct. | ||
| 14 | - | func BuildHandlerDeps(_ context.Context) (*port.HandlerDeps, error) { | 14 | + | // The context is accepted for future use (e.g., passing to constructors that may need cancellation). |
| 15 | + | func BuildHandlerDeps(ctx context.Context) (*port.HandlerDeps, error) { | |||
| 15 | mgr := config.GetManager() | 16 | mgr := config.GetManager() | ||
| 16 | if mgr == nil { | 17 | if mgr == nil { | ||
| 17 | return nil, fmt.Errorf("config manager not initialized") | 18 | return nil, fmt.Errorf("config manager not initialized") | ||
M internal/domain/service/favicon.go +2 -2
| @@ -12,10 +12,10 @@ type FaviconService interface { | |||
| 12 | 12 | ||
| 13 | 13 | // GetCached returns favicon bytes only if already cached (no external fetch). | |
| 14 | 14 | // Returns the bytes and true if found, nil and false otherwise. | |
| 15 | - | GetCached(domain string) ([]byte, bool) | |
| 15 | + | GetCached(ctx context.Context, domain string) ([]byte, bool) | |
| 16 | 16 | ||
| 17 | 17 | // Store saves favicon bytes for a domain to cache. | |
| 18 | - | Store(domain string, data []byte) error | |
| 18 | + | Store(ctx context.Context, domain string, data []byte) error | |
| 19 | 19 | ||
| 20 | 20 | // DiskPath returns the filesystem path where a domain's favicon is cached (ICO). | |
| 21 | 21 | DiskPath(domain string) string | |
| @@ -12,10 +12,10 @@ type FaviconService interface { | |||||
| 12 | 12 | ||||
| 13 | // GetCached returns favicon bytes only if already cached (no external fetch). | 13 | // GetCached returns favicon bytes only if already cached (no external fetch). | ||
| 14 | // Returns the bytes and true if found, nil and false otherwise. | 14 | // Returns the bytes and true if found, nil and false otherwise. | ||
| 15 | - | GetCached(domain string) ([]byte, bool) | 15 | + | GetCached(ctx context.Context, domain string) ([]byte, bool) |
| 16 | 16 | ||||
| 17 | // Store saves favicon bytes for a domain to cache. | 17 | // Store saves favicon bytes for a domain to cache. | ||
| 18 | - | Store(domain string, data []byte) error | 18 | + | Store(ctx context.Context, domain string, data []byte) error |
| 19 | 19 | ||||
| 20 | // DiskPath returns the filesystem path where a domain's favicon is cached (ICO). | 20 | // DiskPath returns the filesystem path where a domain's favicon is cached (ICO). | ||
| 21 | DiskPath(domain string) string | 21 | DiskPath(domain string) string | ||
M internal/infrastructure/cef/app.go +4 -1
| @@ -32,7 +32,10 @@ func (a *dumberApp) OnBeforeCommandLineProcessing(processType string, commandLin | |||
| 32 | 32 | if commandLine != nil { | |
| 33 | 33 | cmdline = commandLine.GetCommandLineString() | |
| 34 | 34 | if len(cmdline) > maxCmdLineLogLen { | |
| 35 | - | cmdline = cmdline[:maxCmdLineLogLen] + "…" | |
| 35 | + | runes := []rune(cmdline) | |
| 36 | + | if len(runes) > maxCmdLineLogLen { | |
| 37 | + | cmdline = string(runes[:maxCmdLineLogLen]) + "…" | |
| 38 | + | } | |
| 36 | 39 | } | |
| 37 | 40 | } | |
| 38 | 41 | log.Debug(). | |
| @@ -32,7 +32,10 @@ func (a *dumberApp) OnBeforeCommandLineProcessing(processType string, commandLin | |||||
| 32 | if commandLine != nil { | 32 | if commandLine != nil { | ||
| 33 | cmdline = commandLine.GetCommandLineString() | 33 | cmdline = commandLine.GetCommandLineString() | ||
| 34 | if len(cmdline) > maxCmdLineLogLen { | 34 | if len(cmdline) > maxCmdLineLogLen { | ||
| 35 | - | cmdline = cmdline[:maxCmdLineLogLen] + "…" | 35 | + | runes := []rune(cmdline) |
| 36 | + | if len(runes) > maxCmdLineLogLen { | |||
| 37 | + | cmdline = string(runes[:maxCmdLineLogLen]) + "…" | |||
| 38 | + | } | |||
| 36 | } | 39 | } | ||
| 37 | } | 40 | } | ||
| 38 | log.Debug(). | 41 | log.Debug(). | ||
M internal/infrastructure/cef/engine.go +4 -0
| @@ -231,6 +231,8 @@ func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.Accent | |||
| 231 | 231 | } | |
| 232 | 232 | if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 { | |
| 233 | 233 | handler.OnKeyPressed(ctx, r, p.Shift) | |
| 234 | + | } else { | |
| 235 | + | log.Debug().Str("char", p.Char).Msg("cef: ignoring invalid accent_key_press char") | |
| 234 | 236 | } | |
| 235 | 237 | return nil, nil | |
| 236 | 238 | }, | |
| @@ -248,6 +250,8 @@ func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.Accent | |||
| 248 | 250 | } | |
| 249 | 251 | if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 { | |
| 250 | 252 | handler.OnKeyReleased(ctx, r) | |
| 253 | + | } else { | |
| 254 | + | log.Debug().Str("char", p.Char).Msg("cef: ignoring invalid accent_key_release char") | |
| 251 | 255 | } | |
| 252 | 256 | return nil, nil | |
| 253 | 257 | }, | |
| @@ -231,6 +231,8 @@ func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.Accent | |||||
| 231 | } | 231 | } | ||
| 232 | if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 { | 232 | if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 { | ||
| 233 | handler.OnKeyPressed(ctx, r, p.Shift) | 233 | handler.OnKeyPressed(ctx, r, p.Shift) | ||
| 234 | + | } else { | |||
| 235 | + | log.Debug().Str("char", p.Char).Msg("cef: ignoring invalid accent_key_press char") | |||
| 234 | } | 236 | } | ||
| 235 | return nil, nil | 237 | return nil, nil | ||
| 236 | }, | 238 | }, | ||
| @@ -248,6 +250,8 @@ func (e *Engine) RegisterAccentHandlers(ctx context.Context, handler port.Accent | |||||
| 248 | } | 250 | } | ||
| 249 | if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 { | 251 | if r, _ := utf8.DecodeRuneInString(p.Char); r != utf8.RuneError && utf8.RuneCountInString(p.Char) == 1 { | ||
| 250 | handler.OnKeyReleased(ctx, r) | 252 | handler.OnKeyReleased(ctx, r) | ||
| 253 | + | } else { | |||
| 254 | + | log.Debug().Str("char", p.Char).Msg("cef: ignoring invalid accent_key_release char") | |||
| 251 | } | 255 | } | ||
| 252 | return nil, nil | 256 | return nil, nil | ||
| 253 | }, | 257 | }, | ||
M internal/infrastructure/cef/engine_adapters.go +1 -1
| @@ -97,5 +97,5 @@ func (n *noopSettingsApplier) ApplyToAll(_ context.Context, _ []port.WebView) {} | |||
| 97 | 97 | type noopFaviconDatabase struct{} | |
| 98 | 98 | ||
| 99 | 99 | func (n *noopFaviconDatabase) GetFaviconAsync(_ string, callback func(port.Texture)) { | |
| 100 | - | go callback(nil) | |
| 100 | + | callback(nil) | |
| 101 | 101 | } | |
| @@ -97,5 +97,5 @@ func (n *noopSettingsApplier) ApplyToAll(_ context.Context, _ []port.WebView) {} | |||||
| 97 | type noopFaviconDatabase struct{} | 97 | type noopFaviconDatabase struct{} | ||
| 98 | 98 | ||||
| 99 | func (n *noopFaviconDatabase) GetFaviconAsync(_ string, callback func(port.Texture)) { | 99 | func (n *noopFaviconDatabase) GetFaviconAsync(_ string, callback func(port.Texture)) { | ||
| 100 | - | go callback(nil) | 100 | + | callback(nil) |
| 101 | } | 101 | } | ||
M internal/infrastructure/cef/gl_loader.go +8 -0
| @@ -96,6 +96,14 @@ type glLoader struct { | |||
| 96 | 96 | disable func(cap uint32) | |
| 97 | 97 | } | |
| 98 | 98 | ||
| 99 | + | // Close releases the OpenGL library handle. | |
| 100 | + | func (gl *glLoader) Close() error { | |
| 101 | + | if gl.handle != 0 { | |
| 102 | + | return purego.Dlclose(gl.handle) | |
| 103 | + | } | |
| 104 | + | return nil | |
| 105 | + | } | |
| 106 | + | ||
| 99 | 107 | // newGLLoader opens libGL.so.1 and loads all required OpenGL function pointers. | |
| 100 | 108 | func newGLLoader() (*glLoader, error) { | |
| 101 | 109 | handle, err := purego.Dlopen("libGL.so.1", purego.RTLD_LAZY) | |
| @@ -96,6 +96,14 @@ type glLoader struct { | |||||
| 96 | disable func(cap uint32) | 96 | disable func(cap uint32) | ||
| 97 | } | 97 | } | ||
| 98 | 98 | ||||
| 99 | + | // Close releases the OpenGL library handle. | |||
| 100 | + | func (gl *glLoader) Close() error { | |||
| 101 | + | if gl.handle != 0 { | |||
| 102 | + | return purego.Dlclose(gl.handle) | |||
| 103 | + | } | |||
| 104 | + | return nil | |||
| 105 | + | } | |||
| 106 | + | ||||
| 99 | // newGLLoader opens libGL.so.1 and loads all required OpenGL function pointers. | 107 | // newGLLoader opens libGL.so.1 and loads all required OpenGL function pointers. | ||
| 100 | func newGLLoader() (*glLoader, error) { | 108 | func newGLLoader() (*glLoader, error) { | ||
| 101 | handle, err := purego.Dlopen("libGL.so.1", purego.RTLD_LAZY) | 109 | handle, err := purego.Dlopen("libGL.so.1", purego.RTLD_LAZY) | ||
M internal/infrastructure/cef/input_bridge.go +2 -1
| @@ -227,7 +227,8 @@ func (ib *inputBridge) onMousePress(x, y float64, button, mods uint, clickCount | |||
| 227 | 227 | } | |
| 228 | 228 | ||
| 229 | 229 | // Middle-click on a link opens in a new tab instead of sending to CEF. | |
| 230 | - | // The URI is resolved by the callback closure (factory wiring reads wv.lastHoverURI). | |
| 230 | + | // The empty string parameter is unused — the callback closure resolves | |
| 231 | + | // the actual URI via wv.lastHoverURI at invocation time. | |
| 231 | 232 | if button == 2 && middleCB != nil { | |
| 232 | 233 | go middleCB("") | |
| 233 | 234 | return | |
| @@ -227,7 +227,8 @@ func (ib *inputBridge) onMousePress(x, y float64, button, mods uint, clickCount | |||||
| 227 | } | 227 | } | ||
| 228 | 228 | ||||
| 229 | // Middle-click on a link opens in a new tab instead of sending to CEF. | 229 | // Middle-click on a link opens in a new tab instead of sending to CEF. | ||
| 230 | - | // The URI is resolved by the callback closure (factory wiring reads wv.lastHoverURI). | 230 | + | // The empty string parameter is unused — the callback closure resolves |
| 231 | + | // the actual URI via wv.lastHoverURI at invocation time. | |||
| 231 | if button == 2 && middleCB != nil { | 232 | if button == 2 && middleCB != nil { | ||
| 232 | go middleCB("") | 233 | go middleCB("") | ||
| 233 | return | 234 | return | ||
M internal/infrastructure/cef/render_pipeline.go +11 -23
| @@ -392,11 +392,13 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, render | |||
| 392 | 392 | rp.mu.Unlock() | |
| 393 | 393 | ||
| 394 | 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") | |
| 395 | + | if rp.ctx != nil { | |
| 396 | + | logging.FromContext(rp.ctx).Error(). | |
| 397 | + | Int32("width", w). | |
| 398 | + | Int32("height", h). | |
| 399 | + | Bool("full_upload", fullUpload). | |
| 400 | + | Msg("cef: glUnmapBuffer reported corrupted PBO contents; dropping frame") | |
| 401 | + | } | |
| 400 | 402 | gl.bindBuffer(glPixelUnpackBuffer, 0) | |
| 401 | 403 | return 0 | |
| 402 | 404 | } | |
| @@ -693,10 +695,10 @@ func clampDirtyRect(r rect, width, height int32) (rect, bool) { | |||
| 693 | 695 | return rect{}, false | |
| 694 | 696 | } | |
| 695 | 697 | ||
| 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) | |
| 698 | + | x0 := max(r.X, 0) | |
| 699 | + | y0 := max(r.Y, 0) | |
| 700 | + | x1 := min(r.X+r.Width, width) | |
| 701 | + | y1 := min(r.Y+r.Height, height) | |
| 700 | 702 | ||
| 701 | 703 | if x1 <= x0 || y1 <= y0 { | |
| 702 | 704 | return rect{}, false | |
| @@ -710,20 +712,6 @@ func clampDirtyRect(r rect, width, height int32) (rect, bool) { | |||
| 710 | 712 | }, true | |
| 711 | 713 | } | |
| 712 | 714 | ||
| 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 | - | ||
| 727 | 715 | // destroy releases all GL resources. | |
| 728 | 716 | func (rp *renderPipeline) destroy() { | |
| 729 | 717 | if !rp.glReady { | |
| @@ -392,11 +392,13 @@ func (rp *renderPipeline) uploadToPBO(dirtyRects []rect, fullUpload bool, render | |||||
| 392 | rp.mu.Unlock() | 392 | rp.mu.Unlock() | ||
| 393 | 393 | ||||
| 394 | if !gl.unmapBuffer(glPixelUnpackBuffer) { | 394 | if !gl.unmapBuffer(glPixelUnpackBuffer) { | ||
| 395 | - | logging.FromContext(rp.ctx).Error(). | 395 | + | if rp.ctx != nil { |
| 396 | - | Int32("width", w). | 396 | + | logging.FromContext(rp.ctx).Error(). |
| 397 | - | Int32("height", h). | 397 | + | Int32("width", w). |
| 398 | - | Bool("full_upload", fullUpload). | 398 | + | Int32("height", h). |
| 399 | - | Msg("cef: glUnmapBuffer reported corrupted PBO contents; dropping frame") | 399 | + | Bool("full_upload", fullUpload). |
| 400 | + | Msg("cef: glUnmapBuffer reported corrupted PBO contents; dropping frame") | |||
| 401 | + | } | |||
| 400 | gl.bindBuffer(glPixelUnpackBuffer, 0) | 402 | gl.bindBuffer(glPixelUnpackBuffer, 0) | ||
| 401 | return 0 | 403 | return 0 | ||
| 402 | } | 404 | } | ||
| @@ -693,10 +695,10 @@ func clampDirtyRect(r rect, width, height int32) (rect, bool) { | |||||
| 693 | return rect{}, false | 695 | return rect{}, false | ||
| 694 | } | 696 | } | ||
| 695 | 697 | ||||
| 696 | - | x0 := maxInt32(r.X, 0) | 698 | + | x0 := max(r.X, 0) |
| 697 | - | y0 := maxInt32(r.Y, 0) | 699 | + | y0 := max(r.Y, 0) |
| 698 | - | x1 := minInt32(r.X+r.Width, width) | 700 | + | x1 := min(r.X+r.Width, width) |
| 699 | - | y1 := minInt32(r.Y+r.Height, height) | 701 | + | y1 := min(r.Y+r.Height, height) |
| 700 | 702 | ||||
| 701 | if x1 <= x0 || y1 <= y0 { | 703 | if x1 <= x0 || y1 <= y0 { | ||
| 702 | return rect{}, false | 704 | return rect{}, false | ||
| @@ -710,20 +712,6 @@ func clampDirtyRect(r rect, width, height int32) (rect, bool) { | |||||
| 710 | }, true | 712 | }, true | ||
| 711 | } | 713 | } | ||
| 712 | 714 | ||||
| 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 | - | ||||
| 727 | // destroy releases all GL resources. | 715 | // destroy releases all GL resources. | ||
| 728 | func (rp *renderPipeline) destroy() { | 716 | func (rp *renderPipeline) destroy() { | ||
| 729 | if !rp.glReady { | 717 | if !rp.glReady { | ||
M internal/infrastructure/cef/scheme_handler.go +2 -1
| @@ -6,6 +6,7 @@ import ( | |||
| 6 | 6 | "encoding/base64" | |
| 7 | 7 | "encoding/json" | |
| 8 | 8 | "fmt" | |
| 9 | + | "html" | |
| 9 | 10 | "io/fs" | |
| 10 | 11 | "mime" | |
| 11 | 12 | "net/http" | |
| @@ -267,7 +268,7 @@ func (h *dumbSchemeHandler) newRawResourceHandler(status int, contentType string | |||
| 267 | 268 | } | |
| 268 | 269 | ||
| 269 | 270 | func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler { | |
| 270 | - | escaped := strings.ReplaceAll(strings.ReplaceAll(msg, "&", "&"), "<", "<") | |
| 271 | + | escaped := html.EscapeString(msg) | |
| 271 | 272 | body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped) | |
| 272 | 273 | return h.newRawResourceHandler(status, "text/html; charset=utf-8", []byte(body)) | |
| 273 | 274 | } | |
| @@ -6,6 +6,7 @@ import ( | |||||
| 6 | "encoding/base64" | 6 | "encoding/base64" | ||
| 7 | "encoding/json" | 7 | "encoding/json" | ||
| 8 | "fmt" | 8 | "fmt" | ||
| 9 | + | "html" | |||
| 9 | "io/fs" | 10 | "io/fs" | ||
| 10 | "mime" | 11 | "mime" | ||
| 11 | "net/http" | 12 | "net/http" | ||
| @@ -267,7 +268,7 @@ func (h *dumbSchemeHandler) newRawResourceHandler(status int, contentType string | |||||
| 267 | } | 268 | } | ||
| 268 | 269 | ||||
| 269 | func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler { | 270 | func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler { | ||
| 270 | - | escaped := strings.ReplaceAll(strings.ReplaceAll(msg, "&", "&"), "<", "<") | 271 | + | escaped := html.EscapeString(msg) |
| 271 | body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped) | 272 | body := fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped) | ||
| 272 | return h.newRawResourceHandler(status, "text/html; charset=utf-8", []byte(body)) | 273 | return h.newRawResourceHandler(status, "text/html; charset=utf-8", []byte(body)) | ||
| 273 | } | 274 | } | ||
M internal/infrastructure/cef/webview.go +21 -1
| @@ -132,10 +132,16 @@ func (wv *WebView) LoadURI(_ context.Context, uri string) error { | |||
| 132 | 132 | } | |
| 133 | 133 | ||
| 134 | 134 | // LoadHTML loads HTML content with an optional base URI (ignored in Phase 1). | |
| 135 | - | func (wv *WebView) LoadHTML(_ context.Context, content, _ string) error { | |
| 135 | + | func (wv *WebView) LoadHTML(ctx context.Context, content, _ string) error { | |
| 136 | 136 | if wv.destroyed.Load() { | |
| 137 | 137 | return errDestroyed | |
| 138 | 138 | } | |
| 139 | + | const maxDataURLSize = 1 << 20 // 1MB | |
| 140 | + | if len(content) > maxDataURLSize { | |
| 141 | + | logging.FromContext(ctx).Warn(). | |
| 142 | + | Int("content_len", len(content)). | |
| 143 | + | Msg("cef: LoadHTML content exceeds 1MB, data URL may fail") | |
| 144 | + | } | |
| 139 | 145 | wv.mu.RLock() | |
| 140 | 146 | browser := wv.browser | |
| 141 | 147 | wv.mu.RUnlock() | |
| @@ -557,6 +563,13 @@ func (wv *WebView) updateHoverURI(uri string) { | |||
| 557 | 563 | wv.mu.Unlock() | |
| 558 | 564 | } | |
| 559 | 565 | ||
| 566 | + | // scheduleZoomRefresh posts two invalidation requests to the CEF UI thread: | |
| 567 | + | // - 16ms delay: one frame at 60fps, gives the renderer time to process | |
| 568 | + | // the zoom IPC before we request the first repaint. | |
| 569 | + | // - 48ms delay: three frames at 60fps, a second invalidation to catch | |
| 570 | + | // any compositor frame that was still in-flight during the first. | |
| 571 | + | // | |
| 572 | + | // These values are empirically tuned for CEF's async zoom application. | |
| 560 | 573 | func (wv *WebView) scheduleZoomRefresh() { | |
| 561 | 574 | for _, delayMs := range [...]int64{16, 48} { | |
| 562 | 575 | purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() { | |
| @@ -574,6 +587,13 @@ func (wv *WebView) scheduleZoomRefresh() { | |||
| 574 | 587 | } | |
| 575 | 588 | } | |
| 576 | 589 | ||
| 590 | + | // scheduleZoomReadback posts two diagnostic readbacks to verify zoom applied: | |
| 591 | + | // - 0ms delay: immediate check on the CEF UI thread to log the zoom level | |
| 592 | + | // right after SetZoomLevel returns (usually still the old value). | |
| 593 | + | // - 64ms delay: four frames at 60fps, enough time for the renderer to | |
| 594 | + | // process the zoom IPC and reflect the new level back to the browser. | |
| 595 | + | // | |
| 596 | + | // These values are empirically tuned for CEF's async zoom application. | |
| 577 | 597 | func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) { | |
| 578 | 598 | for _, delayMs := range [...]int64{0, 64} { | |
| 579 | 599 | purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() { | |
| @@ -132,10 +132,16 @@ func (wv *WebView) LoadURI(_ context.Context, uri string) error { | |||||
| 132 | } | 132 | } | ||
| 133 | 133 | ||||
| 134 | // LoadHTML loads HTML content with an optional base URI (ignored in Phase 1). | 134 | // LoadHTML loads HTML content with an optional base URI (ignored in Phase 1). | ||
| 135 | - | func (wv *WebView) LoadHTML(_ context.Context, content, _ string) error { | 135 | + | func (wv *WebView) LoadHTML(ctx context.Context, content, _ string) error { |
| 136 | if wv.destroyed.Load() { | 136 | if wv.destroyed.Load() { | ||
| 137 | return errDestroyed | 137 | return errDestroyed | ||
| 138 | } | 138 | } | ||
| 139 | + | const maxDataURLSize = 1 << 20 // 1MB | |||
| 140 | + | if len(content) > maxDataURLSize { | |||
| 141 | + | logging.FromContext(ctx).Warn(). | |||
| 142 | + | Int("content_len", len(content)). | |||
| 143 | + | Msg("cef: LoadHTML content exceeds 1MB, data URL may fail") | |||
| 144 | + | } | |||
| 139 | wv.mu.RLock() | 145 | wv.mu.RLock() | ||
| 140 | browser := wv.browser | 146 | browser := wv.browser | ||
| 141 | wv.mu.RUnlock() | 147 | wv.mu.RUnlock() | ||
| @@ -557,6 +563,13 @@ func (wv *WebView) updateHoverURI(uri string) { | |||||
| 557 | wv.mu.Unlock() | 563 | wv.mu.Unlock() | ||
| 558 | } | 564 | } | ||
| 559 | 565 | ||||
| 566 | + | // scheduleZoomRefresh posts two invalidation requests to the CEF UI thread: | |||
| 567 | + | // - 16ms delay: one frame at 60fps, gives the renderer time to process | |||
| 568 | + | // the zoom IPC before we request the first repaint. | |||
| 569 | + | // - 48ms delay: three frames at 60fps, a second invalidation to catch | |||
| 570 | + | // any compositor frame that was still in-flight during the first. | |||
| 571 | + | // | |||
| 572 | + | // These values are empirically tuned for CEF's async zoom application. | |||
| 560 | func (wv *WebView) scheduleZoomRefresh() { | 573 | func (wv *WebView) scheduleZoomRefresh() { | ||
| 561 | for _, delayMs := range [...]int64{16, 48} { | 574 | for _, delayMs := range [...]int64{16, 48} { | ||
| 562 | purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() { | 575 | purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() { | ||
| @@ -574,6 +587,13 @@ func (wv *WebView) scheduleZoomRefresh() { | |||||
| 574 | } | 587 | } | ||
| 575 | } | 588 | } | ||
| 576 | 589 | ||||
| 590 | + | // scheduleZoomReadback posts two diagnostic readbacks to verify zoom applied: | |||
| 591 | + | // - 0ms delay: immediate check on the CEF UI thread to log the zoom level | |||
| 592 | + | // right after SetZoomLevel returns (usually still the old value). | |||
| 593 | + | // - 64ms delay: four frames at 60fps, enough time for the renderer to | |||
| 594 | + | // process the zoom IPC and reflect the new level back to the browser. | |||
| 595 | + | // | |||
| 596 | + | // These values are empirically tuned for CEF's async zoom application. | |||
| 577 | func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) { | 597 | func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) { | ||
| 578 | for _, delayMs := range [...]int64{0, 64} { | 598 | for _, delayMs := range [...]int64{0, 64} { | ||
| 579 | purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() { | 599 | purecef.PostDelayedTask(purecef.ThreadIDTidUi, purecef.NewTask(cefTaskFunc(func() { | ||
M internal/infrastructure/cef/webview_handlers_test.go +4 -1
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 4 | 5 | "testing" | |
| 5 | 6 | ||
| 6 | 7 | purecef "github.com/bnema/purego-cef/cef" | |
| @@ -44,7 +45,7 @@ func (f stubFrame) CreateUrlrequest(purecef.Request, purecef.UrlrequestClient) p | |||
| 44 | 45 | } | |
| 45 | 46 | ||
| 46 | 47 | func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) { | |
| 47 | - | wv := &WebView{} | |
| 48 | + | wv := &WebView{ctx: context.Background()} | |
| 48 | 49 | var gotEvents []port.LoadEvent | |
| 49 | 50 | wv.SetCallbacks(&port.WebViewCallbacks{ | |
| 50 | 51 | OnLoadChanged: func(event port.LoadEvent) { | |
| @@ -64,6 +65,7 @@ func TestGetViewRectUsesDIPCoordinates(t *testing.T) { | |||
| 64 | 65 | rect := &purecef.Rect{} | |
| 65 | 66 | h := &handlerSet{ | |
| 66 | 67 | wv: &WebView{ | |
| 68 | + | ctx: context.Background(), | |
| 67 | 69 | pipeline: &renderPipeline{ | |
| 68 | 70 | width: 800, | |
| 69 | 71 | height: 600, | |
| @@ -84,6 +86,7 @@ func TestGetScreenInfoUsesDIPRectAndScale(t *testing.T) { | |||
| 84 | 86 | info := &purecef.ScreenInfo{} | |
| 85 | 87 | h := &handlerSet{ | |
| 86 | 88 | wv: &WebView{ | |
| 89 | + | ctx: context.Background(), | |
| 87 | 90 | pipeline: &renderPipeline{ | |
| 88 | 91 | width: 1500, | |
| 89 | 92 | height: 900, | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package cef | 1 | package cef | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 4 | "testing" | 5 | "testing" | ||
| 5 | 6 | ||||
| 6 | purecef "github.com/bnema/purego-cef/cef" | 7 | purecef "github.com/bnema/purego-cef/cef" | ||
| @@ -44,7 +45,7 @@ func (f stubFrame) CreateUrlrequest(purecef.Request, purecef.UrlrequestClient) p | |||||
| 44 | } | 45 | } | ||
| 45 | 46 | ||||
| 46 | func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) { | 47 | func TestOnLoadStartFiresCommittedAndUpdatesURI(t *testing.T) { | ||
| 47 | - | wv := &WebView{} | 48 | + | wv := &WebView{ctx: context.Background()} |
| 48 | var gotEvents []port.LoadEvent | 49 | var gotEvents []port.LoadEvent | ||
| 49 | wv.SetCallbacks(&port.WebViewCallbacks{ | 50 | wv.SetCallbacks(&port.WebViewCallbacks{ | ||
| 50 | OnLoadChanged: func(event port.LoadEvent) { | 51 | OnLoadChanged: func(event port.LoadEvent) { | ||
| @@ -64,6 +65,7 @@ func TestGetViewRectUsesDIPCoordinates(t *testing.T) { | |||||
| 64 | rect := &purecef.Rect{} | 65 | rect := &purecef.Rect{} | ||
| 65 | h := &handlerSet{ | 66 | h := &handlerSet{ | ||
| 66 | wv: &WebView{ | 67 | wv: &WebView{ | ||
| 68 | + | ctx: context.Background(), | |||
| 67 | pipeline: &renderPipeline{ | 69 | pipeline: &renderPipeline{ | ||
| 68 | width: 800, | 70 | width: 800, | ||
| 69 | height: 600, | 71 | height: 600, | ||
| @@ -84,6 +86,7 @@ func TestGetScreenInfoUsesDIPRectAndScale(t *testing.T) { | |||||
| 84 | info := &purecef.ScreenInfo{} | 86 | info := &purecef.ScreenInfo{} | ||
| 85 | h := &handlerSet{ | 87 | h := &handlerSet{ | ||
| 86 | wv: &WebView{ | 88 | wv: &WebView{ | ||
| 89 | + | ctx: context.Background(), | |||
| 87 | pipeline: &renderPipeline{ | 90 | pipeline: &renderPipeline{ | ||
| 88 | width: 1500, | 91 | width: 1500, | ||
| 89 | height: 900, | 92 | height: 900, | ||
M internal/infrastructure/cef/webview_zoom_test.go +8 -6
| @@ -1,18 +1,20 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "fmt" | |
| 4 | 5 | "math" | |
| 5 | 6 | "testing" | |
| 6 | 7 | ) | |
| 7 | 8 | ||
| 8 | 9 | func TestZoomConversionsRoundTrip(t *testing.T) { | |
| 9 | 10 | factors := []float64{0.5, 0.8, 1.0, 1.1, 1.2, 1.4, 2.0} | |
| 10 | - | ||
| 11 | 11 | for _, factor := range factors { | |
| 12 | - | level := cefZoomFromFactor(factor) | |
| 13 | - | got := factorFromCEFZoom(level) | |
| 14 | - | if math.Abs(got-factor) > 1e-9 { | |
| 15 | - | t.Fatalf("round-trip mismatch for factor %.6f: level %.6f -> factor %.12f", factor, level, got) | |
| 16 | - | } | |
| 12 | + | t.Run(fmt.Sprintf("factor_%.1f", factor), func(t *testing.T) { | |
| 13 | + | level := cefZoomFromFactor(factor) | |
| 14 | + | got := factorFromCEFZoom(level) | |
| 15 | + | if math.Abs(got-factor) > 1e-9 { | |
| 16 | + | t.Errorf("round-trip mismatch for factor %.6f: level %.6f -> factor %.12f", factor, level, got) | |
| 17 | + | } | |
| 18 | + | }) | |
| 17 | 19 | } | |
| 18 | 20 | } | |
| @@ -1,18 +1,20 @@ | |||||
| 1 | package cef | 1 | package cef | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "fmt" | |||
| 4 | "math" | 5 | "math" | ||
| 5 | "testing" | 6 | "testing" | ||
| 6 | ) | 7 | ) | ||
| 7 | 8 | ||||
| 8 | func TestZoomConversionsRoundTrip(t *testing.T) { | 9 | func TestZoomConversionsRoundTrip(t *testing.T) { | ||
| 9 | factors := []float64{0.5, 0.8, 1.0, 1.1, 1.2, 1.4, 2.0} | 10 | factors := []float64{0.5, 0.8, 1.0, 1.1, 1.2, 1.4, 2.0} | ||
| 10 | - | ||||
| 11 | for _, factor := range factors { | 11 | for _, factor := range factors { | ||
| 12 | - | level := cefZoomFromFactor(factor) | 12 | + | t.Run(fmt.Sprintf("factor_%.1f", factor), func(t *testing.T) { |
| 13 | - | got := factorFromCEFZoom(level) | 13 | + | level := cefZoomFromFactor(factor) |
| 14 | - | if math.Abs(got-factor) > 1e-9 { | 14 | + | got := factorFromCEFZoom(level) |
| 15 | - | t.Fatalf("round-trip mismatch for factor %.6f: level %.6f -> factor %.12f", factor, level, got) | 15 | + | if math.Abs(got-factor) > 1e-9 { |
| 16 | - | } | 16 | + | t.Errorf("round-trip mismatch for factor %.6f: level %.6f -> factor %.12f", factor, level, got) |
| 17 | + | } | |||
| 18 | + | }) | |||
| 17 | } | 19 | } | ||
| 18 | } | 20 | } | ||
M internal/infrastructure/config/schema_provider.go +1 -1
| @@ -916,7 +916,7 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn | |||
| 916 | 916 | Type: "int32", | |
| 917 | 917 | Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFWindowlessFrameRate()), | |
| 918 | 918 | Description: "Maximum off-screen frame rate for the CEF engine", | |
| 919 | - | Range: ">=1", | |
| 919 | + | Range: ">=0", | |
| 920 | 920 | Section: SectionPerformance, | |
| 921 | 921 | }, | |
| 922 | 922 | { | |
| @@ -916,7 +916,7 @@ func (*SchemaProvider) getPerformanceKeys(defaults *Config) []entity.ConfigKeyIn | |||||
| 916 | Type: "int32", | 916 | Type: "int32", | ||
| 917 | Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFWindowlessFrameRate()), | 917 | Default: fmt.Sprintf("%d", defaults.Engine.CEF.CEFWindowlessFrameRate()), | ||
| 918 | Description: "Maximum off-screen frame rate for the CEF engine", | 918 | Description: "Maximum off-screen frame rate for the CEF engine", | ||
| 919 | - | Range: ">=1", | 919 | + | Range: ">=0", |
| 920 | Section: SectionPerformance, | 920 | Section: SectionPerformance, | ||
| 921 | }, | 921 | }, | ||
| 922 | { | 922 | { | ||
M internal/infrastructure/config/validation.go +2 -7
| @@ -3,7 +3,6 @@ package config | |||
| 3 | 3 | import ( | |
| 4 | 4 | "fmt" | |
| 5 | 5 | "net/url" | |
| 6 | - | "os" | |
| 7 | 6 | "sort" | |
| 8 | 7 | "strings" | |
| 9 | 8 | ||
| @@ -467,12 +466,8 @@ func validatePerformanceProfile(config *Config) []string { | |||
| 467 | 466 | var validationErrors []string | |
| 468 | 467 | ||
| 469 | 468 | // Performance profiles and webkit-specific tuning only apply to the webkit engine. | |
| 470 | - | // Check both config and DUMBER_ENGINE env var override. | |
| 471 | - | engineType := config.Engine.Type | |
| 472 | - | if envEngine := os.Getenv("DUMBER_ENGINE"); envEngine != "" { | |
| 473 | - | engineType = envEngine | |
| 474 | - | } | |
| 475 | - | if engineType != "" && engineType != "webkit" { | |
| 469 | + | engineType := config.Engine.ResolveEngineType() | |
| 470 | + | if engineType != "webkit" { | |
| 476 | 471 | return nil | |
| 477 | 472 | } | |
| 478 | 473 | ||
| @@ -3,7 +3,6 @@ package config | |||||
| 3 | import ( | 3 | import ( | ||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | "net/url" | 5 | "net/url" | ||
| 6 | - | "os" | |||
| 7 | "sort" | 6 | "sort" | ||
| 8 | "strings" | 7 | "strings" | ||
| 9 | 8 | ||||
| @@ -467,12 +466,8 @@ func validatePerformanceProfile(config *Config) []string { | |||||
| 467 | var validationErrors []string | 466 | var validationErrors []string | ||
| 468 | 467 | ||||
| 469 | // Performance profiles and webkit-specific tuning only apply to the webkit engine. | 468 | // Performance profiles and webkit-specific tuning only apply to the webkit engine. | ||
| 470 | - | // Check both config and DUMBER_ENGINE env var override. | 469 | + | engineType := config.Engine.ResolveEngineType() |
| 471 | - | engineType := config.Engine.Type | 470 | + | if engineType != "webkit" { |
| 472 | - | if envEngine := os.Getenv("DUMBER_ENGINE"); envEngine != "" { | |||
| 473 | - | engineType = envEngine | |||
| 474 | - | } | |||
| 475 | - | if engineType != "" && engineType != "webkit" { | |||
| 476 | return nil | 471 | return nil | ||
| 477 | } | 472 | } | ||
| 478 | 473 | ||||
M internal/infrastructure/favicon/cache.go +4 -4
| @@ -53,11 +53,11 @@ func NewCache(diskDir string) *Cache { | |||
| 53 | 53 | // Get retrieves favicon bytes for a domain. | |
| 54 | 54 | // Checks memory cache first, then disk cache. | |
| 55 | 55 | // Returns the bytes and true if found, nil and false otherwise. | |
| 56 | - | func (c *Cache) Get(domain string) ([]byte, bool) { | |
| 56 | + | func (c *Cache) Get(ctx context.Context, domain string) ([]byte, bool) { | |
| 57 | 57 | if domain == "" { | |
| 58 | 58 | return nil, false | |
| 59 | 59 | } | |
| 60 | - | log := logging.FromContext(context.Background()) | |
| 60 | + | log := logging.FromContext(ctx) | |
| 61 | 61 | ||
| 62 | 62 | // Check memory cache first | |
| 63 | 63 | c.mu.RLock() | |
| @@ -91,11 +91,11 @@ func (c *Cache) Get(domain string) ([]byte, bool) { | |||
| 91 | 91 | ||
| 92 | 92 | // Set stores favicon bytes for a domain. | |
| 93 | 93 | // Writes to memory cache immediately and queues async disk write. | |
| 94 | - | func (c *Cache) Set(domain string, data []byte) { | |
| 94 | + | func (c *Cache) Set(ctx context.Context, domain string, data []byte) { | |
| 95 | 95 | if domain == "" || len(data) == 0 { | |
| 96 | 96 | return | |
| 97 | 97 | } | |
| 98 | - | logging.FromContext(context.Background()).Debug(). | |
| 98 | + | logging.FromContext(ctx).Debug(). | |
| 99 | 99 | Str("domain", domain). | |
| 100 | 100 | Int("bytes", len(data)). | |
| 101 | 101 | Msg("favicon: Cache.Set") | |
| @@ -53,11 +53,11 @@ func NewCache(diskDir string) *Cache { | |||||
| 53 | // Get retrieves favicon bytes for a domain. | 53 | // Get retrieves favicon bytes for a domain. | ||
| 54 | // Checks memory cache first, then disk cache. | 54 | // Checks memory cache first, then disk cache. | ||
| 55 | // Returns the bytes and true if found, nil and false otherwise. | 55 | // Returns the bytes and true if found, nil and false otherwise. | ||
| 56 | - | func (c *Cache) Get(domain string) ([]byte, bool) { | 56 | + | func (c *Cache) Get(ctx context.Context, domain string) ([]byte, bool) { |
| 57 | if domain == "" { | 57 | if domain == "" { | ||
| 58 | return nil, false | 58 | return nil, false | ||
| 59 | } | 59 | } | ||
| 60 | - | log := logging.FromContext(context.Background()) | 60 | + | log := logging.FromContext(ctx) |
| 61 | 61 | ||||
| 62 | // Check memory cache first | 62 | // Check memory cache first | ||
| 63 | c.mu.RLock() | 63 | c.mu.RLock() | ||
| @@ -91,11 +91,11 @@ func (c *Cache) Get(domain string) ([]byte, bool) { | |||||
| 91 | 91 | ||||
| 92 | // Set stores favicon bytes for a domain. | 92 | // Set stores favicon bytes for a domain. | ||
| 93 | // Writes to memory cache immediately and queues async disk write. | 93 | // Writes to memory cache immediately and queues async disk write. | ||
| 94 | - | func (c *Cache) Set(domain string, data []byte) { | 94 | + | func (c *Cache) Set(ctx context.Context, domain string, data []byte) { |
| 95 | if domain == "" || len(data) == 0 { | 95 | if domain == "" || len(data) == 0 { | ||
| 96 | return | 96 | return | ||
| 97 | } | 97 | } | ||
| 98 | - | logging.FromContext(context.Background()).Debug(). | 98 | + | logging.FromContext(ctx).Debug(). |
| 99 | Str("domain", domain). | 99 | Str("domain", domain). | ||
| 100 | Int("bytes", len(data)). | 100 | Int("bytes", len(data)). | ||
| 101 | Msg("favicon: Cache.Set") | 101 | Msg("favicon: Cache.Set") | ||
M internal/infrastructure/favicon/service.go +8 -8
| @@ -47,7 +47,7 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) { | |||
| 47 | 47 | } | |
| 48 | 48 | ||
| 49 | 49 | // Check cache first (memory + disk) | |
| 50 | - | if data, ok := s.cache.Get(domain); ok { | |
| 50 | + | if data, ok := s.cache.Get(ctx, domain); ok { | |
| 51 | 51 | log.Debug(). | |
| 52 | 52 | Str("domain", domain). | |
| 53 | 53 | Int("bytes", len(data)). | |
| @@ -64,7 +64,7 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) { | |||
| 64 | 64 | ||
| 65 | 65 | // Store in cache if we got data | |
| 66 | 66 | if len(data) > 0 { | |
| 67 | - | s.cache.Set(domain, data) | |
| 67 | + | s.cache.Set(ctx, domain, data) | |
| 68 | 68 | } | |
| 69 | 69 | log.Debug(). | |
| 70 | 70 | Str("domain", domain). | |
| @@ -86,9 +86,9 @@ func GetLogoBytes() []byte { | |||
| 86 | 86 | } | |
| 87 | 87 | ||
| 88 | 88 | // GetCached returns favicon bytes only if already cached (no external fetch). | |
| 89 | - | func (s *Service) GetCached(domain string) ([]byte, bool) { | |
| 90 | - | data, ok := s.cache.Get(domain) | |
| 91 | - | logging.FromContext(context.Background()).Debug(). | |
| 89 | + | func (s *Service) GetCached(ctx context.Context, domain string) ([]byte, bool) { | |
| 90 | + | data, ok := s.cache.Get(ctx, domain) | |
| 91 | + | logging.FromContext(ctx).Debug(). | |
| 92 | 92 | Str("domain", domain). | |
| 93 | 93 | Bool("hit", ok). | |
| 94 | 94 | Int("bytes", len(data)). | |
| @@ -97,8 +97,8 @@ func (s *Service) GetCached(domain string) ([]byte, bool) { | |||
| 97 | 97 | } | |
| 98 | 98 | ||
| 99 | 99 | // Store saves favicon bytes for a domain to cache. | |
| 100 | - | func (s *Service) Store(domain string, data []byte) error { | |
| 101 | - | s.cache.Set(domain, data) | |
| 100 | + | func (s *Service) Store(ctx context.Context, domain string, data []byte) error { | |
| 101 | + | s.cache.Set(ctx, domain, data) | |
| 102 | 102 | return nil | |
| 103 | 103 | } | |
| 104 | 104 | ||
| @@ -150,7 +150,7 @@ func (s *Service) EnsureDiskCache(ctx context.Context, domain string) { | |||
| 150 | 150 | } | |
| 151 | 151 | ||
| 152 | 152 | if len(data) > 0 { | |
| 153 | - | s.cache.Set(domain, data) | |
| 153 | + | s.cache.Set(ctx, domain, data) | |
| 154 | 154 | } | |
| 155 | 155 | log.Debug(). | |
| 156 | 156 | Str("domain", domain). | |
| @@ -47,7 +47,7 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) { | |||||
| 47 | } | 47 | } | ||
| 48 | 48 | ||||
| 49 | // Check cache first (memory + disk) | 49 | // Check cache first (memory + disk) | ||
| 50 | - | if data, ok := s.cache.Get(domain); ok { | 50 | + | if data, ok := s.cache.Get(ctx, domain); ok { |
| 51 | log.Debug(). | 51 | log.Debug(). | ||
| 52 | Str("domain", domain). | 52 | Str("domain", domain). | ||
| 53 | Int("bytes", len(data)). | 53 | Int("bytes", len(data)). | ||
| @@ -64,7 +64,7 @@ func (s *Service) Get(ctx context.Context, domain string) ([]byte, error) { | |||||
| 64 | 64 | ||||
| 65 | // Store in cache if we got data | 65 | // Store in cache if we got data | ||
| 66 | if len(data) > 0 { | 66 | if len(data) > 0 { | ||
| 67 | - | s.cache.Set(domain, data) | 67 | + | s.cache.Set(ctx, domain, data) |
| 68 | } | 68 | } | ||
| 69 | log.Debug(). | 69 | log.Debug(). | ||
| 70 | Str("domain", domain). | 70 | Str("domain", domain). | ||
| @@ -86,9 +86,9 @@ func GetLogoBytes() []byte { | |||||
| 86 | } | 86 | } | ||
| 87 | 87 | ||||
| 88 | // GetCached returns favicon bytes only if already cached (no external fetch). | 88 | // GetCached returns favicon bytes only if already cached (no external fetch). | ||
| 89 | - | func (s *Service) GetCached(domain string) ([]byte, bool) { | 89 | + | func (s *Service) GetCached(ctx context.Context, domain string) ([]byte, bool) { |
| 90 | - | data, ok := s.cache.Get(domain) | 90 | + | data, ok := s.cache.Get(ctx, domain) |
| 91 | - | logging.FromContext(context.Background()).Debug(). | 91 | + | logging.FromContext(ctx).Debug(). |
| 92 | Str("domain", domain). | 92 | Str("domain", domain). | ||
| 93 | Bool("hit", ok). | 93 | Bool("hit", ok). | ||
| 94 | Int("bytes", len(data)). | 94 | Int("bytes", len(data)). | ||
| @@ -97,8 +97,8 @@ func (s *Service) GetCached(domain string) ([]byte, bool) { | |||||
| 97 | } | 97 | } | ||
| 98 | 98 | ||||
| 99 | // Store saves favicon bytes for a domain to cache. | 99 | // Store saves favicon bytes for a domain to cache. | ||
| 100 | - | func (s *Service) Store(domain string, data []byte) error { | 100 | + | func (s *Service) Store(ctx context.Context, domain string, data []byte) error { |
| 101 | - | s.cache.Set(domain, data) | 101 | + | s.cache.Set(ctx, domain, data) |
| 102 | return nil | 102 | return nil | ||
| 103 | } | 103 | } | ||
| 104 | 104 | ||||
| @@ -150,7 +150,7 @@ func (s *Service) EnsureDiskCache(ctx context.Context, domain string) { | |||||
| 150 | } | 150 | } | ||
| 151 | 151 | ||||
| 152 | if len(data) > 0 { | 152 | if len(data) > 0 { | ||
| 153 | - | s.cache.Set(domain, data) | 153 | + | s.cache.Set(ctx, domain, data) |
| 154 | } | 154 | } | ||
| 155 | log.Debug(). | 155 | log.Debug(). | ||
| 156 | Str("domain", domain). | 156 | Str("domain", domain). | ||
M internal/ui/adapter/favicon.go +16 -10
| @@ -5,6 +5,7 @@ import ( | |||
| 5 | 5 | "context" | |
| 6 | 6 | "os" | |
| 7 | 7 | "sync" | |
| 8 | + | "time" | |
| 8 | 9 | ||
| 9 | 10 | "github.com/bnema/dumber/internal/application/port" | |
| 10 | 11 | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| @@ -136,7 +137,7 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||
| 136 | 137 | } | |
| 137 | 138 | ||
| 138 | 139 | // Check service cache (memory + disk) and convert to texture | |
| 139 | - | if data, ok := a.service.GetCached(domain); ok && len(data) > 0 { | |
| 140 | + | if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 { | |
| 140 | 141 | log.Debug(). | |
| 141 | 142 | Str("domain", domain). | |
| 142 | 143 | Int("bytes", len(data)). | |
| @@ -333,9 +334,9 @@ func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, t | |||
| 333 | 334 | ||
| 334 | 335 | // PreloadFromCache attempts to load a favicon from cache without fetching. | |
| 335 | 336 | // Returns the texture if found in memory or disk cache, nil otherwise. | |
| 336 | - | func (a *FaviconAdapter) PreloadFromCache(pageURL string) *gdk.Texture { | |
| 337 | + | func (a *FaviconAdapter) PreloadFromCache(ctx context.Context, pageURL string) *gdk.Texture { | |
| 337 | 338 | if a.disabled { | |
| 338 | - | logging.FromContext(context.Background()).Debug(). | |
| 339 | + | logging.FromContext(ctx).Debug(). | |
| 339 | 340 | Str("page_url", pageURL). | |
| 340 | 341 | Str("env", disableFaviconsEnv). | |
| 341 | 342 | Msg("favicon: PreloadFromCache skipped because favicon pipeline is disabled") | |
| @@ -345,7 +346,7 @@ func (a *FaviconAdapter) PreloadFromCache(pageURL string) *gdk.Texture { | |||
| 345 | 346 | if domain == "" { | |
| 346 | 347 | return nil | |
| 347 | 348 | } | |
| 348 | - | log := logging.FromContext(context.Background()) | |
| 349 | + | log := logging.FromContext(ctx) | |
| 349 | 350 | log.Debug(). | |
| 350 | 351 | Str("page_url", pageURL). | |
| 351 | 352 | Str("domain", domain). | |
| @@ -358,7 +359,7 @@ func (a *FaviconAdapter) PreloadFromCache(pageURL string) *gdk.Texture { | |||
| 358 | 359 | } | |
| 359 | 360 | ||
| 360 | 361 | // Check service cache (memory + disk) | |
| 361 | - | if data, ok := a.service.GetCached(domain); ok && len(data) > 0 { | |
| 362 | + | if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 { | |
| 362 | 363 | log.Debug(). | |
| 363 | 364 | Str("domain", domain). | |
| 364 | 365 | Int("bytes", len(data)). | |
| @@ -510,8 +511,8 @@ func (a *FaviconAdapter) textureFromBytesOnGTK(data []byte) *gdk.Texture { | |||
| 510 | 511 | Int("bytes", len(data)). | |
| 511 | 512 | Msg("favicon: textureFromBytesOnGTK begin") | |
| 512 | 513 | ||
| 513 | - | ctx := glib.MainContextDefault() | |
| 514 | - | if ctx != nil && ctx.IsOwner() { | |
| 514 | + | glibCtx := glib.MainContextDefault() | |
| 515 | + | if glibCtx != nil && glibCtx.IsOwner() { | |
| 515 | 516 | texture := bytesToTexture(data) | |
| 516 | 517 | log.Debug().Bool("texture_nil", texture == nil).Msg("favicon: textureFromBytesOnGTK inline") | |
| 517 | 518 | return texture | |
| @@ -523,9 +524,14 @@ func (a *FaviconAdapter) textureFromBytesOnGTK(data []byte) *gdk.Texture { | |||
| 523 | 524 | resultCh <- textureResult{texture: bytesToTexture(data)} | |
| 524 | 525 | }) | |
| 525 | 526 | glib.IdleAddOnce(&cb, 0) | |
| 526 | - | result := <-resultCh | |
| 527 | - | log.Debug().Bool("texture_nil", result.texture == nil).Msg("favicon: textureFromBytesOnGTK end") | |
| 528 | - | return result.texture | |
| 527 | + | select { | |
| 528 | + | case result := <-resultCh: | |
| 529 | + | log.Debug().Bool("texture_nil", result.texture == nil).Msg("favicon: textureFromBytesOnGTK end") | |
| 530 | + | return result.texture | |
| 531 | + | case <-time.After(5 * time.Second): | |
| 532 | + | log.Error().Msg("textureFromBytesOnGTK timeout - possible deadlock") | |
| 533 | + | return nil | |
| 534 | + | } | |
| 529 | 535 | } | |
| 530 | 536 | ||
| 531 | 537 | // bytesToTexture converts raw favicon bytes to a GDK texture. | |
| @@ -5,6 +5,7 @@ import ( | |||||
| 5 | "context" | 5 | "context" | ||
| 6 | "os" | 6 | "os" | ||
| 7 | "sync" | 7 | "sync" | ||
| 8 | + | "time" | |||
| 8 | 9 | ||||
| 9 | "github.com/bnema/dumber/internal/application/port" | 10 | "github.com/bnema/dumber/internal/application/port" | ||
| 10 | domainurl "github.com/bnema/dumber/internal/domain/url" | 11 | domainurl "github.com/bnema/dumber/internal/domain/url" | ||
| @@ -136,7 +137,7 @@ func (a *FaviconAdapter) GetOrFetch(ctx context.Context, pageURL string, callbac | |||||
| 136 | } | 137 | } | ||
| 137 | 138 | ||||
| 138 | // Check service cache (memory + disk) and convert to texture | 139 | // Check service cache (memory + disk) and convert to texture | ||
| 139 | - | if data, ok := a.service.GetCached(domain); ok && len(data) > 0 { | 140 | + | if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 { |
| 140 | log.Debug(). | 141 | log.Debug(). | ||
| 141 | Str("domain", domain). | 142 | Str("domain", domain). | ||
| 142 | Int("bytes", len(data)). | 143 | Int("bytes", len(data)). | ||
| @@ -333,9 +334,9 @@ func (a *FaviconAdapter) saveFaviconToDisk(ctx context.Context, domain string, t | |||||
| 333 | 334 | ||||
| 334 | // PreloadFromCache attempts to load a favicon from cache without fetching. | 335 | // PreloadFromCache attempts to load a favicon from cache without fetching. | ||
| 335 | // Returns the texture if found in memory or disk cache, nil otherwise. | 336 | // Returns the texture if found in memory or disk cache, nil otherwise. | ||
| 336 | - | func (a *FaviconAdapter) PreloadFromCache(pageURL string) *gdk.Texture { | 337 | + | func (a *FaviconAdapter) PreloadFromCache(ctx context.Context, pageURL string) *gdk.Texture { |
| 337 | if a.disabled { | 338 | if a.disabled { | ||
| 338 | - | logging.FromContext(context.Background()).Debug(). | 339 | + | logging.FromContext(ctx).Debug(). |
| 339 | Str("page_url", pageURL). | 340 | Str("page_url", pageURL). | ||
| 340 | Str("env", disableFaviconsEnv). | 341 | Str("env", disableFaviconsEnv). | ||
| 341 | Msg("favicon: PreloadFromCache skipped because favicon pipeline is disabled") | 342 | Msg("favicon: PreloadFromCache skipped because favicon pipeline is disabled") | ||
| @@ -345,7 +346,7 @@ func (a *FaviconAdapter) PreloadFromCache(pageURL string) *gdk.Texture { | |||||
| 345 | if domain == "" { | 346 | if domain == "" { | ||
| 346 | return nil | 347 | return nil | ||
| 347 | } | 348 | } | ||
| 348 | - | log := logging.FromContext(context.Background()) | 349 | + | log := logging.FromContext(ctx) |
| 349 | log.Debug(). | 350 | log.Debug(). | ||
| 350 | Str("page_url", pageURL). | 351 | Str("page_url", pageURL). | ||
| 351 | Str("domain", domain). | 352 | Str("domain", domain). | ||
| @@ -358,7 +359,7 @@ func (a *FaviconAdapter) PreloadFromCache(pageURL string) *gdk.Texture { | |||||
| 358 | } | 359 | } | ||
| 359 | 360 | ||||
| 360 | // Check service cache (memory + disk) | 361 | // Check service cache (memory + disk) | ||
| 361 | - | if data, ok := a.service.GetCached(domain); ok && len(data) > 0 { | 362 | + | if data, ok := a.service.GetCached(ctx, domain); ok && len(data) > 0 { |
| 362 | log.Debug(). | 363 | log.Debug(). | ||
| 363 | Str("domain", domain). | 364 | Str("domain", domain). | ||
| 364 | Int("bytes", len(data)). | 365 | Int("bytes", len(data)). | ||
| @@ -510,8 +511,8 @@ func (a *FaviconAdapter) textureFromBytesOnGTK(data []byte) *gdk.Texture { | |||||
| 510 | Int("bytes", len(data)). | 511 | Int("bytes", len(data)). | ||
| 511 | Msg("favicon: textureFromBytesOnGTK begin") | 512 | Msg("favicon: textureFromBytesOnGTK begin") | ||
| 512 | 513 | ||||
| 513 | - | ctx := glib.MainContextDefault() | 514 | + | glibCtx := glib.MainContextDefault() |
| 514 | - | if ctx != nil && ctx.IsOwner() { | 515 | + | if glibCtx != nil && glibCtx.IsOwner() { |
| 515 | texture := bytesToTexture(data) | 516 | texture := bytesToTexture(data) | ||
| 516 | log.Debug().Bool("texture_nil", texture == nil).Msg("favicon: textureFromBytesOnGTK inline") | 517 | log.Debug().Bool("texture_nil", texture == nil).Msg("favicon: textureFromBytesOnGTK inline") | ||
| 517 | return texture | 518 | return texture | ||
| @@ -523,9 +524,14 @@ func (a *FaviconAdapter) textureFromBytesOnGTK(data []byte) *gdk.Texture { | |||||
| 523 | resultCh <- textureResult{texture: bytesToTexture(data)} | 524 | resultCh <- textureResult{texture: bytesToTexture(data)} | ||
| 524 | }) | 525 | }) | ||
| 525 | glib.IdleAddOnce(&cb, 0) | 526 | glib.IdleAddOnce(&cb, 0) | ||
| 526 | - | result := <-resultCh | 527 | + | select { |
| 527 | - | log.Debug().Bool("texture_nil", result.texture == nil).Msg("favicon: textureFromBytesOnGTK end") | 528 | + | case result := <-resultCh: |
| 528 | - | return result.texture | 529 | + | log.Debug().Bool("texture_nil", result.texture == nil).Msg("favicon: textureFromBytesOnGTK end") |
| 530 | + | return result.texture | |||
| 531 | + | case <-time.After(5 * time.Second): | |||
| 532 | + | log.Error().Msg("textureFromBytesOnGTK timeout - possible deadlock") | |||
| 533 | + | return nil | |||
| 534 | + | } | |||
| 529 | } | 535 | } | ||
| 530 | 536 | ||||
| 531 | // bytesToTexture converts raw favicon bytes to a GDK texture. | 537 | // bytesToTexture converts raw favicon bytes to a GDK texture. | ||
M internal/ui/coordinator/content/popup.go +5 -1
| @@ -370,7 +370,11 @@ func (c *Coordinator) handlePopupReadyToShow(ctx context.Context, popupID port.W | |||
| 370 | 370 | // For engines that create independent browsers (not real popups), | |
| 371 | 371 | // the WebView has no pending navigation — load the target URI. | |
| 372 | 372 | if pending.TargetURI != "" && !pending.WebView.IsLoading() && pending.WebView.URI() == "" { | |
| 373 | - | _ = pending.WebView.LoadURI(ctx, pending.TargetURI) | |
| 373 | + | if err := pending.WebView.LoadURI(ctx, pending.TargetURI); err != nil { | |
| 374 | + | log.Warn().Err(err). | |
| 375 | + | Str("uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)). | |
| 376 | + | Msg("failed to load target URI in popup") | |
| 377 | + | } | |
| 374 | 378 | } | |
| 375 | 379 | } | |
| 376 | 380 | ||
| @@ -370,7 +370,11 @@ func (c *Coordinator) handlePopupReadyToShow(ctx context.Context, popupID port.W | |||||
| 370 | // For engines that create independent browsers (not real popups), | 370 | // For engines that create independent browsers (not real popups), | ||
| 371 | // the WebView has no pending navigation — load the target URI. | 371 | // the WebView has no pending navigation — load the target URI. | ||
| 372 | if pending.TargetURI != "" && !pending.WebView.IsLoading() && pending.WebView.URI() == "" { | 372 | if pending.TargetURI != "" && !pending.WebView.IsLoading() && pending.WebView.URI() == "" { | ||
| 373 | - | _ = pending.WebView.LoadURI(ctx, pending.TargetURI) | 373 | + | if err := pending.WebView.LoadURI(ctx, pending.TargetURI); err != nil { |
| 374 | + | log.Warn().Err(err). | |||
| 375 | + | Str("uri", logging.TruncateURL(pending.TargetURI, logURLMaxLen)). | |||
| 376 | + | Msg("failed to load target URI in popup") | |||
| 377 | + | } | |||
| 374 | } | 378 | } | ||
| 375 | } | 379 | } | ||
| 376 | 380 | ||||
M internal/ui/coordinator/content/title.go +1 -1
| @@ -286,7 +286,7 @@ func (c *Coordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.Pa | |||
| 286 | 286 | } | |
| 287 | 287 | ||
| 288 | 288 | // Check memory and disk cache (no external fetch) | |
| 289 | - | texture := c.faviconAdapter.PreloadFromCache(uri) | |
| 289 | + | texture := c.faviconAdapter.PreloadFromCache(ctx, uri) | |
| 290 | 290 | select { | |
| 291 | 291 | case <-ctx.Done(): | |
| 292 | 292 | return | |
| @@ -286,7 +286,7 @@ func (c *Coordinator) PreloadCachedFavicon(ctx context.Context, paneID entity.Pa | |||||
| 286 | } | 286 | } | ||
| 287 | 287 | ||||
| 288 | // Check memory and disk cache (no external fetch) | 288 | // Check memory and disk cache (no external fetch) | ||
| 289 | - | texture := c.faviconAdapter.PreloadFromCache(uri) | 289 | + | texture := c.faviconAdapter.PreloadFromCache(ctx, uri) |
| 290 | select { | 290 | select { | ||
| 291 | case <-ctx.Done(): | 291 | case <-ctx.Done(): | ||
| 292 | return | 292 | return | ||