v0.23.2 bfd5eb4

scheme_handler.go RawHistoryFolder

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>`

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close