v0.27.2 5960dd8

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