fix(release): address final review blockers
- commit
- 2d810ef49146bfe99fc935286b6c76d28eef3bca
- author
- brice · · 2026-06-27 06:48 UTC
- committer
- brice · · 2026-06-27 06:56 UTC
- parent
- c0d5b53
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
15 files · +200 -61
M .goreleaser.yaml +2 -1
| @@ -4,8 +4,9 @@ version: 2 | |||
| 4 | 4 | ||
| 5 | 5 | before: | |
| 6 | 6 | hooks: | |
| 7 | - | # Build systemviews assets before building binaries | |
| 7 | + | # Build and verify systemviews assets before building binaries | |
| 8 | 8 | - make build-systemviews | |
| 9 | + | - make verify-systemviews-assets | |
| 9 | 10 | ||
| 10 | 11 | builds: | |
| 11 | 12 | # Pure Go build (no CGO required) | |
| @@ -4,8 +4,9 @@ version: 2 | |||||
| 4 | 4 | ||||
| 5 | before: | 5 | before: | ||
| 6 | hooks: | 6 | hooks: | ||
| 7 | - | # Build systemviews assets before building binaries | 7 | + | # Build and verify systemviews assets before building binaries |
| 8 | - make build-systemviews | 8 | - make build-systemviews | ||
| 9 | + | - make verify-systemviews-assets | |||
| 9 | 10 | ||||
| 10 | builds: | 11 | builds: | ||
| 11 | # Pure Go build (no CGO required) | 12 | # Pure Go build (no CGO required) | ||
M Makefile +5 -1
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # Makefile for dumber (Clean Architecture - puregotk) | |
| 2 | 2 | ||
| 3 | - | .PHONY: build build-systemviews generate-systemviews build-quick install-local test lint staticcheck clean install-tools install-golangci-lint install-staticcheck dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego verify-generated check | |
| 3 | + | .PHONY: build build-systemviews generate-systemviews build-quick install-local test lint staticcheck clean install-tools install-golangci-lint install-staticcheck dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego verify-generated verify-systemviews-assets check | |
| 4 | 4 | ||
| 5 | 5 | # Load local overrides from .env.local if present (Makefile syntax) | |
| 6 | 6 | ifneq (,$(wildcard .env.local)) | |
| @@ -136,6 +136,10 @@ verify-generated: ## Verify generated systemviews artifacts are committed | |||
| 136 | 136 | exit 1; \ | |
| 137 | 137 | } | |
| 138 | 138 | ||
| 139 | + | verify-systemviews-assets: ## Verify release embeds a compressed systemviews WASM asset | |
| 140 | + | @echo "Verifying embedded systemviews WASM asset..." | |
| 141 | + | DUMBER_REQUIRE_SYSTEMVIEWS_WASM=1 GOFLAGS=$(GOFLAGS) go test -count=1 -run TestWebUIAssetsIncludesCompressedSystemviewsWASM ./assets | |
| 142 | + | ||
| 139 | 143 | # Linting | |
| 140 | 144 | lint: ## Run golangci-lint | |
| 141 | 145 | @echo "Running golangci-lint..." | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | # Makefile for dumber (Clean Architecture - puregotk) | 1 | # Makefile for dumber (Clean Architecture - puregotk) | ||
| 2 | 2 | ||||
| 3 | - | .PHONY: build build-systemviews generate-systemviews build-quick install-local test lint staticcheck clean install-tools install-golangci-lint install-staticcheck dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego verify-generated check | 3 | + | .PHONY: build build-systemviews generate-systemviews build-quick install-local test lint staticcheck clean install-tools install-golangci-lint install-staticcheck dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego verify-generated verify-systemviews-assets check |
| 4 | 4 | ||||
| 5 | # Load local overrides from .env.local if present (Makefile syntax) | 5 | # Load local overrides from .env.local if present (Makefile syntax) | ||
| 6 | ifneq (,$(wildcard .env.local)) | 6 | ifneq (,$(wildcard .env.local)) | ||
| @@ -136,6 +136,10 @@ verify-generated: ## Verify generated systemviews artifacts are committed | |||||
| 136 | exit 1; \ | 136 | exit 1; \ | ||
| 137 | } | 137 | } | ||
| 138 | 138 | ||||
| 139 | + | verify-systemviews-assets: ## Verify release embeds a compressed systemviews WASM asset | |||
| 140 | + | @echo "Verifying embedded systemviews WASM asset..." | |||
| 141 | + | DUMBER_REQUIRE_SYSTEMVIEWS_WASM=1 GOFLAGS=$(GOFLAGS) go test -count=1 -run TestWebUIAssetsIncludesCompressedSystemviewsWASM ./assets | |||
| 142 | + | ||||
| 139 | # Linting | 143 | # Linting | ||
| 140 | lint: ## Run golangci-lint | 144 | lint: ## Run golangci-lint | ||
| 141 | @echo "Running golangci-lint..." | 145 | @echo "Running golangci-lint..." | ||
A assets/webui_embed_test.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | package assets | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "bytes" | |
| 5 | + | "io" | |
| 6 | + | "os" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/andybalholm/brotli" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | func TestWebUIAssetsIncludesCompressedSystemviewsWASM(t *testing.T) { | |
| 13 | + | compressed, err := WebUIAssets.ReadFile("systemviews/systemviews.wasm.br") | |
| 14 | + | if err != nil { | |
| 15 | + | if os.Getenv("DUMBER_REQUIRE_SYSTEMVIEWS_WASM") == "" { | |
| 16 | + | t.Skipf("generated systemviews WASM asset not present; run make build-systemviews: %v", err) | |
| 17 | + | } | |
| 18 | + | t.Fatalf("read embedded systemviews WASM asset: %v", err) | |
| 19 | + | } | |
| 20 | + | if len(compressed) == 0 { | |
| 21 | + | t.Fatal("embedded systemviews WASM asset is empty") | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | reader := brotli.NewReader(bytes.NewReader(compressed)) | |
| 25 | + | header := make([]byte, 8) | |
| 26 | + | if _, err := io.ReadFull(reader, header); err != nil { | |
| 27 | + | t.Fatalf("decompress embedded systemviews WASM header: %v", err) | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | wantHeader := []byte{0x00, 'a', 's', 'm', 0x01, 0x00, 0x00, 0x00} | |
| 31 | + | if !bytes.Equal(header, wantHeader) { | |
| 32 | + | t.Fatalf("embedded systemviews asset is not a WASM module: got header % x", header) | |
| 33 | + | } | |
| 34 | + | } | |
| @@ -0,0 +1,34 @@ | |||||
| 1 | + | package assets | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "bytes" | |||
| 5 | + | "io" | |||
| 6 | + | "os" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/andybalholm/brotli" | |||
| 10 | + | ) | |||
| 11 | + | ||||
| 12 | + | func TestWebUIAssetsIncludesCompressedSystemviewsWASM(t *testing.T) { | |||
| 13 | + | compressed, err := WebUIAssets.ReadFile("systemviews/systemviews.wasm.br") | |||
| 14 | + | if err != nil { | |||
| 15 | + | if os.Getenv("DUMBER_REQUIRE_SYSTEMVIEWS_WASM") == "" { | |||
| 16 | + | t.Skipf("generated systemviews WASM asset not present; run make build-systemviews: %v", err) | |||
| 17 | + | } | |||
| 18 | + | t.Fatalf("read embedded systemviews WASM asset: %v", err) | |||
| 19 | + | } | |||
| 20 | + | if len(compressed) == 0 { | |||
| 21 | + | t.Fatal("embedded systemviews WASM asset is empty") | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | reader := brotli.NewReader(bytes.NewReader(compressed)) | |||
| 25 | + | header := make([]byte, 8) | |||
| 26 | + | if _, err := io.ReadFull(reader, header); err != nil { | |||
| 27 | + | t.Fatalf("decompress embedded systemviews WASM header: %v", err) | |||
| 28 | + | } | |||
| 29 | + | ||||
| 30 | + | wantHeader := []byte{0x00, 'a', 's', 'm', 0x01, 0x00, 0x00, 0x00} | |||
| 31 | + | if !bytes.Equal(header, wantHeader) { | |||
| 32 | + | t.Fatalf("embedded systemviews asset is not a WASM module: got header % x", header) | |||
| 33 | + | } | |||
| 34 | + | } | |||
M docs/config/index.md +16 -14
| @@ -149,27 +149,29 @@ auto_open_on_new_pane = false | |||
| 149 | 149 | ### Color Palettes | |
| 150 | 150 | ||
| 151 | 151 | **Light palette:** | |
| 152 | + | ||
| 152 | 153 | ```toml | |
| 153 | 154 | [appearance.light_palette] | |
| 154 | - | background = "#f8f8f8" | |
| 155 | - | surface = "#f2f2f2" | |
| 156 | - | surface_variant = "#ececec" | |
| 157 | - | text = "#1a1a1a" | |
| 158 | - | muted = "#6e6e6e" | |
| 159 | - | accent = "#404040" | |
| 160 | - | border = "#d2d2d2" | |
| 155 | + | background = "#fafafa" | |
| 156 | + | surface = "#f4f4f5" | |
| 157 | + | surface_variant = "#e4e4e7" | |
| 158 | + | text = "#18181b" | |
| 159 | + | muted = "#71717a" | |
| 160 | + | accent = "#22c55e" | |
| 161 | + | border = "#d4d4d8" | |
| 161 | 162 | ``` | |
| 162 | 163 | ||
| 163 | 164 | **Dark palette:** | |
| 165 | + | ||
| 164 | 166 | ```toml | |
| 165 | 167 | [appearance.dark_palette] | |
| 166 | - | background = "#0e0e0e" | |
| 167 | - | surface = "#1a1a1a" | |
| 168 | - | surface_variant = "#141414" | |
| 169 | - | text = "#e4e4e4" | |
| 170 | - | muted = "#848484" | |
| 171 | - | accent = "#a8a8a8" | |
| 172 | - | border = "#363636" | |
| 168 | + | background = "#0a0a0b" | |
| 169 | + | surface = "#18181b" | |
| 170 | + | surface_variant = "#27272a" | |
| 171 | + | text = "#fafafa" | |
| 172 | + | muted = "#a1a1aa" | |
| 173 | + | accent = "#4ade80" | |
| 174 | + | border = "#3f3f46" | |
| 173 | 175 | ``` | |
| 174 | 176 | ||
| 175 | 177 | ### External Theme: Noctalia | |
| @@ -149,27 +149,29 @@ auto_open_on_new_pane = false | |||||
| 149 | ### Color Palettes | 149 | ### Color Palettes | ||
| 150 | 150 | ||||
| 151 | **Light palette:** | 151 | **Light palette:** | ||
| 152 | + | ||||
| 152 | ```toml | 153 | ```toml | ||
| 153 | [appearance.light_palette] | 154 | [appearance.light_palette] | ||
| 154 | - | background = "#f8f8f8" | 155 | + | background = "#fafafa" |
| 155 | - | surface = "#f2f2f2" | 156 | + | surface = "#f4f4f5" |
| 156 | - | surface_variant = "#ececec" | 157 | + | surface_variant = "#e4e4e7" |
| 157 | - | text = "#1a1a1a" | 158 | + | text = "#18181b" |
| 158 | - | muted = "#6e6e6e" | 159 | + | muted = "#71717a" |
| 159 | - | accent = "#404040" | 160 | + | accent = "#22c55e" |
| 160 | - | border = "#d2d2d2" | 161 | + | border = "#d4d4d8" |
| 161 | ``` | 162 | ``` | ||
| 162 | 163 | ||||
| 163 | **Dark palette:** | 164 | **Dark palette:** | ||
| 165 | + | ||||
| 164 | ```toml | 166 | ```toml | ||
| 165 | [appearance.dark_palette] | 167 | [appearance.dark_palette] | ||
| 166 | - | background = "#0e0e0e" | 168 | + | background = "#0a0a0b" |
| 167 | - | surface = "#1a1a1a" | 169 | + | surface = "#18181b" |
| 168 | - | surface_variant = "#141414" | 170 | + | surface_variant = "#27272a" |
| 169 | - | text = "#e4e4e4" | 171 | + | text = "#fafafa" |
| 170 | - | muted = "#848484" | 172 | + | muted = "#a1a1aa" |
| 171 | - | accent = "#a8a8a8" | 173 | + | accent = "#4ade80" |
| 172 | - | border = "#363636" | 174 | + | border = "#3f3f46" |
| 173 | ``` | 175 | ``` | ||
| 174 | 176 | ||||
| 175 | ### External Theme: Noctalia | 177 | ### External Theme: Noctalia | ||
M docs/reference/configuration.md +14 -14
| @@ -38,20 +38,20 @@ | |||
| 38 | 38 | | `appearance.external_theme.provider` | string | `noctalia` | `noctalia` | | |
| 39 | 39 | | `appearance.external_theme.format` | string | `colors-json` | `colors-json`, `dumber-json` | | |
| 40 | 40 | | `appearance.external_theme.path` | string | `$XDG_CONFIG_HOME/noctalia/colors.json` | path to Noctalia `colors.json` or a `dumber-json` theme file; when empty and `format = "dumber-json"`, dumber uses the built-in dumber-json template path | | |
| 41 | - | | `appearance.light_palette.background` | string | `#f8f8f8` | | | |
| 42 | - | | `appearance.light_palette.surface` | string | `#f2f2f2` | | | |
| 43 | - | | `appearance.light_palette.surface_variant` | string | `#ececec` | | | |
| 44 | - | | `appearance.light_palette.text` | string | `#1a1a1a` | | | |
| 45 | - | | `appearance.light_palette.muted` | string | `#6e6e6e` | | | |
| 46 | - | | `appearance.light_palette.accent` | string | `#404040` | | | |
| 47 | - | | `appearance.light_palette.border` | string | `#d2d2d2` | | | |
| 48 | - | | `appearance.dark_palette.background` | string | `#0e0e0e` | | | |
| 49 | - | | `appearance.dark_palette.surface` | string | `#1a1a1a` | | | |
| 50 | - | | `appearance.dark_palette.surface_variant` | string | `#141414` | | | |
| 51 | - | | `appearance.dark_palette.text` | string | `#e4e4e4` | | | |
| 52 | - | | `appearance.dark_palette.muted` | string | `#848484` | | | |
| 53 | - | | `appearance.dark_palette.accent` | string | `#a8a8a8` | | | |
| 54 | - | | `appearance.dark_palette.border` | string | `#363636` | | | |
| 41 | + | | `appearance.light_palette.background` | string | `#fafafa` | | | |
| 42 | + | | `appearance.light_palette.surface` | string | `#f4f4f5` | | | |
| 43 | + | | `appearance.light_palette.surface_variant` | string | `#e4e4e7` | | | |
| 44 | + | | `appearance.light_palette.text` | string | `#18181b` | | | |
| 45 | + | | `appearance.light_palette.muted` | string | `#71717a` | | | |
| 46 | + | | `appearance.light_palette.accent` | string | `#22c55e` | | | |
| 47 | + | | `appearance.light_palette.border` | string | `#d4d4d8` | | | |
| 48 | + | | `appearance.dark_palette.background` | string | `#0a0a0b` | | | |
| 49 | + | | `appearance.dark_palette.surface` | string | `#18181b` | | | |
| 50 | + | | `appearance.dark_palette.surface_variant` | string | `#27272a` | | | |
| 51 | + | | `appearance.dark_palette.text` | string | `#fafafa` | | | |
| 52 | + | | `appearance.dark_palette.muted` | string | `#a1a1aa` | | | |
| 53 | + | | `appearance.dark_palette.accent` | string | `#4ade80` | | | |
| 54 | + | | `appearance.dark_palette.border` | string | `#3f3f46` | | | |
| 55 | 55 | | `debug.enable_devtools` | bool | `true` | | | |
| 56 | 56 | | `engine.cef.log_file` | string | `` | CEF runtime log path | | |
| 57 | 57 | | `engine.cef.log_severity` | int32 | `0` | `0`, `1`, `2`, `3`, `4`, `99` | | |
| @@ -38,20 +38,20 @@ | |||||
| 38 | | `appearance.external_theme.provider` | string | `noctalia` | `noctalia` | | 38 | | `appearance.external_theme.provider` | string | `noctalia` | `noctalia` | | ||
| 39 | | `appearance.external_theme.format` | string | `colors-json` | `colors-json`, `dumber-json` | | 39 | | `appearance.external_theme.format` | string | `colors-json` | `colors-json`, `dumber-json` | | ||
| 40 | | `appearance.external_theme.path` | string | `$XDG_CONFIG_HOME/noctalia/colors.json` | path to Noctalia `colors.json` or a `dumber-json` theme file; when empty and `format = "dumber-json"`, dumber uses the built-in dumber-json template path | | 40 | | `appearance.external_theme.path` | string | `$XDG_CONFIG_HOME/noctalia/colors.json` | path to Noctalia `colors.json` or a `dumber-json` theme file; when empty and `format = "dumber-json"`, dumber uses the built-in dumber-json template path | | ||
| 41 | - | | `appearance.light_palette.background` | string | `#f8f8f8` | | | 41 | + | | `appearance.light_palette.background` | string | `#fafafa` | | |
| 42 | - | | `appearance.light_palette.surface` | string | `#f2f2f2` | | | 42 | + | | `appearance.light_palette.surface` | string | `#f4f4f5` | | |
| 43 | - | | `appearance.light_palette.surface_variant` | string | `#ececec` | | | 43 | + | | `appearance.light_palette.surface_variant` | string | `#e4e4e7` | | |
| 44 | - | | `appearance.light_palette.text` | string | `#1a1a1a` | | | 44 | + | | `appearance.light_palette.text` | string | `#18181b` | | |
| 45 | - | | `appearance.light_palette.muted` | string | `#6e6e6e` | | | 45 | + | | `appearance.light_palette.muted` | string | `#71717a` | | |
| 46 | - | | `appearance.light_palette.accent` | string | `#404040` | | | 46 | + | | `appearance.light_palette.accent` | string | `#22c55e` | | |
| 47 | - | | `appearance.light_palette.border` | string | `#d2d2d2` | | | 47 | + | | `appearance.light_palette.border` | string | `#d4d4d8` | | |
| 48 | - | | `appearance.dark_palette.background` | string | `#0e0e0e` | | | 48 | + | | `appearance.dark_palette.background` | string | `#0a0a0b` | | |
| 49 | - | | `appearance.dark_palette.surface` | string | `#1a1a1a` | | | 49 | + | | `appearance.dark_palette.surface` | string | `#18181b` | | |
| 50 | - | | `appearance.dark_palette.surface_variant` | string | `#141414` | | | 50 | + | | `appearance.dark_palette.surface_variant` | string | `#27272a` | | |
| 51 | - | | `appearance.dark_palette.text` | string | `#e4e4e4` | | | 51 | + | | `appearance.dark_palette.text` | string | `#fafafa` | | |
| 52 | - | | `appearance.dark_palette.muted` | string | `#848484` | | | 52 | + | | `appearance.dark_palette.muted` | string | `#a1a1aa` | | |
| 53 | - | | `appearance.dark_palette.accent` | string | `#a8a8a8` | | | 53 | + | | `appearance.dark_palette.accent` | string | `#4ade80` | | |
| 54 | - | | `appearance.dark_palette.border` | string | `#363636` | | | 54 | + | | `appearance.dark_palette.border` | string | `#3f3f46` | | |
| 55 | | `debug.enable_devtools` | bool | `true` | | | 55 | | `debug.enable_devtools` | bool | `true` | | | ||
| 56 | | `engine.cef.log_file` | string | `` | CEF runtime log path | | 56 | | `engine.cef.log_file` | string | `` | CEF runtime log path | | ||
| 57 | | `engine.cef.log_severity` | int32 | `0` | `0`, `1`, `2`, `3`, `4`, `99` | | 57 | | `engine.cef.log_severity` | int32 | `0` | `0`, `1`, `2`, `3`, `4`, `99` | | ||
M internal/application/port/mocks/local_path_resolver.go +2 -2
| @@ -99,8 +99,8 @@ func (_c *MockLocalPathResolver_ResolveExistingPath_Call) Run(run func(ctx conte | |||
| 99 | 99 | return _c | |
| 100 | 100 | } | |
| 101 | 101 | ||
| 102 | - | func (_c *MockLocalPathResolver_ResolveExistingPath_Call) Return(s string, b bool, err error) *MockLocalPathResolver_ResolveExistingPath_Call { | |
| 103 | - | _c.Call.Return(s, b, err) | |
| 102 | + | func (_c *MockLocalPathResolver_ResolveExistingPath_Call) Return(absPath string, ok bool, err error) *MockLocalPathResolver_ResolveExistingPath_Call { | |
| 103 | + | _c.Call.Return(absPath, ok, err) | |
| 104 | 104 | return _c | |
| 105 | 105 | } | |
| 106 | 106 | ||
| @@ -99,8 +99,8 @@ func (_c *MockLocalPathResolver_ResolveExistingPath_Call) Run(run func(ctx conte | |||||
| 99 | return _c | 99 | return _c | ||
| 100 | } | 100 | } | ||
| 101 | 101 | ||||
| 102 | - | func (_c *MockLocalPathResolver_ResolveExistingPath_Call) Return(s string, b bool, err error) *MockLocalPathResolver_ResolveExistingPath_Call { | 102 | + | func (_c *MockLocalPathResolver_ResolveExistingPath_Call) Return(absPath string, ok bool, err error) *MockLocalPathResolver_ResolveExistingPath_Call { |
| 103 | - | _c.Call.Return(s, b, err) | 103 | + | _c.Call.Return(absPath, ok, err) |
| 104 | return _c | 104 | return _c | ||
| 105 | } | 105 | } | ||
| 106 | 106 | ||||
M internal/infrastructure/cef/scheme_handler.go +1 -1
| @@ -616,7 +616,7 @@ func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath | |||
| 616 | 616 | return fullPath, cleanRelPath, true | |
| 617 | 617 | } | |
| 618 | 618 | ||
| 619 | - | func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, error) { | |
| 619 | + | func readAssetWithEncoding(assets fs.FS, fullPath, relPath string) ([]byte, error) { | |
| 620 | 620 | if strings.HasSuffix(relPath, ".wasm") { | |
| 621 | 621 | if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil { | |
| 622 | 622 | data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1)) | |
| @@ -616,7 +616,7 @@ func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath | |||||
| 616 | return fullPath, cleanRelPath, true | 616 | return fullPath, cleanRelPath, true | ||
| 617 | } | 617 | } | ||
| 618 | 618 | ||||
| 619 | - | func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, error) { | 619 | + | func readAssetWithEncoding(assets fs.FS, fullPath, relPath string) ([]byte, error) { |
| 620 | if strings.HasSuffix(relPath, ".wasm") { | 620 | if strings.HasSuffix(relPath, ".wasm") { | ||
| 621 | if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil { | 621 | if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil { | ||
| 622 | data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1)) | 622 | data, err := io.ReadAll(io.LimitReader(brotli.NewReader(bytes.NewReader(compressed)), maxSystemviewsWASMBytes+1)) | ||
M internal/infrastructure/cef/scheme_handler_systemviews_test.go +24 -7
| @@ -1,10 +1,12 @@ | |||
| 1 | 1 | package cef | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "net/url" | |
| 5 | 6 | "testing" | |
| 7 | + | "testing/fstest" | |
| 6 | 8 | ||
| 7 | - | "github.com/bnema/dumber/assets" | |
| 9 | + | "github.com/andybalholm/brotli" | |
| 8 | 10 | ) | |
| 9 | 11 | ||
| 10 | 12 | func TestResolveAssetPath_SystemviewsRootsUseSystemviewsShell(t *testing.T) { | |
| @@ -42,19 +44,34 @@ func TestResolveAssetPath_SystemviewsRootsUseSystemviewsShell(t *testing.T) { | |||
| 42 | 44 | } | |
| 43 | 45 | } | |
| 44 | 46 | ||
| 45 | - | func TestReadAssetWithEncoding_DecompressesEmbeddedBrotliWASM(t *testing.T) { | |
| 47 | + | func TestReadAssetWithEncoding_DecompressesBrotliWASM(t *testing.T) { | |
| 46 | 48 | t.Parallel() | |
| 47 | 49 | ||
| 48 | - | data, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm") | |
| 50 | + | wasm := []byte("\x00asmfixture") | |
| 51 | + | fsys := fstest.MapFS{ | |
| 52 | + | "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)}, | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | data, err := readAssetWithEncoding(fsys, "systemviews/systemviews.wasm", "systemviews.wasm") | |
| 49 | 56 | if err != nil { | |
| 50 | 57 | t.Fatalf("readAssetWithEncoding() error = %v", err) | |
| 51 | 58 | } | |
| 52 | - | if len(data) < 4 { | |
| 53 | - | t.Fatalf("decompressed wasm too short: got %d bytes", len(data)) | |
| 59 | + | if !bytes.Equal(data, wasm) { | |
| 60 | + | t.Fatalf("decompressed wasm = %q, want %q", data, wasm) | |
| 61 | + | } | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | func brotliCompressForTest(t *testing.T, data []byte) []byte { | |
| 65 | + | t.Helper() | |
| 66 | + | var buf bytes.Buffer | |
| 67 | + | w := brotli.NewWriter(&buf) | |
| 68 | + | if _, err := w.Write(data); err != nil { | |
| 69 | + | t.Fatalf("brotli write: %v", err) | |
| 54 | 70 | } | |
| 55 | - | if string(data[:4]) != "\x00asm" { | |
| 56 | - | t.Fatalf("decompressed wasm magic = %q, want \\x00asm", data[:4]) | |
| 71 | + | if err := w.Close(); err != nil { | |
| 72 | + | t.Fatalf("brotli close: %v", err) | |
| 57 | 73 | } | |
| 74 | + | return buf.Bytes() | |
| 58 | 75 | } | |
| 59 | 76 | ||
| 60 | 77 | func mustParseURL(t *testing.T, raw string) *url.URL { | |
| @@ -1,10 +1,12 @@ | |||||
| 1 | package cef | 1 | package cef | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 4 | "net/url" | 5 | "net/url" | ||
| 5 | "testing" | 6 | "testing" | ||
| 7 | + | "testing/fstest" | |||
| 6 | 8 | ||||
| 7 | - | "github.com/bnema/dumber/assets" | 9 | + | "github.com/andybalholm/brotli" |
| 8 | ) | 10 | ) | ||
| 9 | 11 | ||||
| 10 | func TestResolveAssetPath_SystemviewsRootsUseSystemviewsShell(t *testing.T) { | 12 | func TestResolveAssetPath_SystemviewsRootsUseSystemviewsShell(t *testing.T) { | ||
| @@ -42,19 +44,34 @@ func TestResolveAssetPath_SystemviewsRootsUseSystemviewsShell(t *testing.T) { | |||||
| 42 | } | 44 | } | ||
| 43 | } | 45 | } | ||
| 44 | 46 | ||||
| 45 | - | func TestReadAssetWithEncoding_DecompressesEmbeddedBrotliWASM(t *testing.T) { | 47 | + | func TestReadAssetWithEncoding_DecompressesBrotliWASM(t *testing.T) { |
| 46 | t.Parallel() | 48 | t.Parallel() | ||
| 47 | 49 | ||||
| 48 | - | data, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm") | 50 | + | wasm := []byte("\x00asmfixture") |
| 51 | + | fsys := fstest.MapFS{ | |||
| 52 | + | "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)}, | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | data, err := readAssetWithEncoding(fsys, "systemviews/systemviews.wasm", "systemviews.wasm") | |||
| 49 | if err != nil { | 56 | if err != nil { | ||
| 50 | t.Fatalf("readAssetWithEncoding() error = %v", err) | 57 | t.Fatalf("readAssetWithEncoding() error = %v", err) | ||
| 51 | } | 58 | } | ||
| 52 | - | if len(data) < 4 { | 59 | + | if !bytes.Equal(data, wasm) { |
| 53 | - | t.Fatalf("decompressed wasm too short: got %d bytes", len(data)) | 60 | + | t.Fatalf("decompressed wasm = %q, want %q", data, wasm) |
| 61 | + | } | |||
| 62 | + | } | |||
| 63 | + | ||||
| 64 | + | func brotliCompressForTest(t *testing.T, data []byte) []byte { | |||
| 65 | + | t.Helper() | |||
| 66 | + | var buf bytes.Buffer | |||
| 67 | + | w := brotli.NewWriter(&buf) | |||
| 68 | + | if _, err := w.Write(data); err != nil { | |||
| 69 | + | t.Fatalf("brotli write: %v", err) | |||
| 54 | } | 70 | } | ||
| 55 | - | if string(data[:4]) != "\x00asm" { | 71 | + | if err := w.Close(); err != nil { |
| 56 | - | t.Fatalf("decompressed wasm magic = %q, want \\x00asm", data[:4]) | 72 | + | t.Fatalf("brotli close: %v", err) |
| 57 | } | 73 | } | ||
| 74 | + | return buf.Bytes() | |||
| 58 | } | 75 | } | ||
| 59 | 76 | ||||
| 60 | func mustParseURL(t *testing.T, raw string) *url.URL { | 77 | func mustParseURL(t *testing.T, raw string) *url.URL { | ||
M internal/infrastructure/cef/webview_handlers_test.go +8 -4
| @@ -263,14 +263,14 @@ func TestHandleNativePopupAborted_PreservesPrimedNavigationForFallback(t *testin | |||
| 263 | 263 | } | |
| 264 | 264 | ||
| 265 | 265 | func TestOnBeforePopup_TimesOutGTKDispatchAndBlocksPopup(t *testing.T) { | |
| 266 | - | var delayed func() | |
| 266 | + | delayed := make(chan func(), 1) | |
| 267 | 267 | parentWV := &WebView{ | |
| 268 | 268 | ctx: context.Background(), | |
| 269 | 269 | id: 16, | |
| 270 | 270 | gtkSyncTimeout: 5 * time.Millisecond, | |
| 271 | 271 | gtkSyncIsOwner: func() bool { return false }, | |
| 272 | 272 | gtkSyncDispatch: func(fn func()) { | |
| 273 | - | delayed = fn | |
| 273 | + | delayed <- fn | |
| 274 | 274 | }, | |
| 275 | 275 | } | |
| 276 | 276 | var createCalls atomic.Int32 | |
| @@ -286,9 +286,13 @@ func TestOnBeforePopup_TimesOutGTKDispatchAndBlocksPopup(t *testing.T) { | |||
| 286 | 286 | ||
| 287 | 287 | require.True(t, blocked) | |
| 288 | 288 | require.Zero(t, createCalls.Load()) | |
| 289 | - | require.NotNil(t, delayed) | |
| 290 | 289 | ||
| 291 | - | delayed() | |
| 290 | + | select { | |
| 291 | + | case fn := <-delayed: | |
| 292 | + | fn() | |
| 293 | + | case <-time.After(time.Second): | |
| 294 | + | t.Fatal("timed out waiting for delayed popup dispatch") | |
| 295 | + | } | |
| 292 | 296 | require.Zero(t, createCalls.Load()) | |
| 293 | 297 | } | |
| 294 | 298 | ||
| @@ -263,14 +263,14 @@ func TestHandleNativePopupAborted_PreservesPrimedNavigationForFallback(t *testin | |||||
| 263 | } | 263 | } | ||
| 264 | 264 | ||||
| 265 | func TestOnBeforePopup_TimesOutGTKDispatchAndBlocksPopup(t *testing.T) { | 265 | func TestOnBeforePopup_TimesOutGTKDispatchAndBlocksPopup(t *testing.T) { | ||
| 266 | - | var delayed func() | 266 | + | delayed := make(chan func(), 1) |
| 267 | parentWV := &WebView{ | 267 | parentWV := &WebView{ | ||
| 268 | ctx: context.Background(), | 268 | ctx: context.Background(), | ||
| 269 | id: 16, | 269 | id: 16, | ||
| 270 | gtkSyncTimeout: 5 * time.Millisecond, | 270 | gtkSyncTimeout: 5 * time.Millisecond, | ||
| 271 | gtkSyncIsOwner: func() bool { return false }, | 271 | gtkSyncIsOwner: func() bool { return false }, | ||
| 272 | gtkSyncDispatch: func(fn func()) { | 272 | gtkSyncDispatch: func(fn func()) { | ||
| 273 | - | delayed = fn | 273 | + | delayed <- fn |
| 274 | }, | 274 | }, | ||
| 275 | } | 275 | } | ||
| 276 | var createCalls atomic.Int32 | 276 | var createCalls atomic.Int32 | ||
| @@ -286,9 +286,13 @@ func TestOnBeforePopup_TimesOutGTKDispatchAndBlocksPopup(t *testing.T) { | |||||
| 286 | 286 | ||||
| 287 | require.True(t, blocked) | 287 | require.True(t, blocked) | ||
| 288 | require.Zero(t, createCalls.Load()) | 288 | require.Zero(t, createCalls.Load()) | ||
| 289 | - | require.NotNil(t, delayed) | |||
| 290 | 289 | ||||
| 291 | - | delayed() | 290 | + | select { |
| 291 | + | case fn := <-delayed: | |||
| 292 | + | fn() | |||
| 293 | + | case <-time.After(time.Second): | |||
| 294 | + | t.Fatal("timed out waiting for delayed popup dispatch") | |||
| 295 | + | } | |||
| 292 | require.Zero(t, createCalls.Load()) | 296 | require.Zero(t, createCalls.Load()) | ||
| 293 | } | 297 | } | ||
| 294 | 298 | ||||
M internal/infrastructure/updater/github.go +5 -0
| @@ -279,6 +279,11 @@ func isRetryableRequestError(err error) bool { | |||
| 279 | 279 | return true | |
| 280 | 280 | } | |
| 281 | 281 | ||
| 282 | + | var dnsErr *net.DNSError | |
| 283 | + | if errors.As(err, &dnsErr) && dnsErr.IsTemporary { | |
| 284 | + | return true | |
| 285 | + | } | |
| 286 | + | ||
| 282 | 287 | var opErr *net.OpError | |
| 283 | 288 | if errors.As(err, &opErr) { | |
| 284 | 289 | if isTransientSyscallError(opErr.Err) { | |
| @@ -279,6 +279,11 @@ func isRetryableRequestError(err error) bool { | |||||
| 279 | return true | 279 | return true | ||
| 280 | } | 280 | } | ||
| 281 | 281 | ||||
| 282 | + | var dnsErr *net.DNSError | |||
| 283 | + | if errors.As(err, &dnsErr) && dnsErr.IsTemporary { | |||
| 284 | + | return true | |||
| 285 | + | } | |||
| 286 | + | ||||
| 282 | var opErr *net.OpError | 287 | var opErr *net.OpError | ||
| 283 | if errors.As(err, &opErr) { | 288 | if errors.As(err, &opErr) { | ||
| 284 | if isTransientSyscallError(opErr.Err) { | 289 | if isTransientSyscallError(opErr.Err) { | ||
M internal/infrastructure/updater/github_test.go +1 -0
| @@ -405,6 +405,7 @@ func TestIsRetryableRequestError(t *testing.T) { | |||
| 405 | 405 | {name: "context canceled", err: context.Canceled, want: false}, | |
| 406 | 406 | {name: "context deadline exceeded", err: context.DeadlineExceeded, want: true}, | |
| 407 | 407 | {name: "timeout net error", err: &net.DNSError{IsTimeout: true}, want: true}, | |
| 408 | + | {name: "temporary dns error", err: &net.DNSError{IsTemporary: true}, want: true}, | |
| 408 | 409 | {name: "non-timeout net error", err: &net.DNSError{Err: "lookup failed"}, want: false}, | |
| 409 | 410 | {name: "transient errno through net op error", err: &net.OpError{Op: "read", Net: "tcp", Err: syscall.ECONNRESET}, want: true}, | |
| 410 | 411 | {name: "non-transient errno", err: syscall.EINVAL, want: false}, | |
| @@ -405,6 +405,7 @@ func TestIsRetryableRequestError(t *testing.T) { | |||||
| 405 | {name: "context canceled", err: context.Canceled, want: false}, | 405 | {name: "context canceled", err: context.Canceled, want: false}, | ||
| 406 | {name: "context deadline exceeded", err: context.DeadlineExceeded, want: true}, | 406 | {name: "context deadline exceeded", err: context.DeadlineExceeded, want: true}, | ||
| 407 | {name: "timeout net error", err: &net.DNSError{IsTimeout: true}, want: true}, | 407 | {name: "timeout net error", err: &net.DNSError{IsTimeout: true}, want: true}, | ||
| 408 | + | {name: "temporary dns error", err: &net.DNSError{IsTemporary: true}, want: true}, | |||
| 408 | {name: "non-timeout net error", err: &net.DNSError{Err: "lookup failed"}, want: false}, | 409 | {name: "non-timeout net error", err: &net.DNSError{Err: "lookup failed"}, want: false}, | ||
| 409 | {name: "transient errno through net op error", err: &net.OpError{Op: "read", Net: "tcp", Err: syscall.ECONNRESET}, want: true}, | 410 | {name: "transient errno through net op error", err: &net.OpError{Op: "read", Net: "tcp", Err: syscall.ECONNRESET}, want: true}, | ||
| 410 | {name: "non-transient errno", err: syscall.EINVAL, want: false}, | 411 | {name: "non-transient errno", err: syscall.EINVAL, want: false}, | ||
M internal/infrastructure/webkit/messaging.go +37 -12
| @@ -68,7 +68,22 @@ func (r *MessageRouter) SetBaseContext(ctx context.Context) { | |||
| 68 | 68 | if ctx == nil { | |
| 69 | 69 | ctx = context.Background() | |
| 70 | 70 | } | |
| 71 | + | r.mu.Lock() | |
| 71 | 72 | r.baseCtx = ctx | |
| 73 | + | r.mu.Unlock() | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | func (r *MessageRouter) baseContext() context.Context { | |
| 77 | + | if r == nil { | |
| 78 | + | return context.Background() | |
| 79 | + | } | |
| 80 | + | r.mu.RLock() | |
| 81 | + | ctx := r.baseCtx | |
| 82 | + | r.mu.RUnlock() | |
| 83 | + | if ctx == nil { | |
| 84 | + | return context.Background() | |
| 85 | + | } | |
| 86 | + | return ctx | |
| 72 | 87 | } | |
| 73 | 88 | ||
| 74 | 89 | // RegisterHandler registers a handler for a message type. | |
| @@ -116,7 +131,8 @@ func (r *MessageRouter) RegisterHandlerWithCallbacks(msgType, callback, errorCal | |||
| 116 | 131 | // WebKit's messageHandlers is only available in main world. | |
| 117 | 132 | // The isolated world GUI scripts dispatch CustomEvents to main world, which forwards to this handler. | |
| 118 | 133 | func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ string) (uint, error) { | |
| 119 | - | log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger() | |
| 134 | + | baseCtx := r.baseContext() | |
| 135 | + | log := logging.FromContext(baseCtx).With().Str("component", "message-router").Logger() | |
| 120 | 136 | ||
| 121 | 137 | log.Debug().Msg("SetupMessageHandler called") | |
| 122 | 138 | ||
| @@ -166,7 +182,8 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ st | |||
| 166 | 182 | ||
| 167 | 183 | // handleScriptMessage decodes the JSC value and routes it to the correct handler. | |
| 168 | 184 | func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, valuePtr uintptr) { | |
| 169 | - | log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger() | |
| 185 | + | baseCtx := r.baseContext() | |
| 186 | + | log := logging.FromContext(baseCtx).With().Str("component", "message-router").Logger() | |
| 170 | 187 | ||
| 171 | 188 | log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("handleScriptMessage entry") | |
| 172 | 189 | ||
| @@ -225,13 +242,11 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, | |||
| 225 | 242 | ||
| 226 | 243 | entry, ok := r.getHandler(msg.Type) | |
| 227 | 244 | if !ok || entry.handler == nil { | |
| 228 | - | log.Warn().Str("type", msg.Type).Int("registered_handlers", len(r.handlers)).Msg("no handler registered for message type") | |
| 229 | - | // Debug: list all registered handlers | |
| 230 | - | r.mu.RLock() | |
| 231 | - | for handlerType := range r.handlers { | |
| 245 | + | handlerTypes := r.registeredHandlerTypes() | |
| 246 | + | log.Warn().Str("type", msg.Type).Int("registered_handlers", len(handlerTypes)).Msg("no handler registered for message type") | |
| 247 | + | for _, handlerType := range handlerTypes { | |
| 232 | 248 | log.Debug().Str("registered_type", handlerType).Msg("available handler") | |
| 233 | 249 | } | |
| 234 | - | r.mu.RUnlock() | |
| 235 | 250 | return | |
| 236 | 251 | } | |
| 237 | 252 | ||
| @@ -242,12 +257,12 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, | |||
| 242 | 257 | Str("callback", entry.callback). | |
| 243 | 258 | Msg("received script message, dispatching to handler") | |
| 244 | 259 | ||
| 245 | - | resp, err := entry.handler.Handle(r.baseCtx, WebViewID(msg.WebViewID), msg.Payload) | |
| 260 | + | resp, err := entry.handler.Handle(baseCtx, WebViewID(msg.WebViewID), msg.Payload) | |
| 246 | 261 | log.Debug().Str("type", msg.Type).Err(err).Msg("handler execution completed") | |
| 247 | 262 | if err != nil { | |
| 248 | 263 | log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error") | |
| 249 | 264 | if entry.errorCallback != "" { | |
| 250 | - | if respErr := r.dispatchResponse(r.baseCtx, WebViewID(msg.WebViewID), entry.errorCallback, entry.world, err.Error()); respErr != nil { | |
| 265 | + | if respErr := r.dispatchResponse(baseCtx, WebViewID(msg.WebViewID), entry.errorCallback, entry.world, err.Error()); respErr != nil { | |
| 251 | 266 | log.Warn().Err(respErr).Msg("failed to dispatch error callback") | |
| 252 | 267 | } | |
| 253 | 268 | } | |
| @@ -255,7 +270,7 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, | |||
| 255 | 270 | } | |
| 256 | 271 | ||
| 257 | 272 | if entry.callback != "" { | |
| 258 | - | if err := r.dispatchResponse(r.baseCtx, WebViewID(msg.WebViewID), entry.callback, entry.world, resp); err != nil { | |
| 273 | + | if err := r.dispatchResponse(baseCtx, WebViewID(msg.WebViewID), entry.callback, entry.world, resp); err != nil { | |
| 259 | 274 | log.Warn().Err(err). | |
| 260 | 275 | Str("callback", entry.callback). | |
| 261 | 276 | Uint64("webview_id", msg.WebViewID). | |
| @@ -291,7 +306,7 @@ func (r *MessageRouter) syncWebViewID(wv *WebView) { | |||
| 291 | 306 | return | |
| 292 | 307 | } | |
| 293 | 308 | script := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(wv.ID())) | |
| 294 | - | wv.RunJavaScriptInWorld(r.baseCtx, script, "") | |
| 309 | + | wv.RunJavaScriptInWorld(r.baseContext(), script, "") | |
| 295 | 310 | } | |
| 296 | 311 | ||
| 297 | 312 | func (r *MessageRouter) markWebViewIDSynced(id WebViewID) bool { | |
| @@ -312,10 +327,20 @@ func (r *MessageRouter) getHandler(msgType string) (handlerEntry, bool) { | |||
| 312 | 327 | return entry, ok | |
| 313 | 328 | } | |
| 314 | 329 | ||
| 330 | + | func (r *MessageRouter) registeredHandlerTypes() []string { | |
| 331 | + | r.mu.RLock() | |
| 332 | + | defer r.mu.RUnlock() | |
| 333 | + | types := make([]string, 0, len(r.handlers)) | |
| 334 | + | for handlerType := range r.handlers { | |
| 335 | + | types = append(types, handlerType) | |
| 336 | + | } | |
| 337 | + | return types | |
| 338 | + | } | |
| 339 | + | ||
| 315 | 340 | // dispatchResponse serializes the payload and invokes a window callback in JS. | |
| 316 | 341 | func (r *MessageRouter) dispatchResponse(ctx context.Context, webviewID WebViewID, callback, world string, payload any) error { | |
| 317 | 342 | if ctx == nil { | |
| 318 | - | ctx = r.baseCtx | |
| 343 | + | ctx = r.baseContext() | |
| 319 | 344 | } | |
| 320 | 345 | ||
| 321 | 346 | if callback == "" { | |
| @@ -68,7 +68,22 @@ func (r *MessageRouter) SetBaseContext(ctx context.Context) { | |||||
| 68 | if ctx == nil { | 68 | if ctx == nil { | ||
| 69 | ctx = context.Background() | 69 | ctx = context.Background() | ||
| 70 | } | 70 | } | ||
| 71 | + | r.mu.Lock() | |||
| 71 | r.baseCtx = ctx | 72 | r.baseCtx = ctx | ||
| 73 | + | r.mu.Unlock() | |||
| 74 | + | } | |||
| 75 | + | ||||
| 76 | + | func (r *MessageRouter) baseContext() context.Context { | |||
| 77 | + | if r == nil { | |||
| 78 | + | return context.Background() | |||
| 79 | + | } | |||
| 80 | + | r.mu.RLock() | |||
| 81 | + | ctx := r.baseCtx | |||
| 82 | + | r.mu.RUnlock() | |||
| 83 | + | if ctx == nil { | |||
| 84 | + | return context.Background() | |||
| 85 | + | } | |||
| 86 | + | return ctx | |||
| 72 | } | 87 | } | ||
| 73 | 88 | ||||
| 74 | // RegisterHandler registers a handler for a message type. | 89 | // RegisterHandler registers a handler for a message type. | ||
| @@ -116,7 +131,8 @@ func (r *MessageRouter) RegisterHandlerWithCallbacks(msgType, callback, errorCal | |||||
| 116 | // WebKit's messageHandlers is only available in main world. | 131 | // WebKit's messageHandlers is only available in main world. | ||
| 117 | // The isolated world GUI scripts dispatch CustomEvents to main world, which forwards to this handler. | 132 | // The isolated world GUI scripts dispatch CustomEvents to main world, which forwards to this handler. | ||
| 118 | func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ string) (uint, error) { | 133 | func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ string) (uint, error) { | ||
| 119 | - | log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger() | 134 | + | baseCtx := r.baseContext() |
| 135 | + | log := logging.FromContext(baseCtx).With().Str("component", "message-router").Logger() | |||
| 120 | 136 | ||||
| 121 | log.Debug().Msg("SetupMessageHandler called") | 137 | log.Debug().Msg("SetupMessageHandler called") | ||
| 122 | 138 | ||||
| @@ -166,7 +182,8 @@ func (r *MessageRouter) SetupMessageHandler(ucm *webkit.UserContentManager, _ st | |||||
| 166 | 182 | ||||
| 167 | // handleScriptMessage decodes the JSC value and routes it to the correct handler. | 183 | // handleScriptMessage decodes the JSC value and routes it to the correct handler. | ||
| 168 | func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, valuePtr uintptr) { | 184 | func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, valuePtr uintptr) { | ||
| 169 | - | log := logging.FromContext(r.baseCtx).With().Str("component", "message-router").Logger() | 185 | + | baseCtx := r.baseContext() |
| 186 | + | log := logging.FromContext(baseCtx).With().Str("component", "message-router").Logger() | |||
| 170 | 187 | ||||
| 171 | log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("handleScriptMessage entry") | 188 | log.Debug().Uint64("value_ptr", uint64(valuePtr)).Msg("handleScriptMessage entry") | ||
| 172 | 189 | ||||
| @@ -225,13 +242,11 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, | |||||
| 225 | 242 | ||||
| 226 | entry, ok := r.getHandler(msg.Type) | 243 | entry, ok := r.getHandler(msg.Type) | ||
| 227 | if !ok || entry.handler == nil { | 244 | if !ok || entry.handler == nil { | ||
| 228 | - | log.Warn().Str("type", msg.Type).Int("registered_handlers", len(r.handlers)).Msg("no handler registered for message type") | 245 | + | handlerTypes := r.registeredHandlerTypes() |
| 229 | - | // Debug: list all registered handlers | 246 | + | log.Warn().Str("type", msg.Type).Int("registered_handlers", len(handlerTypes)).Msg("no handler registered for message type") |
| 230 | - | r.mu.RLock() | 247 | + | for _, handlerType := range handlerTypes { |
| 231 | - | for handlerType := range r.handlers { | |||
| 232 | log.Debug().Str("registered_type", handlerType).Msg("available handler") | 248 | log.Debug().Str("registered_type", handlerType).Msg("available handler") | ||
| 233 | } | 249 | } | ||
| 234 | - | r.mu.RUnlock() | |||
| 235 | return | 250 | return | ||
| 236 | } | 251 | } | ||
| 237 | 252 | ||||
| @@ -242,12 +257,12 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, | |||||
| 242 | Str("callback", entry.callback). | 257 | Str("callback", entry.callback). | ||
| 243 | Msg("received script message, dispatching to handler") | 258 | Msg("received script message, dispatching to handler") | ||
| 244 | 259 | ||||
| 245 | - | resp, err := entry.handler.Handle(r.baseCtx, WebViewID(msg.WebViewID), msg.Payload) | 260 | + | resp, err := entry.handler.Handle(baseCtx, WebViewID(msg.WebViewID), msg.Payload) |
| 246 | log.Debug().Str("type", msg.Type).Err(err).Msg("handler execution completed") | 261 | log.Debug().Str("type", msg.Type).Err(err).Msg("handler execution completed") | ||
| 247 | if err != nil { | 262 | if err != nil { | ||
| 248 | log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error") | 263 | log.Error().Err(err).Str("type", msg.Type).Msg("message handler returned error") | ||
| 249 | if entry.errorCallback != "" { | 264 | if entry.errorCallback != "" { | ||
| 250 | - | if respErr := r.dispatchResponse(r.baseCtx, WebViewID(msg.WebViewID), entry.errorCallback, entry.world, err.Error()); respErr != nil { | 265 | + | if respErr := r.dispatchResponse(baseCtx, WebViewID(msg.WebViewID), entry.errorCallback, entry.world, err.Error()); respErr != nil { |
| 251 | log.Warn().Err(respErr).Msg("failed to dispatch error callback") | 266 | log.Warn().Err(respErr).Msg("failed to dispatch error callback") | ||
| 252 | } | 267 | } | ||
| 253 | } | 268 | } | ||
| @@ -255,7 +270,7 @@ func (r *MessageRouter) handleScriptMessage(senderUCM webkit.UserContentManager, | |||||
| 255 | } | 270 | } | ||
| 256 | 271 | ||||
| 257 | if entry.callback != "" { | 272 | if entry.callback != "" { | ||
| 258 | - | if err := r.dispatchResponse(r.baseCtx, WebViewID(msg.WebViewID), entry.callback, entry.world, resp); err != nil { | 273 | + | if err := r.dispatchResponse(baseCtx, WebViewID(msg.WebViewID), entry.callback, entry.world, resp); err != nil { |
| 259 | log.Warn().Err(err). | 274 | log.Warn().Err(err). | ||
| 260 | Str("callback", entry.callback). | 275 | Str("callback", entry.callback). | ||
| 261 | Uint64("webview_id", msg.WebViewID). | 276 | Uint64("webview_id", msg.WebViewID). | ||
| @@ -291,7 +306,7 @@ func (r *MessageRouter) syncWebViewID(wv *WebView) { | |||||
| 291 | return | 306 | return | ||
| 292 | } | 307 | } | ||
| 293 | script := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(wv.ID())) | 308 | script := fmt.Sprintf("window.__dumber_webview_id=%d;", uint64(wv.ID())) | ||
| 294 | - | wv.RunJavaScriptInWorld(r.baseCtx, script, "") | 309 | + | wv.RunJavaScriptInWorld(r.baseContext(), script, "") |
| 295 | } | 310 | } | ||
| 296 | 311 | ||||
| 297 | func (r *MessageRouter) markWebViewIDSynced(id WebViewID) bool { | 312 | func (r *MessageRouter) markWebViewIDSynced(id WebViewID) bool { | ||
| @@ -312,10 +327,20 @@ func (r *MessageRouter) getHandler(msgType string) (handlerEntry, bool) { | |||||
| 312 | return entry, ok | 327 | return entry, ok | ||
| 313 | } | 328 | } | ||
| 314 | 329 | ||||
| 330 | + | func (r *MessageRouter) registeredHandlerTypes() []string { | |||
| 331 | + | r.mu.RLock() | |||
| 332 | + | defer r.mu.RUnlock() | |||
| 333 | + | types := make([]string, 0, len(r.handlers)) | |||
| 334 | + | for handlerType := range r.handlers { | |||
| 335 | + | types = append(types, handlerType) | |||
| 336 | + | } | |||
| 337 | + | return types | |||
| 338 | + | } | |||
| 339 | + | ||||
| 315 | // dispatchResponse serializes the payload and invokes a window callback in JS. | 340 | // dispatchResponse serializes the payload and invokes a window callback in JS. | ||
| 316 | func (r *MessageRouter) dispatchResponse(ctx context.Context, webviewID WebViewID, callback, world string, payload any) error { | 341 | func (r *MessageRouter) dispatchResponse(ctx context.Context, webviewID WebViewID, callback, world string, payload any) error { | ||
| 317 | if ctx == nil { | 342 | if ctx == nil { | ||
| 318 | - | ctx = r.baseCtx | 343 | + | ctx = r.baseContext() |
| 319 | } | 344 | } | ||
| 320 | 345 | ||||
| 321 | if callback == "" { | 346 | if callback == "" { | ||
M internal/infrastructure/webkit/messaging_test.go +29 -0
| @@ -1,11 +1,40 @@ | |||
| 1 | 1 | package webkit | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "context" | |
| 5 | + | "sync" | |
| 4 | 6 | "testing" | |
| 5 | 7 | ||
| 6 | 8 | "github.com/stretchr/testify/require" | |
| 7 | 9 | ) | |
| 8 | 10 | ||
| 11 | + | type messageRouterContextKey struct{} | |
| 12 | + | ||
| 13 | + | func TestMessageRouterBaseContextConcurrentUpdate(t *testing.T) { | |
| 14 | + | t.Parallel() | |
| 15 | + | ||
| 16 | + | router := NewMessageRouter(context.Background()) | |
| 17 | + | var wg sync.WaitGroup | |
| 18 | + | wg.Add(2) | |
| 19 | + | ||
| 20 | + | go func() { | |
| 21 | + | defer wg.Done() | |
| 22 | + | for i := range 1000 { | |
| 23 | + | router.SetBaseContext(context.WithValue(context.Background(), messageRouterContextKey{}, i)) | |
| 24 | + | } | |
| 25 | + | }() | |
| 26 | + | go func() { | |
| 27 | + | defer wg.Done() | |
| 28 | + | for range 1000 { | |
| 29 | + | if router.baseContext() == nil { | |
| 30 | + | t.Error("base context must never be nil") | |
| 31 | + | } | |
| 32 | + | } | |
| 33 | + | }() | |
| 34 | + | ||
| 35 | + | wg.Wait() | |
| 36 | + | } | |
| 37 | + | ||
| 9 | 38 | func TestIsTrustedBridgeURI(t *testing.T) { | |
| 10 | 39 | t.Parallel() | |
| 11 | 40 | ||
| @@ -1,11 +1,40 @@ | |||||
| 1 | package webkit | 1 | package webkit | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "context" | |||
| 5 | + | "sync" | |||
| 4 | "testing" | 6 | "testing" | ||
| 5 | 7 | ||||
| 6 | "github.com/stretchr/testify/require" | 8 | "github.com/stretchr/testify/require" | ||
| 7 | ) | 9 | ) | ||
| 8 | 10 | ||||
| 11 | + | type messageRouterContextKey struct{} | |||
| 12 | + | ||||
| 13 | + | func TestMessageRouterBaseContextConcurrentUpdate(t *testing.T) { | |||
| 14 | + | t.Parallel() | |||
| 15 | + | ||||
| 16 | + | router := NewMessageRouter(context.Background()) | |||
| 17 | + | var wg sync.WaitGroup | |||
| 18 | + | wg.Add(2) | |||
| 19 | + | ||||
| 20 | + | go func() { | |||
| 21 | + | defer wg.Done() | |||
| 22 | + | for i := range 1000 { | |||
| 23 | + | router.SetBaseContext(context.WithValue(context.Background(), messageRouterContextKey{}, i)) | |||
| 24 | + | } | |||
| 25 | + | }() | |||
| 26 | + | go func() { | |||
| 27 | + | defer wg.Done() | |||
| 28 | + | for range 1000 { | |||
| 29 | + | if router.baseContext() == nil { | |||
| 30 | + | t.Error("base context must never be nil") | |||
| 31 | + | } | |||
| 32 | + | } | |||
| 33 | + | }() | |||
| 34 | + | ||||
| 35 | + | wg.Wait() | |||
| 36 | + | } | |||
| 37 | + | ||||
| 9 | func TestIsTrustedBridgeURI(t *testing.T) { | 38 | func TestIsTrustedBridgeURI(t *testing.T) { | ||
| 10 | t.Parallel() | 39 | t.Parallel() | ||
| 11 | 40 | ||||
M internal/infrastructure/webkit/scheme_handler.go +1 -1
| @@ -499,7 +499,7 @@ func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath | |||
| 499 | 499 | return fullPath, cleanRelPath, true | |
| 500 | 500 | } | |
| 501 | 501 | ||
| 502 | - | func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, error) { | |
| 502 | + | func readAssetWithEncoding(assets fs.FS, fullPath, relPath string) ([]byte, error) { | |
| 503 | 503 | var compressedErr error | |
| 504 | 504 | if strings.HasSuffix(relPath, ".wasm") { | |
| 505 | 505 | if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil { | |
| @@ -499,7 +499,7 @@ func safeSystemviewsAssetPath(assetDir, relPath string) (fullPath, cleanRelPath | |||||
| 499 | return fullPath, cleanRelPath, true | 499 | return fullPath, cleanRelPath, true | ||
| 500 | } | 500 | } | ||
| 501 | 501 | ||||
| 502 | - | func readAssetWithEncoding(assets embed.FS, fullPath, relPath string) ([]byte, error) { | 502 | + | func readAssetWithEncoding(assets fs.FS, fullPath, relPath string) ([]byte, error) { |
| 503 | var compressedErr error | 503 | var compressedErr error | ||
| 504 | if strings.HasSuffix(relPath, ".wasm") { | 504 | if strings.HasSuffix(relPath, ".wasm") { | ||
| 505 | if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil { | 505 | if compressed, err := fs.ReadFile(assets, fullPath+".br"); err == nil { | ||
M internal/infrastructure/webkit/scheme_handler_test.go +21 -4
| @@ -1,12 +1,15 @@ | |||
| 1 | 1 | package webkit | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "bytes" | |
| 4 | 5 | "context" | |
| 5 | 6 | "net/http" | |
| 6 | 7 | "net/url" | |
| 7 | 8 | "strings" | |
| 8 | 9 | "testing" | |
| 10 | + | "testing/fstest" | |
| 9 | 11 | ||
| 12 | + | "github.com/andybalholm/brotli" | |
| 10 | 13 | "github.com/bnema/dumber/assets" | |
| 11 | 14 | "github.com/stretchr/testify/assert" | |
| 12 | 15 | "github.com/stretchr/testify/require" | |
| @@ -327,13 +330,27 @@ func TestResponseHeadersForPath_WASMIncludesContentTypeAndCORS(t *testing.T) { | |||
| 327 | 330 | assert.Equal(t, "GET, POST, OPTIONS", headers["Access-Control-Allow-Methods"]) | |
| 328 | 331 | } | |
| 329 | 332 | ||
| 330 | - | func TestReadAssetWithEncodingDecompressesEmbeddedBrotliWASM(t *testing.T) { | |
| 333 | + | func TestReadAssetWithEncodingDecompressesBrotliWASM(t *testing.T) { | |
| 331 | 334 | t.Parallel() | |
| 332 | 335 | ||
| 333 | - | data, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm") | |
| 336 | + | wasm := []byte("\x00asmfixture") | |
| 337 | + | fsys := fstest.MapFS{ | |
| 338 | + | "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)}, | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | data, err := readAssetWithEncoding(fsys, "systemviews/systemviews.wasm", "systemviews.wasm") | |
| 342 | + | require.NoError(t, err) | |
| 343 | + | assert.Equal(t, wasm, data) | |
| 344 | + | } | |
| 345 | + | ||
| 346 | + | func brotliCompressForTest(t *testing.T, data []byte) []byte { | |
| 347 | + | t.Helper() | |
| 348 | + | var buf bytes.Buffer | |
| 349 | + | w := brotli.NewWriter(&buf) | |
| 350 | + | _, err := w.Write(data) | |
| 334 | 351 | require.NoError(t, err) | |
| 335 | - | require.GreaterOrEqual(t, len(data), 4) | |
| 336 | - | assert.Equal(t, "\x00asm", string(data[:4])) | |
| 352 | + | require.NoError(t, w.Close()) | |
| 353 | + | return buf.Bytes() | |
| 337 | 354 | } | |
| 338 | 355 | ||
| 339 | 356 | func TestHandleAssetRejectsTraversalOutsideSystemviews(t *testing.T) { | |
| @@ -1,12 +1,15 @@ | |||||
| 1 | package webkit | 1 | package webkit | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "bytes" | |||
| 4 | "context" | 5 | "context" | ||
| 5 | "net/http" | 6 | "net/http" | ||
| 6 | "net/url" | 7 | "net/url" | ||
| 7 | "strings" | 8 | "strings" | ||
| 8 | "testing" | 9 | "testing" | ||
| 10 | + | "testing/fstest" | |||
| 9 | 11 | ||||
| 12 | + | "github.com/andybalholm/brotli" | |||
| 10 | "github.com/bnema/dumber/assets" | 13 | "github.com/bnema/dumber/assets" | ||
| 11 | "github.com/stretchr/testify/assert" | 14 | "github.com/stretchr/testify/assert" | ||
| 12 | "github.com/stretchr/testify/require" | 15 | "github.com/stretchr/testify/require" | ||
| @@ -327,13 +330,27 @@ func TestResponseHeadersForPath_WASMIncludesContentTypeAndCORS(t *testing.T) { | |||||
| 327 | assert.Equal(t, "GET, POST, OPTIONS", headers["Access-Control-Allow-Methods"]) | 330 | assert.Equal(t, "GET, POST, OPTIONS", headers["Access-Control-Allow-Methods"]) | ||
| 328 | } | 331 | } | ||
| 329 | 332 | ||||
| 330 | - | func TestReadAssetWithEncodingDecompressesEmbeddedBrotliWASM(t *testing.T) { | 333 | + | func TestReadAssetWithEncodingDecompressesBrotliWASM(t *testing.T) { |
| 331 | t.Parallel() | 334 | t.Parallel() | ||
| 332 | 335 | ||||
| 333 | - | data, err := readAssetWithEncoding(assets.WebUIAssets, "systemviews/systemviews.wasm", "systemviews.wasm") | 336 | + | wasm := []byte("\x00asmfixture") |
| 337 | + | fsys := fstest.MapFS{ | |||
| 338 | + | "systemviews/systemviews.wasm.br": {Data: brotliCompressForTest(t, wasm)}, | |||
| 339 | + | } | |||
| 340 | + | ||||
| 341 | + | data, err := readAssetWithEncoding(fsys, "systemviews/systemviews.wasm", "systemviews.wasm") | |||
| 342 | + | require.NoError(t, err) | |||
| 343 | + | assert.Equal(t, wasm, data) | |||
| 344 | + | } | |||
| 345 | + | ||||
| 346 | + | func brotliCompressForTest(t *testing.T, data []byte) []byte { | |||
| 347 | + | t.Helper() | |||
| 348 | + | var buf bytes.Buffer | |||
| 349 | + | w := brotli.NewWriter(&buf) | |||
| 350 | + | _, err := w.Write(data) | |||
| 334 | require.NoError(t, err) | 351 | require.NoError(t, err) | ||
| 335 | - | require.GreaterOrEqual(t, len(data), 4) | 352 | + | require.NoError(t, w.Close()) |
| 336 | - | assert.Equal(t, "\x00asm", string(data[:4])) | 353 | + | return buf.Bytes() |
| 337 | } | 354 | } | ||
| 338 | 355 | ||||
| 339 | func TestHandleAssetRejectsTraversalOutsideSystemviews(t *testing.T) { | 356 | func TestHandleAssetRejectsTraversalOutsideSystemviews(t *testing.T) { | ||