detached 4a71c89

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · arch-gpg Verified for the committer email
4 files · +83 -27
M Makefile +1 -1
@@ -38,7 +38,7 @@ help: ## Show this help message
3838 @awk 'BEGIN {FS = ":.*?## "} /^[a-zA-Z_-]+:.*?## / {printf " \033[36m%-15s\033[0m %s\n", $$1, $$2}' $(MAKEFILE_LIST)
3939
4040 # 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)
4242 @echo "Building $(BINARY_NAME) $(VERSION) using $(NPROCS) cores..."
4343 @mkdir -p $(DIST_DIR)
4444 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 targets40 # 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
294294 func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
295295 h.mu.RLock()
296296 hasAssets := h.assetsSet
297- assetDir := h.assetDir
298297 h.mu.RUnlock()
299298
300299 if !hasAssets {
301300 return h.newErrorResourceHandler(http.StatusInternalServerError, "Assets not configured")
302301 }
303302
304- relPath, ok := resolveAssetPath(u)
303+ assetDir, relPath, ok := resolveAssetPath(u)
305304 if !ok {
306305 return h.newErrorResourceHandler(http.StatusNotFound, "Page not found")
307306 }
307+ if assetDir == "" {
308+ assetDir = h.assetDir
309+ }
308310
309311 fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath))
310312 data, err := fs.ReadFile(h.assets, fullPath)
@@ -325,9 +327,9 @@ func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
325327
326328 // resolveAssetPath maps either a dumb:// URL or the actual internal HTTPS URL
327329 // 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) {
329331 if u == nil {
330- return "", false
332+ return "", "", false
331333 }
332334
333335 if strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost) {
@@ -337,57 +339,67 @@ func resolveAssetPath(u *url.URL) (string, bool) {
337339 return resolveConceptualAssetPath(u)
338340 }
339341
340-func resolveConceptualAssetPath(u *url.URL) (string, bool) {
342+func resolveConceptualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
341343 if u == nil {
342- return "", false
344+ return "", "", false
343345 }
344346
345347 if root, ok := pageRootFiles[u.Host]; ok {
346348 path := strings.TrimPrefix(u.Path, "/")
347349 if path == "" {
348- return root, true
350+ return assetDirForPageHost(u.Host), root, true
349351 }
350352 // Don't serve API paths as assets.
351353 if strings.HasPrefix(path, "api/") {
352- return "", false
354+ return "", "", false
353355 }
354- return path, true
356+ return assetDirForPageHost(u.Host), path, true
355357 }
356358
357359 // Handle opaque URLs like dumb:home.
358360 if root, ok := pageRootFiles[u.Opaque]; ok {
359- return root, true
361+ return assetDirForPageHost(u.Opaque), root, true
360362 }
361- return "", false
363+ return "", "", false
362364 }
363365
364-func resolveActualAssetPath(u *url.URL) (string, bool) {
366+func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
365367 if u == nil {
366- return "", false
368+ return "", "", false
367369 }
368370
369371 path := strings.Trim(u.Path, "/")
370372 if path == "" {
371- return "", false
373+ return "", "", false
372374 }
373375
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
376386 }
377387
378388 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
387390 }
388391
389392 // 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+ }
391403 }
392404
393405 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.assetsSet296 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 URL328 // 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 "", false332+ 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 "", false344+ 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, true350+ 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 "", false354+ return "", "", false
353 }355 }
354- return path, true356+ 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, true361+ return assetDirForPageHost(u.Opaque), root, true
360 }362 }
361- return "", false363+ 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 "", false368+ 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 "", false373+ return "", "", false
372 }374 }
373 375
374- if root, ok := pageRootFiles[path]; ok {376+ parts := strings.SplitN(path, "/", 2)
375- return root, true377+ 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 "", false389+ 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, true393+ 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) {
288288 func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
289289 h.mu.RLock()
290290 hasAssets := h.assets != (embed.FS{})
291- assetDir := h.assetDir
292291 h.mu.RUnlock()
293292
294293 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 {

Keyboard shortcuts

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

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

Branches 8

Tags 42

↑↓ moveenter openesc close