scheme_handler.go RawHistoryFolder

1package webkit
2
3import (
4	"context"
5	"embed"
6	"fmt"
7	"net/http"
8	"net/url"
9	"strings"
10	"sync"
11
12	"github.com/bnema/dumber/internal/application/port"
13	"github.com/bnema/dumber/internal/infrastructure/webutil"
14	"github.com/bnema/dumber/internal/logging"
15	"github.com/bnema/puregotk/v4/gio"
16	"github.com/bnema/puregotk/v4/soup"
17	"github.com/bnema/puregotk/v4/webkit"
18	"github.com/rs/zerolog"
19)
20
21// Scheme path constants
22const (
23	HistoryPath             = "history"
24	FavoritesPath           = "favorites"
25	ConfigPath              = "config"
26	ErrorPath               = "error"
27	CrashPath               = "crash"
28	IndexHTML               = "index.html"
29	httpGET                 = "GET"
30	maxSystemviewsWASMBytes = webutil.MaxSystemviewsWASMBytes
31	systemviewsAssetDir     = webutil.SystemviewsAssetDir
32)
33
34// systemviewTrust accepts dumb:// system-view requesters. WebKit gives
35// custom-scheme pages an opaque "null" Origin, so the referrer decides then.
36var systemviewTrust = webutil.SystemviewTrust{
37	IsTrustedURL:             isTrustedSystemviewURL,
38	OpaqueOriginUsesReferrer: true,
39}
40
41// SchemeRequest represents a request to a custom URI scheme.
42type SchemeRequest struct {
43	inner   *webkit.URISchemeRequest
44	URI     string
45	Path    string
46	Method  string
47	Scheme  string
48	Origin  string
49	Referer string
50}
51
52// SchemeResponse represents a response to a scheme request.
53type SchemeResponse struct {
54	Data                   []byte
55	ContentType            string
56	StatusCode             int
57	Headers                map[string]string
58	SuppressDefaultHeaders bool
59}
60
61// PageHandler generates content for a specific page path.
62type PageHandler interface {
63	Handle(req *SchemeRequest) *SchemeResponse
64}
65
66// PageHandlerFunc is an adapter to allow use of ordinary functions as PageHandlers.
67type PageHandlerFunc func(req *SchemeRequest) *SchemeResponse
68
69func (f PageHandlerFunc) Handle(req *SchemeRequest) *SchemeResponse {
70	return f(req)
71}
72
73// DumbSchemeHandler handles dumb:// URI scheme requests.
74type DumbSchemeHandler struct {
75	ctx                  context.Context
76	handlers             map[string]PageHandler
77	assets               embed.FS
78	faviconResolver      port.FaviconSystemviewResolver
79	assetDir             string // default subdirectory within embed.FS (e.g., "systemviews")
80	logger               zerolog.Logger
81	mu                   sync.RWMutex
82	currentConfigPayload func() ([]byte, error)
83	defaultConfigPayload func() ([]byte, error)
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		ctx:      ctx,
92		handlers: make(map[string]PageHandler),
93		assetDir: systemviewsAssetDir,
94		logger:   log.With().Str("component", "scheme-handler").Logger(),
95	}
96
97	// Register default pages
98	h.registerDefaults()
99
100	return h
101}
102
103// SetAssets sets the embedded filesystem containing systemviews assets.
104func (h *DumbSchemeHandler) SetAssets(assets embed.FS) {
105	h.mu.Lock()
106	defer h.mu.Unlock()
107	h.assets = assets
108	h.logger.Debug().Msg("assets filesystem configured")
109}
110
111// SetConfigPayloadBuilders wires the config payload builders used by /api/config.
112func (h *DumbSchemeHandler) SetConfigPayloadBuilders(current, defaultPayload func() ([]byte, error)) {
113	h.mu.Lock()
114	defer h.mu.Unlock()
115	h.currentConfigPayload = current
116	h.defaultConfigPayload = defaultPayload
117	h.logger.Debug().Msg("config payload builders configured")
118}
119
120func (h *DumbSchemeHandler) SetFaviconResolver(resolver port.FaviconSystemviewResolver) {
121	h.mu.Lock()
122	defer h.mu.Unlock()
123	h.faviconResolver = resolver
124}
125
126// registerDefaults sets up default page handlers.
127func (h *DumbSchemeHandler) registerDefaults() {
128	// Error page (static fallback)
129	h.RegisterPage("/error", PageHandlerFunc(func(_ *SchemeRequest) *SchemeResponse {
130		return &SchemeResponse{
131			Data:        []byte(errorPageHTML),
132			ContentType: "text/html",
133			StatusCode:  http.StatusOK,
134		}
135	}))
136
137	// Crash page (web process termination fallback)
138	h.RegisterPage("/"+CrashPath, PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
139		if req.Method != "" && req.Method != httpGET {
140			return nil
141		}
142		originalURI := sanitizeCrashPageOriginalURI(crashOriginalURI(req.URI))
143		return &SchemeResponse{
144			Data:        []byte(buildCrashPageHTML(originalURI)),
145			ContentType: "text/html; charset=utf-8",
146			StatusCode:  http.StatusOK,
147		}
148	}))
149
150	// API: Get current config (used by dumb://config)
151	h.RegisterPage("/api/config", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
152		if req.Method != "" && req.Method != httpGET {
153			return nil
154		}
155		if !isTrustedSystemviewAPIRequest(req) {
156			return privateJSONErrorResponse(http.StatusForbidden, "forbidden")
157		}
158
159		h.mu.RLock()
160		build := h.currentConfigPayload
161		h.mu.RUnlock()
162		return buildConfigResponse(build)
163	}))
164
165	// API: Get default config (used by Reset Defaults in dumb://config)
166	h.RegisterPage("/api/config/default", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
167		if req.Method != "" && req.Method != httpGET {
168			return nil
169		}
170		if !isTrustedSystemviewAPIRequest(req) {
171			return privateJSONErrorResponse(http.StatusForbidden, "forbidden")
172		}
173
174		h.mu.RLock()
175		build := h.defaultConfigPayload
176		h.mu.RUnlock()
177		return buildConfigResponse(build)
178	}))
179
180	h.RegisterPage("/api/favicon", PageHandlerFunc(func(req *SchemeRequest) *SchemeResponse {
181		if req.Method != "" && req.Method != httpGET {
182			return nil
183		}
184		return h.handleFaviconAPI(req)
185	}))
186}
187
188func crashOriginalURI(requestURI string) string {
189	if requestURI == "" {
190		return ""
191	}
192	parsed, err := url.Parse(requestURI)
193	if err != nil {
194		return ""
195	}
196	return strings.TrimSpace(parsed.Query().Get("url"))
197}
198
199func sanitizeCrashPageOriginalURI(originalURI string) string {
200	return webutil.SanitizeCrashPageOriginalURI(originalURI)
201}
202
203func buildCrashPageHTML(originalURI string) string {
204	return webutil.BuildCrashPageHTML(originalURI)
205}
206
207func (h *DumbSchemeHandler) handleFaviconAPI(req *SchemeRequest) *SchemeResponse {
208	if !isTrustedSystemviewFaviconRequest(req) {
209		return privateJSONErrorResponse(http.StatusForbidden, "forbidden")
210	}
211	h.mu.RLock()
212	resolver := h.faviconResolver
213	h.mu.RUnlock()
214	if resolver == nil {
215		return privateJSONErrorResponse(http.StatusNotFound, "favicon unavailable")
216	}
217	if req == nil {
218		return privateJSONErrorResponse(http.StatusBadRequest, "invalid request")
219	}
220	domain, size, err := webutil.ParseSystemviewFaviconRequest(req.URI)
221	if err != nil {
222		return privateJSONErrorResponse(http.StatusBadRequest, err.Error())
223	}
224
225	resolveCtx := h.ctx
226	if resolveCtx == nil {
227		resolveCtx = context.Background()
228	}
229	resolved, err := resolver.ResolveSystemviewIcon(resolveCtx, domain, size)
230	if err != nil || resolved == nil || len(resolved.Bytes) == 0 {
231		return privateJSONErrorResponse(http.StatusNotFound, "favicon not cached")
232	}
233	contentType := resolved.ContentType
234	if contentType == "" {
235		contentType = "image/png"
236	}
237	return &SchemeResponse{
238		Data:                   resolved.Bytes,
239		ContentType:            contentType,
240		StatusCode:             http.StatusOK,
241		Headers:                map[string]string{"Cache-Control": "no-store"},
242		SuppressDefaultHeaders: true,
243	}
244}
245
246func isTrustedSystemviewFaviconRequest(req *SchemeRequest) bool {
247	return isTrustedSystemviewAPIRequest(req)
248}
249
250func isTrustedSystemviewAPIRequest(req *SchemeRequest) bool {
251	return req != nil && systemviewTrust.Allows(req.Origin, req.Referer)
252}
253
254func isTrustedSystemviewURL(raw string) bool {
255	host, ok := webutil.DumbURLHost(raw)
256	if !ok {
257		return false
258	}
259	switch host {
260	case HistoryPath, FavoritesPath, ConfigPath, ErrorPath, CrashPath:
261		return true
262	default:
263		return false
264	}
265}
266
267func jsonErrorResponse(status int, message string) *SchemeResponse {
268	return &SchemeResponse{
269		Data:        fmt.Appendf(nil, `{"error":%q}`, message),
270		ContentType: "application/json",
271		StatusCode:  status,
272	}
273}
274
275func privateJSONErrorResponse(status int, message string) *SchemeResponse {
276	resp := jsonErrorResponse(status, message)
277	resp.Headers = map[string]string{"Cache-Control": "no-store"}
278	resp.SuppressDefaultHeaders = true
279	return resp
280}
281
282func trustedPrivateAPIHeaders() map[string]string {
283	return map[string]string{
284		"Access-Control-Allow-Origin":  "*",
285		"Access-Control-Allow-Methods": "GET, OPTIONS",
286		"Access-Control-Allow-Headers": "Content-Type",
287		"Cache-Control":                "no-store",
288	}
289}
290
291func buildConfigResponse(build func() ([]byte, error)) *SchemeResponse {
292	if build == nil {
293		resp := privateJSONErrorResponse(http.StatusInternalServerError, "config payload builder not configured")
294		resp.Headers = trustedPrivateAPIHeaders()
295		return resp
296	}
297
298	data, err := build()
299	if err != nil {
300		resp := privateJSONErrorResponse(http.StatusInternalServerError, err.Error())
301		resp.Headers = trustedPrivateAPIHeaders()
302		return resp
303	}
304
305	return &SchemeResponse{
306		Data:                   data,
307		ContentType:            "application/json",
308		StatusCode:             http.StatusOK,
309		Headers:                trustedPrivateAPIHeaders(),
310		SuppressDefaultHeaders: true,
311	}
312}
313
314// RegisterPage registers a handler for a specific path.
315func (h *DumbSchemeHandler) RegisterPage(pagePath string, handler PageHandler) {
316	h.mu.Lock()
317	defer h.mu.Unlock()
318	h.handlers[pagePath] = handler
319	h.logger.Debug().Str("path", pagePath).Msg("registered page handler")
320}
321
322// HandleRequest processes a scheme request and sends the response.
323func (h *DumbSchemeHandler) HandleRequest(reqPtr uintptr) {
324	req := webkit.URISchemeRequestNewFromInternalPtr(reqPtr)
325	if req == nil {
326		return
327	}
328
329	uri := req.GetUri()
330	requestHeaders := req.GetHttpHeaders()
331	schemeReq := &SchemeRequest{
332		inner:  req,
333		URI:    uri,
334		Path:   req.GetPath(),
335		Method: req.GetHttpMethod(),
336		Scheme: req.GetScheme(),
337	}
338	if requestHeaders != nil {
339		schemeReq.Origin = strings.TrimSpace(requestHeaders.GetOne("Origin"))
340		schemeReq.Referer = strings.TrimSpace(requestHeaders.GetOne("Referer"))
341	}
342
343	h.logger.Debug().
344		Str("uri", schemeReq.URI).
345		Str("path", schemeReq.Path).
346		Str("method", schemeReq.Method).
347		Msg("handling scheme request")
348
349	// Parse the URI to extract host and path
350	u, err := url.Parse(uri)
351	if err != nil || u.Scheme != "dumb" {
352		h.logger.Error().Err(err).Str("uri", uri).Msg("invalid URI")
353		h.sendResponse(req, &SchemeResponse{
354			Data:        []byte("Invalid URI"),
355			ContentType: "text/plain",
356			StatusCode:  http.StatusBadRequest,
357		})
358		return
359	}
360
361	// API endpoints should never be treated as static assets
362	if strings.HasPrefix(schemeReq.Path, "/api/") {
363		h.mu.RLock()
364		handler, ok := h.handlers[schemeReq.Path]
365		if !ok {
366			handler, ok = h.handlers[strings.TrimPrefix(schemeReq.Path, "/")]
367		}
368		h.mu.RUnlock()
369		if ok {
370			response := handler.Handle(schemeReq)
371			h.sendResponse(req, response)
372			return
373		}
374	}
375
376	// Try to serve from embedded assets
377	if response := h.handleAsset(u); response != nil {
378		h.sendResponse(req, response)
379		return
380	}
381
382	// Fall back to registered handlers
383	h.mu.RLock()
384	handler, ok := h.handlers[schemeReq.Path]
385	if !ok {
386		handler, ok = h.handlers[strings.TrimPrefix(schemeReq.Path, "/")]
387	}
388	h.mu.RUnlock()
389
390	var response *SchemeResponse
391	if ok {
392		response = handler.Handle(schemeReq)
393	} else {
394		response = &SchemeResponse{
395			Data:        []byte(notFoundHTML),
396			ContentType: "text/html",
397			StatusCode:  http.StatusNotFound,
398		}
399	}
400
401	h.sendResponse(req, response)
402}
403
404// handleAsset serves static assets from the embedded filesystem.
405// Returns nil if no asset was found (allowing fallback to registered handlers).
406func (h *DumbSchemeHandler) handleAsset(u *url.URL) *SchemeResponse {
407	h.mu.RLock()
408	hasAssets := h.assets != (embed.FS{})
409	h.mu.RUnlock()
410
411	if !hasAssets {
412		return nil
413	}
414
415	assetDir, relPath, ok := resolveAssetPath(u)
416	if !ok {
417		return nil
418	}
419	if assetDir == "" {
420		assetDir = h.assetDir
421	}
422
423	fullPath, relPath, ok := safeSystemviewsAssetPath(assetDir, relPath)
424	if !ok {
425		return nil
426	}
427
428	data, err := readAssetWithEncoding(h.assets, fullPath, relPath)
429	if err != nil {
430		h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
431		return nil
432	}
433
434	contentType := webutil.GetMimeType(relPath)
435	h.logger.Debug().
436		Str("path", fullPath).
437		Str("content_type", contentType).
438		Int("size", len(data)).
439		Msg("serving asset")
440
441	return &SchemeResponse{
442		Data:        data,
443		ContentType: contentType,
444		StatusCode:  http.StatusOK,
445		Headers:     nil,
446	}
447}
448
449var (
450	safeSystemviewsAssetPath = webutil.SafeSystemviewsAssetPath
451	readAssetWithEncoding    = webutil.ReadSystemviewAsset
452)
453
454func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
455	if u == nil {
456		return "", "", false
457	}
458
459	rootByHost := map[string]struct {
460		assetDir string
461		file     string
462	}{
463		HistoryPath:   {assetDir: systemviewsAssetDir, file: IndexHTML},
464		FavoritesPath: {assetDir: systemviewsAssetDir, file: IndexHTML},
465		ConfigPath:    {assetDir: systemviewsAssetDir, file: IndexHTML},
466		ErrorPath:     {assetDir: systemviewsAssetDir, file: IndexHTML},
467		CrashPath:     {assetDir: systemviewsAssetDir, file: IndexHTML},
468	}
469
470	if root, ok := rootByHost[u.Host]; ok {
471		assetPath := strings.TrimPrefix(u.Path, "/")
472		if assetPath == "" {
473			return root.assetDir, root.file, true
474		}
475		return root.assetDir, assetPath, true
476	}
477
478	switch u.Opaque {
479	case HistoryPath, FavoritesPath, ConfigPath, ErrorPath, CrashPath:
480		return systemviewsAssetDir, IndexHTML, true
481	default:
482		return "", "", false
483	}
484}
485
486func shouldAddCORSHeaders(requestPath string) bool {
487	requestPath = strings.TrimSpace(requestPath)
488	return strings.HasSuffix(requestPath, ".wasm")
489}
490
491func responseHeadersForPath(requestPath, contentType string) map[string]string {
492	if !shouldAddCORSHeaders(requestPath) {
493		return nil
494	}
495
496	headers := map[string]string{
497		"Access-Control-Allow-Origin":  "*",
498		"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
499		"Access-Control-Allow-Headers": "Content-Type",
500		"Access-Control-Max-Age":       "86400",
501	}
502	if contentType != "" {
503		headers["Content-Type"] = contentType
504	}
505	return headers
506}
507
508// sendResponse sends the response back to WebKit.
509func (h *DumbSchemeHandler) sendResponse(req *webkit.URISchemeRequest, response *SchemeResponse) {
510	if response == nil {
511		response = &SchemeResponse{
512			Data:        []byte("Internal error"),
513			ContentType: "text/plain",
514			StatusCode:  http.StatusInternalServerError,
515		}
516	}
517
518	contentType := response.ContentType
519	if contentType == "" {
520		contentType = "text/html"
521	}
522
523	// Create MemoryInputStream from data directly
524	stream := gio.NewMemoryInputStreamFromData(response.Data, len(response.Data), nil)
525	if stream == nil {
526		h.logger.Error().Msg("failed to create MemoryInputStream for response")
527		return
528	}
529
530	// Create response object for more control
531	schemeResp := webkit.NewURISchemeResponse(&stream.InputStream, int64(len(response.Data)))
532	if schemeResp == nil {
533		h.logger.Error().Msg("failed to create URISchemeResponse")
534		return
535	}
536	schemeResp.SetContentType(contentType)
537	schemeResp.SetStatus(uint(response.StatusCode), nil)
538
539	// WebKit can treat custom schemes as CORS-relevant for the wasm runtime asset.
540	// Private systemview APIs opt out with SuppressDefaultHeaders and validate callers.
541	var headers map[string]string
542	if !response.SuppressDefaultHeaders {
543		headers = responseHeadersForPath(req.GetPath(), contentType)
544	}
545	for name, value := range response.Headers {
546		if headers == nil {
547			headers = map[string]string{}
548		}
549		headers[name] = value
550	}
551	if len(headers) > 0 {
552		hdrs := soup.NewMessageHeaders(soup.MessageHeadersResponseValue)
553		for name, value := range headers {
554			hdrs.Append(name, value)
555		}
556		schemeResp.SetHttpHeaders(hdrs)
557	}
558
559	req.FinishWithResponse(schemeResp)
560}
561
562// RegisterWithContext registers the dumb:// scheme with a WebKitContext.
563// The scheme is always registered on the default WebContext to ensure WebViews
564// (which use the default WebContext) can load dumb:// URLs.
565func (h *DumbSchemeHandler) RegisterWithContext(wkCtx *WebKitContext) {
566	if wkCtx == nil || wkCtx.Context() == nil {
567		h.logger.Error().Msg("cannot register scheme: context is nil")
568		return
569	}
570
571	callback := webkit.URISchemeRequestCallback(func(reqPtr, _ uintptr) {
572		h.HandleRequest(reqPtr)
573	})
574
575	// Always register on the default WebContext since WebViews use it
576	defaultCtx := webkit.WebContextGetDefault()
577	if defaultCtx != nil {
578		defaultCtx.RegisterUriScheme("dumb", &callback, 0, nil)
579
580		// Mark scheme as local, secure, and CORS-enabled for proper security policies
581		if secMgr := defaultCtx.GetSecurityManager(); secMgr != nil {
582			secMgr.RegisterUriSchemeAsLocal("dumb")
583			secMgr.RegisterUriSchemeAsSecure("dumb")
584			secMgr.RegisterUriSchemeAsCorsEnabled("dumb")
585		}
586		h.logger.Debug().Msg("dumb:// scheme registered on default WebContext")
587	}
588
589	// Also register on custom WebContext if different from default
590	customCtx := wkCtx.Context()
591	if customCtx != nil && customCtx.GoPointer() != 0 && (defaultCtx == nil || customCtx.GoPointer() != defaultCtx.GoPointer()) {
592		wkCtx.Context().RegisterUriScheme("dumb", &callback, 0, nil)
593
594		if secMgr := customCtx.GetSecurityManager(); secMgr != nil {
595			secMgr.RegisterUriSchemeAsLocal("dumb")
596			secMgr.RegisterUriSchemeAsSecure("dumb")
597			secMgr.RegisterUriSchemeAsCorsEnabled("dumb")
598		}
599		h.logger.Debug().Msg("dumb:// scheme also registered on custom WebContext")
600	}
601
602	h.logger.Info().Msg("dumb:// scheme registered")
603}
604
605// Default page templates (fallback when assets not available)
606
607const errorPageHTML = `<!DOCTYPE html>
608<html>
609<head>
610    <meta charset="utf-8">
611    <title>Error</title>
612    <style>
613        body {
614            font-family: system-ui, -apple-system, sans-serif;
615            background: #1a1a2e;
616            color: #eee;
617            display: flex;
618            align-items: center;
619            justify-content: center;
620            height: 100vh;
621            margin: 0;
622        }
623        .container {
624            text-align: center;
625        }
626        h1 { color: #e74c3c; }
627        p { color: #888; }
628    </style>
629</head>
630<body>
631    <div class="container">
632        <h1>Error</h1>
633        <p>The page could not be loaded.</p>
634    </div>
635</body>
636</html>`
637
638const notFoundHTML = `<!DOCTYPE html>
639<html>
640<head>
641    <meta charset="utf-8">
642    <title>Not Found</title>
643    <style>
644        body {
645            font-family: system-ui, -apple-system, sans-serif;
646            background: #1a1a2e;
647            color: #eee;
648            display: flex;
649            align-items: center;
650            justify-content: center;
651            height: 100vh;
652            margin: 0;
653        }
654        .container {
655            text-align: center;
656        }
657        h1 { color: #f39c12; }
658        p { color: #888; }
659    </style>
660</head>
661<body>
662    <div class="container">
663        <h1>404</h1>
664        <p>Page not found.</p>
665    </div>
666</body>
667</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