build(wasm): add systemviews runtime foundation
- commit
- b14a8e4b58332ae48749b6ecae025c5b667e2325
- author
- brice · · 2026-04-11 18:32 UTC
- committer
- brice · · 2026-04-12 04:52 UTC
- parent
- 0355bc1
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
15 files · +383 -35
M Makefile +9 -1
| @@ -1,6 +1,6 @@ | |||
| 1 | 1 | # Makefile for dumber (Clean Architecture - puregotk) | |
| 2 | 2 | ||
| 3 | - | .PHONY: build build-frontend test lint clean install-tools dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego check | |
| 3 | + | .PHONY: build build-frontend build-systemviews test lint clean install-tools dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego check | |
| 4 | 4 | ||
| 5 | 5 | # Load local overrides from .env.local if present (Makefile syntax) | |
| 6 | 6 | ifneq (,$(wildcard .env.local)) | |
| @@ -50,6 +50,13 @@ build-frontend: ## Build homepage and error pages | |||
| 50 | 50 | @cd webui && npm install --silent && npm run build | |
| 51 | 51 | @echo "Frontend build complete" | |
| 52 | 52 | ||
| 53 | + | build-systemviews: ## Build the WASM systemviews runtime | |
| 54 | + | @echo "Building systemviews wasm assets..." | |
| 55 | + | @mkdir -p assets/systemviews | |
| 56 | + | @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js | |
| 57 | + | GOOS=js GOARCH=wasm go build -o assets/systemviews/systemviews.wasm ./cmd/systemviews | |
| 58 | + | @echo "Systemviews build complete" | |
| 59 | + | ||
| 53 | 60 | build-quick: ## Build without frontend (faster for backend development) | |
| 54 | 61 | @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..." | |
| 55 | 62 | @mkdir -p $(DIST_DIR) | |
| @@ -125,6 +132,7 @@ clean: ## Clean build artifacts | |||
| 125 | 132 | rm -f $(BINARY_NAME) | |
| 126 | 133 | rm -rf webui/dist webui/node_modules | |
| 127 | 134 | rm -f assets/webui/homepage.min.js assets/webui/error.min.js assets/webui/config.min.js assets/webui/index.html assets/webui/error.html assets/webui/config.html assets/webui/style.css | |
| 135 | + | rm -f assets/systemviews/wasm_exec.js assets/systemviews/systemviews.wasm | |
| 128 | 136 | rm -f coverage.out coverage.html | |
| 129 | 137 | go clean -cache | |
| 130 | 138 | go clean -testcache | |
| @@ -1,6 +1,6 @@ | |||||
| 1 | # Makefile for dumber (Clean Architecture - puregotk) | 1 | # Makefile for dumber (Clean Architecture - puregotk) | ||
| 2 | 2 | ||||
| 3 | - | .PHONY: build build-frontend test lint clean install-tools dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego check | 3 | + | .PHONY: build build-frontend build-systemviews test lint clean install-tools dev generate help init man flatpak-deps flatpak-build flatpak-install flatpak-run flatpak-clean stress-omnibox-callbacks verify-purego 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)) | ||
| @@ -50,6 +50,13 @@ build-frontend: ## Build homepage and error pages | |||||
| 50 | @cd webui && npm install --silent && npm run build | 50 | @cd webui && npm install --silent && npm run build | ||
| 51 | @echo "Frontend build complete" | 51 | @echo "Frontend build complete" | ||
| 52 | 52 | ||||
| 53 | + | build-systemviews: ## Build the WASM systemviews runtime | |||
| 54 | + | @echo "Building systemviews wasm assets..." | |||
| 55 | + | @mkdir -p assets/systemviews | |||
| 56 | + | @cp "$$(go env GOROOT)/lib/wasm/wasm_exec.js" assets/systemviews/wasm_exec.js | |||
| 57 | + | GOOS=js GOARCH=wasm go build -o assets/systemviews/systemviews.wasm ./cmd/systemviews | |||
| 58 | + | @echo "Systemviews build complete" | |||
| 59 | + | ||||
| 53 | build-quick: ## Build without frontend (faster for backend development) | 60 | build-quick: ## Build without frontend (faster for backend development) | ||
| 54 | @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..." | 61 | @echo "Building $(BINARY_NAME) $(VERSION) (quick, no frontend)..." | ||
| 55 | @mkdir -p $(DIST_DIR) | 62 | @mkdir -p $(DIST_DIR) | ||
| @@ -125,6 +132,7 @@ clean: ## Clean build artifacts | |||||
| 125 | rm -f $(BINARY_NAME) | 132 | rm -f $(BINARY_NAME) | ||
| 126 | rm -rf webui/dist webui/node_modules | 133 | rm -rf webui/dist webui/node_modules | ||
| 127 | rm -f assets/webui/homepage.min.js assets/webui/error.min.js assets/webui/config.min.js assets/webui/index.html assets/webui/error.html assets/webui/config.html assets/webui/style.css | 134 | rm -f assets/webui/homepage.min.js assets/webui/error.min.js assets/webui/config.min.js assets/webui/index.html assets/webui/error.html assets/webui/config.html assets/webui/style.css | ||
| 135 | + | rm -f assets/systemviews/wasm_exec.js assets/systemviews/systemviews.wasm | |||
| 128 | rm -f coverage.out coverage.html | 136 | rm -f coverage.out coverage.html | ||
| 129 | go clean -cache | 137 | go clean -cache | ||
| 130 | go clean -testcache | 138 | go clean -testcache | ||
A assets/systemviews/index.html +17 -0
| @@ -0,0 +1,17 @@ | |||
| 1 | + | <!doctype html> | |
| 2 | + | <html lang="en"> | |
| 3 | + | <head> | |
| 4 | + | <meta charset="utf-8"> | |
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 6 | + | <title>systemviews</title> | |
| 7 | + | <script src="./wasm_exec.js"></script> | |
| 8 | + | <script> | |
| 9 | + | const go = new Go(); | |
| 10 | + | WebAssembly.instantiateStreaming(fetch('./systemviews.wasm'), go.importObject) | |
| 11 | + | .then((result) => go.run(result.instance)); | |
| 12 | + | </script> | |
| 13 | + | </head> | |
| 14 | + | <body> | |
| 15 | + | <main id="app">Loading systemviews…</main> | |
| 16 | + | </body> | |
| 17 | + | </html> | |
| @@ -0,0 +1,17 @@ | |||||
| 1 | + | <!doctype html> | |||
| 2 | + | <html lang="en"> | |||
| 3 | + | <head> | |||
| 4 | + | <meta charset="utf-8"> | |||
| 5 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| 6 | + | <title>systemviews</title> | |||
| 7 | + | <script src="./wasm_exec.js"></script> | |||
| 8 | + | <script> | |||
| 9 | + | const go = new Go(); | |||
| 10 | + | WebAssembly.instantiateStreaming(fetch('./systemviews.wasm'), go.importObject) | |||
| 11 | + | .then((result) => go.run(result.instance)); | |||
| 12 | + | </script> | |||
| 13 | + | </head> | |||
| 14 | + | <body> | |||
| 15 | + | <main id="app">Loading systemviews…</main> | |||
| 16 | + | </body> | |||
| 17 | + | </html> | |||
M assets/webui_embed.go +4 -3
| @@ -4,10 +4,11 @@ import ( | |||
| 4 | 4 | "embed" | |
| 5 | 5 | ) | |
| 6 | 6 | ||
| 7 | - | // WebUIAssets contains the embedded webui build output (homepage, error, config, and webrtc pages). | |
| 8 | - | // Used by the scheme handler to serve dumb://home, dumb://error, dumb://config, and dumb://webrtc. | |
| 7 | + | // WebUIAssets contains the embedded webui and systemviews build output. | |
| 8 | + | // Used by the scheme handlers during the transition to serve dumb://home, | |
| 9 | + | // dumb://error, dumb://config, dumb://webrtc, dumb://history, and dumb://favorites. | |
| 9 | 10 | // | |
| 10 | - | //go:embed webui/* | |
| 11 | + | //go:embed webui/* systemviews/* | |
| 11 | 12 | var WebUIAssets embed.FS | |
| 12 | 13 | ||
| 13 | 14 | // LogoSVG contains the dumber logo as SVG for desktop icon installation. | |
| @@ -4,10 +4,11 @@ import ( | |||||
| 4 | "embed" | 4 | "embed" | ||
| 5 | ) | 5 | ) | ||
| 6 | 6 | ||||
| 7 | - | // WebUIAssets contains the embedded webui build output (homepage, error, config, and webrtc pages). | 7 | + | // WebUIAssets contains the embedded webui and systemviews build output. |
| 8 | - | // Used by the scheme handler to serve dumb://home, dumb://error, dumb://config, and dumb://webrtc. | 8 | + | // Used by the scheme handlers during the transition to serve dumb://home, |
| 9 | + | // dumb://error, dumb://config, dumb://webrtc, dumb://history, and dumb://favorites. | |||
| 9 | // | 10 | // | ||
| 10 | - | //go:embed webui/* | 11 | + | //go:embed webui/* systemviews/* |
| 11 | var WebUIAssets embed.FS | 12 | var WebUIAssets embed.FS | ||
| 12 | 13 | ||||
| 13 | // LogoSVG contains the dumber logo as SVG for desktop icon installation. | 14 | // LogoSVG contains the dumber logo as SVG for desktop icon installation. | ||
A cmd/systemviews/main.go +23 -0
| @@ -0,0 +1,23 @@ | |||
| 1 | + | //go:build js && wasm | |
| 2 | + | ||
| 3 | + | package main | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "syscall/js" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/ui/systemviews" | |
| 9 | + | ) | |
| 10 | + | ||
| 11 | + | func main() { | |
| 12 | + | app := systemviews.NewApp(systemviews.Dependencies{ | |
| 13 | + | DOM: systemviews.NewDOM(), | |
| 14 | + | LocationURI: js.Global().Get("location").Get("href").String(), | |
| 15 | + | }) | |
| 16 | + | ||
| 17 | + | if err := app.Run(); err != nil { | |
| 18 | + | js.Global().Get("console").Call("error", err.Error()) | |
| 19 | + | panic(err) | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | select {} | |
| 23 | + | } | |
| @@ -0,0 +1,23 @@ | |||||
| 1 | + | //go:build js && wasm | |||
| 2 | + | ||||
| 3 | + | package main | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "syscall/js" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/ui/systemviews" | |||
| 9 | + | ) | |||
| 10 | + | ||||
| 11 | + | func main() { | |||
| 12 | + | app := systemviews.NewApp(systemviews.Dependencies{ | |||
| 13 | + | DOM: systemviews.NewDOM(), | |||
| 14 | + | LocationURI: js.Global().Get("location").Get("href").String(), | |||
| 15 | + | }) | |||
| 16 | + | ||||
| 17 | + | if err := app.Run(); err != nil { | |||
| 18 | + | js.Global().Get("console").Call("error", err.Error()) | |||
| 19 | + | panic(err) | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | select {} | |||
| 23 | + | } | |||
A cmd/systemviews/main_stub.go +5 -0
| @@ -0,0 +1,5 @@ | |||
| 1 | + | //go:build !js || !wasm | |
| 2 | + | ||
| 3 | + | package main | |
| 4 | + | ||
| 5 | + | func main() {} | |
| @@ -0,0 +1,5 @@ | |||||
| 1 | + | //go:build !js || !wasm | |||
| 2 | + | ||||
| 3 | + | package main | |||
| 4 | + | ||||
| 5 | + | func main() {} | |||
M internal/infrastructure/cef/internal_urls.go +1 -1
| @@ -102,7 +102,7 @@ func toConceptualInternalURL(raw string) string { | |||
| 102 | 102 | ||
| 103 | 103 | func isInternalPageHost(host string) bool { | |
| 104 | 104 | switch host { | |
| 105 | - | case homePath, configPath, webrtcPath, errorPath: | |
| 105 | + | case homePath, historyPath, favoritesPath, configPath, webrtcPath, errorPath: | |
| 106 | 106 | return true | |
| 107 | 107 | default: | |
| 108 | 108 | return false | |
| @@ -102,7 +102,7 @@ func toConceptualInternalURL(raw string) string { | |||||
| 102 | 102 | ||||
| 103 | func isInternalPageHost(host string) bool { | 103 | func isInternalPageHost(host string) bool { | ||
| 104 | switch host { | 104 | switch host { | ||
| 105 | - | case homePath, configPath, webrtcPath, errorPath: | 105 | + | case homePath, historyPath, favoritesPath, configPath, webrtcPath, errorPath: |
| 106 | return true | 106 | return true | ||
| 107 | default: | 107 | default: | ||
| 108 | return false | 108 | return false | ||
M internal/infrastructure/cef/internal_urls_test.go +30 -0
| @@ -15,6 +15,21 @@ func TestToActualInternalURL(t *testing.T) { | |||
| 15 | 15 | in: "dumb://home", | |
| 16 | 16 | want: "https://dumber.invalid/home", | |
| 17 | 17 | }, | |
| 18 | + | { | |
| 19 | + | name: "history page root", | |
| 20 | + | in: "dumb://history", | |
| 21 | + | want: "https://dumber.invalid/history", | |
| 22 | + | }, | |
| 23 | + | { | |
| 24 | + | name: "favorites page root", | |
| 25 | + | in: "dumb://favorites", | |
| 26 | + | want: "https://dumber.invalid/favorites", | |
| 27 | + | }, | |
| 28 | + | { | |
| 29 | + | name: "config page root", | |
| 30 | + | in: "dumb://config", | |
| 31 | + | want: "https://dumber.invalid/config", | |
| 32 | + | }, | |
| 18 | 33 | { | |
| 19 | 34 | name: "api path stays at origin root", | |
| 20 | 35 | in: "dumb://home/api/message", | |
| @@ -60,6 +75,21 @@ func TestToConceptualInternalURL(t *testing.T) { | |||
| 60 | 75 | in: "https://dumber.invalid/home", | |
| 61 | 76 | want: "dumb://home", | |
| 62 | 77 | }, | |
| 78 | + | { | |
| 79 | + | name: "history page root", | |
| 80 | + | in: "https://dumber.invalid/history", | |
| 81 | + | want: "dumb://history", | |
| 82 | + | }, | |
| 83 | + | { | |
| 84 | + | name: "favorites page root", | |
| 85 | + | in: "https://dumber.invalid/favorites", | |
| 86 | + | want: "dumb://favorites", | |
| 87 | + | }, | |
| 88 | + | { | |
| 89 | + | name: "config page root", | |
| 90 | + | in: "https://dumber.invalid/config", | |
| 91 | + | want: "dumb://config", | |
| 92 | + | }, | |
| 63 | 93 | { | |
| 64 | 94 | name: "page subroute", | |
| 65 | 95 | in: "https://dumber.invalid/home/crash?url=https%3A%2F%2Fexample.com", | |
| @@ -15,6 +15,21 @@ func TestToActualInternalURL(t *testing.T) { | |||||
| 15 | in: "dumb://home", | 15 | in: "dumb://home", | ||
| 16 | want: "https://dumber.invalid/home", | 16 | want: "https://dumber.invalid/home", | ||
| 17 | }, | 17 | }, | ||
| 18 | + | { | |||
| 19 | + | name: "history page root", | |||
| 20 | + | in: "dumb://history", | |||
| 21 | + | want: "https://dumber.invalid/history", | |||
| 22 | + | }, | |||
| 23 | + | { | |||
| 24 | + | name: "favorites page root", | |||
| 25 | + | in: "dumb://favorites", | |||
| 26 | + | want: "https://dumber.invalid/favorites", | |||
| 27 | + | }, | |||
| 28 | + | { | |||
| 29 | + | name: "config page root", | |||
| 30 | + | in: "dumb://config", | |||
| 31 | + | want: "https://dumber.invalid/config", | |||
| 32 | + | }, | |||
| 18 | { | 33 | { | ||
| 19 | name: "api path stays at origin root", | 34 | name: "api path stays at origin root", | ||
| 20 | in: "dumb://home/api/message", | 35 | in: "dumb://home/api/message", | ||
| @@ -60,6 +75,21 @@ func TestToConceptualInternalURL(t *testing.T) { | |||||
| 60 | in: "https://dumber.invalid/home", | 75 | in: "https://dumber.invalid/home", | ||
| 61 | want: "dumb://home", | 76 | want: "dumb://home", | ||
| 62 | }, | 77 | }, | ||
| 78 | + | { | |||
| 79 | + | name: "history page root", | |||
| 80 | + | in: "https://dumber.invalid/history", | |||
| 81 | + | want: "dumb://history", | |||
| 82 | + | }, | |||
| 83 | + | { | |||
| 84 | + | name: "favorites page root", | |||
| 85 | + | in: "https://dumber.invalid/favorites", | |||
| 86 | + | want: "dumb://favorites", | |||
| 87 | + | }, | |||
| 88 | + | { | |||
| 89 | + | name: "config page root", | |||
| 90 | + | in: "https://dumber.invalid/config", | |||
| 91 | + | want: "dumb://config", | |||
| 92 | + | }, | |||
| 63 | { | 93 | { | ||
| 64 | name: "page subroute", | 94 | name: "page subroute", | ||
| 65 | in: "https://dumber.invalid/home/crash?url=https%3A%2F%2Fexample.com", | 95 | in: "https://dumber.invalid/home/crash?url=https%3A%2F%2Fexample.com", | ||
M internal/infrastructure/cef/scheme_handler.go +9 -4
| @@ -27,19 +27,24 @@ import ( | |||
| 27 | 27 | // Scheme path constants matching WebKit's naming. | |
| 28 | 28 | const ( | |
| 29 | 29 | homePath = "home" | |
| 30 | + | historyPath = "history" | |
| 31 | + | favoritesPath = "favorites" | |
| 30 | 32 | configPath = "config" | |
| 31 | 33 | webrtcPath = "webrtc" | |
| 32 | 34 | errorPath = "error" | |
| 33 | 35 | indexHTML = "index.html" | |
| 36 | + | systemviewsIndexHTML = "systemviews/index.html" | |
| 34 | 37 | maxSchemeTruncatedURLLength = 240 | |
| 35 | 38 | ) | |
| 36 | 39 | ||
| 37 | 40 | // pageRootFiles maps internal page hosts/paths to their HTML entry points. | |
| 38 | 41 | var pageRootFiles = map[string]string{ | |
| 39 | - | homePath: indexHTML, | |
| 40 | - | configPath: "config.html", | |
| 41 | - | webrtcPath: "webrtc.html", | |
| 42 | - | errorPath: "error.html", | |
| 42 | + | homePath: indexHTML, | |
| 43 | + | historyPath: systemviewsIndexHTML, | |
| 44 | + | favoritesPath: systemviewsIndexHTML, | |
| 45 | + | configPath: systemviewsIndexHTML, | |
| 46 | + | webrtcPath: "webrtc.html", | |
| 47 | + | errorPath: "error.html", | |
| 43 | 48 | } | |
| 44 | 49 | ||
| 45 | 50 | // dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual | |
| @@ -27,19 +27,24 @@ import ( | |||||
| 27 | // Scheme path constants matching WebKit's naming. | 27 | // Scheme path constants matching WebKit's naming. | ||
| 28 | const ( | 28 | const ( | ||
| 29 | homePath = "home" | 29 | homePath = "home" | ||
| 30 | + | historyPath = "history" | |||
| 31 | + | favoritesPath = "favorites" | |||
| 30 | configPath = "config" | 32 | configPath = "config" | ||
| 31 | webrtcPath = "webrtc" | 33 | webrtcPath = "webrtc" | ||
| 32 | errorPath = "error" | 34 | errorPath = "error" | ||
| 33 | indexHTML = "index.html" | 35 | indexHTML = "index.html" | ||
| 36 | + | systemviewsIndexHTML = "systemviews/index.html" | |||
| 34 | maxSchemeTruncatedURLLength = 240 | 37 | maxSchemeTruncatedURLLength = 240 | ||
| 35 | ) | 38 | ) | ||
| 36 | 39 | ||||
| 37 | // pageRootFiles maps internal page hosts/paths to their HTML entry points. | 40 | // pageRootFiles maps internal page hosts/paths to their HTML entry points. | ||
| 38 | var pageRootFiles = map[string]string{ | 41 | var pageRootFiles = map[string]string{ | ||
| 39 | - | homePath: indexHTML, | 42 | + | homePath: indexHTML, |
| 40 | - | configPath: "config.html", | 43 | + | historyPath: systemviewsIndexHTML, |
| 41 | - | webrtcPath: "webrtc.html", | 44 | + | favoritesPath: systemviewsIndexHTML, |
| 42 | - | errorPath: "error.html", | 45 | + | configPath: systemviewsIndexHTML, |
| 46 | + | webrtcPath: "webrtc.html", | |||
| 47 | + | errorPath: "error.html", | |||
| 43 | } | 48 | } | ||
| 44 | 49 | ||||
| 45 | // dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual | 50 | // dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual | ||
M internal/infrastructure/webkit/scheme_handler.go +35 -24
| @@ -21,13 +21,16 @@ import ( | |||
| 21 | 21 | ||
| 22 | 22 | // Scheme path constants | |
| 23 | 23 | const ( | |
| 24 | - | HomePath = "home" | |
| 25 | - | ConfigPath = "config" | |
| 26 | - | WebRTCPath = "webrtc" | |
| 27 | - | ErrorPath = "error" | |
| 28 | - | CrashPath = "crash" | |
| 29 | - | IndexHTML = "index.html" | |
| 30 | - | httpGET = "GET" | |
| 24 | + | HomePath = "home" | |
| 25 | + | HistoryPath = "history" | |
| 26 | + | FavoritesPath = "favorites" | |
| 27 | + | ConfigPath = "config" | |
| 28 | + | WebRTCPath = "webrtc" | |
| 29 | + | ErrorPath = "error" | |
| 30 | + | CrashPath = "crash" | |
| 31 | + | IndexHTML = "index.html" | |
| 32 | + | SystemViewsIndexHTML = "systemviews/index.html" | |
| 33 | + | httpGET = "GET" | |
| 31 | 34 | ) | |
| 32 | 35 | ||
| 33 | 36 | // SchemeRequest represents a request to a custom URI scheme. | |
| @@ -62,7 +65,7 @@ func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse { | |||
| 62 | 65 | type DumbSchemeHandler struct { | |
| 63 | 66 | handlers map[string]PageHandler | |
| 64 | 67 | assets embed.FS | |
| 65 | - | assetDir string // subdirectory within embed.FS (e.g., "assets/webui") | |
| 68 | + | assetDir string // default subdirectory within embed.FS (e.g., "webui") | |
| 66 | 69 | logger zerolog.Logger | |
| 67 | 70 | mu sync.RWMutex | |
| 68 | 71 | currentConfigPayload func() ([]byte, error) | |
| @@ -292,10 +295,13 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |||
| 292 | 295 | return nil | |
| 293 | 296 | } | |
| 294 | 297 | ||
| 295 | - | relPath, ok := resolveAssetPath(u) | |
| 298 | + | assetDir, relPath, ok := resolveAssetPath(u) | |
| 296 | 299 | if !ok { | |
| 297 | 300 | return nil | |
| 298 | 301 | } | |
| 302 | + | if assetDir == "" { | |
| 303 | + | assetDir = h.assetDir | |
| 304 | + | } | |
| 299 | 305 | ||
| 300 | 306 | // Read the asset from embedded FS | |
| 301 | 307 | fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath)) | |
| @@ -319,37 +325,42 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |||
| 319 | 325 | } | |
| 320 | 326 | } | |
| 321 | 327 | ||
| 322 | - | func resolveAssetPath(u *url.URL) (string, bool) { | |
| 328 | + | func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { | |
| 323 | 329 | if u == nil { | |
| 324 | - | return "", false | |
| 330 | + | return "", "", false | |
| 325 | 331 | } | |
| 326 | 332 | ||
| 327 | - | rootByHost := map[string]string{ | |
| 328 | - | HomePath: IndexHTML, | |
| 329 | - | ConfigPath: "config.html", | |
| 330 | - | WebRTCPath: "webrtc.html", | |
| 331 | - | ErrorPath: "error.html", | |
| 333 | + | rootByHost := map[string]struct { | |
| 334 | + | assetDir string | |
| 335 | + | file string | |
| 336 | + | }{ | |
| 337 | + | HomePath: {assetDir: "webui", file: IndexHTML}, | |
| 338 | + | HistoryPath: {assetDir: "systemviews", file: IndexHTML}, | |
| 339 | + | FavoritesPath: {assetDir: "systemviews", file: IndexHTML}, | |
| 340 | + | ConfigPath: {assetDir: "systemviews", file: IndexHTML}, | |
| 341 | + | WebRTCPath: {assetDir: "webui", file: "webrtc.html"}, | |
| 342 | + | ErrorPath: {assetDir: "webui", file: "error.html"}, | |
| 332 | 343 | } | |
| 333 | 344 | ||
| 334 | 345 | if root, ok := rootByHost[u.Host]; ok { | |
| 335 | 346 | path := strings.TrimPrefix(u.Path, "/") | |
| 336 | 347 | if path == "" { | |
| 337 | - | return root, true | |
| 348 | + | return root.assetDir, root.file, true | |
| 338 | 349 | } | |
| 339 | - | return path, true | |
| 350 | + | return root.assetDir, path, true | |
| 340 | 351 | } | |
| 341 | 352 | ||
| 342 | 353 | switch u.Opaque { | |
| 343 | 354 | case HomePath: | |
| 344 | - | return IndexHTML, true | |
| 345 | - | case ConfigPath: | |
| 346 | - | return "config.html", true | |
| 355 | + | return "webui", IndexHTML, true | |
| 356 | + | case HistoryPath, FavoritesPath, ConfigPath: | |
| 357 | + | return "systemviews", IndexHTML, true | |
| 347 | 358 | case ErrorPath: | |
| 348 | - | return "error.html", true | |
| 359 | + | return "webui", "error.html", true | |
| 349 | 360 | case WebRTCPath: | |
| 350 | - | return "webrtc.html", true | |
| 361 | + | return "webui", "webrtc.html", true | |
| 351 | 362 | default: | |
| 352 | - | return "", false | |
| 363 | + | return "", "", false | |
| 353 | 364 | } | |
| 354 | 365 | } | |
| 355 | 366 | ||
| @@ -21,13 +21,16 @@ import ( | |||||
| 21 | 21 | ||||
| 22 | // Scheme path constants | 22 | // Scheme path constants | ||
| 23 | const ( | 23 | const ( | ||
| 24 | - | HomePath = "home" | 24 | + | HomePath = "home" |
| 25 | - | ConfigPath = "config" | 25 | + | HistoryPath = "history" |
| 26 | - | WebRTCPath = "webrtc" | 26 | + | FavoritesPath = "favorites" |
| 27 | - | ErrorPath = "error" | 27 | + | ConfigPath = "config" |
| 28 | - | CrashPath = "crash" | 28 | + | WebRTCPath = "webrtc" |
| 29 | - | IndexHTML = "index.html" | 29 | + | ErrorPath = "error" |
| 30 | - | httpGET = "GET" | 30 | + | CrashPath = "crash" |
| 31 | + | IndexHTML = "index.html" | |||
| 32 | + | SystemViewsIndexHTML = "systemviews/index.html" | |||
| 33 | + | httpGET = "GET" | |||
| 31 | ) | 34 | ) | ||
| 32 | 35 | ||||
| 33 | // SchemeRequest represents a request to a custom URI scheme. | 36 | // SchemeRequest represents a request to a custom URI scheme. | ||
| @@ -62,7 +65,7 @@ func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse { | |||||
| 62 | type DumbSchemeHandler struct { | 65 | type DumbSchemeHandler struct { | ||
| 63 | handlers map[string]PageHandler | 66 | handlers map[string]PageHandler | ||
| 64 | assets embed.FS | 67 | assets embed.FS | ||
| 65 | - | assetDir string // subdirectory within embed.FS (e.g., "assets/webui") | 68 | + | assetDir string // default subdirectory within embed.FS (e.g., "webui") |
| 66 | logger zerolog.Logger | 69 | logger zerolog.Logger | ||
| 67 | mu sync.RWMutex | 70 | mu sync.RWMutex | ||
| 68 | currentConfigPayload func() ([]byte, error) | 71 | currentConfigPayload func() ([]byte, error) | ||
| @@ -292,10 +295,13 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |||||
| 292 | return nil | 295 | return nil | ||
| 293 | } | 296 | } | ||
| 294 | 297 | ||||
| 295 | - | relPath, ok := resolveAssetPath(u) | 298 | + | assetDir, relPath, ok := resolveAssetPath(u) |
| 296 | if !ok { | 299 | if !ok { | ||
| 297 | return nil | 300 | return nil | ||
| 298 | } | 301 | } | ||
| 302 | + | if assetDir == "" { | |||
| 303 | + | assetDir = h.assetDir | |||
| 304 | + | } | |||
| 299 | 305 | ||||
| 300 | // Read the asset from embedded FS | 306 | // Read the asset from embedded FS | ||
| 301 | fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath)) | 307 | fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath)) | ||
| @@ -319,37 +325,42 @@ func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse { | |||||
| 319 | } | 325 | } | ||
| 320 | } | 326 | } | ||
| 321 | 327 | ||||
| 322 | - | func resolveAssetPath(u *url.URL) (string, bool) { | 328 | + | func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) { |
| 323 | if u == nil { | 329 | if u == nil { | ||
| 324 | - | return "", false | 330 | + | return "", "", false |
| 325 | } | 331 | } | ||
| 326 | 332 | ||||
| 327 | - | rootByHost := map[string]string{ | 333 | + | rootByHost := map[string]struct { |
| 328 | - | HomePath: IndexHTML, | 334 | + | assetDir string |
| 329 | - | ConfigPath: "config.html", | 335 | + | file string |
| 330 | - | WebRTCPath: "webrtc.html", | 336 | + | }{ |
| 331 | - | ErrorPath: "error.html", | 337 | + | HomePath: {assetDir: "webui", file: IndexHTML}, |
| 338 | + | HistoryPath: {assetDir: "systemviews", file: IndexHTML}, | |||
| 339 | + | FavoritesPath: {assetDir: "systemviews", file: IndexHTML}, | |||
| 340 | + | ConfigPath: {assetDir: "systemviews", file: IndexHTML}, | |||
| 341 | + | WebRTCPath: {assetDir: "webui", file: "webrtc.html"}, | |||
| 342 | + | ErrorPath: {assetDir: "webui", file: "error.html"}, | |||
| 332 | } | 343 | } | ||
| 333 | 344 | ||||
| 334 | if root, ok := rootByHost[u.Host]; ok { | 345 | if root, ok := rootByHost[u.Host]; ok { | ||
| 335 | path := strings.TrimPrefix(u.Path, "/") | 346 | path := strings.TrimPrefix(u.Path, "/") | ||
| 336 | if path == "" { | 347 | if path == "" { | ||
| 337 | - | return root, true | 348 | + | return root.assetDir, root.file, true |
| 338 | } | 349 | } | ||
| 339 | - | return path, true | 350 | + | return root.assetDir, path, true |
| 340 | } | 351 | } | ||
| 341 | 352 | ||||
| 342 | switch u.Opaque { | 353 | switch u.Opaque { | ||
| 343 | case HomePath: | 354 | case HomePath: | ||
| 344 | - | return IndexHTML, true | 355 | + | return "webui", IndexHTML, true |
| 345 | - | case ConfigPath: | 356 | + | case HistoryPath, FavoritesPath, ConfigPath: |
| 346 | - | return "config.html", true | 357 | + | return "systemviews", IndexHTML, true |
| 347 | case ErrorPath: | 358 | case ErrorPath: | ||
| 348 | - | return "error.html", true | 359 | + | return "webui", "error.html", true |
| 349 | case WebRTCPath: | 360 | case WebRTCPath: | ||
| 350 | - | return "webrtc.html", true | 361 | + | return "webui", "webrtc.html", true |
| 351 | default: | 362 | default: | ||
| 352 | - | return "", false | 363 | + | return "", "", false |
| 353 | } | 364 | } | ||
| 354 | } | 365 | } | ||
| 355 | 366 | ||||
M internal/infrastructure/webkit/scheme_handler_test.go +28 -2
| @@ -117,7 +117,7 @@ func TestHandleAsset_WebRTCOpaqueFormServesInternalWebRTCTester(t *testing.T) { | |||
| 117 | 117 | assert.Contains(t, string(resp.Data), "webrtc.min.js") | |
| 118 | 118 | } | |
| 119 | 119 | ||
| 120 | - | func TestHandleAsset_ConfigOpaqueFormServesConfigPage(t *testing.T) { | |
| 120 | + | func TestHandleAsset_ConfigOpaqueFormServesSystemviewsShell(t *testing.T) { | |
| 121 | 121 | h := NewDumbSchemeHandler(context.Background()) | |
| 122 | 122 | h.SetAssets(assets.WebUIAssets) | |
| 123 | 123 | ||
| @@ -127,7 +127,33 @@ func TestHandleAsset_ConfigOpaqueFormServesConfigPage(t *testing.T) { | |||
| 127 | 127 | resp := h.handleAsset(u) | |
| 128 | 128 | require.NotNil(t, resp) | |
| 129 | 129 | assert.Equal(t, "text/html; charset=utf-8", resp.ContentType) | |
| 130 | - | assert.Contains(t, string(resp.Data), "config") | |
| 130 | + | assert.Contains(t, string(resp.Data), "systemviews.wasm") | |
| 131 | + | } | |
| 132 | + | ||
| 133 | + | func TestHandleAsset_SystemviewsRootsServeShell(t *testing.T) { | |
| 134 | + | h := NewDumbSchemeHandler(context.Background()) | |
| 135 | + | h.SetAssets(assets.WebUIAssets) | |
| 136 | + | ||
| 137 | + | tests := []string{ | |
| 138 | + | "dumb://history", | |
| 139 | + | "dumb:history", | |
| 140 | + | "dumb://favorites", | |
| 141 | + | "dumb:favorites", | |
| 142 | + | "dumb://config", | |
| 143 | + | "dumb:config", | |
| 144 | + | } | |
| 145 | + | ||
| 146 | + | for _, raw := range tests { | |
| 147 | + | t.Run(raw, func(t *testing.T) { | |
| 148 | + | u, err := url.Parse(raw) | |
| 149 | + | require.NoError(t, err) | |
| 150 | + | ||
| 151 | + | resp := h.handleAsset(u) | |
| 152 | + | require.NotNil(t, resp) | |
| 153 | + | assert.Equal(t, "text/html; charset=utf-8", resp.ContentType) | |
| 154 | + | assert.Contains(t, string(resp.Data), "systemviews.wasm") | |
| 155 | + | }) | |
| 156 | + | } | |
| 131 | 157 | } | |
| 132 | 158 | ||
| 133 | 159 | func TestConfigHandlersUseInjectedPayloadBuilders(t *testing.T) { | |
| @@ -117,7 +117,7 @@ func TestHandleAsset_WebRTCOpaqueFormServesInternalWebRTCTester(t *testing.T) { | |||||
| 117 | assert.Contains(t, string(resp.Data), "webrtc.min.js") | 117 | assert.Contains(t, string(resp.Data), "webrtc.min.js") | ||
| 118 | } | 118 | } | ||
| 119 | 119 | ||||
| 120 | - | func TestHandleAsset_ConfigOpaqueFormServesConfigPage(t *testing.T) { | 120 | + | func TestHandleAsset_ConfigOpaqueFormServesSystemviewsShell(t *testing.T) { |
| 121 | h := NewDumbSchemeHandler(context.Background()) | 121 | h := NewDumbSchemeHandler(context.Background()) | ||
| 122 | h.SetAssets(assets.WebUIAssets) | 122 | h.SetAssets(assets.WebUIAssets) | ||
| 123 | 123 | ||||
| @@ -127,7 +127,33 @@ func TestHandleAsset_ConfigOpaqueFormServesConfigPage(t *testing.T) { | |||||
| 127 | resp := h.handleAsset(u) | 127 | resp := h.handleAsset(u) | ||
| 128 | require.NotNil(t, resp) | 128 | require.NotNil(t, resp) | ||
| 129 | assert.Equal(t, "text/html; charset=utf-8", resp.ContentType) | 129 | assert.Equal(t, "text/html; charset=utf-8", resp.ContentType) | ||
| 130 | - | assert.Contains(t, string(resp.Data), "config") | 130 | + | assert.Contains(t, string(resp.Data), "systemviews.wasm") |
| 131 | + | } | |||
| 132 | + | ||||
| 133 | + | func TestHandleAsset_SystemviewsRootsServeShell(t *testing.T) { | |||
| 134 | + | h := NewDumbSchemeHandler(context.Background()) | |||
| 135 | + | h.SetAssets(assets.WebUIAssets) | |||
| 136 | + | ||||
| 137 | + | tests := []string{ | |||
| 138 | + | "dumb://history", | |||
| 139 | + | "dumb:history", | |||
| 140 | + | "dumb://favorites", | |||
| 141 | + | "dumb:favorites", | |||
| 142 | + | "dumb://config", | |||
| 143 | + | "dumb:config", | |||
| 144 | + | } | |||
| 145 | + | ||||
| 146 | + | for _, raw := range tests { | |||
| 147 | + | t.Run(raw, func(t *testing.T) { | |||
| 148 | + | u, err := url.Parse(raw) | |||
| 149 | + | require.NoError(t, err) | |||
| 150 | + | ||||
| 151 | + | resp := h.handleAsset(u) | |||
| 152 | + | require.NotNil(t, resp) | |||
| 153 | + | assert.Equal(t, "text/html; charset=utf-8", resp.ContentType) | |||
| 154 | + | assert.Contains(t, string(resp.Data), "systemviews.wasm") | |||
| 155 | + | }) | |||
| 156 | + | } | |||
| 131 | } | 157 | } | ||
| 132 | 158 | ||||
| 133 | func TestConfigHandlersUseInjectedPayloadBuilders(t *testing.T) { | 159 | func TestConfigHandlersUseInjectedPayloadBuilders(t *testing.T) { | ||
A internal/ui/systemviews/app.go +37 -0
| @@ -0,0 +1,37 @@ | |||
| 1 | + | package systemviews | |
| 2 | + | ||
| 3 | + | import "errors" | |
| 4 | + | ||
| 5 | + | type Dependencies struct { | |
| 6 | + | DOM DOM | |
| 7 | + | LocationURI string | |
| 8 | + | } | |
| 9 | + | ||
| 10 | + | type App struct { | |
| 11 | + | deps Dependencies | |
| 12 | + | currentRoute Route | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | func NewApp(deps Dependencies) *App { | |
| 16 | + | return &App{deps: deps} | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | func (a *App) Run() error { | |
| 20 | + | if a == nil { | |
| 21 | + | return errors.New("app is nil") | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | a.currentRoute = ParseRoute(a.deps.LocationURI) | |
| 25 | + | if a.deps.DOM == nil { | |
| 26 | + | return errors.New("DOM not configured") | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | return a.deps.DOM.Mount(placeholderHTML(a.currentRoute)) | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | func (a *App) CurrentRoute() Route { | |
| 33 | + | if a == nil { | |
| 34 | + | return RouteUnknown | |
| 35 | + | } | |
| 36 | + | return a.currentRoute | |
| 37 | + | } | |
| @@ -0,0 +1,37 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import "errors" | |||
| 4 | + | ||||
| 5 | + | type Dependencies struct { | |||
| 6 | + | DOM DOM | |||
| 7 | + | LocationURI string | |||
| 8 | + | } | |||
| 9 | + | ||||
| 10 | + | type App struct { | |||
| 11 | + | deps Dependencies | |||
| 12 | + | currentRoute Route | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | func NewApp(deps Dependencies) *App { | |||
| 16 | + | return &App{deps: deps} | |||
| 17 | + | } | |||
| 18 | + | ||||
| 19 | + | func (a *App) Run() error { | |||
| 20 | + | if a == nil { | |||
| 21 | + | return errors.New("app is nil") | |||
| 22 | + | } | |||
| 23 | + | ||||
| 24 | + | a.currentRoute = ParseRoute(a.deps.LocationURI) | |||
| 25 | + | if a.deps.DOM == nil { | |||
| 26 | + | return errors.New("DOM not configured") | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | return a.deps.DOM.Mount(placeholderHTML(a.currentRoute)) | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | func (a *App) CurrentRoute() Route { | |||
| 33 | + | if a == nil { | |||
| 34 | + | return RouteUnknown | |||
| 35 | + | } | |||
| 36 | + | return a.currentRoute | |||
| 37 | + | } | |||
A internal/ui/systemviews/app_test.go +61 -0
| @@ -0,0 +1,61 @@ | |||
| 1 | + | package systemviews | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestParseRoute(t *testing.T) { | |
| 11 | + | t.Parallel() | |
| 12 | + | ||
| 13 | + | tests := []struct { | |
| 14 | + | name string | |
| 15 | + | uri string | |
| 16 | + | want Route | |
| 17 | + | }{ | |
| 18 | + | {name: "history host", uri: "dumb://history", want: RouteHistory}, | |
| 19 | + | {name: "history opaque", uri: "dumb:history", want: RouteHistory}, | |
| 20 | + | {name: "favorites host", uri: "dumb://favorites", want: RouteFavorites}, | |
| 21 | + | {name: "favorites opaque", uri: "dumb:favorites", want: RouteFavorites}, | |
| 22 | + | {name: "config host", uri: "dumb://config", want: RouteConfig}, | |
| 23 | + | {name: "config opaque", uri: "dumb:config", want: RouteConfig}, | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | for _, tt := range tests { | |
| 27 | + | t.Run(tt.name, func(t *testing.T) { | |
| 28 | + | t.Parallel() | |
| 29 | + | if got := ParseRoute(tt.uri); got != tt.want { | |
| 30 | + | t.Fatalf("ParseRoute(%q) = %v, want %v", tt.uri, got, tt.want) | |
| 31 | + | } | |
| 32 | + | }) | |
| 33 | + | } | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | func TestAppRunMountsPlaceholderAndRecordsRoute(t *testing.T) { | |
| 37 | + | t.Parallel() | |
| 38 | + | ||
| 39 | + | dom := &fakeDOM{} | |
| 40 | + | app := NewApp(Dependencies{ | |
| 41 | + | DOM: dom, | |
| 42 | + | LocationURI: "dumb://history", | |
| 43 | + | }) | |
| 44 | + | ||
| 45 | + | require.NoError(t, app.Run()) | |
| 46 | + | assert.Equal(t, RouteHistory, app.CurrentRoute()) | |
| 47 | + | assert.True(t, dom.mounted) | |
| 48 | + | assert.Contains(t, dom.html, "history") | |
| 49 | + | assert.Contains(t, dom.html, "systemviews") | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | type fakeDOM struct { | |
| 53 | + | mounted bool | |
| 54 | + | html string | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func (d *fakeDOM) Mount(html string) error { | |
| 58 | + | d.mounted = true | |
| 59 | + | d.html = html | |
| 60 | + | return nil | |
| 61 | + | } | |
| @@ -0,0 +1,61 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestParseRoute(t *testing.T) { | |||
| 11 | + | t.Parallel() | |||
| 12 | + | ||||
| 13 | + | tests := []struct { | |||
| 14 | + | name string | |||
| 15 | + | uri string | |||
| 16 | + | want Route | |||
| 17 | + | }{ | |||
| 18 | + | {name: "history host", uri: "dumb://history", want: RouteHistory}, | |||
| 19 | + | {name: "history opaque", uri: "dumb:history", want: RouteHistory}, | |||
| 20 | + | {name: "favorites host", uri: "dumb://favorites", want: RouteFavorites}, | |||
| 21 | + | {name: "favorites opaque", uri: "dumb:favorites", want: RouteFavorites}, | |||
| 22 | + | {name: "config host", uri: "dumb://config", want: RouteConfig}, | |||
| 23 | + | {name: "config opaque", uri: "dumb:config", want: RouteConfig}, | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | for _, tt := range tests { | |||
| 27 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 28 | + | t.Parallel() | |||
| 29 | + | if got := ParseRoute(tt.uri); got != tt.want { | |||
| 30 | + | t.Fatalf("ParseRoute(%q) = %v, want %v", tt.uri, got, tt.want) | |||
| 31 | + | } | |||
| 32 | + | }) | |||
| 33 | + | } | |||
| 34 | + | } | |||
| 35 | + | ||||
| 36 | + | func TestAppRunMountsPlaceholderAndRecordsRoute(t *testing.T) { | |||
| 37 | + | t.Parallel() | |||
| 38 | + | ||||
| 39 | + | dom := &fakeDOM{} | |||
| 40 | + | app := NewApp(Dependencies{ | |||
| 41 | + | DOM: dom, | |||
| 42 | + | LocationURI: "dumb://history", | |||
| 43 | + | }) | |||
| 44 | + | ||||
| 45 | + | require.NoError(t, app.Run()) | |||
| 46 | + | assert.Equal(t, RouteHistory, app.CurrentRoute()) | |||
| 47 | + | assert.True(t, dom.mounted) | |||
| 48 | + | assert.Contains(t, dom.html, "history") | |||
| 49 | + | assert.Contains(t, dom.html, "systemviews") | |||
| 50 | + | } | |||
| 51 | + | ||||
| 52 | + | type fakeDOM struct { | |||
| 53 | + | mounted bool | |||
| 54 | + | html string | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func (d *fakeDOM) Mount(html string) error { | |||
| 58 | + | d.mounted = true | |||
| 59 | + | d.html = html | |||
| 60 | + | return nil | |||
| 61 | + | } | |||
A internal/ui/systemviews/dom.go +29 -0
| @@ -0,0 +1,29 @@ | |||
| 1 | + | package systemviews | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "html" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | type DOM interface { | |
| 9 | + | Mount(html string) error | |
| 10 | + | } | |
| 11 | + | ||
| 12 | + | func placeholderHTML(route Route) string { | |
| 13 | + | name := html.EscapeString(string(route)) | |
| 14 | + | if name == "" || route == RouteUnknown { | |
| 15 | + | name = "unknown" | |
| 16 | + | } | |
| 17 | + | ||
| 18 | + | return fmt.Sprintf(`<!doctype html> | |
| 19 | + | <html lang="en"> | |
| 20 | + | <head> | |
| 21 | + | <meta charset="utf-8"> | |
| 22 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |
| 23 | + | <title>systemviews</title> | |
| 24 | + | </head> | |
| 25 | + | <body> | |
| 26 | + | <main id="app" data-route="%s">systemviews %s</main> | |
| 27 | + | </body> | |
| 28 | + | </html>`, name, name) | |
| 29 | + | } | |
| @@ -0,0 +1,29 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "fmt" | |||
| 5 | + | "html" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | type DOM interface { | |||
| 9 | + | Mount(html string) error | |||
| 10 | + | } | |||
| 11 | + | ||||
| 12 | + | func placeholderHTML(route Route) string { | |||
| 13 | + | name := html.EscapeString(string(route)) | |||
| 14 | + | if name == "" || route == RouteUnknown { | |||
| 15 | + | name = "unknown" | |||
| 16 | + | } | |||
| 17 | + | ||||
| 18 | + | return fmt.Sprintf(`<!doctype html> | |||
| 19 | + | <html lang="en"> | |||
| 20 | + | <head> | |||
| 21 | + | <meta charset="utf-8"> | |||
| 22 | + | <meta name="viewport" content="width=device-width, initial-scale=1"> | |||
| 23 | + | <title>systemviews</title> | |||
| 24 | + | </head> | |||
| 25 | + | <body> | |||
| 26 | + | <main id="app" data-route="%s">systemviews %s</main> | |||
| 27 | + | </body> | |||
| 28 | + | </html>`, name, name) | |||
| 29 | + | } | |||
A internal/ui/systemviews/dom_js.go +35 -0
| @@ -0,0 +1,35 @@ | |||
| 1 | + | //go:build js && wasm | |
| 2 | + | ||
| 3 | + | package systemviews | |
| 4 | + | ||
| 5 | + | import ( | |
| 6 | + | "fmt" | |
| 7 | + | "syscall/js" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | type browserDOM struct { | |
| 11 | + | document js.Value | |
| 12 | + | target js.Value | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | func NewDOM() DOM { | |
| 16 | + | doc := js.Global().Get("document") | |
| 17 | + | if !doc.Truthy() { | |
| 18 | + | return &browserDOM{} | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | target := doc.Call("getElementById", "app") | |
| 22 | + | if !target.Truthy() { | |
| 23 | + | target = doc.Get("body") | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | return &browserDOM{document: doc, target: target} | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func (d *browserDOM) Mount(html string) error { | |
| 30 | + | if d == nil || !d.target.Truthy() { | |
| 31 | + | return fmt.Errorf("DOM target not available") | |
| 32 | + | } | |
| 33 | + | d.target.Set("innerHTML", html) | |
| 34 | + | return nil | |
| 35 | + | } | |
| @@ -0,0 +1,35 @@ | |||||
| 1 | + | //go:build js && wasm | |||
| 2 | + | ||||
| 3 | + | package systemviews | |||
| 4 | + | ||||
| 5 | + | import ( | |||
| 6 | + | "fmt" | |||
| 7 | + | "syscall/js" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | type browserDOM struct { | |||
| 11 | + | document js.Value | |||
| 12 | + | target js.Value | |||
| 13 | + | } | |||
| 14 | + | ||||
| 15 | + | func NewDOM() DOM { | |||
| 16 | + | doc := js.Global().Get("document") | |||
| 17 | + | if !doc.Truthy() { | |||
| 18 | + | return &browserDOM{} | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | target := doc.Call("getElementById", "app") | |||
| 22 | + | if !target.Truthy() { | |||
| 23 | + | target = doc.Get("body") | |||
| 24 | + | } | |||
| 25 | + | ||||
| 26 | + | return &browserDOM{document: doc, target: target} | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func (d *browserDOM) Mount(html string) error { | |||
| 30 | + | if d == nil || !d.target.Truthy() { | |||
| 31 | + | return fmt.Errorf("DOM target not available") | |||
| 32 | + | } | |||
| 33 | + | d.target.Set("innerHTML", html) | |||
| 34 | + | return nil | |||
| 35 | + | } | |||
A internal/ui/systemviews/router.go +60 -0
| @@ -0,0 +1,60 @@ | |||
| 1 | + | package systemviews | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "net/url" | |
| 5 | + | "strings" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | type Route string | |
| 9 | + | ||
| 10 | + | const ( | |
| 11 | + | RouteUnknown Route = "unknown" | |
| 12 | + | RouteHistory Route = "history" | |
| 13 | + | RouteFavorites Route = "favorites" | |
| 14 | + | RouteConfig Route = "config" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | func ParseRoute(uri string) Route { | |
| 18 | + | if uri == "" { | |
| 19 | + | return RouteUnknown | |
| 20 | + | } | |
| 21 | + | ||
| 22 | + | u, err := url.Parse(uri) | |
| 23 | + | if err != nil { | |
| 24 | + | return RouteUnknown | |
| 25 | + | } | |
| 26 | + | ||
| 27 | + | switch { | |
| 28 | + | case strings.EqualFold(u.Scheme, "dumb"): | |
| 29 | + | return routeFromPageHost(u.Host, u.Opaque) | |
| 30 | + | case strings.EqualFold(u.Scheme, "https") && strings.EqualFold(u.Host, "dumber.invalid"): | |
| 31 | + | path := strings.Trim(u.Path, "/") | |
| 32 | + | if path == "" { | |
| 33 | + | return RouteUnknown | |
| 34 | + | } | |
| 35 | + | if head, _, ok := strings.Cut(path, "/"); ok { | |
| 36 | + | path = head | |
| 37 | + | } | |
| 38 | + | return routeFromPageHost(path, "") | |
| 39 | + | default: | |
| 40 | + | return RouteUnknown | |
| 41 | + | } | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | func routeFromPageHost(host, opaque string) Route { | |
| 45 | + | page := strings.TrimSpace(host) | |
| 46 | + | if page == "" { | |
| 47 | + | page = strings.TrimSpace(opaque) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | switch strings.ToLower(page) { | |
| 51 | + | case string(RouteHistory): | |
| 52 | + | return RouteHistory | |
| 53 | + | case string(RouteFavorites): | |
| 54 | + | return RouteFavorites | |
| 55 | + | case string(RouteConfig): | |
| 56 | + | return RouteConfig | |
| 57 | + | default: | |
| 58 | + | return RouteUnknown | |
| 59 | + | } | |
| 60 | + | } | |
| @@ -0,0 +1,60 @@ | |||||
| 1 | + | package systemviews | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "net/url" | |||
| 5 | + | "strings" | |||
| 6 | + | ) | |||
| 7 | + | ||||
| 8 | + | type Route string | |||
| 9 | + | ||||
| 10 | + | const ( | |||
| 11 | + | RouteUnknown Route = "unknown" | |||
| 12 | + | RouteHistory Route = "history" | |||
| 13 | + | RouteFavorites Route = "favorites" | |||
| 14 | + | RouteConfig Route = "config" | |||
| 15 | + | ) | |||
| 16 | + | ||||
| 17 | + | func ParseRoute(uri string) Route { | |||
| 18 | + | if uri == "" { | |||
| 19 | + | return RouteUnknown | |||
| 20 | + | } | |||
| 21 | + | ||||
| 22 | + | u, err := url.Parse(uri) | |||
| 23 | + | if err != nil { | |||
| 24 | + | return RouteUnknown | |||
| 25 | + | } | |||
| 26 | + | ||||
| 27 | + | switch { | |||
| 28 | + | case strings.EqualFold(u.Scheme, "dumb"): | |||
| 29 | + | return routeFromPageHost(u.Host, u.Opaque) | |||
| 30 | + | case strings.EqualFold(u.Scheme, "https") && strings.EqualFold(u.Host, "dumber.invalid"): | |||
| 31 | + | path := strings.Trim(u.Path, "/") | |||
| 32 | + | if path == "" { | |||
| 33 | + | return RouteUnknown | |||
| 34 | + | } | |||
| 35 | + | if head, _, ok := strings.Cut(path, "/"); ok { | |||
| 36 | + | path = head | |||
| 37 | + | } | |||
| 38 | + | return routeFromPageHost(path, "") | |||
| 39 | + | default: | |||
| 40 | + | return RouteUnknown | |||
| 41 | + | } | |||
| 42 | + | } | |||
| 43 | + | ||||
| 44 | + | func routeFromPageHost(host, opaque string) Route { | |||
| 45 | + | page := strings.TrimSpace(host) | |||
| 46 | + | if page == "" { | |||
| 47 | + | page = strings.TrimSpace(opaque) | |||
| 48 | + | } | |||
| 49 | + | ||||
| 50 | + | switch strings.ToLower(page) { | |||
| 51 | + | case string(RouteHistory): | |||
| 52 | + | return RouteHistory | |||
| 53 | + | case string(RouteFavorites): | |||
| 54 | + | return RouteFavorites | |||
| 55 | + | case string(RouteConfig): | |||
| 56 | + | return RouteConfig | |||
| 57 | + | default: | |||
| 58 | + | return RouteUnknown | |||
| 59 | + | } | |||
| 60 | + | } | |||