1package webkit
2
3import (
4 "context"
5 "embed"
6 "encoding/json"
7 "fmt"
8 "html"
9 "io/fs"
10 "mime"
11 "net/http"
12 "net/url"
13 "path/filepath"
14 "strings"
15 "sync"
16
17 "github.com/bnema/dumber/internal/application/port"
18 "github.com/bnema/dumber/internal/infrastructure/config"
19 "github.com/bnema/dumber/internal/infrastructure/env"
20 "github.com/bnema/dumber/internal/logging"
21 "github.com/bnema/puregotk-webkit/soup"
22 "github.com/bnema/puregotk-webkit/webkit"
23 "github.com/bnema/puregotk/v4/gio"
24 "github.com/rs/zerolog"
25)
26
27// Scheme path constants
28const (
29 HomePath = "home"
30 ConfigPath = "config"
31 WebRTCPath = "webrtc"
32 ErrorPath = "error"
33 CrashPath = "crash"
34 IndexHTML = "index.html"
35 httpGET = "GET"
36)
37
38// SchemeRequest represents a request to a custom URI scheme.
39type SchemeRequest struct {
40 inner *webkit.URISchemeRequest
41 URI string
42 Path string
43 Method string
44 Scheme string
45}
46
47// SchemeResponse represents a response to a scheme request.
48type SchemeResponse struct {
49 Data []byte
50 ContentType string
51 StatusCode int
52}
53
54// PageHandler generates content for a specific page path.
55type PageHandler interface {
56 Handle(req *SchemeRequest) *SchemeResponse
57}
58
59// PageHandlerFunc is an adapter to allow use of ordinary functions as PageHandlers.
60type PageHandlerFunc func(req *SchemeRequest) *SchemeResponse
61
62func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse {
63 return f(req)
64}
65
66// DumbSchemeHandler handles dumb:// URI scheme requests.
67type DumbSchemeHandler struct {
68 handlers map[string]PageHandler
69 assets embed.FS
70 assetDir string // subdirectory within embed.FS (e.g., "assets/webui")
71 logger zerolog.Logger
72 mu sync.RWMutex
73 hwSurveyor *env.HardwareSurveyor
74 ctx context.Context
75}
76
77type configAppearancePayload struct {
78 SansFont string `json:"sans_font"`
79 SerifFont string `json:"serif_font"`
80 MonospaceFont string `json:"monospace_font"`
81 DefaultFontSize int `json:"default_font_size"`
82 ColorScheme string `json:"color_scheme"`
83 LightPalette config.ColorPalette `json:"light_palette"`
84 DarkPalette config.ColorPalette `json:"dark_palette"`
85}
86
87type configHardwarePayload struct {
88 CPUCores int `json:"cpu_cores"`
89 CPUThreads int `json:"cpu_threads"`
90 TotalRAMMB int `json:"total_ram_mb"`
91 GPUVendor string `json:"gpu_vendor"`
92 GPUName string `json:"gpu_name"`
93 VRAMMB int `json:"vram_mb"`
94}
95
96type configPerformancePayload struct {
97 Profile string `json:"profile"`
98 Custom configCustomPerformance `json:"custom"`
99 Resolved configResolvedPerformance `json:"resolved"`
100 Hardware configHardwarePayload `json:"hardware"`
101}
102
103// configCustomPerformance holds user-editable fields for custom profile.
104type configCustomPerformance struct {
105 SkiaCPUThreads int `json:"skia_cpu_threads"`
106 SkiaGPUThreads int `json:"skia_gpu_threads"`
107 WebProcessMemoryMB int `json:"web_process_memory_mb"`
108 NetworkProcessMemoryMB int `json:"network_process_memory_mb"`
109 WebViewPoolPrewarm int `json:"webview_pool_prewarm"`
110}
111
112// configResolvedPerformance shows the actual values that will be applied at startup.
113type configResolvedPerformance struct {
114 SkiaCPUThreads int `json:"skia_cpu_threads"`
115 SkiaGPUThreads int `json:"skia_gpu_threads"`
116 WebProcessMemoryMB int `json:"web_process_memory_mb"`
117 NetworkProcessMemoryMB int `json:"network_process_memory_mb"`
118 WebViewPoolPrewarm int `json:"webview_pool_prewarm"`
119 ConservativeThreshold float64 `json:"conservative_threshold"`
120 StrictThreshold float64 `json:"strict_threshold"`
121}
122
123type configPayload struct {
124 Appearance configAppearancePayload `json:"appearance"`
125 Performance configPerformancePayload `json:"performance"`
126 DefaultUIScale float64 `json:"default_ui_scale"`
127 DefaultSearchEngine string `json:"default_search_engine"`
128 SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"`
129}
130
131// NewDumbSchemeHandler creates a new handler for the dumb:// scheme.
132func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler {
133 log := logging.FromContext(ctx)
134
135 h := &DumbSchemeHandler{
136 handlers: make(map[string]PageHandler),
137 assetDir: "webui",
138 logger: log.With().Str("component", "scheme-handler").Logger(),
139 hwSurveyor: env.NewHardwareSurveyor(),
140 ctx: ctx,
141 }
142
143 // Register default pages
144 h.registerDefaults()
145
146 return h
147}
148
149// SetAssets sets the embedded filesystem containing webui assets.
150func (h *DumbSchemeHandler) SetAssets(assets embed.FS) {
151 h.mu.Lock()
152 defer h.mu.Unlock()
153 h.assets = assets
154 h.logger.Debug().Msg("assets filesystem configured")
155}
156
157// registerDefaults sets up default page handlers.
158func (h *DumbSchemeHandler) registerDefaults() {
159 // Error page (static fallback)
160 h.RegisterPage("/error", PageHandlerFunc(func(_ *SchemeRequest) *SchemeResponse {
161 return &SchemeResponse{
162 Data: []byte(errorPageHTML),
163 ContentType: "text/html",
164 StatusCode: http.StatusOK,
165 }
166 }))
167
168 // Crash page (web process termination fallback)
169 h.RegisterPage("/"+CrashPath, PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
170 if req.Method != "" && req.Method != httpGET {
171 return nil
172 }
173 originalURI := sanitizeCrashPageOriginalURI(crashOriginalURI(req.URI))
174 return &SchemeResponse{
175 Data: []byte(buildCrashPageHTML(originalURI)),
176 ContentType: "text/html; charset=utf-8",
177 StatusCode: http.StatusOK,
178 }
179 }))
180
181 // API: Get current config (used by dumb://config)
182 h.RegisterPage("/api/config", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
183 if req.Method != "" && req.Method != httpGET {
184 return nil
185 }
186
187 return h.buildConfigResponse(config.Get())
188 }))
189
190 // API: Get default config (used by Reset Defaults in dumb://config)
191 h.RegisterPage("/api/config/default", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
192 if req.Method != "" && req.Method != httpGET {
193 return nil
194 }
195
196 return h.buildConfigResponse(config.DefaultConfig())
197 }))
198}
199
200func crashOriginalURI(requestURI string) string {
201 if requestURI == "" {
202 return ""
203 }
204 parsed, err := url.Parse(requestURI)
205 if err != nil {
206 return ""
207 }
208 return strings.TrimSpace(parsed.Query().Get("url"))
209}
210
211func sanitizeCrashPageOriginalURI(originalURI string) string {
212 originalURI = strings.TrimSpace(originalURI)
213 if originalURI == "" {
214 return ""
215 }
216 parsed, err := url.Parse(originalURI)
217 if err != nil {
218 return ""
219 }
220 switch strings.ToLower(parsed.Scheme) {
221 case "http", "https":
222 if parsed.Host == "" {
223 return ""
224 }
225 return parsed.String()
226 case "dumb":
227 if parsed.Host == "" && parsed.Opaque == "" {
228 return ""
229 }
230 return parsed.String()
231 default:
232 return ""
233 }
234}
235
236func buildCrashPageHTML(originalURI string) string {
237 escapedURI := html.EscapeString(originalURI)
238 return fmt.Sprintf(`<!DOCTYPE html>
239<html lang="en">
240<head>
241 <meta charset="utf-8">
242 <meta name="viewport" content="width=device-width, initial-scale=1">
243 <title>Renderer crashed</title>
244 <style>
245 :root {
246 color-scheme: dark;
247 font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
248 }
249 body {
250 margin: 0;
251 min-height: 100vh;
252 display: flex;
253 align-items: center;
254 justify-content: center;
255 background: radial-gradient(circle at top, #253447, #101622 55%%);
256 color: #f2f6fa;
257 padding: 24px;
258 }
259 .card {
260 width: min(640px, 100%%);
261 background: rgba(10, 16, 26, 0.86);
262 border: 1px solid rgba(144, 173, 205, 0.35);
263 border-radius: 16px;
264 box-shadow: 0 24px 64px rgba(0, 0, 0, 0.45);
265 padding: 28px;
266 }
267 h1 {
268 margin: 0 0 12px;
269 font-size: 1.8rem;
270 }
271 p {
272 margin: 0 0 16px;
273 line-height: 1.5;
274 color: #c8d6e6;
275 }
276 .url {
277 margin: 0 0 20px;
278 padding: 12px;
279 border-radius: 10px;
280 background: rgba(26, 38, 56, 0.85);
281 border: 1px solid rgba(139, 167, 194, 0.28);
282 font-family: "IBM Plex Mono", "Fira Code", monospace;
283 font-size: 0.92rem;
284 overflow-wrap: anywhere;
285 }
286 .actions {
287 display: flex;
288 gap: 12px;
289 flex-wrap: wrap;
290 }
291 button {
292 border: 0;
293 border-radius: 10px;
294 padding: 10px 16px;
295 cursor: pointer;
296 font-size: 0.95rem;
297 font-weight: 600;
298 }
299 .primary {
300 background: #4dd0e1;
301 color: #061018;
302 }
303 .secondary {
304 background: #233346;
305 color: #d6e5f5;
306 }
307 </style>
308</head>
309<body>
310 <div class="card">
311 <h1>Renderer process ended</h1>
312 <p>The current page was interrupted. You can reload it to continue browsing.</p>
313 <div class="url">%s</div>
314 <div class="actions">
315 <button class="primary" id="reload-btn" data-target="%s">Reload page</button>
316 <button class="secondary" id="stay-btn">Stay on this page</button>
317 </div>
318 </div>
319 <script>
320 const reloadButton = document.getElementById('reload-btn');
321 const targetUrl = (reloadButton.getAttribute('data-target') || '').trim();
322 reloadButton.addEventListener('click', function() {
323 if (targetUrl) {
324 window.location.href = targetUrl;
325 return;
326 }
327 window.location.reload();
328 });
329 // "Stay on this page" keeps the crash page visible without navigating away.
330 document.getElementById('stay-btn').addEventListener('click', function() {
331 this.disabled = true;
332 this.textContent = 'Staying on page';
333 });
334 </script>
335</body>
336</html>`, escapedURI, escapedURI)
337}
338
339func (h *DumbSchemeHandler) buildConfigResponse(cfg *config.Config) *SchemeResponse {
340 // Get hardware info for display and profile resolution
341 // Use background context since survey results are cached and we don't want
342 // request context cancellation to affect this
343 var hw *port.HardwareInfo
344 if h.hwSurveyor != nil {
345 hwInfo := h.hwSurveyor.Survey(context.Background())
346 hw = &hwInfo
347 }
348
349 // Resolve performance profile with hardware info
350 perfCfg := config.PerformanceConfigFromEngine(&cfg.Engine)
351 resolved := config.ResolvePerformanceProfile(&perfCfg, hw)
352
353 resp := configPayload{
354 DefaultUIScale: cfg.DefaultUIScale,
355 DefaultSearchEngine: cfg.DefaultSearchEngine,
356 SearchShortcuts: cfg.SearchShortcuts,
357 Appearance: configAppearancePayload{
358 SansFont: cfg.Appearance.SansFont,
359 SerifFont: cfg.Appearance.SerifFont,
360 MonospaceFont: cfg.Appearance.MonospaceFont,
361 DefaultFontSize: cfg.Appearance.DefaultFontSize,
362 ColorScheme: cfg.Appearance.ColorScheme,
363 LightPalette: cfg.Appearance.LightPalette,
364 DarkPalette: cfg.Appearance.DarkPalette,
365 },
366 Performance: configPerformancePayload{
367 Profile: string(cfg.Engine.Profile),
368 Custom: configCustomPerformance{
369 SkiaCPUThreads: cfg.Engine.WebKit.SkiaCPUPaintingThreads,
370 SkiaGPUThreads: cfg.Engine.WebKit.SkiaGPUPaintingThreads,
371 WebProcessMemoryMB: cfg.Engine.WebKit.WebProcessMemoryLimitMB,
372 NetworkProcessMemoryMB: cfg.Engine.WebKit.NetworkProcessMemoryLimitMB,
373 WebViewPoolPrewarm: cfg.Engine.PoolPrewarmCount,
374 },
375 Resolved: configResolvedPerformance{
376 SkiaCPUThreads: resolved.SkiaCPUPaintingThreads,
377 SkiaGPUThreads: resolved.SkiaGPUPaintingThreads,
378 WebProcessMemoryMB: resolved.WebProcessMemoryLimitMB,
379 NetworkProcessMemoryMB: resolved.NetworkProcessMemoryLimitMB,
380 WebViewPoolPrewarm: resolved.WebViewPoolPrewarmCount,
381 ConservativeThreshold: resolved.WebProcessMemoryConservativeThreshold,
382 StrictThreshold: resolved.WebProcessMemoryStrictThreshold,
383 },
384 Hardware: buildHardwarePayload(hw),
385 },
386 }
387
388 data, err := json.Marshal(resp)
389 if err != nil {
390 return &SchemeResponse{
391 Data: []byte(fmt.Sprintf(`{"error": %q}`, err)),
392 ContentType: "application/json",
393 StatusCode: http.StatusInternalServerError,
394 }
395 }
396
397 return &SchemeResponse{
398 Data: data,
399 ContentType: "application/json",
400 StatusCode: http.StatusOK,
401 }
402}
403
404// buildHardwarePayload converts HardwareInfo to JSON payload.
405func buildHardwarePayload(hw *port.HardwareInfo) configHardwarePayload {
406 if hw == nil {
407 return configHardwarePayload{}
408 }
409 return configHardwarePayload{
410 CPUCores: hw.CPUCores,
411 CPUThreads: hw.CPUThreads,
412 TotalRAMMB: hw.TotalRAMMB(),
413 GPUVendor: string(hw.GPUVendor),
414 GPUName: hw.GPUName,
415 VRAMMB: hw.VRAMMB(),
416 }
417}
418
419// RegisterPage registers a handler for a specific path.
420func (h *DumbSchemeHandler) RegisterPage(path string, handler PageHandler) {
421 h.mu.Lock()
422 defer h.mu.Unlock()
423 h.handlers[path] = handler
424 h.logger.Debug().Str("path", path).Msg("registered page handler")
425}
426
427// HandleRequest processes a scheme request and sends the response.
428func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) {
429 req := webkit.URISchemeRequestNewFromInternalPtr(reqPtr)
430 if req == nil {
431 return
432 }
433
434 uri := req.GetUri()
435 schemeReq := &SchemeRequest{
436 inner: req,
437 URI: uri,
438 Path: req.GetPath(),
439 Method: req.GetHttpMethod(),
440 Scheme: req.GetScheme(),
441 }
442
443 h.logger.Debug().
444 Str("uri", schemeReq.URI).
445 Str("path", schemeReq.Path).
446 Str("method", schemeReq.Method).
447 Msg("handling scheme request")
448
449 // Parse the URI to extract host and path
450 u, err := url.Parse(uri)
451 if err != nil || u.Scheme != "dumb" {
452 h.logger.Error().Err(err).Str("uri", uri).Msg("invalid URI")
453 h.sendResponse(req, &SchemeResponse{
454 Data: []byte("Invalid URI"),
455 ContentType: "text/plain",
456 StatusCode: http.StatusBadRequest,
457 })
458 return
459 }
460
461 // API endpoints should never be treated as static assets
462 if strings.HasPrefix(schemeReq.Path, "/api/") {
463 h.mu.RLock()
464 handler, ok := h.handlers[schemeReq.Path]
465 if !ok {
466 handler, ok = h.handlers[strings.TrimPrefix(schemeReq.Path, "/")]
467 }
468 h.mu.RUnlock()
469 if ok {
470 response := handler.Handle(schemeReq)
471 h.sendResponse(req, response)
472 return
473 }
474 }
475
476 // Try to serve from embedded assets
477 if response := h.handleAsset(u); response != nil {
478 h.sendResponse(req, response)
479 return
480 }
481
482 // Fall back to registered handlers
483 h.mu.RLock()
484 handler, ok := h.handlers[schemeReq.Path]
485 if !ok {
486 handler, ok = h.handlers[strings.TrimPrefix(schemeReq.Path, "/")]
487 }
488 h.mu.RUnlock()
489
490 var response *SchemeResponse
491 if ok {
492 response = handler.Handle(schemeReq)
493 } else {
494 response = &SchemeResponse{
495 Data: []byte(notFoundHTML),
496 ContentType: "text/html",
497 StatusCode: http.StatusNotFound,
498 }
499 }
500
501 h.sendResponse(req, response)
502}
503
504// handleAsset serves static assets from the embedded filesystem.
505// Returns nil if no asset was found (allowing fallback to registered handlers).
506func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
507 h.mu.RLock()
508 hasAssets := h.assets != (embed.FS{})
509 assetDir := h.assetDir
510 h.mu.RUnlock()
511
512 if !hasAssets {
513 return nil
514 }
515
516 relPath, ok := resolveAssetPath(u)
517 if !ok {
518 return nil
519 }
520
521 // Read the asset from embedded FS
522 fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath))
523 data, err := fs.ReadFile(h.assets, fullPath)
524 if err != nil {
525 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
526 return nil
527 }
528
529 contentType := h.getMimeType(relPath)
530 h.logger.Debug().
531 Str("path", fullPath).
532 Str("content_type", contentType).
533 Int("size", len(data)).
534 Msg("serving asset")
535
536 return &SchemeResponse{
537 Data: data,
538 ContentType: contentType,
539 StatusCode: http.StatusOK,
540 }
541}
542
543func resolveAssetPath(u *url.URL) (string, bool) {
544 if u == nil {
545 return "", false
546 }
547
548 rootByHost := map[string]string{
549 HomePath: IndexHTML,
550 ConfigPath: "config.html",
551 WebRTCPath: "webrtc.html",
552 ErrorPath: "error.html",
553 }
554
555 if root, ok := rootByHost[u.Host]; ok {
556 path := strings.TrimPrefix(u.Path, "/")
557 if path == "" {
558 return root, true
559 }
560 return path, true
561 }
562
563 switch u.Opaque {
564 case HomePath:
565 return IndexHTML, true
566 case ConfigPath:
567 return "config.html", true
568 case ErrorPath:
569 return "error.html", true
570 case WebRTCPath:
571 return "webrtc.html", true
572 default:
573 return "", false
574 }
575}
576
577// getMimeType determines the MIME type for a given file path.
578func (h *DumbSchemeHandler) getMimeType(filename string) string {
579 if h == nil {
580 return "application/octet-stream"
581 }
582 ext := strings.ToLower(filepath.Ext(filename))
583
584 // Try standard mime type first
585 mt := mime.TypeByExtension(ext)
586 if mt != "" {
587 return mt
588 }
589
590 // Fallbacks for common web assets
591 switch ext {
592 case ".js", ".mjs":
593 return "application/javascript"
594 case ".css":
595 return "text/css"
596 case ".svg":
597 return "image/svg+xml"
598 case ".ico":
599 return "image/x-icon"
600 case ".woff":
601 return "font/woff"
602 case ".woff2":
603 return "font/woff2"
604 case ".ttf":
605 return "font/ttf"
606 case ".otf":
607 return "font/otf"
608 case ".png":
609 return "image/png"
610 case ".jpg", ".jpeg":
611 return "image/jpeg"
612 case ".gif":
613 return "image/gif"
614 case ".webp":
615 return "image/webp"
616 case ".json":
617 return "application/json"
618 case ".xml":
619 return "application/xml"
620 case ".html", ".htm":
621 return "text/html; charset=utf-8"
622 default:
623 return "text/plain"
624 }
625}
626
627// sendResponse sends the response back to WebKit.
628func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response *SchemeResponse) {
629 if response == nil {
630 response = &SchemeResponse{
631 Data: []byte("Internal error"),
632 ContentType: "text/plain",
633 StatusCode: http.StatusInternalServerError,
634 }
635 }
636
637 contentType := response.ContentType
638 if contentType == "" {
639 contentType = "text/html"
640 }
641
642 // Create MemoryInputStream from data directly
643 stream := gio.NewMemoryInputStreamFromData(response.Data, len(response.Data), nil)
644 if stream == nil {
645 h.logger.Error().Msg("failed to create MemoryInputStream for response")
646 return
647 }
648
649 // Create response object for more control
650 schemeResp := webkit.NewURISchemeResponse(&stream.InputStream, int64(len(response.Data)))
651 if schemeResp == nil {
652 h.logger.Error().Msg("failed to create URISchemeResponse")
653 return
654 }
655 schemeResp.SetContentType(contentType)
656 schemeResp.SetStatus(uint(response.StatusCode), nil)
657
658 // WebKit can treat custom schemes as CORS-relevant even for same-origin fetch().
659 // We only add CORS headers for our internal API endpoints.
660 if strings.HasPrefix(req.GetPath(), "/api/") {
661 hdrs := soup.NewMessageHeaders(soup.MessageHeadersResponseValue)
662 hdrs.Append("Access-Control-Allow-Origin", "*")
663 hdrs.Append("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
664 hdrs.Append("Access-Control-Allow-Headers", "Content-Type")
665 hdrs.Append("Access-Control-Max-Age", "86400")
666 schemeResp.SetHttpHeaders(hdrs)
667 }
668
669 req.FinishWithResponse(schemeResp)
670}
671
672// RegisterWithContext registers the dumb:// scheme with a WebKitContext.
673// The scheme is always registered on the default WebContext to ensure WebViews
674// (which use the default WebContext) can load dumb:// URLs.
675func (h *DumbSchemeHandler) RegisterWithContext(wkCtx *WebKitContext) {
676 if wkCtx == nil || wkCtx.Context() == nil {
677 h.logger.Error().Msg("cannot register scheme: context is nil")
678 return
679 }
680
681 callback := webkit.URISchemeRequestCallback(func(reqPtr, _ uintptr) {
682 h.HandleRequest(reqPtr)
683 })
684
685 // Always register on the default WebContext since WebViews use it
686 defaultCtx := webkit.WebContextGetDefault()
687 if defaultCtx != nil {
688 defaultCtx.RegisterUriScheme("dumb", &callback, 0, nil)
689
690 // Mark scheme as local, secure, and CORS-enabled for proper security policies
691 if secMgr := defaultCtx.GetSecurityManager(); secMgr != nil {
692 secMgr.RegisterUriSchemeAsLocal("dumb")
693 secMgr.RegisterUriSchemeAsSecure("dumb")
694 secMgr.RegisterUriSchemeAsCorsEnabled("dumb")
695 }
696 h.logger.Debug().Msg("dumb:// scheme registered on default WebContext")
697 }
698
699 // Also register on custom WebContext if different from default
700 customCtx := wkCtx.Context()
701 if customCtx != nil && customCtx.GoPointer() != 0 && (defaultCtx == nil || customCtx.GoPointer() != defaultCtx.GoPointer()) {
702 wkCtx.Context().RegisterUriScheme("dumb", &callback, 0, nil)
703
704 if secMgr := customCtx.GetSecurityManager(); secMgr != nil {
705 secMgr.RegisterUriSchemeAsLocal("dumb")
706 secMgr.RegisterUriSchemeAsSecure("dumb")
707 secMgr.RegisterUriSchemeAsCorsEnabled("dumb")
708 }
709 h.logger.Debug().Msg("dumb:// scheme also registered on custom WebContext")
710 }
711
712 h.logger.Info().Msg("dumb:// scheme registered")
713}
714
715// Default page templates (fallback when assets not available)
716
717const errorPageHTML = `<!DOCTYPE html>
718<html>
719<head>
720 <meta charset="utf-8">
721 <title>Error</title>
722 <style>
723 body {
724 font-family: system-ui, -apple-system, sans-serif;
725 background: #1a1a2e;
726 color: #eee;
727 display: flex;
728 align-items: center;
729 justify-content: center;
730 height: 100vh;
731 margin: 0;
732 }
733 .container {
734 text-align: center;
735 }
736 h1 { color: #e74c3c; }
737 p { color: #888; }
738 </style>
739</head>
740<body>
741 <div class="container">
742 <h1>Error</h1>
743 <p>The page could not be loaded.</p>
744 </div>
745</body>
746</html>`
747
748const notFoundHTML = `<!DOCTYPE html>
749<html>
750<head>
751 <meta charset="utf-8">
752 <title>Not Found</title>
753 <style>
754 body {
755 font-family: system-ui, -apple-system, sans-serif;
756 background: #1a1a2e;
757 color: #eee;
758 display: flex;
759 align-items: center;
760 justify-content: center;
761 height: 100vh;
762 margin: 0;
763 }
764 .container {
765 text-align: center;
766 }
767 h1 { color: #f39c12; }
768 p { color: #888; }
769 </style>
770</head>
771<body>
772 <div class="container">
773 <h1>404</h1>
774 <p>Page not found.</p>
775 </div>
776</body>
777</html>`