v0.28.0 3cb3770

scheme_handler.go RawHistoryFolder

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