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/infrastructure/config"
17 "github.com/bnema/dumber/internal/logging"
18 "github.com/bnema/puregotk-webkit/soup"
19 "github.com/bnema/puregotk-webkit/webkit"
20 "github.com/jwijenbergh/puregotk/v4/gio"
21 "github.com/rs/zerolog"
22)
23
24// Scheme path constants
25const (
26 HomePath = "home"
27 ConfigPath = "config"
28 ErrorPath = "error"
29 IndexHTML = "index.html"
30)
31
32// SchemeRequest represents a request to a custom URI scheme.
33type SchemeRequest struct {
34 inner *webkit.URISchemeRequest
35 URI string
36 Path string
37 Method string
38 Scheme string
39}
40
41// SchemeResponse represents a response to a scheme request.
42type SchemeResponse struct {
43 Data []byte
44 ContentType string
45 StatusCode int
46}
47
48// PageHandler generates content for a specific page path.
49type PageHandler interface {
50 Handle(req *SchemeRequest) *SchemeResponse
51}
52
53// PageHandlerFunc is an adapter to allow use of ordinary functions as PageHandlers.
54type PageHandlerFunc func(req *SchemeRequest) *SchemeResponse
55
56func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse {
57 return f(req)
58}
59
60// DumbSchemeHandler handles dumb:// URI scheme requests.
61type DumbSchemeHandler struct {
62 handlers map[string]PageHandler
63 assets embed.FS
64 assetDir string // subdirectory within embed.FS (e.g., "assets/webui")
65 logger zerolog.Logger
66 mu sync.RWMutex
67}
68
69type configAppearancePayload struct {
70 SansFont string `json:"sans_font"`
71 SerifFont string `json:"serif_font"`
72 MonospaceFont string `json:"monospace_font"`
73 DefaultFontSize int `json:"default_font_size"`
74 ColorScheme string `json:"color_scheme"`
75 LightPalette config.ColorPalette `json:"light_palette"`
76 DarkPalette config.ColorPalette `json:"dark_palette"`
77}
78
79type configPayload struct {
80 Appearance configAppearancePayload `json:"appearance"`
81 DefaultUIScale float64 `json:"default_ui_scale"`
82 DefaultSearchEngine string `json:"default_search_engine"`
83 SearchShortcuts map[string]config.SearchShortcut `json:"search_shortcuts"`
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 handlers: make(map[string]PageHandler),
92 assetDir: "webui",
93 logger: log.With().Str("component", "scheme-handler").Logger(),
94 }
95
96 // Register default pages
97 h.registerDefaults()
98
99 return h
100}
101
102// SetAssets sets the embedded filesystem containing webui assets.
103func (h *DumbSchemeHandler) SetAssets(assets embed.FS) {
104 h.mu.Lock()
105 defer h.mu.Unlock()
106 h.assets = assets
107 h.logger.Debug().Msg("assets filesystem configured")
108}
109
110// registerDefaults sets up default page handlers.
111func (h *DumbSchemeHandler) registerDefaults() {
112 // Error page (static fallback)
113 h.RegisterPage("/error", PageHandlerFunc(func(_ *SchemeRequest) *SchemeResponse {
114 return &SchemeResponse{
115 Data: []byte(errorPageHTML),
116 ContentType: "text/html",
117 StatusCode: http.StatusOK,
118 }
119 }))
120
121 // API: Get current config (used by dumb://config)
122 h.RegisterPage("/api/config", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
123 if req.Method != "GET" {
124 return nil
125 }
126
127 return buildConfigResponse(config.Get())
128 }))
129
130 // API: Get default config (used by Reset Defaults in dumb://config)
131 h.RegisterPage("/api/config/default", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
132 if req.Method != "GET" {
133 return nil
134 }
135
136 return buildConfigResponse(config.DefaultConfig())
137 }))
138}
139
140func buildConfigResponse(cfg *config.Config) *SchemeResponse {
141 resp := configPayload{
142 DefaultUIScale: cfg.DefaultUIScale,
143 DefaultSearchEngine: cfg.DefaultSearchEngine,
144 SearchShortcuts: cfg.SearchShortcuts,
145 Appearance: configAppearancePayload{
146 SansFont: cfg.Appearance.SansFont,
147 SerifFont: cfg.Appearance.SerifFont,
148 MonospaceFont: cfg.Appearance.MonospaceFont,
149 DefaultFontSize: cfg.Appearance.DefaultFontSize,
150 ColorScheme: cfg.Appearance.ColorScheme,
151 LightPalette: cfg.Appearance.LightPalette,
152 DarkPalette: cfg.Appearance.DarkPalette,
153 },
154 }
155
156 data, err := json.Marshal(resp)
157 if err != nil {
158 return &SchemeResponse{
159 Data: []byte(fmt.Sprintf(`{"error": %q}`, err)),
160 ContentType: "application/json",
161 StatusCode: http.StatusInternalServerError,
162 }
163 }
164
165 return &SchemeResponse{
166 Data: data,
167 ContentType: "application/json",
168 StatusCode: http.StatusOK,
169 }
170}
171
172// RegisterPage registers a handler for a specific path.
173func (h *DumbSchemeHandler) RegisterPage(path string, handler PageHandler) {
174 h.mu.Lock()
175 defer h.mu.Unlock()
176 h.handlers[path] = handler
177 h.logger.Debug().Str("path", path).Msg("registered page handler")
178}
179
180// HandleRequest processes a scheme request and sends the response.
181func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) {
182 req := webkit.URISchemeRequestNewFromInternalPtr(reqPtr)
183 if req == nil {
184 return
185 }
186
187 uri := req.GetUri()
188 schemeReq := &SchemeRequest{
189 inner: req,
190 URI: uri,
191 Path: req.GetPath(),
192 Method: req.GetHttpMethod(),
193 Scheme: req.GetScheme(),
194 }
195
196 h.logger.Debug().
197 Str("uri", schemeReq.URI).
198 Str("path", schemeReq.Path).
199 Str("method", schemeReq.Method).
200 Msg("handling scheme request")
201
202 // Parse the URI to extract host and path
203 u, err := url.Parse(uri)
204 if err != nil || u.Scheme != "dumb" {
205 h.logger.Error().Err(err).Str("uri", uri).Msg("invalid URI")
206 h.sendResponse(req, &SchemeResponse{
207 Data: []byte("Invalid URI"),
208 ContentType: "text/plain",
209 StatusCode: http.StatusBadRequest,
210 })
211 return
212 }
213
214 // API endpoints should never be treated as static assets
215 if strings.HasPrefix(schemeReq.Path, "/api/") {
216 h.mu.RLock()
217 handler, ok := h.handlers[schemeReq.Path]
218 if !ok {
219 handler, ok = h.handlers[strings.TrimPrefix(schemeReq.Path, "/")]
220 }
221 h.mu.RUnlock()
222 if ok {
223 response := handler.Handle(schemeReq)
224 h.sendResponse(req, response)
225 return
226 }
227 }
228
229 // Try to serve from embedded assets
230 if response := h.handleAsset(u); response != nil {
231 h.sendResponse(req, response)
232 return
233 }
234
235 // Fall back to registered handlers
236 h.mu.RLock()
237 handler, ok := h.handlers[schemeReq.Path]
238 if !ok {
239 handler, ok = h.handlers[strings.TrimPrefix(schemeReq.Path, "/")]
240 }
241 h.mu.RUnlock()
242
243 var response *SchemeResponse
244 if ok {
245 response = handler.Handle(schemeReq)
246 } else {
247 response = &SchemeResponse{
248 Data: []byte(notFoundHTML),
249 ContentType: "text/html",
250 StatusCode: http.StatusNotFound,
251 }
252 }
253
254 h.sendResponse(req, response)
255}
256
257// handleAsset serves static assets from the embedded filesystem.
258// Returns nil if no asset was found (allowing fallback to registered handlers).
259func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
260 h.mu.RLock()
261 hasAssets := h.assets != (embed.FS{})
262 assetDir := h.assetDir
263 h.mu.RUnlock()
264
265 if !hasAssets {
266 return nil
267 }
268
269 // Determine the target file based on host and path
270 host := u.Host
271 path := strings.TrimPrefix(u.Path, "/")
272
273 var relPath string
274 switch {
275 // Home root maps to index.html.
276 case host == HomePath && (path == "" || path == "/"):
277 relPath = IndexHTML
278 // Home asset paths map directly to assets.
279 case host == HomePath && path != "":
280 relPath = path
281 // Config root maps to config.html.
282 case host == ConfigPath && (path == "" || path == "/"):
283 relPath = "config.html"
284 // Config asset paths map directly to assets.
285 case host == ConfigPath && path != "":
286 relPath = path
287 // Error root maps to error.html.
288 case host == ErrorPath && (path == "" || path == "/"):
289 relPath = "error.html"
290 // Error asset paths map directly to assets.
291 case host == ErrorPath && path != "":
292 relPath = path
293 // Opaque home form maps to index.html.
294 case u.Opaque == HomePath:
295 relPath = IndexHTML
296 // Opaque error form maps to error.html.
297 case u.Opaque == ErrorPath:
298 relPath = "error.html"
299 default:
300 // Not a recognized asset path
301 return nil
302 }
303
304 // Read the asset from embedded FS
305 fullPath := filepath.ToSlash(filepath.Join(assetDir, relPath))
306 data, err := fs.ReadFile(h.assets, fullPath)
307 if err != nil {
308 h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
309 return nil
310 }
311
312 contentType := h.getMimeType(relPath)
313 h.logger.Debug().
314 Str("path", fullPath).
315 Str("content_type", contentType).
316 Int("size", len(data)).
317 Msg("serving asset")
318
319 return &SchemeResponse{
320 Data: data,
321 ContentType: contentType,
322 StatusCode: http.StatusOK,
323 }
324}
325
326// getMimeType determines the MIME type for a given file path.
327func (h *DumbSchemeHandler) getMimeType(filename string) string {
328 if h == nil {
329 return "application/octet-stream"
330 }
331 ext := strings.ToLower(filepath.Ext(filename))
332
333 // Try standard mime type first
334 mt := mime.TypeByExtension(ext)
335 if mt != "" {
336 return mt
337 }
338
339 // Fallbacks for common web assets
340 switch ext {
341 case ".js", ".mjs":
342 return "application/javascript"
343 case ".css":
344 return "text/css"
345 case ".svg":
346 return "image/svg+xml"
347 case ".ico":
348 return "image/x-icon"
349 case ".woff":
350 return "font/woff"
351 case ".woff2":
352 return "font/woff2"
353 case ".ttf":
354 return "font/ttf"
355 case ".otf":
356 return "font/otf"
357 case ".png":
358 return "image/png"
359 case ".jpg", ".jpeg":
360 return "image/jpeg"
361 case ".gif":
362 return "image/gif"
363 case ".webp":
364 return "image/webp"
365 case ".json":
366 return "application/json"
367 case ".xml":
368 return "application/xml"
369 case ".html", ".htm":
370 return "text/html; charset=utf-8"
371 default:
372 return "text/plain"
373 }
374}
375
376// sendResponse sends the response back to WebKit.
377func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response *SchemeResponse) {
378 if response == nil {
379 response = &SchemeResponse{
380 Data: []byte("Internal error"),
381 ContentType: "text/plain",
382 StatusCode: http.StatusInternalServerError,
383 }
384 }
385
386 contentType := response.ContentType
387 if contentType == "" {
388 contentType = "text/html"
389 }
390
391 // Create MemoryInputStream from data directly
392 stream := gio.NewMemoryInputStreamFromData(response.Data, len(response.Data), nil)
393 if stream == nil {
394 h.logger.Error().Msg("failed to create MemoryInputStream for response")
395 return
396 }
397
398 // Create response object for more control
399 schemeResp := webkit.NewURISchemeResponse(&stream.InputStream, int64(len(response.Data)))
400 if schemeResp == nil {
401 h.logger.Error().Msg("failed to create URISchemeResponse")
402 return
403 }
404 schemeResp.SetContentType(contentType)
405 schemeResp.SetStatus(uint(response.StatusCode), nil)
406
407 // WebKit can treat custom schemes as CORS-relevant even for same-origin fetch().
408 // We only add CORS headers for our internal API endpoints.
409 if strings.HasPrefix(req.GetPath(), "/api/") {
410 hdrs := soup.NewMessageHeaders(soup.MessageHeadersResponseValue)
411 hdrs.Append("Access-Control-Allow-Origin", "*")
412 hdrs.Append("Access-Control-Allow-Methods", "GET, POST, OPTIONS")
413 hdrs.Append("Access-Control-Allow-Headers", "Content-Type")
414 hdrs.Append("Access-Control-Max-Age", "86400")
415 schemeResp.SetHttpHeaders(hdrs)
416 }
417
418 req.FinishWithResponse(schemeResp)
419}
420
421// RegisterWithContext registers the dumb:// scheme with a WebKitContext.
422func (h *DumbSchemeHandler) RegisterWithContext(wkCtx *WebKitContext) {
423 if wkCtx == nil || wkCtx.Context() == nil {
424 h.logger.Error().Msg("cannot register scheme: context is nil")
425 return
426 }
427
428 callback := webkit.URISchemeRequestCallback(func(reqPtr, _ uintptr) {
429 h.HandleRequest(reqPtr)
430 })
431
432 wkCtx.Context().RegisterUriScheme("dumb", &callback, 0, nil)
433
434 // Mark scheme as local, secure, and CORS-enabled for proper security policies
435 secMgr := wkCtx.Context().GetSecurityManager()
436 if secMgr != nil {
437 secMgr.RegisterUriSchemeAsLocal("dumb")
438 secMgr.RegisterUriSchemeAsSecure("dumb")
439 secMgr.RegisterUriSchemeAsCorsEnabled("dumb")
440 h.logger.Debug().Msg("dumb:// scheme registered as local, secure, and CORS-enabled")
441 }
442
443 h.logger.Info().Msg("dumb:// scheme registered")
444}
445
446// Default page templates (fallback when assets not available)
447
448const errorPageHTML = `<!DOCTYPE html>
449<html>
450<head>
451 <meta charset="utf-8">
452 <title>Error</title>
453 <style>
454 body {
455 font-family: system-ui, -apple-system, sans-serif;
456 background: #1a1a2e;
457 color: #eee;
458 display: flex;
459 align-items: center;
460 justify-content: center;
461 height: 100vh;
462 margin: 0;
463 }
464 .container {
465 text-align: center;
466 }
467 h1 { color: #e74c3c; }
468 p { color: #888; }
469 </style>
470</head>
471<body>
472 <div class="container">
473 <h1>Error</h1>
474 <p>The page could not be loaded.</p>
475 </div>
476</body>
477</html>`
478
479const notFoundHTML = `<!DOCTYPE html>
480<html>
481<head>
482 <meta charset="utf-8">
483 <title>Not Found</title>
484 <style>
485 body {
486 font-family: system-ui, -apple-system, sans-serif;
487 background: #1a1a2e;
488 color: #eee;
489 display: flex;
490 align-items: center;
491 justify-content: center;
492 height: 100vh;
493 margin: 0;
494 }
495 .container {
496 text-align: center;
497 }
498 h1 { color: #f39c12; }
499 p { color: #888; }
500 </style>
501</head>
502<body>
503 <div class="container">
504 <h1>404</h1>
505 <p>Page not found.</p>
506 </div>
507</body>
508</html>`