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/jwijenbergh/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 resolved := config.ResolvePerformanceProfile(&cfg.Performance, hw)
351
352 resp := configPayload{
353 DefaultUIScale: cfg.DefaultUIScale,
354 DefaultSearchEngine: cfg.DefaultSearchEngine,
355 SearchShortcuts: cfg.SearchShortcuts,
356 Appearance: configAppearancePayload{
357 SansFont: cfg.Appearance.SansFont,
358 SerifFont: cfg.Appearance.SerifFont,
359 MonospaceFont: cfg.Appearance.MonospaceFont,
360 DefaultFontSize: cfg.Appearance.DefaultFontSize,
361 ColorScheme: cfg.Appearance.ColorScheme,
362 LightPalette: cfg.Appearance.LightPalette,
363 DarkPalette: cfg.Appearance.DarkPalette,
364 },
365 Performance: configPerformancePayload{
366 Profile: string(cfg.Performance.Profile),
367 Custom: configCustomPerformance{
368 SkiaCPUThreads: cfg.Performance.SkiaCPUPaintingThreads,
369 SkiaGPUThreads: cfg.Performance.SkiaGPUPaintingThreads,
370 WebProcessMemoryMB: cfg.Performance.WebProcessMemoryLimitMB,
371 NetworkProcessMemoryMB: cfg.Performance.NetworkProcessMemoryLimitMB,
372 WebViewPoolPrewarm: cfg.Performance.WebViewPoolPrewarmCount,
373 },
374 Resolved: configResolvedPerformance{
375 SkiaCPUThreads: resolved.SkiaCPUPaintingThreads,
376 SkiaGPUThreads: resolved.SkiaGPUPaintingThreads,
377 WebProcessMemoryMB: resolved.WebProcessMemoryLimitMB,
378 NetworkProcessMemoryMB: resolved.NetworkProcessMemoryLimitMB,
379 WebViewPoolPrewarm: resolved.WebViewPoolPrewarmCount,
380 ConservativeThreshold: resolved.WebProcessMemoryConservativeThreshold,
381 StrictThreshold: resolved.WebProcessMemoryStrictThreshold,
382 },
383 Hardware: buildHardwarePayload(hw),
384 },
385 }
386
387 data, err := json.Marshal(resp)
388 if err != nil {
389 return &SchemeResponse{
390 Data: []byte(fmt.Sprintf(`{"error": %q}`, err)),
391 ContentType: "application/json",
392 StatusCode: http.StatusInternalServerError,
393 }
394 }
395
396 return &SchemeResponse{
397 Data: data,
398 ContentType: "application/json",
399 StatusCode: http.StatusOK,
400 }
401}
402
403// buildHardwarePayload converts HardwareInfo to JSON payload.
404func buildHardwarePayload(hw *port.HardwareInfo) configHardwarePayload {
405 if hw == nil {
406 return configHardwarePayload{}
407 }
408 return configHardwarePayload{
409 CPUCores: hw.CPUCores,
410 CPUThreads: hw.CPUThreads,
411 TotalRAMMB: hw.TotalRAMMB(),
412 GPUVendor: string(hw.GPUVendor),
413 GPUName: hw.GPUName,
414 VRAMMB: hw.VRAMMB(),
415 }
416}
417
418// RegisterPage registers a handler for a specific path.
419func (h *DumbSchemeHandler) RegisterPage(path string, handler PageHandler) {
420 h.mu.Lock()
421 defer h.mu.Unlock()
422 h.handlers[path] = handler
423 h.logger.Debug().Str("path", path).Msg("registered page handler")
424}
425
426// HandleRequest processes a scheme request and sends the response.
427func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) {
428 req := webkit.URISchemeRequestNewFromInternalPtr(reqPtr)
429 if req == nil {
430 return
431 }
432
433 uri := req.GetUri()
434 schemeReq := &SchemeRequest{
435 inner: req,
436 URI: uri,
437 Path: req.GetPath(),
438 Method: req.GetHttpMethod(),
439 Scheme: req.GetScheme(),
440 }
441
442 h.logger.Debug().
443 Str("uri", schemeReq.URI).
444 Str("path", schemeReq.Path).
445 Str("method", schemeReq.Method).
446 Msg("handling scheme request")
447
448 // Parse the URI to extract host and path
449 u, err := url.Parse(uri)
450 if err != nil || u.Scheme != "dumb" {
451 h.logger.Error().Err(err).Str("uri", uri).Msg("invalid URI")
452 h.sendResponse(req, &SchemeResponse{
453 Data: []byte("Invalid URI"),
454 ContentType: "text/plain",
455 StatusCode: http.StatusBadRequest,
456 })
457 return
458 }
459
460 // API endpoints should never be treated as static assets
461 if strings.HasPrefix(schemeReq.Path, "/api/") {
462 h.mu.RLock()
463 handler, ok := h.handlers[schemeReq.Path]
464 if !ok {
465 handler, ok = h.handlers[strings.TrimPrefix(schemeReq.Path, "/")]
466 }
467 h.mu.RUnlock()
468 if ok {
469 response := handler.Handle(schemeReq)
470 h.sendResponse(req, response)
471 return
472 }
473 }
474
475 // Try to serve from embedded assets
476 if response := h.handleAsset(u); response != nil {
477 h.sendResponse(req, response)
478 return
479 }
480
481 // Fall back to registered handlers
482 h.mu.RLock()
483 handler, ok := h.handlers[schemeReq.Path]
484 if !ok {
485 handler, ok = h.handlers[strings.TrimPrefix(schemeReq.Path, "/")]
486 }
487 h.mu.RUnlock()
488
489 var response *SchemeResponse
490 if ok {
491 response = handler.Handle(schemeReq)
492 } else {
493 response = &SchemeResponse{
494 Data: []byte(notFoundHTML),
495 ContentType: "text/html",
496 StatusCode: http.StatusNotFound,
497 }
498 }
499
500 h.sendResponse(req, response)
501}
502
503// handleAsset serves static assets from the embedded filesystem.
504// Returns nil if no asset was found (allowing fallback to registered handlers).
505func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
506 h.mu.RLock()
507 hasAssets := h.assets != (embed.FS{})
508 assetDir := h.assetDir
509 h.mu.RUnlock()
510
511 if !hasAssets {
512 return nil
513 }
514
515 relPath, ok := resolveAssetPath(u)
516 if !ok {
517 return nil
518 }
519
520 // Read the asset from embedded FS
521 fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath))
522 data, err := fs.ReadFile(h.assets, fullPath)
523 if err != nil {
524 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
525 return nil
526 }
527
528 contentType := h.getMimeType(relPath)
529 h.logger.Debug().
530 Str("path", fullPath).
531 Str("content_type", contentType).
532 Int("size", len(data)).
533 Msg("serving asset")
534
535 return &SchemeResponse{
536 Data: data,
537 ContentType: contentType,
538 StatusCode: http.StatusOK,
539 }
540}
541
542func resolveAssetPath(u *url.URL) (string, bool) {
543 if u == nil {
544 return "", false
545 }
546
547 rootByHost := map[string]string{
548 HomePath: IndexHTML,
549 ConfigPath: "config.html",
550 WebRTCPath: "webrtc.html",
551 ErrorPath: "error.html",
552 }
553
554 if root, ok := rootByHost[u.Host]; ok {
555 path := strings.TrimPrefix(u.Path, "/")
556 if path == "" {
557 return root, true
558 }
559 return path, true
560 }
561
562 switch u.Opaque {
563 case HomePath:
564 return IndexHTML, true
565 case ConfigPath:
566 return "config.html", true
567 case ErrorPath:
568 return "error.html", true
569 case WebRTCPath:
570 return "webrtc.html", true
571 default:
572 return "", false
573 }
574}
575
576// getMimeType determines the MIME type for a given file path.
577func (h *DumbSchemeHandler) getMimeType(filename string) string {
578 if h == nil {
579 return "application/octet-stream"
580 }
581 ext := strings.ToLower(filepath.Ext(filename))
582
583 // Try standard mime type first
584 mt := mime.TypeByExtension(ext)
585 if mt != "" {
586 return mt
587 }
588
589 // Fallbacks for common web assets
590 switch ext {
591 case ".js", ".mjs":
592 return "application/javascript"
593 case ".css":
594 return "text/css"
595 case ".svg":
596 return "image/svg+xml"
597 case ".ico":
598 return "image/x-icon"
599 case ".woff":
600 return "font/woff"
601 case ".woff2":
602 return "font/woff2"
603 case ".ttf":
604 return "font/ttf"
605 case ".otf":
606 return "font/otf"
607 case ".png":
608 return "image/png"
609 case ".jpg", ".jpeg":
610 return "image/jpeg"
611 case ".gif":
612 return "image/gif"
613 case ".webp":
614 return "image/webp"
615 case ".json":
616 return "application/json"
617 case ".xml":
618 return "application/xml"
619 case ".html", ".htm":
620 return "text/html; charset=utf-8"
621 default:
622 return "text/plain"
623 }
624}
625
626// sendResponse sends the response back to WebKit.
627func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response *SchemeResponse) {
628 if response == nil {
629 response = &SchemeResponse{
630 Data: []byte("Internal error"),
631 ContentType: "text/plain",
632 StatusCode: http.StatusInternalServerError,
633 }
634 }
635
636 contentType := response.ContentType
637 if contentType == "" {
638 contentType = "text/html"
639 }
640
641 // Create MemoryInputStream from data directly
642 stream := gio.NewMemoryInputStreamFromData(response.Data, len(response.Data), nil)
643 if stream == nil {
644 h.logger.Error().Msg("failed to create MemoryInputStream for response")
645 return
646 }
647
648 // Create response object for more control
649 schemeResp := webkit.NewURISchemeResponse(&stream.InputStream, int64(len(response.Data)))
650 if schemeResp == nil {
651 h.logger.Error().Msg("failed to create URISchemeResponse")
652 return
653 }
654 schemeResp.SetContentType(contentType)
655 schemeResp.SetStatus(uint(response.StatusCode), nil)
656
657 // WebKit can treat custom schemes as CORS-relevant even for same-origin fetch().
658 // We only add CORS headers for our internal API endpoints.
659 if strings.HasPrefix(req.GetPath(), "/api/") {
660 hdrs := soup.NewMessageHeaders(soup.MessageHeadersResponseValue)
661 hdrs.Append("Access-Control-Allow-Origin", "*")
662 hdrs.Append("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
663 hdrs.Append("Access-Control-Allow-Headers", "Content-Type")
664 hdrs.Append("Access-Control-Max-Age", "86400")
665 schemeResp.SetHttpHeaders(hdrs)
666 }
667
668 req.FinishWithResponse(schemeResp)
669}
670
671// RegisterWithContext registers the dumb:// scheme with a WebKitContext.
672// The scheme is always registered on the default WebContext to ensure WebViews
673// (which use the default WebContext) can load dumb:// URLs.
674func (h *DumbSchemeHandler) RegisterWithContext(wkCtx *WebKitContext) {
675 if wkCtx == nil || wkCtx.Context() == nil {
676 h.logger.Error().Msg("cannot register scheme: context is nil")
677 return
678 }
679
680 callback := webkit.URISchemeRequestCallback(func(reqPtr, _ uintptr) {
681 h.HandleRequest(reqPtr)
682 })
683
684 // Always register on the default WebContext since WebViews use it
685 defaultCtx := webkit.WebContextGetDefault()
686 if defaultCtx != nil {
687 defaultCtx.RegisterUriScheme("dumb", &callback, 0, nil)
688
689 // Mark scheme as local, secure, and CORS-enabled for proper security policies
690 if secMgr := defaultCtx.GetSecurityManager(); secMgr != nil {
691 secMgr.RegisterUriSchemeAsLocal("dumb")
692 secMgr.RegisterUriSchemeAsSecure("dumb")
693 secMgr.RegisterUriSchemeAsCorsEnabled("dumb")
694 }
695 h.logger.Debug().Msg("dumb:// scheme registered on default WebContext")
696 }
697
698 // Also register on custom WebContext if different from default
699 customCtx := wkCtx.Context()
700 if customCtx != nil && customCtx.GoPointer() != 0 && (defaultCtx == nil || customCtx.GoPointer() != defaultCtx.GoPointer()) {
701 wkCtx.Context().RegisterUriScheme("dumb", &callback, 0, nil)
702
703 if secMgr := customCtx.GetSecurityManager(); secMgr != nil {
704 secMgr.RegisterUriSchemeAsLocal("dumb")
705 secMgr.RegisterUriSchemeAsSecure("dumb")
706 secMgr.RegisterUriSchemeAsCorsEnabled("dumb")
707 }
708 h.logger.Debug().Msg("dumb:// scheme also registered on custom WebContext")
709 }
710
711 h.logger.Info().Msg("dumb:// scheme registered")
712}
713
714// Default page templates (fallback when assets not available)
715
716const errorPageHTML = `<!DOCTYPE html>
717<html>
718<head>
719 <meta charset="utf-8">
720 <title>Error</title>
721 <style>
722 body {
723 font-family: system-ui, -apple-system, sans-serif;
724 background: #1a1a2e;
725 color: #eee;
726 display: flex;
727 align-items: center;
728 justify-content: center;
729 height: 100vh;
730 margin: 0;
731 }
732 .container {
733 text-align: center;
734 }
735 h1 { color: #e74c3c; }
736 p { color: #888; }
737 </style>
738</head>
739<body>
740 <div class="container">
741 <h1>Error</h1>
742 <p>The page could not be loaded.</p>
743 </div>
744</body>
745</html>`
746
747const notFoundHTML = `<!DOCTYPE html>
748<html>
749<head>
750 <meta charset="utf-8">
751 <title>Not Found</title>
752 <style>
753 body {
754 font-family: system-ui, -apple-system, sans-serif;
755 background: #1a1a2e;
756 color: #eee;
757 display: flex;
758 align-items: center;
759 justify-content: center;
760 height: 100vh;
761 margin: 0;
762 }
763 .container {
764 text-align: center;
765 }
766 h1 { color: #f39c12; }
767 p { color: #888; }
768 </style>
769</head>
770<body>
771 <div class="container">
772 <h1>404</h1>
773 <p>Page not found.</p>
774 </div>
775</body>
776</html>`