fix(cef): resolve systemviews assets correctly
- commit
- 4a71c89614fc04383e02ba6f35e6630be6a42f8e
- author
- brice · · 2026-04-11 18:39 UTC
- committer
- brice · · 2026-04-12 04:52 UTC
- parent
- b14a8e4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
4 files · +83 -27
M Makefile +1 -1
| @@ -38,7 +38,7 @@ help: ## Show this help message | |||
| 38 | 38 | @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) | |
| 39 | 39 | ||
| 40 | 40 | # Build targets | |
| 41 | - | build: build-frontend ## Build the application (pure Go, no CGO) | |
| 41 | + | build: build-frontend build-systemviews ## 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) | |
| @@ -38,7 +38,7 @@ help: ## Show this help message | |||||
| 38 | @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) | 38 | @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST) | ||
| 39 | 39 | ||||
| 40 | # Build targets | 40 | # Build targets | ||
| 41 | - | build: build-frontend ## Build the application (pure Go, no CGO) | 41 | + | build: build-frontend build-systemviews ## 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) | ||
M internal/infrastructure/cef/scheme_handler.go +37 -25
| @@ -294,17 +294,19 @@ func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location stri | |||
| 294 | 294 | func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler { | |
| 295 | 295 | h.mu.RLock() | |
| 296 | 296 | hasAssets := h.assetsSet | |
| 297 | - | assetDir := h.assetDir | |
| 298 | 297 | h.mu.RUnlock() | |
| 299 | 298 | ||
| 300 | 299 | if !hasAssets { | |
| 301 | 300 | return h.newErrorResourceHandler(http.StatusInternalServerError, "Assets not configured") | |
| 302 | 301 | } | |
| 303 | 302 | ||
| 304 | - | relPath, ok := resolveAssetPath(u) | |
| 303 | + | assetDir, relPath, ok := resolveAssetPath(u) | |
| 305 | 304 | if !ok { | |
| 306 | 305 | return h.newErrorResourceHandler(http.StatusNotFound, "Page not found") | |
| 307 | 306 | } | |
| 307 | + | if assetDir == "" { | |
| 308 | + | assetDir = h.assetDir | |
| 309 | + | } | |
| 308 | 310 | ||
| 309 | 311 | fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath)) | |
| 310 | 312 | data, err := fs.ReadFile(h.assets, fullPath) | |
| @@ -325,9 +327,9 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler { | |||
| 325 | 327 | ||
| 326 | 328 | // resolveAssetPath maps either a dumb:// URL or the actual internal HTTPS URL | |
| 327 | 329 | // to a relative asset path inside assets/webui. | |
| 328 | - | func resolveAssetPath(u *url.URL) (string, bool) { | |
| 330 | + | func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |
| 329 | 331 | if u == nil { | |
| 330 | - | return "", false | |
| 332 | + | return "", "", false | |
| 331 | 333 | } | |
| 332 | 334 | ||
| 333 | 335 | if strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost) { | |
| @@ -337,57 +339,67 @@ func resolveAssetPath(u *url.URL) (string, bool) { | |||
| 337 | 339 | return resolveConceptualAssetPath(u) | |
| 338 | 340 | } | |
| 339 | 341 | ||
| 340 | - | func resolveConceptualAssetPath(u *url.URL) (string, bool) { | |
| 342 | + | func resolveConceptualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |
| 341 | 343 | if u == nil { | |
| 342 | - | return "", false | |
| 344 | + | return "", "", false | |
| 343 | 345 | } | |
| 344 | 346 | ||
| 345 | 347 | if root, ok := pageRootFiles[u.Host]; ok { | |
| 346 | 348 | path := strings.TrimPrefix(u.Path, "/") | |
| 347 | 349 | if path == "" { | |
| 348 | - | return root, true | |
| 350 | + | return assetDirForPageHost(u.Host), root, true | |
| 349 | 351 | } | |
| 350 | 352 | // Don't serve API paths as assets. | |
| 351 | 353 | if strings.HasPrefix(path, "api/") { | |
| 352 | - | return "", false | |
| 354 | + | return "", "", false | |
| 353 | 355 | } | |
| 354 | - | return path, true | |
| 356 | + | return assetDirForPageHost(u.Host), path, true | |
| 355 | 357 | } | |
| 356 | 358 | ||
| 357 | 359 | // Handle opaque URLs like dumb:home. | |
| 358 | 360 | if root, ok := pageRootFiles[u.Opaque]; ok { | |
| 359 | - | return root, true | |
| 361 | + | return assetDirForPageHost(u.Opaque), root, true | |
| 360 | 362 | } | |
| 361 | - | return "", false | |
| 363 | + | return "", "", false | |
| 362 | 364 | } | |
| 363 | 365 | ||
| 364 | - | func resolveActualAssetPath(u *url.URL) (string, bool) { | |
| 366 | + | func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |
| 365 | 367 | if u == nil { | |
| 366 | - | return "", false | |
| 368 | + | return "", "", false | |
| 367 | 369 | } | |
| 368 | 370 | ||
| 369 | 371 | path := strings.Trim(u.Path, "/") | |
| 370 | 372 | if path == "" { | |
| 371 | - | return "", false | |
| 373 | + | return "", "", false | |
| 372 | 374 | } | |
| 373 | 375 | ||
| 374 | - | if root, ok := pageRootFiles[path]; ok { | |
| 375 | - | return root, true | |
| 376 | + | parts := strings.SplitN(path, "/", 2) | |
| 377 | + | page := parts[0] | |
| 378 | + | if root, ok := pageRootFiles[page]; ok { | |
| 379 | + | if len(parts) == 1 { | |
| 380 | + | return assetDirForPageHost(page), root, true | |
| 381 | + | } | |
| 382 | + | if page == homePath && parts[1] == "crash" { | |
| 383 | + | return "", "", false | |
| 384 | + | } | |
| 385 | + | return assetDirForPageHost(page), path, true | |
| 376 | 386 | } | |
| 377 | 387 | ||
| 378 | 388 | if strings.HasPrefix(path, "api/") { | |
| 379 | - | return "", false | |
| 380 | - | } | |
| 381 | - | ||
| 382 | - | parts := strings.SplitN(path, "/", 2) | |
| 383 | - | if len(parts) == 2 && isInternalPageHost(parts[0]) { | |
| 384 | - | if parts[1] == "crash" && parts[0] == homePath { | |
| 385 | - | return "", false | |
| 386 | - | } | |
| 389 | + | return "", "", false | |
| 387 | 390 | } | |
| 388 | 391 | ||
| 389 | 392 | // Serve root assets like homepage.min.js, style.css and favicon.png. | |
| 390 | - | return path, true | |
| 393 | + | return "webui", path, true | |
| 394 | + | } | |
| 395 | + | ||
| 396 | + | func assetDirForPageHost(host string) string { | |
| 397 | + | switch host { | |
| 398 | + | case historyPath, favoritesPath, configPath: | |
| 399 | + | return "systemviews" | |
| 400 | + | default: | |
| 401 | + | return "webui" | |
| 402 | + | } | |
| 391 | 403 | } | |
| 392 | 404 | ||
| 393 | 405 | func isCEFCrashPageURL(u *url.URL) bool { | |
| @@ -294,17 +294,19 @@ func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location stri | |||||
| 294 | func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler { | 294 | func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler { | ||
| 295 | h.mu.RLock() | 295 | h.mu.RLock() | ||
| 296 | hasAssets := h.assetsSet | 296 | hasAssets := h.assetsSet | ||
| 297 | - | assetDir := h.assetDir | |||
| 298 | h.mu.RUnlock() | 297 | h.mu.RUnlock() | ||
| 299 | 298 | ||||
| 300 | if !hasAssets { | 299 | if !hasAssets { | ||
| 301 | return h.newErrorResourceHandler(http.StatusInternalServerError, "Assets not configured") | 300 | return h.newErrorResourceHandler(http.StatusInternalServerError, "Assets not configured") | ||
| 302 | } | 301 | } | ||
| 303 | 302 | ||||
| 304 | - | relPath, ok := resolveAssetPath(u) | 303 | + | assetDir, relPath, ok := resolveAssetPath(u) |
| 305 | if !ok { | 304 | if !ok { | ||
| 306 | return h.newErrorResourceHandler(http.StatusNotFound, "Page not found") | 305 | return h.newErrorResourceHandler(http.StatusNotFound, "Page not found") | ||
| 307 | } | 306 | } | ||
| 307 | + | if assetDir == "" { | |||
| 308 | + | assetDir = h.assetDir | |||
| 309 | + | } | |||
| 308 | 310 | ||||
| 309 | fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath)) | 311 | fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath)) | ||
| 310 | data, err := fs.ReadFile(h.assets, fullPath) | 312 | data, err := fs.ReadFile(h.assets, fullPath) | ||
| @@ -325,9 +327,9 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler { | |||||
| 325 | 327 | ||||
| 326 | // resolveAssetPath maps either a dumb:// URL or the actual internal HTTPS URL | 328 | // resolveAssetPath maps either a dumb:// URL or the actual internal HTTPS URL | ||
| 327 | // to a relative asset path inside assets/webui. | 329 | // to a relative asset path inside assets/webui. | ||
| 328 | - | func resolveAssetPath(u *url.URL) (string, bool) { | 330 | + | func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { |
| 329 | if u == nil { | 331 | if u == nil { | ||
| 330 | - | return "", false | 332 | + | return "", "", false |
| 331 | } | 333 | } | ||
| 332 | 334 | ||||
| 333 | if strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost) { | 335 | if strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost) { | ||
| @@ -337,57 +339,67 @@ func resolveAssetPath(u *url.URL) (string, bool) { | |||||
| 337 | return resolveConceptualAssetPath(u) | 339 | return resolveConceptualAssetPath(u) | ||
| 338 | } | 340 | } | ||
| 339 | 341 | ||||
| 340 | - | func resolveConceptualAssetPath(u *url.URL) (string, bool) { | 342 | + | func resolveConceptualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { |
| 341 | if u == nil { | 343 | if u == nil { | ||
| 342 | - | return "", false | 344 | + | return "", "", false |
| 343 | } | 345 | } | ||
| 344 | 346 | ||||
| 345 | if root, ok := pageRootFiles[u.Host]; ok { | 347 | if root, ok := pageRootFiles[u.Host]; ok { | ||
| 346 | path := strings.TrimPrefix(u.Path, "/") | 348 | path := strings.TrimPrefix(u.Path, "/") | ||
| 347 | if path == "" { | 349 | if path == "" { | ||
| 348 | - | return root, true | 350 | + | return assetDirForPageHost(u.Host), root, true |
| 349 | } | 351 | } | ||
| 350 | // Don't serve API paths as assets. | 352 | // Don't serve API paths as assets. | ||
| 351 | if strings.HasPrefix(path, "api/") { | 353 | if strings.HasPrefix(path, "api/") { | ||
| 352 | - | return "", false | 354 | + | return "", "", false |
| 353 | } | 355 | } | ||
| 354 | - | return path, true | 356 | + | return assetDirForPageHost(u.Host), path, true |
| 355 | } | 357 | } | ||
| 356 | 358 | ||||
| 357 | // Handle opaque URLs like dumb:home. | 359 | // Handle opaque URLs like dumb:home. | ||
| 358 | if root, ok := pageRootFiles[u.Opaque]; ok { | 360 | if root, ok := pageRootFiles[u.Opaque]; ok { | ||
| 359 | - | return root, true | 361 | + | return assetDirForPageHost(u.Opaque), root, true |
| 360 | } | 362 | } | ||
| 361 | - | return "", false | 363 | + | return "", "", false |
| 362 | } | 364 | } | ||
| 363 | 365 | ||||
| 364 | - | func resolveActualAssetPath(u *url.URL) (string, bool) { | 366 | + | func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { |
| 365 | if u == nil { | 367 | if u == nil { | ||
| 366 | - | return "", false | 368 | + | return "", "", false |
| 367 | } | 369 | } | ||
| 368 | 370 | ||||
| 369 | path := strings.Trim(u.Path, "/") | 371 | path := strings.Trim(u.Path, "/") | ||
| 370 | if path == "" { | 372 | if path == "" { | ||
| 371 | - | return "", false | 373 | + | return "", "", false |
| 372 | } | 374 | } | ||
| 373 | 375 | ||||
| 374 | - | if root, ok := pageRootFiles[path]; ok { | 376 | + | parts := strings.SplitN(path, "/", 2) |
| 375 | - | return root, true | 377 | + | page := parts[0] |
| 378 | + | if root, ok := pageRootFiles[page]; ok { | |||
| 379 | + | if len(parts) == 1 { | |||
| 380 | + | return assetDirForPageHost(page), root, true | |||
| 381 | + | } | |||
| 382 | + | if page == homePath && parts[1] == "crash" { | |||
| 383 | + | return "", "", false | |||
| 384 | + | } | |||
| 385 | + | return assetDirForPageHost(page), path, true | |||
| 376 | } | 386 | } | ||
| 377 | 387 | ||||
| 378 | if strings.HasPrefix(path, "api/") { | 388 | if strings.HasPrefix(path, "api/") { | ||
| 379 | - | return "", false | 389 | + | return "", "", false |
| 380 | - | } | |||
| 381 | - | ||||
| 382 | - | parts := strings.SplitN(path, "/", 2) | |||
| 383 | - | if len(parts) == 2 && isInternalPageHost(parts[0]) { | |||
| 384 | - | if parts[1] == "crash" && parts[0] == homePath { | |||
| 385 | - | return "", false | |||
| 386 | - | } | |||
| 387 | } | 390 | } | ||
| 388 | 391 | ||||
| 389 | // Serve root assets like homepage.min.js, style.css and favicon.png. | 392 | // Serve root assets like homepage.min.js, style.css and favicon.png. | ||
| 390 | - | return path, true | 393 | + | return "webui", path, true |
| 394 | + | } | |||
| 395 | + | ||||
| 396 | + | func assetDirForPageHost(host string) string { | |||
| 397 | + | switch host { | |||
| 398 | + | case historyPath, favoritesPath, configPath: | |||
| 399 | + | return "systemviews" | |||
| 400 | + | default: | |||
| 401 | + | return "webui" | |||
| 402 | + | } | |||
| 391 | } | 403 | } | ||
| 392 | 404 | ||||
| 393 | func isCEFCrashPageURL(u *url.URL) bool { | 405 | func isCEFCrashPageURL(u *url.URL) bool { | ||
A internal/infrastructure/cef/scheme_handler_systemviews_test.go +45 -0
| @@ -0,0 +1,45 @@ | |||
| 1 | + | package cef | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net/url" | |
| 5 | + | "testing" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | func TestResolveAssetPath_SystemviewsRootsUseSystemviewsShell(t *testing.T) { | |
| 9 | + | t.Parallel() | |
| 10 | + | ||
| 11 | + | tests := []struct { | |
| 12 | + | name string | |
| 13 | + | raw string | |
| 14 | + | wantDir string | |
| 15 | + | wantRel string | |
| 16 | + | }{ | |
| 17 | + | {name: "history host", raw: "https://dumber.invalid/history", wantDir: "systemviews", wantRel: systemviewsIndexHTML}, | |
| 18 | + | {name: "favorites host", raw: "https://dumber.invalid/favorites", wantDir: "systemviews", wantRel: systemviewsIndexHTML}, | |
| 19 | + | {name: "config host", raw: "https://dumber.invalid/config", wantDir: "systemviews", wantRel: systemviewsIndexHTML}, | |
| 20 | + | {name: "history opaque", raw: "dumb:history", wantDir: "systemviews", wantRel: systemviewsIndexHTML}, | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | for _, tt := range tests { | |
| 24 | + | t.Run(tt.name, func(t *testing.T) { | |
| 25 | + | t.Parallel() | |
| 26 | + | u := mustParseURL(t, tt.raw) | |
| 27 | + | gotDir, gotRel, ok := resolveAssetPath(u) | |
| 28 | + | if !ok { | |
| 29 | + | t.Fatalf("resolveAssetPath(%q) = not ok", tt.raw) | |
| 30 | + | } | |
| 31 | + | if gotDir != tt.wantDir || gotRel != tt.wantRel { | |
| 32 | + | t.Fatalf("resolveAssetPath(%q) = (%q, %q), want (%q, %q)", tt.raw, gotDir, gotRel, tt.wantDir, tt.wantRel) | |
| 33 | + | } | |
| 34 | + | }) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func mustParseURL(t *testing.T, raw string) *url.URL { | |
| 39 | + | t.Helper() | |
| 40 | + | u, err := url.Parse(raw) | |
| 41 | + | if err != nil { | |
| 42 | + | t.Fatalf("url.Parse(%q) error = %v", raw, err) | |
| 43 | + | } | |
| 44 | + | return u | |
| 45 | + | } | |
| @@ -0,0 +1,45 @@ | |||||
| 1 | + | package cef | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net/url" | |||
| 5 | + | "testing" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | func TestResolveAssetPath_SystemviewsRootsUseSystemviewsShell(t *testing.T) { | |||
| 9 | + | t.Parallel() | |||
| 10 | + | ||||
| 11 | + | tests := []struct { | |||
| 12 | + | name string | |||
| 13 | + | raw string | |||
| 14 | + | wantDir string | |||
| 15 | + | wantRel string | |||
| 16 | + | }{ | |||
| 17 | + | {name: "history host", raw: "https://dumber.invalid/history", wantDir: "systemviews", wantRel: systemviewsIndexHTML}, | |||
| 18 | + | {name: "favorites host", raw: "https://dumber.invalid/favorites", wantDir: "systemviews", wantRel: systemviewsIndexHTML}, | |||
| 19 | + | {name: "config host", raw: "https://dumber.invalid/config", wantDir: "systemviews", wantRel: systemviewsIndexHTML}, | |||
| 20 | + | {name: "history opaque", raw: "dumb:history", wantDir: "systemviews", wantRel: systemviewsIndexHTML}, | |||
| 21 | + | } | |||
| 22 | + | ||||
| 23 | + | for _, tt := range tests { | |||
| 24 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 25 | + | t.Parallel() | |||
| 26 | + | u := mustParseURL(t, tt.raw) | |||
| 27 | + | gotDir, gotRel, ok := resolveAssetPath(u) | |||
| 28 | + | if !ok { | |||
| 29 | + | t.Fatalf("resolveAssetPath(%q) = not ok", tt.raw) | |||
| 30 | + | } | |||
| 31 | + | if gotDir != tt.wantDir || gotRel != tt.wantRel { | |||
| 32 | + | t.Fatalf("resolveAssetPath(%q) = (%q, %q), want (%q, %q)", tt.raw, gotDir, gotRel, tt.wantDir, tt.wantRel) | |||
| 33 | + | } | |||
| 34 | + | }) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func mustParseURL(t *testing.T, raw string) *url.URL { | |||
| 39 | + | t.Helper() | |||
| 40 | + | u, err := url.Parse(raw) | |||
| 41 | + | if err != nil { | |||
| 42 | + | t.Fatalf("url.Parse(%q) error = %v", raw, err) | |||
| 43 | + | } | |||
| 44 | + | return u | |||
| 45 | + | } | |||
M internal/infrastructure/webkit/scheme_handler.go +0 -1
| @@ -288,7 +288,6 @@ func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) { | |||
| 288 | 288 | func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |
| 289 | 289 | h.mu.RLock() | |
| 290 | 290 | hasAssets := h.assets != (embed.FS{}) | |
| 291 | - | assetDir := h.assetDir | |
| 292 | 291 | h.mu.RUnlock() | |
| 293 | 292 | ||
| 294 | 293 | if !hasAssets { | |
| @@ -288,7 +288,6 @@ func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) { | |||||
| 288 | func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | 288 | func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | ||
| 289 | h.mu.RLock() | 289 | h.mu.RLock() | ||
| 290 | hasAssets := h.assets != (embed.FS{}) | 290 | hasAssets := h.assets != (embed.FS{}) | ||
| 291 | - | assetDir := h.assetDir | |||
| 292 | h.mu.RUnlock() | 291 | h.mu.RUnlock() | ||
| 293 | 292 | ||||
| 294 | if !hasAssets { | 293 | if !hasAssets { | ||