diff --git a/README.md b/README.md index 45aca34..ff77ecc 100644 --- a/README.md +++ b/README.md @@ -6,7 +6,7 @@ HLCRF DOM compositor with grammar pipeline integration for server-side HTML generation and optional WASM client rendering. Provides a type-safe node tree (El, Text, Raw, If, Each, Switch, Entitled, AriaLabel, AltText, TabIndex, AutoFocus, Role), a five-slot Header/Left/Content/Right/Footer layout compositor with deterministic `data-block` path IDs and ARIA roles, a responsive multi-variant wrapper, a server-side grammar pipeline (StripTags, GrammarImprint via go-i18n reversal, CompareVariants), a build-time Web Component codegen CLI with optional TypeScript declarations, and a WASM module (2.90 MB raw, 842 KB gzip) exposing `renderToString()`. -**Module**: `dappco.re/go/core/html` +**Module**: `dappco.re/go/render` **Licence**: EUPL-1.2 **Language**: Go 1.26 @@ -46,6 +46,50 @@ out := page.RenderTerm(html.NewContext("en-GB"), html.TermOptions{Width: 120}) Try it: `cd go && go run ./cmd/termdemo/ -w 110` +## WebView host (`display/webkit`) + +`display/webkit` is the adaptation seam over wails3 — the surface a desktop app +uses to host a web frontend without importing wails directly. Three helpers cover +what a hosted single-page app actually needs: + +```go +assets, err := webkit.SPAHandler(webkit.SPAOptions{FS: dist}) // or DevServer: "http://localhost:9245" + +cfg := webkit.GuiConfig{ + Assets: webkit.AssetOptions{ + Handler: assets, + Middleware: webkit.CSPMiddleware( + webkit.CSPOptions{Transports: []string{"http://localhost:9099"}}, + webkit.WailsHTTPMiddleware(assets), + ), + }, + Bindings: []webkit.Binding{webkit.Bind(runnerSvc)}, +} +``` + +- **`SPAHandler`** — embedded build or dev-server proxy. Deep links serve the app + shell; a *missing* bundle 404s rather than receiving HTML (the cause of + `Unexpected token '<'` a page-load later); `/wails/*` is refused. +- **`CSPMiddleware`** — each transport origin contributes **both** its `http://` + and `ws://` form. Allowing only the first yields a policy that passes page load + and then silently kills the runtime's event channel. +- **`BindingNames` / `ScanCallByName` / `UnresolvedBindingNames`** — the drift + gate. wails resolves `Call.ByName` through an exact-match map on + `..`, so a Go struct rename invalidates every + hardcoded call string in the frontend with no build-time signal. These turn that + into a failing test. + +### Angular example + +[`go/display/webkit/example/angular`](go/display/webkit/example/angular) is a +minimal Angular application hosted by the seam, carrying both receiver shapes +(`.Service` and `.WailsService`) found in the wild. Its `seam_test.go` runs in +`go test ./...` with no `npm install` and no WebView — it resolves every +`Call.ByName` literal in the Angular sources against the bound Go services, and +asserts the CSP, asset routing and window-state wiring. See its +[README](go/display/webkit/example/angular/README.md) for what needs a real +WebView. + ## Documentation - [Architecture](docs/architecture.md) — node interface, HLCRF layout, responsive compositor, grammar pipeline, WASM module, codegen CLI diff --git a/go/display/webkit/assets.go b/go/display/webkit/assets.go new file mode 100644 index 0000000..cd3f096 --- /dev/null +++ b/go/display/webkit/assets.go @@ -0,0 +1,194 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package webkit + +import ( + "io" + "io/fs" + "net/http" + "net/http/httputil" + "net/url" + "path" + "strings" + + core "dappco.re/go" +) + +// DefaultIndex is the document an SPA handler falls back to for a +// route the build produced no file for. +const DefaultIndex = "index.html" + +// wailsPrefix is the URL space the wails runtime owns. An SPA handler +// must never answer inside it — see SPAOptions for why. +const wailsPrefix = "/wails" + +// SPAOptions configures SPAHandler. +// +// Exactly one source must be set: FS for a production build compiled +// into the binary, or DevServer for a live framework dev server. Both +// or neither is a configuration error, because silently preferring one +// is how a release binary ends up serving from a dev server that is not +// running. +type SPAOptions struct { + // FS is the built frontend, rooted at the directory holding + // index.html. For an Angular application builder that is + // dist//browser — NOT dist/. Pair with + // fs.Sub(embedded, "ui/dist/app/browser"). + FS fs.FS + + // DevServer is the origin of a running framework dev server, e.g. + // "http://localhost:9245" for an `ng serve`. When set, every request + // the handler owns is reverse-proxied there, so HMR, source maps and + // unbundled ES modules all work. Ignored when FS is set. + DevServer string + + // Index is the fallback document name. Empty means DefaultIndex. + Index string +} + +// SPAHandler serves a single-page application — an Angular build in the +// house case — from either an embedded filesystem or a live dev server, +// with the routing behaviour a hosted WebView actually needs. +// +// Three behaviours distinguish it from http.FileServer, and each one is +// a bug that has already been paid for: +// +// 1. A request for a path the build produced no file for falls back to +// index.html with HTTP 200, so a path-routed deep link survives a +// reload. Hash routing (#/settings) never sends the fragment to the +// server and so works either way — but an app that later drops the +// hash does not silently break. +// +// 2. A request that LOOKS like a build asset — anything with a file +// extension, /main-A1B2C3.js, /styles.css, /icon.svg — gets a plain +// 404 when it is missing, never the index fallback. Returning HTML +// for a missing chunk is what produces the notorious +// "Uncaught SyntaxError: Unexpected token '<'" a whole page-load +// later, with nothing pointing at the real cause. +// +// 3. Requests under /wails/* are refused outright. That space belongs +// to the runtime; serving index.html there would hand HTML to the +// script tag loading the runtime. Wire WailsHTTPMiddleware so the +// runtime sees those requests first — this refusal is the backstop +// that makes a missing middleware loud instead of baffling. +// +// assets, err := webkit.SPAHandler(webkit.SPAOptions{FS: dist}) +// cfg := webkit.GuiConfig{Assets: webkit.AssetOptions{ +// Handler: assets, +// Middleware: webkit.WailsHTTPMiddleware(assets), +// }} +// +// Returns an error when neither or both sources are configured, when +// DevServer is not a parseable absolute URL, or when FS carries no +// index document — all of which are start-up-time mistakes that would +// otherwise present as a blank window. +func SPAHandler(opts SPAOptions) (http.Handler, error) { + index := opts.Index + if index == "" { + index = DefaultIndex + } + + hasFS := opts.FS != nil + hasDev := strings.TrimSpace(opts.DevServer) != "" + switch { + case hasFS && hasDev: + return nil, core.E("webkit.SPAHandler", "both FS and DevServer set: pick the embedded build or the dev server, not both", nil) + case !hasFS && !hasDev: + return nil, core.E("webkit.SPAHandler", "neither FS nor DevServer set: nothing to serve", nil) + case hasDev: + return devServerHandler(opts.DevServer) + } + + if _, err := fs.Stat(opts.FS, index); err != nil { + return nil, core.E("webkit.SPAHandler", "no "+index+" in the asset filesystem: point FS at the directory containing index.html (Angular: dist//browser)", err) + } + return &spaFS{fsys: opts.FS, index: index, files: http.FileServer(http.FS(opts.FS))}, nil +} + +// spaFS serves an embedded build with SPA fallback semantics. +type spaFS struct { + fsys fs.FS + index string + files http.Handler +} + +func (h *spaFS) ServeHTTP(w http.ResponseWriter, r *http.Request) { + if isWailsPath(r.URL.Path) { + http.NotFound(w, r) + return + } + + name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/") + // The root and the index document are both answered directly. + // http.FileServer would 301 /index.html to ./ instead, which costs a + // round-trip on every boot of a WebView pointed at the index — and + // makes the "is the app loading?" trace harder to read than it needs + // to be. + if name == "" || name == h.index { + h.serveIndex(w, r) + return + } + + if info, err := fs.Stat(h.fsys, name); err == nil && !info.IsDir() { + h.files.ServeHTTP(w, r) + return + } + + // A missing asset is a 404, never the index document — see + // SPAHandler's contract. + if path.Ext(name) != "" { + http.NotFound(w, r) + return + } + h.serveIndex(w, r) +} + +// serveIndex writes the fallback document with HTTP 200. The index is +// never cached: it names the hashed bundles, so a stale copy pins the +// WebView to a build that no longer exists on disk. +func (h *spaFS) serveIndex(w http.ResponseWriter, r *http.Request) { + file, err := h.fsys.Open(h.index) + if err != nil { + http.Error(w, "index unavailable", http.StatusInternalServerError) + return + } + defer func() { _ = file.Close() }() + + w.Header().Set("Content-Type", "text/html; charset=utf-8") + w.Header().Set("Cache-Control", "no-cache, no-store, must-revalidate") + w.WriteHeader(http.StatusOK) + if r.Method == http.MethodHead { + return + } + _, _ = io.Copy(w, file) +} + +// devServerHandler reverse-proxies to a framework dev server so HMR, +// websockets and unbundled modules pass through untouched. +func devServerHandler(origin string) (http.Handler, error) { + target, err := url.Parse(strings.TrimSpace(origin)) + if err != nil { + return nil, core.E("webkit.SPAHandler", "DevServer is not a valid URL: "+origin, err) + } + if target.Scheme == "" || target.Host == "" { + return nil, core.E("webkit.SPAHandler", "DevServer needs a scheme and host, e.g. http://localhost:9245, got: "+origin, nil) + } + + proxy := httputil.NewSingleHostReverseProxy(target) + // The dev server's own fallback handles unknown routes, so no SPA + // rewriting happens here — proxying it verbatim is what keeps HMR + // and the vite client working. + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if isWailsPath(r.URL.Path) { + http.NotFound(w, r) + return + } + proxy.ServeHTTP(w, r) + }), nil +} + +// isWailsPath reports whether a URL path falls inside the runtime's +// reserved space. Matches /wails and /wails/... but not /wailsfoo. +func isWailsPath(urlPath string) bool { + return urlPath == wailsPrefix || strings.HasPrefix(urlPath, wailsPrefix+"/") +} diff --git a/go/display/webkit/assets_test.go b/go/display/webkit/assets_test.go new file mode 100644 index 0000000..056f9c3 --- /dev/null +++ b/go/display/webkit/assets_test.go @@ -0,0 +1,234 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package webkit + +import ( + "net/http" + "net/http/httptest" + "testing" + "testing/fstest" +) + +// angularDist mimics the shape `ng build` leaves in dist//browser: +// a hash-named entry bundle, a stylesheet, a static asset and the index +// that names them. +func angularDist() fstest.MapFS { + return fstest.MapFS{ + "index.html": &fstest.MapFile{Data: []byte(``)}, + "main-A1B2C3D4.js": &fstest.MapFile{Data: []byte(`console.log('bundle');`)}, + "styles-E5F6A7B8.css": &fstest.MapFile{Data: []byte(`:root{}`)}, + "assets/logo.svg": &fstest.MapFile{Data: []byte(``)}, + "favicon.ico": &fstest.MapFile{Data: []byte{0x00}}, + } +} + +// TestSPAHandler_Good covers the routing table an Angular build needs: +// real files served as themselves, the document root and every unknown +// route falling back to index.html so a deep link survives a reload. +func TestSPAHandler_Good(t *testing.T) { + handler, err := SPAHandler(SPAOptions{FS: angularDist()}) + if err != nil { + t.Fatalf("SPAHandler: %v", err) + } + + cases := []struct { + name string + path string + wantStatus int + wantBody string + }{ + {"root", "/", http.StatusOK, ``}, + {"entry_bundle", "/main-A1B2C3D4.js", http.StatusOK, `console.log('bundle');`}, + {"stylesheet", "/styles-E5F6A7B8.css", http.StatusOK, `:root{}`}, + {"nested_asset", "/assets/logo.svg", http.StatusOK, ``}, + {"deep_link", "/settings/profile", http.StatusOK, ``}, + {"hash_route_base", "/index.html", http.StatusOK, ``}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, tc.path, nil)) + if w.Code != tc.wantStatus { + t.Fatalf("status = %d, want %d", w.Code, tc.wantStatus) + } + if got := w.Body.String(); got != tc.wantBody { + t.Fatalf("body = %q, want %q", got, tc.wantBody) + } + }) + } +} + +// TestSPAHandler_Bad covers the configuration mistakes that would +// otherwise present as a blank window at run time rather than an error +// at start-up. +func TestSPAHandler_Bad(t *testing.T) { + cases := []struct { + name string + opts SPAOptions + }{ + {"no_source", SPAOptions{}}, + {"both_sources", SPAOptions{FS: angularDist(), DevServer: "http://localhost:9245"}}, + {"dev_server_not_a_url", SPAOptions{DevServer: "://nope"}}, + {"dev_server_no_scheme", SPAOptions{DevServer: "localhost:9245"}}, + {"fs_without_index", SPAOptions{FS: fstest.MapFS{"main.js": &fstest.MapFile{Data: []byte("x")}}}}, + {"fs_wrong_root", SPAOptions{FS: fstest.MapFS{"browser/index.html": &fstest.MapFile{Data: []byte("x")}}}}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + handler, err := SPAHandler(tc.opts) + if err == nil { + t.Fatal("expected a start-up error, got none") + } + if handler != nil { + t.Fatal("expected no handler alongside the error") + } + }) + } +} + +// TestSPAHandler_Ugly pins the two fallbacks that must NOT happen. Both +// return HTML where the caller expects something else, and both surface +// far from their cause: a missing chunk becomes "Unexpected token '<'" +// on the next page load, and an index served under /wails hands HTML to +// the script tag loading the runtime. +func TestSPAHandler_Ugly(t *testing.T) { + handler, err := SPAHandler(SPAOptions{FS: angularDist()}) + if err != nil { + t.Fatalf("SPAHandler: %v", err) + } + + cases := []struct { + name string + path string + }{ + {"missing_chunk", "/chunk-DEADBEEF.js"}, + {"missing_stylesheet", "/styles-00000000.css"}, + {"missing_asset", "/assets/absent.png"}, + {"wails_runtime", "/wails/runtime.js"}, + {"wails_websocket", "/wails/ws"}, + {"wails_root", "/wails"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, tc.path, nil)) + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 — never fall back to index.html here", w.Code) + } + if body := w.Body.String(); body == `` { + t.Fatal("served the index document; a script/style request must 404 instead") + } + }) + } + + // A path that merely looks like a directory is still a route. + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/wailsy/route", nil)) + if w.Code != http.StatusOK { + t.Fatalf("/wailsy/route = %d, want 200 — only /wails and /wails/* are reserved", w.Code) + } +} + +// TestSPAHandler_IndexIsNotCached asserts the fallback document carries +// no-store. The index names the hash-versioned bundles, so a cached copy +// pins the WebView to a build that no longer exists on disk — the +// "app is stale after upgrade until you wipe the WebView data" bug. +func TestSPAHandler_IndexIsNotCached(t *testing.T) { + handler, err := SPAHandler(SPAOptions{FS: angularDist()}) + if err != nil { + t.Fatalf("SPAHandler: %v", err) + } + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/settings", nil)) + + if got := w.Header().Get("Cache-Control"); got != "no-cache, no-store, must-revalidate" { + t.Fatalf("Cache-Control = %q, want no-store", got) + } + if got := w.Header().Get("Content-Type"); got != "text/html; charset=utf-8" { + t.Fatalf("Content-Type = %q, want text/html", got) + } +} + +// TestSPAHandler_DevServer_Good asserts the dev arm proxies verbatim — +// including the routes the embedded arm would rewrite, because the dev +// server owns its own fallback and rewriting would break HMR. +func TestSPAHandler_DevServer_Good(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("vite:" + r.URL.Path)) + })) + defer upstream.Close() + + handler, err := SPAHandler(SPAOptions{DevServer: upstream.URL}) + if err != nil { + t.Fatalf("SPAHandler: %v", err) + } + + for _, path := range []string{"/", "/settings/profile", "/@vite/client", "/main.ts"} { + t.Run(path, func(t *testing.T) { + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, path, nil)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + if want := "vite:" + path; w.Body.String() != want { + t.Fatalf("body = %q, want %q", w.Body.String(), want) + } + }) + } +} + +// TestSPAHandler_DevServer_Ugly asserts the dev arm keeps the same +// /wails refusal as the embedded arm, so the two modes cannot disagree +// about who owns the runtime's URL space. +func TestSPAHandler_DevServer_Ugly(t *testing.T) { + upstream := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("proxied")) + })) + defer upstream.Close() + + handler, err := SPAHandler(SPAOptions{DevServer: upstream.URL}) + if err != nil { + t.Fatalf("SPAHandler: %v", err) + } + w := httptest.NewRecorder() + handler.ServeHTTP(w, httptest.NewRequest(http.MethodGet, "/wails/runtime.js", nil)) + if w.Code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 — the dev proxy must not answer for the runtime", w.Code) + } +} + +// TestSPAHandler_WithWailsMiddleware asserts the intended composition: +// with WailsHTTPMiddleware in front, the runtime's requests reach the +// runtime and everything else reaches the SPA. The handler's own /wails +// refusal is a backstop for a MISSING middleware, not a replacement. +func TestSPAHandler_WithWailsMiddleware(t *testing.T) { + assets, err := SPAHandler(SPAOptions{FS: angularDist()}) + if err != nil { + t.Fatalf("SPAHandler: %v", err) + } + runtime := http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.WriteHeader(http.StatusOK) + _, _ = w.Write([]byte("runtime")) + }) + wired := WailsHTTPMiddleware(assets)(runtime) + + cases := []struct{ path, body string }{ + {"/wails/runtime.js", "runtime"}, + {"/main-A1B2C3D4.js", "console.log('bundle');"}, + {"/settings", ``}, + } + for _, tc := range cases { + t.Run(tc.path, func(t *testing.T) { + w := httptest.NewRecorder() + wired.ServeHTTP(w, httptest.NewRequest(http.MethodGet, tc.path, nil)) + if w.Code != http.StatusOK { + t.Fatalf("status = %d, want 200", w.Code) + } + if w.Body.String() != tc.body { + t.Fatalf("body = %q, want %q", w.Body.String(), tc.body) + } + }) + } +} diff --git a/go/display/webkit/binding_names.go b/go/display/webkit/binding_names.go new file mode 100644 index 0000000..51fd1be --- /dev/null +++ b/go/display/webkit/binding_names.go @@ -0,0 +1,159 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package webkit + +import ( + "reflect" + "sort" + "strings" +) + +// internalBindingMethods mirrors the exclusion set wails applies when it +// walks a bound service. These are lifecycle hooks the runtime consumes +// itself, so they never appear as callable bindings and must not appear +// in the names this package reports either. +// +// Kept in sync with application.internalServiceMethods (wails v3 +// pkg/application/bindings.go). A drift here shows up as a name this +// package claims is callable but the runtime rejects — which is exactly +// the failure UnresolvedBindingNames exists to catch, so the test suite +// pins the set explicitly. +var internalBindingMethods = map[string]bool{ + "ServiceName": true, + "ServiceStartup": true, + "ServiceShutdown": true, + "ServeHTTP": true, +} + +// BindingNames returns every fully-qualified name the renderer may pass +// to @wailsio/runtime's `Call.ByName` for a bound service, sorted. +// +// The name shape is the one wails computes by reflection: +// +// .. +// +// e.g. "dappco.re/lthn/desktop/pkg/runner.Service.Start". +// +// The receiver TYPE NAME is part of the wire contract. Renaming the Go +// struct — the `.Service` → `.WailsService` rename that already happened +// in the wild — silently invalidates every hardcoded call string in the +// frontend, because wails resolves Call.ByName through an exact-match +// map (application.Bindings.Get) with no aliasing on the name path. +// Aliases exist only for the numeric ID path (Call.ByID), so a renamed +// receiver cannot be papered over at runtime. +// +// Pass the same pointer given to Bind: +// +// names := webkit.BindingNames(runnerSvc) +// +// Returns nil when instance is not a pointer to a named, non-generic +// struct — the same inputs wails itself rejects at bind time. +func BindingNames(instance any) []string { + pkgPath, typeName, ptrType, ok := bindingReceiver(instance) + if !ok { + return nil + } + + prefix := pkgPath + "." + typeName + "." + names := make([]string, 0, ptrType.NumMethod()) + for i := range ptrType.NumMethod() { + method := ptrType.Method(i) + if internalBindingMethods[method.Name] { + continue + } + names = append(names, prefix+method.Name) + } + sort.Strings(names) + return names +} + +// BindingName returns the fully-qualified Call.ByName string for one +// method on a bound service, and whether that method is actually +// callable from the renderer. +// +// The bool is false for a method that does not exist, is unexported, or +// is one of the wails lifecycle hooks (ServiceName / ServiceStartup / +// ServiceShutdown / ServeHTTP) — all three cases produce the same +// renderer-side symptom, a call that never resolves, so they collapse +// into one signal. +// +// name, ok := webkit.BindingName(runnerSvc, "Start") +// // name == "dappco.re/lthn/desktop/pkg/runner.Service.Start", ok == true +func BindingName(instance any, method string) (string, bool) { + pkgPath, typeName, ptrType, ok := bindingReceiver(instance) + if !ok || method == "" || internalBindingMethods[method] { + return "", false + } + if _, found := ptrType.MethodByName(method); !found { + return "", false + } + return pkgPath + "." + typeName + "." + method, true +} + +// UnresolvedBindingNames returns the subset of called that no service in +// services exposes — the frontend's dead call strings, sorted and +// de-duplicated. +// +// This is the drift gate. A renderer holds Call.ByName literals; the Go +// side holds the receivers those literals name. Nothing in the build +// couples them, so a receiver rename or a deleted method only surfaces +// when a user clicks the thing. Feed the frontend's literals (see +// ScanCallByName) and the bound services into this and the breakage +// becomes a failing test instead: +// +// missing := webkit.UnresolvedBindingNames(called, runnerSvc, serverSvc) +// if len(missing) > 0 { +// t.Fatalf("frontend calls bindings Go does not expose: %v", missing) +// } +// +// An empty result means every call string resolves. Entries in services +// that are not bindable pointers contribute no names, so passing one by +// mistake shows up as calls that fail to resolve rather than as a +// silently permissive pass. +func UnresolvedBindingNames(called []string, services ...any) []string { + exposed := make(map[string]bool) + for _, svc := range services { + for _, name := range BindingNames(svc) { + exposed[name] = true + } + } + + seen := make(map[string]bool, len(called)) + missing := make([]string, 0) + for _, name := range called { + name = strings.TrimSpace(name) + if name == "" || seen[name] || exposed[name] { + continue + } + seen[name] = true + missing = append(missing, name) + } + sort.Strings(missing) + return missing +} + +// bindingReceiver extracts the package path, type name and pointer type +// from a bindable service value, applying the same admissibility rules +// wails does: the value must be a non-nil pointer to a named struct, and +// generic instantiations are rejected (wails cannot bind them). +func bindingReceiver(instance any) (pkgPath string, typeName string, ptrType reflect.Type, ok bool) { + if instance == nil { + return "", "", nil, false + } + value := reflect.ValueOf(instance) + if value.Kind() != reflect.Ptr || value.IsNil() { + return "", "", nil, false + } + + ptrType = value.Type() + namedType := ptrType.Elem() + if namedType.Name() == "" { + return "", "", nil, false + } + // Generic instantiations carry their type arguments in String() — + // "pkg.Box[int]" — and wails refuses to bind them. + if strings.Contains(namedType.String(), "[") { + return "", "", nil, false + } + return namedType.PkgPath(), namedType.Name(), ptrType, true +} diff --git a/go/display/webkit/binding_names_test.go b/go/display/webkit/binding_names_test.go new file mode 100644 index 0000000..99e634f --- /dev/null +++ b/go/display/webkit/binding_names_test.go @@ -0,0 +1,170 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package webkit + +import ( + "context" + "net/http" + "reflect" + "testing" +) + +// bindingPkg is the package path every FQN in this file is built from. +const bindingPkg = "dappco.re/go/render/display/webkit" + +// nameProbeService stands in for a consumer's bound domain service. It +// carries one plain method, one that takes a context (wails allows it), +// an unexported method, and the four lifecycle hooks the runtime +// consumes itself. +type nameProbeService struct{} + +func (*nameProbeService) Start(string) error { return nil } +func (*nameProbeService) Status(context.Context) (string, error) { return "", nil } +func (*nameProbeService) internalOnly() {} +func (*nameProbeService) ServiceName() string { return "probe" } +func (*nameProbeService) ServiceStartup(context.Context, any) error { return nil } +func (*nameProbeService) ServiceShutdown() error { return nil } +func (*nameProbeService) ServeHTTP(http.ResponseWriter, *http.Request) {} + +// renamedProbeService is the same surface under the other receiver name +// seen in the wild (`.WailsService.` rather than `.Service.`). It exists +// to pin the fact that the receiver TYPE NAME is part of the wire +// contract — the whole reason the drift gate is needed. +type renamedProbeService struct{} + +func (*renamedProbeService) Start(string) error { return nil } + +// genericProbeService is a generic type; wails refuses to bind these. +type genericProbeService[T any] struct{ value T } + +func (*genericProbeService[T]) Start() error { return nil } + +// TestBindingNames_Good asserts the FQN shape wails computes: package +// path, receiver type name, method name — exported methods only, with +// the runtime's own lifecycle hooks excluded, sorted. +func TestBindingNames_Good(t *testing.T) { + got := BindingNames(&nameProbeService{}) + want := []string{ + bindingPkg + ".nameProbeService.Start", + bindingPkg + ".nameProbeService.Status", + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("BindingNames = %v, want %v", got, want) + } +} + +// TestBindingNames_Bad covers every input wails itself rejects at bind +// time. Each must yield no names rather than a partial or panicking +// result, so a caller feeding the wrong thing sees unresolved calls +// instead of a silently permissive gate. +func TestBindingNames_Bad(t *testing.T) { + cases := []struct { + name string + instance any + }{ + {"nil", nil}, + {"nil_typed_pointer", (*nameProbeService)(nil)}, + {"value_not_pointer", nameProbeService{}}, + {"pointer_to_unnamed", &struct{ A int }{}}, + {"generic", &genericProbeService[int]{}}, + {"not_a_struct", func() any { n := 3; return &n }()}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + if got := BindingNames(tc.instance); len(got) != 0 { + t.Fatalf("BindingNames(%s) = %v, want none", tc.name, got) + } + }) + } +} + +// TestBindingNames_Ugly pins the receiver-rename breakage itself: the +// same method on a renamed struct produces a DIFFERENT call string, and +// wails resolves Call.ByName through an exact-match map. This is the +// silent frontend break the drift gate catches. +func TestBindingNames_Ugly(t *testing.T) { + original := BindingNames(&nameProbeService{})[0] + renamed := BindingNames(&renamedProbeService{})[0] + + if original == renamed { + t.Fatal("receiver rename produced the same FQN; the gate would never fire") + } + if want := bindingPkg + ".renamedProbeService.Start"; renamed != want { + t.Fatalf("renamed FQN = %q, want %q", renamed, want) + } + // A frontend pinned to the old string resolves against neither the + // renamed service nor a service list containing only the new one. + missing := UnresolvedBindingNames([]string{original}, &renamedProbeService{}) + if len(missing) != 1 || missing[0] != original { + t.Fatalf("stale call string not reported: %v", missing) + } +} + +// TestBindingName_Good resolves one method to its call string. +func TestBindingName_Good(t *testing.T) { + got, ok := BindingName(&nameProbeService{}, "Start") + if !ok { + t.Fatal("BindingName(Start) not ok") + } + if want := bindingPkg + ".nameProbeService.Start"; got != want { + t.Fatalf("BindingName = %q, want %q", got, want) + } +} + +// TestBindingName_Bad collapses the three "never resolves at runtime" +// cases — absent, unexported, lifecycle hook — onto one false signal. +func TestBindingName_Bad(t *testing.T) { + cases := []string{"", "Missing", "internalOnly", "ServiceName", "ServiceStartup", "ServiceShutdown", "ServeHTTP"} + for _, method := range cases { + t.Run("method_"+method, func(t *testing.T) { + if name, ok := BindingName(&nameProbeService{}, method); ok { + t.Fatalf("BindingName(%q) = %q, want not-callable", method, name) + } + }) + } +} + +// TestUnresolvedBindingNames_Good asserts only the strings no service +// exposes come back, de-duplicated and sorted. +func TestUnresolvedBindingNames_Good(t *testing.T) { + called := []string{ + bindingPkg + ".nameProbeService.Start", + bindingPkg + ".nameProbeService.Vanished", + bindingPkg + ".nameProbeService.Vanished", // duplicate call site + bindingPkg + ".renamedProbeService.Start", + " ", // whitespace-only entries are not call strings + } + got := UnresolvedBindingNames(called, &nameProbeService{}, &renamedProbeService{}) + want := []string{bindingPkg + ".nameProbeService.Vanished"} + if !reflect.DeepEqual(got, want) { + t.Fatalf("UnresolvedBindingNames = %v, want %v", got, want) + } +} + +// TestUnresolvedBindingNames_Bad asserts an empty call list and a +// serviceless call both behave: no calls means nothing to report; no +// services means every call is unresolved rather than vacuously fine. +func TestUnresolvedBindingNames_Bad(t *testing.T) { + if got := UnresolvedBindingNames(nil, &nameProbeService{}); len(got) != 0 { + t.Fatalf("no calls should report nothing, got %v", got) + } + called := []string{bindingPkg + ".nameProbeService.Start"} + if got := UnresolvedBindingNames(called); len(got) != 1 { + t.Fatalf("no services should report every call, got %v", got) + } +} + +// TestInternalBindingMethods_Good pins this package's copy of the wails +// exclusion set against the behaviour it mirrors. If wails adds a hook +// and this map lags, BindingNames advertises a name the runtime rejects. +func TestInternalBindingMethods_Good(t *testing.T) { + want := []string{"ServeHTTP", "ServiceName", "ServiceShutdown", "ServiceStartup"} + for _, method := range want { + if !internalBindingMethods[method] { + t.Fatalf("%q missing from internalBindingMethods", method) + } + } + if len(internalBindingMethods) != len(want) { + t.Fatalf("internalBindingMethods has %d entries, want %d — resync with wails pkg/application/bindings.go", len(internalBindingMethods), len(want)) + } +} diff --git a/go/display/webkit/binding_scan.go b/go/display/webkit/binding_scan.go new file mode 100644 index 0000000..606dfc6 --- /dev/null +++ b/go/display/webkit/binding_scan.go @@ -0,0 +1,269 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package webkit + +import ( + "io/fs" + "path" + "regexp" + "sort" + "strconv" + "strings" + + core "dappco.re/go" +) + +// callByNamePattern matches a literal first argument to the wails +// runtime's ByName call, in any of the shapes a frontend writes it: +// +// Call.ByName('pkg.Type.Method') — the @wailsio/runtime namespace import +// Calls.ByName("pkg.Type.Method") — an aliased namespace +// ByName(`pkg.Type.Method`) — a named import +// +// Only string LITERALS are matched. A call assembled at runtime +// (ByName(prefix + name)) cannot be checked statically and is reported +// separately by ScanCallByName so it never masquerades as verified. +var callByNamePattern = regexp.MustCompile(`(?:\b[A-Za-z_$][\w$]*\s*\.\s*)?\bByName\s*\(\s*(?:('[^']*')|("[^"]*")|(` + "`[^`]*`" + `))`) + +// dynamicByNamePattern matches a ByName call whose first argument is not +// a plain string literal. +var dynamicByNamePattern = regexp.MustCompile(`(?:\b[A-Za-z_$][\w$]*\s*\.\s*)?\bByName\s*\(\s*[^'"` + "`" + `\s)]`) + +// scanExtensions are the frontend source suffixes worth reading. Angular +// projects put call strings in .ts; generated bindings and plain scripts +// land in .js/.mjs. +var scanExtensions = map[string]bool{ + ".ts": true, + ".tsx": true, + ".js": true, + ".mjs": true, + ".mts": true, +} + +// scanSkipDirs are directory names never worth walking. node_modules +// carries the runtime's own ByName definition (and megabytes of noise); +// build output duplicates sources that were already scanned. +var scanSkipDirs = map[string]bool{ + "node_modules": true, + "dist": true, + ".angular": true, + ".git": true, + "coverage": true, +} + +// CallSites is the result of scanning a frontend tree for wails binding +// calls. +type CallSites struct { + // Names are the distinct fully-qualified binding names the frontend + // passes to Call.ByName as string literals, sorted. Feed these to + // UnresolvedBindingNames. + Names []string + + // Files maps each name to the source files that call it, sorted. + // Used to point a failing drift gate at the code to fix. + Files map[string][]string + + // Dynamic lists files containing a ByName call whose argument is + // not a string literal. Those calls cannot be verified statically — + // the list exists so a gate can report the blind spot rather than + // imply full coverage. + Dynamic []string +} + +// ScanCallByName walks a frontend source tree and collects every +// fully-qualified binding name passed literally to the wails runtime's +// Call.ByName. +// +// It is the frontend half of the drift gate: pair it with +// UnresolvedBindingNames and a rename on either side of the seam fails a +// test instead of a user's click. +// +// sites, err := webkit.ScanCallByName(os.DirFS("frontend/src")) +// missing := webkit.UnresolvedBindingNames(sites.Names, runnerSvc) +// +// node_modules, dist, .angular, .git and coverage are skipped. Only +// .ts/.tsx/.js/.mjs/.mts files are read. An unreadable individual file +// is a hard error rather than a silent omission — a gate that skips what +// it cannot read reports a false pass. +func ScanCallByName(fsys fs.FS) (CallSites, error) { + sites := CallSites{Files: make(map[string][]string)} + if fsys == nil { + return sites, core.E("webkit.ScanCallByName", "nil filesystem", nil) + } + + dynamic := make(map[string]bool) + walkErr := fs.WalkDir(fsys, ".", func(name string, entry fs.DirEntry, err error) error { + if err != nil { + return core.E("webkit.ScanCallByName", "walk "+name, err) + } + if entry.IsDir() { + if name != "." && scanSkipDirs[entry.Name()] { + return fs.SkipDir + } + return nil + } + if !scanExtensions[strings.ToLower(path.Ext(name))] { + return nil + } + + content, readErr := fs.ReadFile(fsys, name) + if readErr != nil { + return core.E("webkit.ScanCallByName", "read "+name, readErr) + } + source := stripComments(string(content)) + + for _, match := range callByNamePattern.FindAllStringSubmatch(source, -1) { + literal, ok := unquoteCallArgument(match) + if !ok || literal == "" { + // An interpolated template literal reaches here. It is a + // call whose name cannot be checked, not a call to + // ignore — record the file so the gate reports the gap. + dynamic[name] = true + continue + } + sites.Files[literal] = appendUnique(sites.Files[literal], name) + } + if dynamicByNamePattern.MatchString(source) { + dynamic[name] = true + } + return nil + }) + if walkErr != nil { + return CallSites{Files: make(map[string][]string)}, walkErr + } + + sites.Names = make([]string, 0, len(sites.Files)) + for name := range sites.Files { + sites.Names = append(sites.Names, name) + sort.Strings(sites.Files[name]) + } + sort.Strings(sites.Names) + + sites.Dynamic = make([]string, 0, len(dynamic)) + for name := range dynamic { + sites.Dynamic = append(sites.Dynamic, name) + } + sort.Strings(sites.Dynamic) + + return sites, nil +} + +// unquoteCallArgument turns the matched quoted literal into its string +// value. Single- and back-quoted forms are stripped directly; the +// double-quoted form goes through strconv so JS escapes (".") match +// what the runtime actually sends. +// +// A template literal carrying a substitution — Call.ByName(`${PKG}.T.M`), +// the natural way to write these once a package prefix is factored into +// a constant — is NOT a literal. Reporting the raw source text as a +// binding name would invent a name no service exposes and fail the gate +// on a call that is perfectly correct, so the second return is false and +// the call is recorded as unverifiable instead. +func unquoteCallArgument(match []string) (string, bool) { + switch { + case match[1] != "": + return strings.Trim(match[1], "'"), true + case match[2] != "": + if unquoted, err := strconv.Unquote(match[2]); err == nil { + return unquoted, true + } + return strings.Trim(match[2], `"`), true + case match[3] != "": + if strings.Contains(match[3], "${") { + return "", false + } + return strings.Trim(match[3], "`"), true + } + return "", false +} + +// appendUnique appends value to list unless already present. The lists +// are single-digit in practice, so the linear scan costs nothing. +func appendUnique(list []string, value string) []string { + for _, existing := range list { + if existing == value { + return list + } + } + return append(list, value) +} + +// stripComments blanks out // and /* */ comments, preserving every byte +// offset and every newline by substituting spaces. +// +// Without this, a doc comment that DOCUMENTS a binding name is read as a +// call to it: prose like +// +// /** Call.ByName('pkg.Service.Method') resolves the runner. */ +// +// contributes a name no service need expose, failing the drift gate on +// a comment — and the interpolated form in such a comment gets a whole +// file reported as unverifiable. Commenting your bindings must not break +// the check on your bindings. +// +// The scan is string-literal aware, so a `//` inside a quoted string +// ("https://example.test") is not mistaken for a comment. Escapes are +// honoured inside quotes; template literals are treated as plain quoted +// spans, which is sufficient because a substitution can only make a call +// unverifiable, never resolvable. +func stripComments(source string) string { + const ( + code = iota + lineComment + blockComment + quoted + ) + + out := []byte(source) + state := code + var quote byte + escaped := false + + for i := 0; i < len(source); i++ { + char := source[i] + switch state { + case code: + switch { + case char == '/' && i+1 < len(source) && source[i+1] == '/': + state = lineComment + out[i], out[i+1] = ' ', ' ' + i++ + case char == '/' && i+1 < len(source) && source[i+1] == '*': + state = blockComment + out[i], out[i+1] = ' ', ' ' + i++ + case char == '\'' || char == '"' || char == '`': + state, quote, escaped = quoted, char, false + } + + case lineComment: + if char == '\n' { + state = code + continue + } + out[i] = ' ' + + case blockComment: + if char == '*' && i+1 < len(source) && source[i+1] == '/' { + state = code + out[i], out[i+1] = ' ', ' ' + i++ + continue + } + if char != '\n' { + out[i] = ' ' + } + + case quoted: + switch { + case escaped: + escaped = false + case char == '\\': + escaped = true + case char == quote: + state = code + } + } + } + return string(out) +} diff --git a/go/display/webkit/binding_scan_test.go b/go/display/webkit/binding_scan_test.go new file mode 100644 index 0000000..00b7fe7 --- /dev/null +++ b/go/display/webkit/binding_scan_test.go @@ -0,0 +1,209 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package webkit + +import ( + "reflect" + "strings" + "testing" + "testing/fstest" +) + +// TestScanCallByName_Good covers the three literal quoting styles, the +// namespace-import and named-import call shapes, per-name file +// attribution, and de-duplication across call sites. +func TestScanCallByName_Good(t *testing.T) { + fsys := fstest.MapFS{ + "app/runner.service.ts": &fstest.MapFile{Data: []byte(` + import { Call } from '@wailsio/runtime'; + export const start = () => Call.ByName('pkg/runner.Service.Start'); + export const stop = () => Call.ByName("pkg/runner.Service.Stop"); + `)}, + "app/status.component.ts": &fstest.MapFile{Data: []byte( + "import { ByName } from '@wailsio/runtime';\n" + + "const poll = () => ByName(`pkg/runner.Service.Status`);\n" + + "const again = () => ByName('pkg/runner.Service.Start');\n")}, + } + + sites, err := ScanCallByName(fsys) + if err != nil { + t.Fatalf("ScanCallByName: %v", err) + } + + wantNames := []string{ + "pkg/runner.Service.Start", + "pkg/runner.Service.Status", + "pkg/runner.Service.Stop", + } + if !reflect.DeepEqual(sites.Names, wantNames) { + t.Fatalf("Names = %v, want %v", sites.Names, wantNames) + } + + wantFiles := []string{"app/runner.service.ts", "app/status.component.ts"} + if got := sites.Files["pkg/runner.Service.Start"]; !reflect.DeepEqual(got, wantFiles) { + t.Fatalf("Files[Start] = %v, want %v", got, wantFiles) + } + if len(sites.Dynamic) != 0 { + t.Fatalf("Dynamic = %v, want none", sites.Dynamic) + } +} + +// TestScanCallByName_Bad asserts the scanner does not invent names from +// files it has no business reading — vendored runtime code, build +// output, and non-source extensions — and rejects a nil filesystem +// rather than reporting a clean scan of nothing. +func TestScanCallByName_Bad(t *testing.T) { + if _, err := ScanCallByName(nil); err == nil { + t.Fatal("nil filesystem should error, not report a clean scan") + } + + fsys := fstest.MapFS{ + "node_modules/@wailsio/runtime/calls.js": &fstest.MapFile{ + Data: []byte(`export function ByName(methodName) { return Call({ methodName }); }`)}, + "dist/main.js": &fstest.MapFile{Data: []byte(`Call.ByName('pkg.Built.Bundled')`)}, + "README.md": &fstest.MapFile{Data: []byte(`Call.ByName('pkg.Doc.Example')`)}, + "src/notes.txt": &fstest.MapFile{Data: []byte(`Call.ByName('pkg.Note.Ignored')`)}, + } + sites, err := ScanCallByName(fsys) + if err != nil { + t.Fatalf("ScanCallByName: %v", err) + } + if len(sites.Names) != 0 { + t.Fatalf("Names = %v, want none — skipped trees and non-source files must not contribute", sites.Names) + } +} + +// TestScanCallByName_Ugly pins the blind spot honestly: a call assembled +// at runtime cannot be verified statically, so it must be reported as +// Dynamic rather than dropped (which would let the gate claim full +// coverage it does not have). +func TestScanCallByName_Ugly(t *testing.T) { + fsys := fstest.MapFS{ + "app/dynamic.service.ts": &fstest.MapFile{Data: []byte(` + const PREFIX = 'pkg/runner.Service.'; + export const invoke = (m: string) => Call.ByName(PREFIX + m); + export const known = () => Call.ByName('pkg/runner.Service.Start'); + `)}, + } + + sites, err := ScanCallByName(fsys) + if err != nil { + t.Fatalf("ScanCallByName: %v", err) + } + if want := []string{"pkg/runner.Service.Start"}; !reflect.DeepEqual(sites.Names, want) { + t.Fatalf("Names = %v, want %v", sites.Names, want) + } + if want := []string{"app/dynamic.service.ts"}; !reflect.DeepEqual(sites.Dynamic, want) { + t.Fatalf("Dynamic = %v, want %v — an unverifiable call must be reported", sites.Dynamic, want) + } +} + +// TestScanCallByName_Drift is the gate itself, end to end: frontend +// literals scanned out of source, resolved against the Go services that +// back them, with the stale one named. +func TestScanCallByName_Drift(t *testing.T) { + fsys := fstest.MapFS{ + "app/probe.service.ts": &fstest.MapFile{Data: []byte( + `Call.ByName('` + bindingPkg + `.nameProbeService.Start');` + + `Call.ByName('` + bindingPkg + `.nameProbeService.Renamed');`)}, + } + + sites, err := ScanCallByName(fsys) + if err != nil { + t.Fatalf("ScanCallByName: %v", err) + } + missing := UnresolvedBindingNames(sites.Names, &nameProbeService{}) + want := []string{bindingPkg + ".nameProbeService.Renamed"} + if !reflect.DeepEqual(missing, want) { + t.Fatalf("UnresolvedBindingNames = %v, want %v", missing, want) + } + if got := sites.Files[want[0]]; len(got) != 1 || got[0] != "app/probe.service.ts" { + t.Fatalf("Files for the stale call = %v, want [app/probe.service.ts]", got) + } +} + +// TestScanCallByName_InterpolatedTemplate pins the trap the in-tree +// Angular example walked straight into: factoring the package prefix +// into a constant and calling Call.ByName(`${PKG}.Type.Method`) is the +// natural way to write these, and the source text is NOT the binding +// name. Emitting it would invent a name no service exposes and fail the +// gate on a perfectly correct call, so it must be reported as +// unverifiable instead. +func TestScanCallByName_InterpolatedTemplate(t *testing.T) { + fsys := fstest.MapFS{ + "app/wails.service.ts": &fstest.MapFile{Data: []byte( + "const PKG = 'pkg/runner';\n" + + "const echo = () => Call.ByName(`${PKG}.Service.Echo`);\n")}, + } + + sites, err := ScanCallByName(fsys) + if err != nil { + t.Fatalf("ScanCallByName: %v", err) + } + for _, name := range sites.Names { + if strings.Contains(name, "${") { + t.Fatalf("Names contains raw source text %q — that is not a binding name", name) + } + } + if len(sites.Names) != 0 { + t.Fatalf("Names = %v, want none — an interpolated call cannot be verified", sites.Names) + } + if want := []string{"app/wails.service.ts"}; !reflect.DeepEqual(sites.Dynamic, want) { + t.Fatalf("Dynamic = %v, want %v", sites.Dynamic, want) + } +} + +// TestScanCallByName_CommentsAreNotCalls pins the trap the in-tree +// example hit second: documenting a binding must not register a call to +// it. Before comment stripping, the doc comment on the example's own +// wails.service.ts made the drift gate fail on prose. +func TestScanCallByName_CommentsAreNotCalls(t *testing.T) { + fsys := fstest.MapFS{ + "app/documented.service.ts": &fstest.MapFile{Data: []byte( + "/**\n" + + " * Prefer Call.ByName('pkg.Ghost.FromBlockComment') over the old API.\n" + + " * The interpolated form Call.ByName(`${PKG}.T.M`) cannot be checked.\n" + + " */\n" + + "// Call.ByName('pkg.Ghost.FromLineComment') was removed in v2.\n" + + "export const real = () => Call.ByName('pkg.Runner.Start');\n")}, + } + + sites, err := ScanCallByName(fsys) + if err != nil { + t.Fatalf("ScanCallByName: %v", err) + } + if want := []string{"pkg.Runner.Start"}; !reflect.DeepEqual(sites.Names, want) { + t.Fatalf("Names = %v, want %v — only the real call counts", sites.Names, want) + } + if len(sites.Dynamic) != 0 { + t.Fatalf("Dynamic = %v, want none — an interpolated call in a COMMENT is not a blind spot", sites.Dynamic) + } +} + +// TestStripComments_Good asserts a // sequence inside a string literal +// survives, so a URL in a call argument is not mistaken for a comment +// and silently truncated. +func TestStripComments_Good(t *testing.T) { + source := `const url = "https://example.test/a"; // trailing note +const tick = 'a//b'; +/* block */ const kept = 1;` + + stripped := stripComments(source) + + for _, keep := range []string{`"https://example.test/a"`, `'a//b'`, "const kept = 1;"} { + if !strings.Contains(stripped, keep) { + t.Errorf("stripped source lost %q:\n%s", keep, stripped) + } + } + for _, gone := range []string{"trailing note", "block"} { + if strings.Contains(stripped, gone) { + t.Errorf("stripped source still carries comment text %q:\n%s", gone, stripped) + } + } + if got, want := strings.Count(stripped, "\n"), strings.Count(source, "\n"); got != want { + t.Fatalf("newline count = %d, want %d — offsets must be preserved", got, want) + } + if len(stripped) != len(source) { + t.Fatalf("length = %d, want %d — stripping must preserve byte offsets", len(stripped), len(source)) + } +} diff --git a/go/display/webkit/csp.go b/go/display/webkit/csp.go new file mode 100644 index 0000000..ebbef9c --- /dev/null +++ b/go/display/webkit/csp.go @@ -0,0 +1,212 @@ +// SPDX-Licence-Identifier: EUPL-1.2 + +package webkit + +import ( + "net/http" + "net/url" + "sort" + "strings" +) + +// CSPHeader is the enforcing Content-Security-Policy header name. +const CSPHeader = "Content-Security-Policy" + +// CSPReportOnlyHeader is the non-enforcing variant: violations are +// reported to the console but nothing is blocked. Use it to find what a +// tightened policy would break before it breaks for a user. +const CSPReportOnlyHeader = "Content-Security-Policy-Report-Only" + +// CSPOptions describes the Content-Security-Policy a hosted WebView +// needs: strict enough to be worth setting, permissive enough that the +// wails transport and an Angular application both still work. +type CSPOptions struct { + // Transports are the origins the wails runtime talks to beyond the + // document's own — the asset/WS transport and the binding + // transport, e.g. "http://localhost:9099" and + // "http://localhost:9199". + // + // Each entry contributes BOTH its http(s):// and its ws(s):// + // form to connect-src. That pairing is the point: allowing the + // HTTP origin while omitting the WebSocket one produces a policy + // that passes every page-load check and then silently kills the + // runtime's event channel — the failure lthn/desktop shipped as + // issue #93. + Transports []string + + // DevServer is the framework dev server origin, e.g. + // "http://localhost:9245". When set, the policy additionally + // permits that origin (and its WebSocket form, for HMR) and adds + // 'unsafe-eval' to script-src, which unbundled dev builds require. + // Leave empty for production — the release policy must not carry + // the dev relaxations. + DevServer string + + // StyleNonce is the nonce Angular was configured with via + // ngCspNonce. When set, style-src carries 'nonce-' instead + // of 'unsafe-inline', which is the only way to keep runtime-injected + // component styles working under a strict policy. Empty falls back + // to 'unsafe-inline', because Angular injects component styles as + // plain