1package webkit
2
3import (
4 "context"
5 "embed"
6 "fmt"
7 "net/http"
8 "net/url"
9 "strings"
10 "sync"
11
12 "github.com/bnema/dumber/internal/application/port"
13 "github.com/bnema/dumber/internal/infrastructure/webutil"
14 "github.com/bnema/dumber/internal/logging"
15 "github.com/bnema/puregotk/v4/gio"
16 "github.com/bnema/puregotk/v4/soup"
17 "github.com/bnema/puregotk/v4/webkit"
18 "github.com/rs/zerolog"
19)
20
21// Scheme path constants
22const (
23 HistoryPath = "history"
24 FavoritesPath = "favorites"
25 ConfigPath = "config"
26 ErrorPath = "error"
27 CrashPath = "crash"
28 IndexHTML = "index.html"
29 httpGET = "GET"
30 maxSystemviewsWASMBytes = webutil.MaxSystemviewsWASMBytes
31 systemviewsAssetDir = webutil.SystemviewsAssetDir
32)
33
34// systemviewTrust accepts dumb:// system-view requesters. WebKit gives
35// custom-scheme pages an opaque "null" Origin, so the referrer decides then.
36var systemviewTrust = webutil.SystemviewTrust{
37 IsTrustedURL: isTrustedSystemviewURL,
38 OpaqueOriginUsesReferrer: true,
39}
40
41// SchemeRequest represents a request to a custom URI scheme.
42type SchemeRequest struct {
43 inner *webkit.URISchemeRequest
44 URI string
45 Path string
46 Method string
47 Scheme string
48 Origin string
49 Referer string
50}
51
52// SchemeResponse represents a response to a scheme request.
53type SchemeResponse struct {
54 Data []byte
55 ContentType string
56 StatusCode int
57 Headers map[string]string
58 SuppressDefaultHeaders bool
59}
60
61// PageHandler generates content for a specific page path.
62type PageHandler interface {
63 Handle(req *SchemeRequest) *SchemeResponse
64}
65
66// PageHandlerFunc is an adapter to allow use of ordinary functions as PageHandlers.
67type PageHandlerFunc func(req *SchemeRequest) *SchemeResponse
68
69func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse {
70 return f(req)
71}
72
73// DumbSchemeHandler handles dumb:// URI scheme requests.
74type DumbSchemeHandler struct {
75 ctx context.Context
76 handlers map[string]PageHandler
77 assets embed.FS
78 faviconResolver port.FaviconSystemviewResolver
79 assetDir string // default subdirectory within embed.FS (e.g., "systemviews")
80 logger zerolog.Logger
81 mu sync.RWMutex
82 currentConfigPayload func() ([]byte, error)
83 defaultConfigPayload func() ([]byte, error)
84}
85
86// NewDumbSchemeHandler creates a new handler for the dumb:// scheme.
87func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler {
88 log := logging.FromContext(ctx)
89
90 h := &DumbSchemeHandler{
91 ctx: ctx,
92 handlers: make(map[string]PageHandler),
93 assetDir: systemviewsAssetDir,
94 logger: log.With().Str("component", "scheme-handler").Logger(),
95 }
96
97 // Register default pages
98 h.registerDefaults()
99
100 return h
101}
102
103// SetAssets sets the embedded filesystem containing systemviews assets.
104func (h *DumbSchemeHandler) SetAssets(assets embed.FS) {
105 h.mu.Lock()
106 defer h.mu.Unlock()
107 h.assets = assets
108 h.logger.Debug().Msg("assets filesystem configured")
109}
110
111// SetConfigPayloadBuilders wires the config payload builders used by /api/config.
112func (h *DumbSchemeHandler) SetConfigPayloadBuilders(current, defaultPayload func() ([]byte, error)) {
113 h.mu.Lock()
114 defer h.mu.Unlock()
115 h.currentConfigPayload = current
116 h.defaultConfigPayload = defaultPayload
117 h.logger.Debug().Msg("config payload builders configured")
118}
119
120func (h *DumbSchemeHandler) SetFaviconResolver(resolver port.FaviconSystemviewResolver) {
121 h.mu.Lock()
122 defer h.mu.Unlock()
123 h.faviconResolver = resolver
124}
125
126// registerDefaults sets up default page handlers.
127func (h *DumbSchemeHandler) registerDefaults() {
128 // Error page (static fallback)
129 h.RegisterPage("/error", PageHandlerFunc(func(_ *SchemeRequest) *SchemeResponse {
130 return &SchemeResponse{
131 Data: []byte(errorPageHTML),
132 ContentType: "text/html",
133 StatusCode: http.StatusOK,
134 }
135 }))
136
137 // Crash page (web process termination fallback)
138 h.RegisterPage("/"+CrashPath, PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
139 if req.Method != "" && req.Method != httpGET {
140 return nil
141 }
142 originalURI := sanitizeCrashPageOriginalURI(crashOriginalURI(req.URI))
143 return &SchemeResponse{
144 Data: []byte(buildCrashPageHTML(originalURI)),
145 ContentType: "text/html; charset=utf-8",
146 StatusCode: http.StatusOK,
147 }
148 }))
149
150 // API: Get current config (used by dumb://config)
151 h.RegisterPage("/api/config", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
152 if req.Method != "" && req.Method != httpGET {
153 return nil
154 }
155 if !isTrustedSystemviewAPIRequest(req) {
156 return privateJSONErrorResponse(http.StatusForbidden, "forbidden")
157 }
158
159 h.mu.RLock()
160 build := h.currentConfigPayload
161 h.mu.RUnlock()
162 return buildConfigResponse(build)
163 }))
164
165 // API: Get default config (used by Reset Defaults in dumb://config)
166 h.RegisterPage("/api/config/default", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
167 if req.Method != "" && req.Method != httpGET {
168 return nil
169 }
170 if !isTrustedSystemviewAPIRequest(req) {
171 return privateJSONErrorResponse(http.StatusForbidden, "forbidden")
172 }
173
174 h.mu.RLock()
175 build := h.defaultConfigPayload
176 h.mu.RUnlock()
177 return buildConfigResponse(build)
178 }))
179
180 h.RegisterPage("/api/favicon", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
181 if req.Method != "" && req.Method != httpGET {
182 return nil
183 }
184 return h.handleFaviconAPI(req)
185 }))
186}
187
188func crashOriginalURI(requestURI string) string {
189 if requestURI == "" {
190 return ""
191 }
192 parsed, err := url.Parse(requestURI)
193 if err != nil {
194 return ""
195 }
196 return strings.TrimSpace(parsed.Query().Get("url"))
197}
198
199func sanitizeCrashPageOriginalURI(originalURI string) string {
200 return webutil.SanitizeCrashPageOriginalURI(originalURI)
201}
202
203func buildCrashPageHTML(originalURI string) string {
204 return webutil.BuildCrashPageHTML(originalURI)
205}
206
207func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse {
208 if !isTrustedSystemviewFaviconRequest(req) {
209 return privateJSONErrorResponse(http.StatusForbidden, "forbidden")
210 }
211 h.mu.RLock()
212 resolver := h.faviconResolver
213 h.mu.RUnlock()
214 if resolver == nil {
215 return privateJSONErrorResponse(http.StatusNotFound, "favicon unavailable")
216 }
217 if req == nil {
218 return privateJSONErrorResponse(http.StatusBadRequest, "invalid request")
219 }
220 domain, size, err := webutil.ParseSystemviewFaviconRequest(req.URI)
221 if err != nil {
222 return privateJSONErrorResponse(http.StatusBadRequest, err.Error())
223 }
224
225 resolveCtx := h.ctx
226 if resolveCtx == nil {
227 resolveCtx = context.Background()
228 }
229 resolved, err := resolver.ResolveSystemviewIcon(resolveCtx, domain, size)
230 if err != nil || resolved == nil || len(resolved.Bytes) == 0 {
231 return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")
232 }
233 contentType := resolved.ContentType
234 if contentType == "" {
235 contentType = "image/png"
236 }
237 return &SchemeResponse{
238 Data: resolved.Bytes,
239 ContentType: contentType,
240 StatusCode: http.StatusOK,
241 Headers: map[string]string{"Cache-Control": "no-store"},
242 SuppressDefaultHeaders: true,
243 }
244}
245
246func isTrustedSystemviewFaviconRequest(req *SchemeRequest) bool {
247 return isTrustedSystemviewAPIRequest(req)
248}
249
250func isTrustedSystemviewAPIRequest(req *SchemeRequest) bool {
251 return req != nil && systemviewTrust.Allows(req.Origin, req.Referer)
252}
253
254func isTrustedSystemviewURL(raw string) bool {
255 host, ok := webutil.DumbURLHost(raw)
256 if !ok {
257 return false
258 }
259 switch host {
260 case HistoryPath, FavoritesPath, ConfigPath, ErrorPath, CrashPath:
261 return true
262 default:
263 return false
264 }
265}
266
267func jsonErrorResponse(status int, message string) *SchemeResponse {
268 return &SchemeResponse{
269 Data: fmt.Appendf(nil, `{"error":%q}`, message),
270 ContentType: "application/json",
271 StatusCode: status,
272 }
273}
274
275func privateJSONErrorResponse(status int, message string) *SchemeResponse {
276 resp := jsonErrorResponse(status, message)
277 resp.Headers = map[string]string{"Cache-Control": "no-store"}
278 resp.SuppressDefaultHeaders = true
279 return resp
280}
281
282func trustedPrivateAPIHeaders() map[string]string {
283 return map[string]string{
284 "Access-Control-Allow-Origin": "*",
285 "Access-Control-Allow-Methods": "GET, OPTIONS",
286 "Access-Control-Allow-Headers": "Content-Type",
287 "Cache-Control": "no-store",
288 }
289}
290
291func buildConfigResponse(build func() ([]byte, error)) *SchemeResponse {
292 if build == nil {
293 resp := privateJSONErrorResponse(http.StatusInternalServerError, "config payload builder not configured")
294 resp.Headers = trustedPrivateAPIHeaders()
295 return resp
296 }
297
298 data, err := build()
299 if err != nil {
300 resp := privateJSONErrorResponse(http.StatusInternalServerError, err.Error())
301 resp.Headers = trustedPrivateAPIHeaders()
302 return resp
303 }
304
305 return &SchemeResponse{
306 Data: data,
307 ContentType: "application/json",
308 StatusCode: http.StatusOK,
309 Headers: trustedPrivateAPIHeaders(),
310 SuppressDefaultHeaders: true,
311 }
312}
313
314// RegisterPage registers a handler for a specific path.
315func (h *DumbSchemeHandler) RegisterPage(pagePath string, handler PageHandler) {
316 h.mu.Lock()
317 defer h.mu.Unlock()
318 h.handlers[pagePath] = handler
319 h.logger.Debug().Str("path", pagePath).Msg("registered page handler")
320}
321
322// HandleRequest processes a scheme request and sends the response.
323func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) {
324 req := webkit.URISchemeRequestNewFromInternalPtr(reqPtr)
325 if req == nil {
326 return
327 }
328
329 uri := req.GetUri()
330 requestHeaders := req.GetHttpHeaders()
331 schemeReq := &SchemeRequest{
332 inner: req,
333 URI: uri,
334 Path: req.GetPath(),
335 Method: req.GetHttpMethod(),
336 Scheme: req.GetScheme(),
337 }
338 if requestHeaders != nil {
339 schemeReq.Origin = strings.TrimSpace(requestHeaders.GetOne("Origin"))
340 schemeReq.Referer = strings.TrimSpace(requestHeaders.GetOne("Referer"))
341 }
342
343 h.logger.Debug().
344 Str("uri", schemeReq.URI).
345 Str("path", schemeReq.Path).
346 Str("method", schemeReq.Method).
347 Msg("handling scheme request")
348
349 // Parse the URI to extract host and path
350 u, err := url.Parse(uri)
351 if err != nil || u.Scheme != "dumb" {
352 h.logger.Error().Err(err).Str("uri", uri).Msg("invalid URI")
353 h.sendResponse(req, &SchemeResponse{
354 Data: []byte("Invalid URI"),
355 ContentType: "text/plain",
356 StatusCode: http.StatusBadRequest,
357 })
358 return
359 }
360
361 // API endpoints should never be treated as static assets
362 if strings.HasPrefix(schemeReq.Path, "/api/") {
363 h.mu.RLock()
364 handler, ok := h.handlers[schemeReq.Path]
365 if !ok {
366 handler, ok = h.handlers[strings.TrimPrefix(schemeReq.Path, "/")]
367 }
368 h.mu.RUnlock()
369 if ok {
370 response := handler.Handle(schemeReq)
371 h.sendResponse(req, response)
372 return
373 }
374 }
375
376 // Try to serve from embedded assets
377 if response := h.handleAsset(u); response != nil {
378 h.sendResponse(req, response)
379 return
380 }
381
382 // Fall back to registered handlers
383 h.mu.RLock()
384 handler, ok := h.handlers[schemeReq.Path]
385 if !ok {
386 handler, ok = h.handlers[strings.TrimPrefix(schemeReq.Path, "/")]
387 }
388 h.mu.RUnlock()
389
390 var response *SchemeResponse
391 if ok {
392 response = handler.Handle(schemeReq)
393 } else {
394 response = &SchemeResponse{
395 Data: []byte(notFoundHTML),
396 ContentType: "text/html",
397 StatusCode: http.StatusNotFound,
398 }
399 }
400
401 h.sendResponse(req, response)
402}
403
404// handleAsset serves static assets from the embedded filesystem.
405// Returns nil if no asset was found (allowing fallback to registered handlers).
406func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
407 h.mu.RLock()
408 hasAssets := h.assets != (embed.FS{})
409 h.mu.RUnlock()
410
411 if !hasAssets {
412 return nil
413 }
414
415 assetDir, relPath, ok := resolveAssetPath(u)
416 if !ok {
417 return nil
418 }
419 if assetDir == "" {
420 assetDir = h.assetDir
421 }
422
423 fullPath, relPath, ok := safeSystemviewsAssetPath(assetDir, relPath)
424 if !ok {
425 return nil
426 }
427
428 data, err := readAssetWithEncoding(h.assets, fullPath, relPath)
429 if err != nil {
430 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
431 return nil
432 }
433
434 contentType := webutil.GetMimeType(relPath)
435 h.logger.Debug().
436 Str("path", fullPath).
437 Str("content_type", contentType).
438 Int("size", len(data)).
439 Msg("serving asset")
440
441 return &SchemeResponse{
442 Data: data,
443 ContentType: contentType,
444 StatusCode: http.StatusOK,
445 Headers: nil,
446 }
447}
448
449var (
450 safeSystemviewsAssetPath = webutil.SafeSystemviewsAssetPath
451 readAssetWithEncoding = webutil.ReadSystemviewAsset
452)
453
454func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
455 if u == nil {
456 return "", "", false
457 }
458
459 rootByHost := map[string]struct {
460 assetDir string
461 file string
462 }{
463 HistoryPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
464 FavoritesPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
465 ConfigPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
466 ErrorPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
467 CrashPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
468 }
469
470 if root, ok := rootByHost[u.Host]; ok {
471 assetPath := strings.TrimPrefix(u.Path, "/")
472 if assetPath == "" {
473 return root.assetDir, root.file, true
474 }
475 return root.assetDir, assetPath, true
476 }
477
478 switch u.Opaque {
479 case HistoryPath, FavoritesPath, ConfigPath, ErrorPath, CrashPath:
480 return systemviewsAssetDir, IndexHTML, true
481 default:
482 return "", "", false
483 }
484}
485
486func shouldAddCORSHeaders(requestPath string) bool {
487 requestPath = strings.TrimSpace(requestPath)
488 return strings.HasSuffix(requestPath, ".wasm")
489}
490
491func responseHeadersForPath(requestPath, contentType string) map[string]string {
492 if !shouldAddCORSHeaders(requestPath) {
493 return nil
494 }
495
496 headers := map[string]string{
497 "Access-Control-Allow-Origin": "*",
498 "Access-Control-Allow-Methods": "GET, POST, OPTIONS",
499 "Access-Control-Allow-Headers": "Content-Type",
500 "Access-Control-Max-Age": "86400",
501 }
502 if contentType != "" {
503 headers["Content-Type"] = contentType
504 }
505 return headers
506}
507
508// sendResponse sends the response back to WebKit.
509func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response *SchemeResponse) {
510 if response == nil {
511 response = &SchemeResponse{
512 Data: []byte("Internal error"),
513 ContentType: "text/plain",
514 StatusCode: http.StatusInternalServerError,
515 }
516 }
517
518 contentType := response.ContentType
519 if contentType == "" {
520 contentType = "text/html"
521 }
522
523 // Create MemoryInputStream from data directly
524 stream := gio.NewMemoryInputStreamFromData(response.Data, len(response.Data), nil)
525 if stream == nil {
526 h.logger.Error().Msg("failed to create MemoryInputStream for response")
527 return
528 }
529
530 // Create response object for more control
531 schemeResp := webkit.NewURISchemeResponse(&stream.InputStream, int64(len(response.Data)))
532 if schemeResp == nil {
533 h.logger.Error().Msg("failed to create URISchemeResponse")
534 return
535 }
536 schemeResp.SetContentType(contentType)
537 schemeResp.SetStatus(uint(response.StatusCode), nil)
538
539 // WebKit can treat custom schemes as CORS-relevant for the wasm runtime asset.
540 // Private systemview APIs opt out with SuppressDefaultHeaders and validate callers.
541 var headers map[string]string
542 if !response.SuppressDefaultHeaders {
543 headers = responseHeadersForPath(req.GetPath(), contentType)
544 }
545 for name, value := range response.Headers {
546 if headers == nil {
547 headers = map[string]string{}
548 }
549 headers[name] = value
550 }
551 if len(headers) > 0 {
552 hdrs := soup.NewMessageHeaders(soup.MessageHeadersResponseValue)
553 for name, value := range headers {
554 hdrs.Append(name, value)
555 }
556 schemeResp.SetHttpHeaders(hdrs)
557 }
558
559 req.FinishWithResponse(schemeResp)
560}
561
562// RegisterWithContext registers the dumb:// scheme with a WebKitContext.
563// The scheme is always registered on the default WebContext to ensure WebViews
564// (which use the default WebContext) can load dumb:// URLs.
565func (h *DumbSchemeHandler) RegisterWithContext(wkCtx *WebKitContext) {
566 if wkCtx == nil || wkCtx.Context() == nil {
567 h.logger.Error().Msg("cannot register scheme: context is nil")
568 return
569 }
570
571 callback := webkit.URISchemeRequestCallback(func(reqPtr, _ uintptr) {
572 h.HandleRequest(reqPtr)
573 })
574
575 // Always register on the default WebContext since WebViews use it
576 defaultCtx := webkit.WebContextGetDefault()
577 if defaultCtx != nil {
578 defaultCtx.RegisterUriScheme("dumb", &callback, 0, nil)
579
580 // Mark scheme as local, secure, and CORS-enabled for proper security policies
581 if secMgr := defaultCtx.GetSecurityManager(); secMgr != nil {
582 secMgr.RegisterUriSchemeAsLocal("dumb")
583 secMgr.RegisterUriSchemeAsSecure("dumb")
584 secMgr.RegisterUriSchemeAsCorsEnabled("dumb")
585 }
586 h.logger.Debug().Msg("dumb:// scheme registered on default WebContext")
587 }
588
589 // Also register on custom WebContext if different from default
590 customCtx := wkCtx.Context()
591 if customCtx != nil && customCtx.GoPointer() != 0 && (defaultCtx == nil || customCtx.GoPointer() != defaultCtx.GoPointer()) {
592 wkCtx.Context().RegisterUriScheme("dumb", &callback, 0, nil)
593
594 if secMgr := customCtx.GetSecurityManager(); secMgr != nil {
595 secMgr.RegisterUriSchemeAsLocal("dumb")
596 secMgr.RegisterUriSchemeAsSecure("dumb")
597 secMgr.RegisterUriSchemeAsCorsEnabled("dumb")
598 }
599 h.logger.Debug().Msg("dumb:// scheme also registered on custom WebContext")
600 }
601
602 h.logger.Info().Msg("dumb:// scheme registered")
603}
604
605// Default page templates (fallback when assets not available)
606
607const errorPageHTML = `<!DOCTYPE html>
608<html>
609<head>
610 <meta charset="utf-8">
611 <title>Error</title>
612 <style>
613 body {
614 font-family: system-ui, -apple-system, sans-serif;
615 background: #1a1a2e;
616 color: #eee;
617 display: flex;
618 align-items: center;
619 justify-content: center;
620 height: 100vh;
621 margin: 0;
622 }
623 .container {
624 text-align: center;
625 }
626 h1 { color: #e74c3c; }
627 p { color: #888; }
628 </style>
629</head>
630<body>
631 <div class="container">
632 <h1>Error</h1>
633 <p>The page could not be loaded.</p>
634 </div>
635</body>
636</html>`
637
638const notFoundHTML = `<!DOCTYPE html>
639<html>
640<head>
641 <meta charset="utf-8">
642 <title>Not Found</title>
643 <style>
644 body {
645 font-family: system-ui, -apple-system, sans-serif;
646 background: #1a1a2e;
647 color: #eee;
648 display: flex;
649 align-items: center;
650 justify-content: center;
651 height: 100vh;
652 margin: 0;
653 }
654 .container {
655 text-align: center;
656 }
657 h1 { color: #f39c12; }
658 p { color: #888; }
659 </style>
660</head>
661<body>
662 <div class="container">
663 <h1>404</h1>
664 <p>Page not found.</p>
665 </div>
666</body>
667</html>`