1package webkit
2
3import (
4 "context"
5 "embed"
6 "encoding/json"
7 "fmt"
8 "io/fs"
9 "mime"
10 "net/http"
11 "net/url"
12 "path/filepath"
13 "strings"
14 "sync"
15
16 "github.com/bnema/dumber/internal/application/port"
17 "github.com/bnema/dumber/internal/infrastructure/config"
18 "github.com/bnema/dumber/internal/infrastructure/env"
19 "github.com/bnema/dumber/internal/logging"
20 "github.com/bnema/puregotk-webkit/soup"
21 "github.com/bnema/puregotk-webkit/webkit"
22 "github.com/jwijenbergh/puregotk/v4/gio"
23 "github.com/rs/zerolog"
24)
25
26// Scheme path constants
27const (
28 HomePath = "home"
29 ConfigPath = "config"
30 ErrorPath = "error"
31 IndexHTML = "index.html"
32)
33
34// SchemeRequest represents a request to a custom URI scheme.
35type SchemeRequest struct {
36 inner *webkit.URISchemeRequest
37 URI string
38 Path string
39 Method string
40 Scheme string
41}
42
43// SchemeResponse represents a response to a scheme request.
44type SchemeResponse struct {
45 Data []byte
46 ContentType string
47 StatusCode int
48}
49
50// PageHandler generates content for a specific page path.
51type PageHandler interface {
52 Handle(req *SchemeRequest) *SchemeResponse
53}
54
55// PageHandlerFunc is an adapter to allow use of ordinary functions as PageHandlers.
56type PageHandlerFunc func(req *SchemeRequest) *SchemeResponse
57
58func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse {
59 return f(req)
60}
61
62// DumbSchemeHandler handles dumb:// URI scheme requests.
63type DumbSchemeHandler struct {
64 handlers map[string]PageHandler
65 assets embed.FS
66 assetDir string // subdirectory within embed.FS (e.g., "assets/webui")
67 logger zerolog.Logger
68 mu sync.RWMutex
69 hwSurveyor *env.HardwareSurveyor
70 ctx context.Context
71}
72
73type configAppearancePayload struct {
74 SansFont string `json:"sans_font"`
75 SerifFont string `json:"serif_font"`
76 MonospaceFont string `json:"monospace_font"`
77 DefaultFontSize int `json:"default_font_size"`
78 ColorScheme string `json:"color_scheme"`
79 LightPalette config.ColorPalette `json:"light_palette"`
80 DarkPalette config.ColorPalette `json:"dark_palette"`
81}
82
83type configHardwarePayload struct {
84 CPUCores int `json:"cpu_cores"`
85 CPUThreads int `json:"cpu_threads"`
86 TotalRAMMB int `json:"total_ram_mb"`
87 GPUVendor string `json:"gpu_vendor"`
88 GPUName string `json:"gpu_name"`
89 VRAMMB int `json:"vram_mb"`
90}
91
92type configPerformancePayload struct {
93 Profile string `json:"profile"`
94 Custom configCustomPerformance `json:"custom"`
95 Resolved configResolvedPerformance `json:"resolved"`
96 Hardware configHardwarePayload `json:"hardware"`
97}
98
99// configCustomPerformance holds user-editable fields for custom profile.
100type configCustomPerformance struct {
101 SkiaCPUThreads int `json:"skia_cpu_threads"`
102 SkiaGPUThreads int `json:"skia_gpu_threads"`
103 WebProcessMemoryMB int `json:"web_process_memory_mb"`
104 NetworkProcessMemoryMB int `json:"network_process_memory_mb"`
105 WebViewPoolPrewarm int `json:"webview_pool_prewarm"`
106}
107
108// configResolvedPerformance shows the actual values that will be applied at startup.
109type configResolvedPerformance struct {
110 SkiaCPUThreads int `json:"skia_cpu_threads"`
111 SkiaGPUThreads int `json:"skia_gpu_threads"`
112 WebProcessMemoryMB int `json:"web_process_memory_mb"`
113 NetworkProcessMemoryMB int `json:"network_process_memory_mb"`
114 WebViewPoolPrewarm int `json:"webview_pool_prewarm"`
115 ConservativeThreshold float64 `json:"conservative_threshold"`
116 StrictThreshold float64 `json:"strict_threshold"`
117}
118
119type configPayload struct {
120 Appearance configAppearancePayload `json:"appearance"`
121 Performance configPerformancePayload `json:"performance"`
122 DefaultUIScale float64 `json:"default_ui_scale"`
123 DefaultSearchEngine string `json:"default_search_engine"`
124 SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"`
125}
126
127// NewDumbSchemeHandler creates a new handler for the dumb:// scheme.
128func NewDumbSchemeHandler(ctx context.Context) *DumbSchemeHandler {
129 log := logging.FromContext(ctx)
130
131 h := &DumbSchemeHandler{
132 handlers: make(map[string]PageHandler),
133 assetDir: "webui",
134 logger: log.With().Str("component", "scheme-handler").Logger(),
135 hwSurveyor: env.NewHardwareSurveyor(),
136 ctx: ctx,
137 }
138
139 // Register default pages
140 h.registerDefaults()
141
142 return h
143}
144
145// SetAssets sets the embedded filesystem containing webui assets.
146func (h *DumbSchemeHandler) SetAssets(assets embed.FS) {
147 h.mu.Lock()
148 defer h.mu.Unlock()
149 h.assets = assets
150 h.logger.Debug().Msg("assets filesystem configured")
151}
152
153// registerDefaults sets up default page handlers.
154func (h *DumbSchemeHandler) registerDefaults() {
155 // Error page (static fallback)
156 h.RegisterPage("/error", PageHandlerFunc(func(_ *SchemeRequest) *SchemeResponse {
157 return &SchemeResponse{
158 Data: []byte(errorPageHTML),
159 ContentType: "text/html",
160 StatusCode: http.StatusOK,
161 }
162 }))
163
164 // API: Get current config (used by dumb://config)
165 h.RegisterPage("/api/config", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
166 if req.Method != "GET" {
167 return nil
168 }
169
170 return h.buildConfigResponse(config.Get())
171 }))
172
173 // API: Get default config (used by Reset Defaults in dumb://config)
174 h.RegisterPage("/api/config/default", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
175 if req.Method != "GET" {
176 return nil
177 }
178
179 return h.buildConfigResponse(config.DefaultConfig())
180 }))
181}
182
183func (h *DumbSchemeHandler) buildConfigResponse(cfg *config.Config) *SchemeResponse {
184 // Get hardware info for display and profile resolution
185 // Use background context since survey results are cached and we don't want
186 // request context cancellation to affect this
187 var hw *port.HardwareInfo
188 if h.hwSurveyor != nil {
189 hwInfo := h.hwSurveyor.Survey(context.Background())
190 hw = &hwInfo
191 }
192
193 // Resolve performance profile with hardware info
194 resolved := config.ResolvePerformanceProfile(&cfg.Performance, hw)
195
196 resp := configPayload{
197 DefaultUIScale: cfg.DefaultUIScale,
198 DefaultSearchEngine: cfg.DefaultSearchEngine,
199 SearchShortcuts: cfg.SearchShortcuts,
200 Appearance: configAppearancePayload{
201 SansFont: cfg.Appearance.SansFont,
202 SerifFont: cfg.Appearance.SerifFont,
203 MonospaceFont: cfg.Appearance.MonospaceFont,
204 DefaultFontSize: cfg.Appearance.DefaultFontSize,
205 ColorScheme: cfg.Appearance.ColorScheme,
206 LightPalette: cfg.Appearance.LightPalette,
207 DarkPalette: cfg.Appearance.DarkPalette,
208 },
209 Performance: configPerformancePayload{
210 Profile: string(cfg.Performance.Profile),
211 Custom: configCustomPerformance{
212 SkiaCPUThreads: cfg.Performance.SkiaCPUPaintingThreads,
213 SkiaGPUThreads: cfg.Performance.SkiaGPUPaintingThreads,
214 WebProcessMemoryMB: cfg.Performance.WebProcessMemoryLimitMB,
215 NetworkProcessMemoryMB: cfg.Performance.NetworkProcessMemoryLimitMB,
216 WebViewPoolPrewarm: cfg.Performance.WebViewPoolPrewarmCount,
217 },
218 Resolved: configResolvedPerformance{
219 SkiaCPUThreads: resolved.SkiaCPUPaintingThreads,
220 SkiaGPUThreads: resolved.SkiaGPUPaintingThreads,
221 WebProcessMemoryMB: resolved.WebProcessMemoryLimitMB,
222 NetworkProcessMemoryMB: resolved.NetworkProcessMemoryLimitMB,
223 WebViewPoolPrewarm: resolved.WebViewPoolPrewarmCount,
224 ConservativeThreshold: resolved.WebProcessMemoryConservativeThreshold,
225 StrictThreshold: resolved.WebProcessMemoryStrictThreshold,
226 },
227 Hardware: buildHardwarePayload(hw),
228 },
229 }
230
231 data, err := json.Marshal(resp)
232 if err != nil {
233 return &SchemeResponse{
234 Data: []byte(fmt.Sprintf(`{"error": %q}`, err)),
235 ContentType: "application/json",
236 StatusCode: http.StatusInternalServerError,
237 }
238 }
239
240 return &SchemeResponse{
241 Data: data,
242 ContentType: "application/json",
243 StatusCode: http.StatusOK,
244 }
245}
246
247// buildHardwarePayload converts HardwareInfo to JSON payload.
248func buildHardwarePayload(hw *port.HardwareInfo) configHardwarePayload {
249 if hw == nil {
250 return configHardwarePayload{}
251 }
252 return configHardwarePayload{
253 CPUCores: hw.CPUCores,
254 CPUThreads: hw.CPUThreads,
255 TotalRAMMB: hw.TotalRAMMB(),
256 GPUVendor: string(hw.GPUVendor),
257 GPUName: hw.GPUName,
258 VRAMMB: hw.VRAMMB(),
259 }
260}
261
262// RegisterPage registers a handler for a specific path.
263func (h *DumbSchemeHandler) RegisterPage(path string, handler PageHandler) {
264 h.mu.Lock()
265 defer h.mu.Unlock()
266 h.handlers[path] = handler
267 h.logger.Debug().Str("path", path).Msg("registered page handler")
268}
269
270// HandleRequest processes a scheme request and sends the response.
271func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) {
272 req := webkit.URISchemeRequestNewFromInternalPtr(reqPtr)
273 if req == nil {
274 return
275 }
276
277 uri := req.GetUri()
278 schemeReq := &SchemeRequest{
279 inner: req,
280 URI: uri,
281 Path: req.GetPath(),
282 Method: req.GetHttpMethod(),
283 Scheme: req.GetScheme(),
284 }
285
286 h.logger.Debug().
287 Str("uri", schemeReq.URI).
288 Str("path", schemeReq.Path).
289 Str("method", schemeReq.Method).
290 Msg("handling scheme request")
291
292 // Parse the URI to extract host and path
293 u, err := url.Parse(uri)
294 if err != nil || u.Scheme != "dumb" {
295 h.logger.Error().Err(err).Str("uri", uri).Msg("invalid URI")
296 h.sendResponse(req, &SchemeResponse{
297 Data: []byte("Invalid URI"),
298 ContentType: "text/plain",
299 StatusCode: http.StatusBadRequest,
300 })
301 return
302 }
303
304 // API endpoints should never be treated as static assets
305 if strings.HasPrefix(schemeReq.Path, "/api/") {
306 h.mu.RLock()
307 handler, ok := h.handlers[schemeReq.Path]
308 if !ok {
309 handler, ok = h.handlers[strings.TrimPrefix(schemeReq.Path, "/")]
310 }
311 h.mu.RUnlock()
312 if ok {
313 response := handler.Handle(schemeReq)
314 h.sendResponse(req, response)
315 return
316 }
317 }
318
319 // Try to serve from embedded assets
320 if response := h.handleAsset(u); response != nil {
321 h.sendResponse(req, response)
322 return
323 }
324
325 // Fall back to registered handlers
326 h.mu.RLock()
327 handler, ok := h.handlers[schemeReq.Path]
328 if !ok {
329 handler, ok = h.handlers[strings.TrimPrefix(schemeReq.Path, "/")]
330 }
331 h.mu.RUnlock()
332
333 var response *SchemeResponse
334 if ok {
335 response = handler.Handle(schemeReq)
336 } else {
337 response = &SchemeResponse{
338 Data: []byte(notFoundHTML),
339 ContentType: "text/html",
340 StatusCode: http.StatusNotFound,
341 }
342 }
343
344 h.sendResponse(req, response)
345}
346
347// handleAsset serves static assets from the embedded filesystem.
348// Returns nil if no asset was found (allowing fallback to registered handlers).
349func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
350 h.mu.RLock()
351 hasAssets := h.assets != (embed.FS{})
352 assetDir := h.assetDir
353 h.mu.RUnlock()
354
355 if !hasAssets {
356 return nil
357 }
358
359 // Determine the target file based on host and path
360 host := u.Host
361 path := strings.TrimPrefix(u.Path, "/")
362
363 var relPath string
364 switch {
365 // Home root maps to index.html.
366 case host == HomePath && (path == "" || path == "/"):
367 relPath = IndexHTML
368 // Home asset paths map directly to assets.
369 case host == HomePath && path != "":
370 relPath = path
371 // Config root maps to config.html.
372 case host == ConfigPath && (path == "" || path == "/"):
373 relPath = "config.html"
374 // Config asset paths map directly to assets.
375 case host == ConfigPath && path != "":
376 relPath = path
377 // Error root maps to error.html.
378 case host == ErrorPath && (path == "" || path == "/"):
379 relPath = "error.html"
380 // Error asset paths map directly to assets.
381 case host == ErrorPath && path != "":
382 relPath = path
383 // Opaque home form maps to index.html.
384 case u.Opaque == HomePath:
385 relPath = IndexHTML
386 // Opaque error form maps to error.html.
387 case u.Opaque == ErrorPath:
388 relPath = "error.html"
389 default:
390 // Not a recognized asset path
391 return nil
392 }
393
394 // Read the asset from embedded FS
395 fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath))
396 data, err := fs.ReadFile(h.assets, fullPath)
397 if err != nil {
398 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
399 return nil
400 }
401
402 contentType := h.getMimeType(relPath)
403 h.logger.Debug().
404 Str("path", fullPath).
405 Str("content_type", contentType).
406 Int("size", len(data)).
407 Msg("serving asset")
408
409 return &SchemeResponse{
410 Data: data,
411 ContentType: contentType,
412 StatusCode: http.StatusOK,
413 }
414}
415
416// getMimeType determines the MIME type for a given file path.
417func (h *DumbSchemeHandler) getMimeType(filename string) string {
418 if h == nil {
419 return "application/octet-stream"
420 }
421 ext := strings.ToLower(filepath.Ext(filename))
422
423 // Try standard mime type first
424 mt := mime.TypeByExtension(ext)
425 if mt != "" {
426 return mt
427 }
428
429 // Fallbacks for common web assets
430 switch ext {
431 case ".js", ".mjs":
432 return "application/javascript"
433 case ".css":
434 return "text/css"
435 case ".svg":
436 return "image/svg+xml"
437 case ".ico":
438 return "image/x-icon"
439 case ".woff":
440 return "font/woff"
441 case ".woff2":
442 return "font/woff2"
443 case ".ttf":
444 return "font/ttf"
445 case ".otf":
446 return "font/otf"
447 case ".png":
448 return "image/png"
449 case ".jpg", ".jpeg":
450 return "image/jpeg"
451 case ".gif":
452 return "image/gif"
453 case ".webp":
454 return "image/webp"
455 case ".json":
456 return "application/json"
457 case ".xml":
458 return "application/xml"
459 case ".html", ".htm":
460 return "text/html; charset=utf-8"
461 default:
462 return "text/plain"
463 }
464}
465
466// sendResponse sends the response back to WebKit.
467func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response *SchemeResponse) {
468 if response == nil {
469 response = &SchemeResponse{
470 Data: []byte("Internal error"),
471 ContentType: "text/plain",
472 StatusCode: http.StatusInternalServerError,
473 }
474 }
475
476 contentType := response.ContentType
477 if contentType == "" {
478 contentType = "text/html"
479 }
480
481 // Create MemoryInputStream from data directly
482 stream := gio.NewMemoryInputStreamFromData(response.Data, len(response.Data), nil)
483 if stream == nil {
484 h.logger.Error().Msg("failed to create MemoryInputStream for response")
485 return
486 }
487
488 // Create response object for more control
489 schemeResp := webkit.NewURISchemeResponse(&stream.InputStream, int64(len(response.Data)))
490 if schemeResp == nil {
491 h.logger.Error().Msg("failed to create URISchemeResponse")
492 return
493 }
494 schemeResp.SetContentType(contentType)
495 schemeResp.SetStatus(uint(response.StatusCode), nil)
496
497 // WebKit can treat custom schemes as CORS-relevant even for same-origin fetch().
498 // We only add CORS headers for our internal API endpoints.
499 if strings.HasPrefix(req.GetPath(), "/api/") {
500 hdrs := soup.NewMessageHeaders(soup.MessageHeadersResponseValue)
501 hdrs.Append("Access-Control-Allow-Origin", "*")
502 hdrs.Append("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
503 hdrs.Append("Access-Control-Allow-Headers", "Content-Type")
504 hdrs.Append("Access-Control-Max-Age", "86400")
505 schemeResp.SetHttpHeaders(hdrs)
506 }
507
508 req.FinishWithResponse(schemeResp)
509}
510
511// RegisterWithContext registers the dumb:// scheme with a WebKitContext.
512// The scheme is always registered on the default WebContext to ensure WebViews
513// (which use the default WebContext) can load dumb:// URLs.
514func (h *DumbSchemeHandler) RegisterWithContext(wkCtx *WebKitContext) {
515 if wkCtx == nil || wkCtx.Context() == nil {
516 h.logger.Error().Msg("cannot register scheme: context is nil")
517 return
518 }
519
520 callback := webkit.URISchemeRequestCallback(func(reqPtr, _ uintptr) {
521 h.HandleRequest(reqPtr)
522 })
523
524 // Always register on the default WebContext since WebViews use it
525 defaultCtx := webkit.WebContextGetDefault()
526 if defaultCtx != nil {
527 defaultCtx.RegisterUriScheme("dumb", &callback, 0, nil)
528
529 // Mark scheme as local, secure, and CORS-enabled for proper security policies
530 if secMgr := defaultCtx.GetSecurityManager(); secMgr != nil {
531 secMgr.RegisterUriSchemeAsLocal("dumb")
532 secMgr.RegisterUriSchemeAsSecure("dumb")
533 secMgr.RegisterUriSchemeAsCorsEnabled("dumb")
534 }
535 h.logger.Debug().Msg("dumb:// scheme registered on default WebContext")
536 }
537
538 // Also register on custom WebContext if different from default
539 customCtx := wkCtx.Context()
540 if customCtx != nil && customCtx.GoPointer() != 0 && (defaultCtx == nil || customCtx.GoPointer() != defaultCtx.GoPointer()) {
541 wkCtx.Context().RegisterUriScheme("dumb", &callback, 0, nil)
542
543 if secMgr := customCtx.GetSecurityManager(); secMgr != nil {
544 secMgr.RegisterUriSchemeAsLocal("dumb")
545 secMgr.RegisterUriSchemeAsSecure("dumb")
546 secMgr.RegisterUriSchemeAsCorsEnabled("dumb")
547 }
548 h.logger.Debug().Msg("dumb:// scheme also registered on custom WebContext")
549 }
550
551 h.logger.Info().Msg("dumb:// scheme registered")
552}
553
554// Default page templates (fallback when assets not available)
555
556const errorPageHTML = `<!DOCTYPE html>
557<html>
558<head>
559 <meta charset="utf-8">
560 <title>Error</title>
561 <style>
562 body {
563 font-family: system-ui, -apple-system, sans-serif;
564 background: #1a1a2e;
565 color: #eee;
566 display: flex;
567 align-items: center;
568 justify-content: center;
569 height: 100vh;
570 margin: 0;
571 }
572 .container {
573 text-align: center;
574 }
575 h1 { color: #e74c3c; }
576 p { color: #888; }
577 </style>
578</head>
579<body>
580 <div class="container">
581 <h1>Error</h1>
582 <p>The page could not be loaded.</p>
583 </div>
584</body>
585</html>`
586
587const notFoundHTML = `<!DOCTYPE html>
588<html>
589<head>
590 <meta charset="utf-8">
591 <title>Not Found</title>
592 <style>
593 body {
594 font-family: system-ui, -apple-system, sans-serif;
595 background: #1a1a2e;
596 color: #eee;
597 display: flex;
598 align-items: center;
599 justify-content: center;
600 height: 100vh;
601 margin: 0;
602 }
603 .container {
604 text-align: center;
605 }
606 h1 { color: #f39c12; }
607 p { color: #888; }
608 </style>
609</head>
610<body>
611 <div class="container">
612 <h1>404</h1>
613 <p>Page not found.</p>
614 </div>
615</body>
616</html>`