scheme_handler.go RawHistoryFolder

1package cef
2
3import (
4	"bytes"
5	"context"
6	"crypto/sha256"
7	"encoding/base64"
8	"encoding/hex"
9	"encoding/json"
10	"fmt"
11	"html"
12	"io/fs"
13	"maps"
14	"net/http"
15	"net/url"
16	"strings"
17	"sync"
18	"unsafe"
19
20	purecef "github.com/bnema/purego-cef/cef"
21
22	"github.com/bnema/dumber/internal/application/port"
23	"github.com/bnema/dumber/internal/infrastructure/webutil"
24	"github.com/bnema/dumber/internal/logging"
25	"github.com/rs/zerolog"
26
27	"github.com/bnema/dumber/assets"
28)
29
30// Scheme path constants matching WebKit's naming.
31const (
32	historyPath                 = "history"
33	favoritesPath               = "favorites"
34	configPath                  = "config"
35	errorPath                   = "error"
36	indexHTML                   = "index.html"
37	maxSchemeTruncatedURLLength = 240
38	maxSystemviewsWASMBytes     = webutil.MaxSystemviewsWASMBytes
39	systemviewsAssetDir         = webutil.SystemviewsAssetDir
40)
41
42// systemviewTrust accepts requesters served from the internal HTTPS origin or
43// dumb:// pages. CEF system views have a real origin, so an opaque "null"
44// Origin is rejected rather than falling back to the referrer.
45var systemviewTrust = webutil.SystemviewTrust{IsTrustedURL: isTrustedSystemviewURL}
46
47var (
48	safeSystemviewsAssetPath = webutil.SafeSystemviewsAssetPath
49	readAssetWithEncoding    = webutil.ReadSystemviewAsset
50)
51
52// pageRootFiles maps internal page hosts/paths to their HTML entry points.
53var pageRootFiles = map[string]string{
54	historyPath:   indexHTML,
55	favoritesPath: indexHTML,
56	configPath:    indexHTML,
57	errorPath:     indexHTML,
58}
59
60var cefNewResourceHandler = purecef.NewResourceHandler
61
62// dumbSchemeHandler serves both the conceptual dumb:// URLs and the actual
63// internal https://dumber.invalid origin used by CEF.
64type dumbSchemeHandler struct {
65	ctx             context.Context
66	messageRouter   *MessageRouter
67	faviconResolver port.FaviconSystemviewResolver
68	// assets is the captured immutable asset filesystem. Together with
69	// bundle it forms one replaceable bundle state: installing a different
70	// FS replaces both, and in-flight requests retain their captured pair.
71	// A replaced bundle's cached WASM stays alive until its last in-flight
72	// request finishes, so old and new bundles briefly overlap in memory;
73	// the retained cache budget is one WASM per active bundle. Production
74	// embeds are immutable; no live file watching is added.
75	assets               fs.FS
76	bundle               *systemviewAssetBundle
77	assetDir             string
78	logger               zerolog.Logger
79	currentConfigPayload func() ([]byte, error)
80	defaultConfigPayload func() ([]byte, error)
81	mu                   sync.RWMutex
82
83	// onClipboardSet is called when JS sends copied text via /api/clipboard-set.
84	// Set by the engine to write to the GDK system clipboard.
85	onClipboardSet func(text string)
86
87	// onEditableFocus is called when JS reports that an editable element gained
88	// DOM focus. The engine uses this to reassert CEF browser focus in OSR mode.
89	onEditableFocus func(browser purecef.Browser)
90
91	// onPopupOpen/onPopupNavigate/onPopupClose bridge synthetic window.open()
92	// proxies from page JavaScript back into the browser process when native
93	// related popups are not available (e.g. CEF OSR regular-webview fallback path).
94	onPopupOpen              func(browser purecef.Browser, payload rendererBridgePopupOpenPayload)
95	onPopupNavigate          func(browser purecef.Browser, payload rendererBridgePopupNavigatePayload)
96	onPopupClose             func(browser purecef.Browser, payload rendererBridgePopupClosePayload)
97	onPopupOpenerNavigate    func(browser purecef.Browser, payload popupOpenerNavigatePayload)
98	onPopupOpenerPostMessage func(browser purecef.Browser, payload popupOpenerPostMessagePayload)
99
100	// bridgeNonceValidator checks whether a bridge nonce belongs to the active
101	// browser/navigation context that issued the request.
102	bridgeNonceValidator func(browser purecef.Browser, bridgeNonce string) bool
103}
104
105// newDumbSchemeHandler creates a handler for internal CEF pages.
106func newDumbSchemeHandler(
107	ctx context.Context,
108	router *MessageRouter,
109	currentConfigPayload func() ([]byte, error),
110	defaultConfigPayload func() ([]byte, error),
111) (*dumbSchemeHandler, error) {
112	if currentConfigPayload == nil {
113		return nil, fmt.Errorf("current config payload builder not configured")
114	}
115	if defaultConfigPayload == nil {
116		return nil, fmt.Errorf("default config payload builder not configured")
117	}
118
119	log := logging.FromContext(ctx)
120	return &dumbSchemeHandler{
121		ctx:                  ctx,
122		messageRouter:        router,
123		assetDir:             systemviewsAssetDir,
124		logger:               log.With().Str("component", "scheme-handler").Logger(),
125		currentConfigPayload: currentConfigPayload,
126		defaultConfigPayload: defaultConfigPayload,
127	}, nil
128}
129
130// setAssets installs one immutable asset filesystem and its fresh WASM
131// cache as a single bundle state. In-flight requests keep their captured
132// bundle and can never publish old bytes into the new cache.
133func (h *dumbSchemeHandler) setAssets(assetsFS fs.FS) {
134	h.mu.Lock()
135	defer h.mu.Unlock()
136	h.assets = assetsFS
137	h.bundle = newSystemviewAssetBundle(assetsFS)
138	h.logger.Debug().Msg("assets filesystem configured")
139}
140
141func (h *dumbSchemeHandler) setFaviconResolver(resolver port.FaviconSystemviewResolver) {
142	h.mu.Lock()
143	defer h.mu.Unlock()
144	h.faviconResolver = resolver
145}
146
147// Create implements purecef.SchemeHandlerFactory. Called on the CEF IO thread
148// for requests to either dumb:// or the internal https origin.
149func (h *dumbSchemeHandler) Create(browser purecef.Browser, _ purecef.Frame, _ string, request purecef.Request) purecef.ResourceHandler {
150	reqURL := request.GetURL()
151	method := request.GetMethod()
152
153	h.logger.Debug().
154		Str("url", reqURL).
155		Str("method", method).
156		Msg("scheme handler create")
157
158	u, err := url.Parse(reqURL)
159	if err != nil {
160		h.logger.Error().Err(err).Str("url", reqURL).Msg("invalid URL")
161		return h.newErrorResourceHandler(http.StatusBadRequest, "Invalid URL")
162	}
163
164	if isCEFCrashPageURL(u) {
165		originalURI := sanitizeCEFCrashPageOriginalURI(strings.TrimSpace(u.Query().Get("url")))
166		return h.newRawResourceHandler(http.StatusOK, "text/html; charset=utf-8", []byte(buildCEFCrashPageHTML(originalURI)))
167	}
168
169	// Route API requests before redirecting conceptual dumb:// URLs to the
170	// internal HTTPS origin. External pages use dumb://api/... for clipboard,
171	// focus, and popup bridges specifically to bypass page CSP; redirecting
172	// those requests to https://dumber.invalid makes site connect-src policies
173	// block them.
174	if apiPath, ok := resolveAPIPath(u); ok {
175		return h.handleAPI(browser, method, apiPath, request)
176	}
177
178	// Keep dumb:// as the app-level abstraction, but redirect CEF page/asset
179	// loads to a normal HTTPS origin so Chromium applies the standard web
180	// security model.
181	if isConceptualInternalURL(reqURL) {
182		targetURL := toActualInternalURL(reqURL)
183		if targetURL != "" && targetURL != reqURL {
184			return h.newRedirectResourceHandler(http.StatusTemporaryRedirect, targetURL)
185		}
186	}
187
188	// Serve static assets.
189	return h.handleAsset(u)
190}
191
192// handleAPI routes API requests to the message router or built-in handlers.
193func (h *dumbSchemeHandler) handleAPI(
194	browser purecef.Browser,
195	method string,
196	requestPath string,
197	request purecef.Request,
198) purecef.ResourceHandler {
199	if strings.EqualFold(method, http.MethodOptions) {
200		if requestPath == "/api/config" || requestPath == "/api/config/default" {
201			if denied := h.rejectUntrustedConfigRequester(request); denied != nil {
202				return denied
203			}
204			return h.newPrivateAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil)
205		}
206		return h.newAPIRawResourceHandler(http.StatusNoContent, "text/plain; charset=utf-8", nil)
207	}
208	if isAPIGetMethod(method) {
209		if handler, ok := h.handleAPIGet(requestPath, request); ok {
210			return handler
211		}
212	}
213	if isAPIPostMethod(method) {
214		if handler, ok := h.handleAPIPost(browser, requestPath, request); ok {
215			return handler
216		}
217	}
218	return h.newAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "not found"})
219}
220
221func isAPIGetMethod(method string) bool {
222	return method == "" || strings.EqualFold(method, http.MethodGet)
223}
224
225func isAPIPostMethod(method string) bool {
226	return strings.EqualFold(method, http.MethodPost)
227}
228
229func (h *dumbSchemeHandler) handleAPIGet(requestPath string, request purecef.Request) (purecef.ResourceHandler, bool) {
230	switch requestPath {
231	case "/api/config":
232		if denied := h.rejectUntrustedConfigRequester(request); denied != nil {
233			return denied, true
234		}
235		return h.handleConfigAPI(h.currentConfigPayload), true
236	case "/api/config/default":
237		if denied := h.rejectUntrustedConfigRequester(request); denied != nil {
238			return denied, true
239		}
240		return h.handleConfigAPI(h.defaultConfigPayload), true
241	case "/api/favicon":
242		return h.handleFaviconAPI(request), true
243	default:
244		return nil, false
245	}
246}
247
248func (h *dumbSchemeHandler) handleAPIPost(
249	browser purecef.Browser,
250	requestPath string,
251	request purecef.Request,
252) (purecef.ResourceHandler, bool) {
253	switch requestPath {
254	case "/api/message":
255		return h.handleMessageAPI(request), true
256	case "/api/clipboard-set":
257		return h.handleClipboardSet(request, browser), true
258	case "/api/focus-sync":
259		return h.handleFocusSync(request, browser), true
260	case "/api/popup-open":
261		return h.handlePopupOpen(request, browser), true
262	case "/api/popup-navigate":
263		return h.handlePopupNavigate(request, browser), true
264	case "/api/popup-close":
265		return h.handlePopupClose(request, browser), true
266	case "/api/popup-opener-navigate":
267		return h.handlePopupOpenerNavigate(request, browser), true
268	case "/api/popup-opener-post-message":
269		return h.handlePopupOpenerPostMessage(request, browser), true
270	default:
271		return nil, false
272	}
273}
274
275func (h *dumbSchemeHandler) handleMessageAPI(request purecef.Request) purecef.ResourceHandler {
276	if denied := h.rejectUntrustedSystemviewRequester(request); denied != nil {
277		return denied
278	}
279	body := readBodyFromHeader(request)
280	if body == nil {
281		return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
282	}
283	resp, err := h.messageRouter.HandleMessage(h.ctx, 0, body)
284	if err != nil {
285		return h.newAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})
286	}
287	return h.newAPIRawResourceHandler(http.StatusOK, "application/json", resp)
288}
289
290func (h *dumbSchemeHandler) rejectForbiddenAPIOrigin(request purecef.Request) purecef.ResourceHandler {
291	origin := strings.TrimSpace(request.GetHeaderByName("Origin"))
292	if origin == "" || strings.EqualFold(origin, actualInternalOrigin) {
293		return nil
294	}
295	return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden origin"})
296}
297
298func (h *dumbSchemeHandler) rejectUntrustedConfigRequester(request purecef.Request) purecef.ResourceHandler {
299	return h.rejectUntrustedSystemviewRequester(request)
300}
301
302func (h *dumbSchemeHandler) rejectUntrustedSystemviewRequester(request purecef.Request) purecef.ResourceHandler {
303	if request == nil {
304		return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
305	}
306	origin := request.GetHeaderByName("Origin")
307	referrer := ""
308	if strings.TrimSpace(origin) == "" {
309		referrer = strings.TrimSpace(request.GetReferrerURL())
310		if referrer == "" {
311			referrer = request.GetHeaderByName("Referer")
312		}
313	}
314	if !systemviewTrust.Allows(origin, referrer) {
315		return h.newPrivateAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
316	}
317	return nil
318}
319
320func (h *dumbSchemeHandler) handleFaviconAPI(request purecef.Request) purecef.ResourceHandler {
321	if denied := h.rejectUntrustedFaviconRequester(request); denied != nil {
322		return denied
323	}
324
325	h.mu.RLock()
326	resolver := h.faviconResolver
327	h.mu.RUnlock()
328	if resolver == nil {
329		return h.newPrivateAPIJSONResourceHandler(http.StatusNotFound, map[string]string{"error": "favicon unavailable"})
330	}
331
332	domain, size, err := webutil.ParseSystemviewFaviconRequest(request.GetURL())
333	if err != nil {
334		return h.newPrivateAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()})
335	}
336
337	return newFaviconResourceHandler(h.ctx, resolver, domain, size)
338}
339
340func (h *dumbSchemeHandler) rejectUntrustedFaviconRequester(request purecef.Request) purecef.ResourceHandler {
341	return h.rejectUntrustedSystemviewRequester(request)
342}
343
344func isTrustedSystemviewURL(raw string) bool {
345	parsed, err := url.Parse(strings.TrimSpace(raw))
346	if err != nil || parsed.Scheme == "" {
347		return false
348	}
349	if strings.EqualFold(parsed.Scheme, actualInternalScheme) {
350		return strings.EqualFold(parsed.Host, actualInternalHost)
351	}
352	host, ok := webutil.DumbURLHost(raw)
353	return ok && isInternalPageHost(host)
354}
355
356func resolveConfigPayload(build func() ([]byte, error)) ([]byte, error) {
357	if build == nil {
358		return nil, fmt.Errorf("config payload builder not configured")
359	}
360	return build()
361}
362
363// handleConfigAPI returns a JSON response with the given config payload builder.
364func (h *dumbSchemeHandler) handleConfigAPI(build func() ([]byte, error)) purecef.ResourceHandler {
365	data, err := resolveConfigPayload(build)
366	if err != nil {
367		return h.newPrivateAPIJSONResourceHandler(http.StatusInternalServerError, map[string]string{"error": err.Error()})
368	}
369	return h.newPrivateAPIRawResourceHandler(http.StatusOK, "application/json", data)
370}
371
372// handleClipboardSet receives copied text from JS copy/cut events and writes
373// it to the system clipboard via the engine callback.
374const (
375	maxClipboardBytes            = 10 << 20 // 10 MB
376	dumberBodyHeaderName         = "X-Dumber-Body"
377	dumberBridgeActionHeaderName = "X-Dumber-Bridge-Action"
378	dumberBridgeNonceHeaderName  = "X-Dumber-Bridge-Nonce"
379	dumberBridgeActionFocusSync  = "focus-sync"
380)
381
382func (h *dumbSchemeHandler) handleClipboardSet(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
383	h.logger.Debug().Msg("cef: /api/clipboard-set request received")
384
385	if !h.hasTrustedBridgeNonce(request, browser) {
386		h.logger.Warn().Msg("cef: clipboard-set — rejected request without valid bridge nonce")
387		return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
388	}
389
390	body := readBodyFromHeader(request)
391	if body == nil {
392		h.logger.Debug().Msg("cef: clipboard-set — empty body (no X-Dumber-Body header)")
393		return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
394	}
395	if len(body) > maxClipboardBytes {
396		h.logger.Warn().Int("body_len", len(body)).Msg("cef: clipboard-set — payload too large")
397		return h.newAPIJSONResourceHandler(http.StatusRequestEntityTooLarge, map[string]string{"error": "payload too large"})
398	}
399
400	var payload struct {
401		Text string `json:"text"`
402	}
403	if err := json.Unmarshal(body, &payload); err != nil || payload.Text == "" {
404		h.logger.Debug().Int("body_len", len(body)).Msg("cef: clipboard-set — invalid or empty payload")
405		return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "invalid payload"})
406	}
407
408	h.logger.Debug().Int("text_len", len(payload.Text)).Msg("cef: clipboard-set — forwarding to GDK clipboard")
409
410	if h.onClipboardSet != nil {
411		h.onClipboardSet(payload.Text)
412	} else {
413		h.logger.Warn().Msg("cef: clipboard-set — onClipboardSet callback not wired")
414	}
415
416	return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
417}
418
419func (h *dumbSchemeHandler) handleFocusSync(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
420	bridgeAction := ""
421	if request != nil {
422		bridgeAction = strings.TrimSpace(request.GetHeaderByName(dumberBridgeActionHeaderName))
423	}
424	if !strings.EqualFold(bridgeAction, dumberBridgeActionFocusSync) {
425		h.logger.Warn().Msg("cef: focus-sync — rejected request without trusted bridge action header")
426		return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
427	}
428	if browser == nil {
429		h.logger.Debug().Msg("cef: focus-sync — browser unavailable")
430		return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})
431	}
432	if !h.hasTrustedBridgeNonce(request, browser) {
433		h.logger.Warn().Msg("cef: focus-sync — rejected request without valid bridge nonce")
434		return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
435	}
436
437	if h.onEditableFocus != nil {
438		h.onEditableFocus(browser)
439	} else {
440		h.logger.Debug().Msg("cef: focus-sync — onEditableFocus callback not wired")
441	}
442
443	return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
444}
445
446func handlePopupBridgeRequest[T any](
447	h *dumbSchemeHandler,
448	request purecef.Request,
449	browser purecef.Browser,
450	action string,
451	decode func([]byte) (T, error),
452	dispatch func(purecef.Browser, T),
453) purecef.ResourceHandler {
454	if browser == nil {
455		h.logger.Debug().Msg("cef: " + action + " — browser unavailable")
456		return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "browser unavailable"})
457	}
458	if !h.hasTrustedBridgeNonce(request, browser) {
459		h.logger.Warn().Msg("cef: " + action + " — rejected request without valid bridge nonce")
460		return h.newAPIJSONResourceHandler(http.StatusForbidden, map[string]string{"error": "forbidden"})
461	}
462	body := readBodyFromHeader(request)
463	if body == nil {
464		return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": "empty body"})
465	}
466	payload, err := decode(body)
467	if err != nil {
468		return h.newAPIJSONResourceHandler(http.StatusBadRequest, map[string]string{"error": err.Error()})
469	}
470	if dispatch != nil {
471		dispatch(browser, payload)
472	} else {
473		h.logger.Warn().Msg("cef: " + action + " — callback not wired")
474	}
475	return h.newAPIJSONResourceHandler(http.StatusOK, map[string]any{"ok": true})
476}
477
478func (h *dumbSchemeHandler) handlePopupOpen(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
479	return handlePopupBridgeRequest(h, request, browser, "popup-open", decodeRendererBridgePopupOpenPayload, h.onPopupOpen)
480}
481
482func (h *dumbSchemeHandler) handlePopupNavigate(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
483	return handlePopupBridgeRequest(h, request, browser, "popup-navigate", decodeRendererBridgePopupNavigatePayload, h.onPopupNavigate)
484}
485
486func (h *dumbSchemeHandler) handlePopupClose(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
487	return handlePopupBridgeRequest(h, request, browser, "popup-close", decodeRendererBridgePopupClosePayload, h.onPopupClose)
488}
489
490func decodePopupOpenerNavigatePayload(body []byte) (popupOpenerNavigatePayload, error) {
491	var payload popupOpenerNavigatePayload
492	if err := json.Unmarshal(body, &payload); err != nil {
493		return payload, err
494	}
495	payload.URL = strings.TrimSpace(payload.URL)
496	if payload.URL == "" {
497		return payload, fmt.Errorf("missing url")
498	}
499	return payload, nil
500}
501
502func decodePopupOpenerPostMessagePayload(body []byte) (popupOpenerPostMessagePayload, error) {
503	var payload popupOpenerPostMessagePayload
504	if err := json.Unmarshal(body, &payload); err != nil {
505		return payload, err
506	}
507	payload.DataKind = strings.TrimSpace(payload.DataKind)
508	payload.TargetOrigin = strings.TrimSpace(payload.TargetOrigin)
509	payload.SourceOrigin = strings.TrimSpace(payload.SourceOrigin)
510	payload.SourceHref = strings.TrimSpace(payload.SourceHref)
511	if payload.TargetOrigin == "" {
512		return payload, fmt.Errorf("missing target origin")
513	}
514	return payload, nil
515}
516
517func (h *dumbSchemeHandler) handlePopupOpenerNavigate(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
518	return handlePopupBridgeRequest(h, request, browser, "popup-opener-navigate", decodePopupOpenerNavigatePayload, h.onPopupOpenerNavigate)
519}
520
521func (h *dumbSchemeHandler) handlePopupOpenerPostMessage(request purecef.Request, browser purecef.Browser) purecef.ResourceHandler {
522	return handlePopupBridgeRequest(
523		h,
524		request,
525		browser,
526		"popup-opener-post-message",
527		decodePopupOpenerPostMessagePayload,
528		h.onPopupOpenerPostMessage,
529	)
530}
531
532func (h *dumbSchemeHandler) hasTrustedBridgeNonce(request purecef.Request, browser purecef.Browser) bool {
533	if browser == nil || h == nil || h.bridgeNonceValidator == nil || request == nil {
534		return false
535	}
536	bridgeNonce := strings.TrimSpace(request.GetHeaderByName(dumberBridgeNonceHeaderName))
537	if bridgeNonce == "" {
538		return false
539	}
540	return h.bridgeNonceValidator(browser, bridgeNonce)
541}
542
543func (h *dumbSchemeHandler) newRedirectResourceHandler(status int, location string) purecef.ResourceHandler {
544	return newStaticResourceHandler(status, "text/plain; charset=utf-8", nil, map[string]string{
545		"Location":      location,
546		"Cache-Control": "no-store",
547	})
548}
549
550// handleAsset serves static files from the captured asset bundle.
551func (h *dumbSchemeHandler) handleAsset(u *url.URL) purecef.ResourceHandler {
552	h.mu.RLock()
553	assetsFS, bundle := h.assets, h.bundle
554	h.mu.RUnlock()
555
556	if assetsFS == nil || bundle == nil {
557		return h.newErrorResourceHandler(http.StatusInternalServerError, "Assets not configured")
558	}
559
560	assetDir, relPath, ok := resolveAssetPath(u)
561	if !ok {
562		return h.newErrorResourceHandler(http.StatusNotFound, "Page not found")
563	}
564	if assetDir == "" {
565		assetDir = h.assetDir
566	}
567
568	fullPath, relPath, ok := safeSystemviewsAssetPath(assetDir, relPath)
569	if !ok {
570		return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
571	}
572
573	// The bundle WASM is served from the once-loaded immutable cache
574	// through a deferred handler: Create returns immediately and the
575	// decode runs off the CEF IO thread. The shell keeps its usable
576	// relative URLs on disk but is served with content-versioned
577	// references from the same captured bundle. Every other asset keeps
578	// the legacy synchronous per-request read path.
579	if relPath == indexHTML {
580		return h.serveVersionedShell(assetsFS, bundle, fullPath)
581	}
582	if isVersionedAssetFile(relPath) {
583		v, present, valid := parseAssetVersion(u)
584		return h.serveVersionedStatic(assetsFS, bundle, fullPath, relPath, v, present, valid)
585	}
586
587	data, err := readAssetWithEncoding(assetsFS, fullPath, relPath)
588	if err != nil {
589		h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
590		return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
591	}
592
593	contentType := getMimeType(relPath)
594	h.logger.Debug().
595		Str("path", fullPath).
596		Str("content_type", contentType).
597		Int("size", len(data)).
598		Msg("serving asset")
599
600	// Unversioned static assets are servable but explicitly noncacheable;
601	// only version-verified bytes earn immutable headers below.
602	return newStaticResourceHandler(http.StatusOK, contentType, data,
603		map[string]string{"Cache-Control": noStoreCacheControl})
604}
605
606// immutableCacheControl grants long-lived private caching to exactly one
607// verified content version. It is served only for allowlisted static
608// files whose ?v= digest matches the SHA-256 of the exact bytes served.
609const immutableCacheControl = "private, max-age=31536000, immutable"
610
611// noStoreCacheControl marks responses that must never be cached: the
612// unversioned shell and static assets, dynamic API content, and every
613// version-gating error.
614const noStoreCacheControl = "no-store"
615
616// serveVersionedShell serves the internal shell with its known static
617// references rewritten to content-versioned URLs (?v=<sha256>) from the
618// captured bundle's manifest. The shell itself is unversioned and stays
619// noncacheable; version queries on the shell are ignored because the
620// shell document is never immutable. An invalid manifest yields a clear
621// noncacheable error instead of unverified versioned content.
622func (h *dumbSchemeHandler) serveVersionedShell(assetsFS fs.FS, bundle *systemviewAssetBundle, fullPath string) purecef.ResourceHandler {
623	data, err := readAssetWithEncoding(assetsFS, fullPath, indexHTML)
624	if err != nil {
625		h.logger.Debug().Str("path", fullPath).Err(err).Msg("shell not found")
626		return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
627	}
628	m, err := bundle.Manifest()
629	if err != nil {
630		h.logger.Error().Err(err).Msg("asset manifest invalid; refusing to version shell")
631		return newStaticResourceHandler(http.StatusInternalServerError, "text/html; charset=utf-8",
632			errorPageBody(http.StatusInternalServerError, "Asset manifest invalid"),
633			map[string]string{"Cache-Control": noStoreCacheControl})
634	}
635	return newStaticResourceHandler(http.StatusOK, getMimeType(indexHTML), versionShellRefs(data, m),
636		map[string]string{"Cache-Control": noStoreCacheControl})
637}
638
639// versionShellRefs rewrites the shell's known static references with
640// content-version query values: ./systemviews.wasm becomes
641// ./systemviews.wasm?v=<digest>, likewise for the pinned JS and CSS.
642// Only manifest-pinned files are rewritten and already-versioned
643// references pass through untouched, so the on-disk shell keeps concrete
644// usable relative URLs for alternate shells.
645func versionShellRefs(shell []byte, m assets.Manifest) []byte {
646	out := shell
647	for _, name := range assets.ManifestFiles {
648		plain := "./" + name
649		if bytes.Contains(out, []byte(plain+"?v=")) {
650			continue
651		}
652		out = bytes.ReplaceAll(out, []byte(plain), []byte(plain+"?v="+m.Files[name].SHA256))
653	}
654	return out
655}
656
657// isVersionedAssetFile reports whether relPath is a canonical allowlisted
658// static file: exactly the manifest-pinned servables. Only these may earn
659// immutable headers; everything else (including the manifest itself and
660// the shell) stays on the noncacheable path.
661func isVersionedAssetFile(relPath string) bool {
662	for _, name := range assets.ManifestFiles {
663		if relPath == name {
664			return true
665		}
666	}
667	return false
668}
669
670// parseAssetVersion extracts the content-version query value with
671// exactly-one semantics: absent reports present=false (unversioned
672// request), while duplicate or empty values report present=true with
673// valid=false (a version-gating error, never an unversioned request).
674// Malformed escape sequences are fail-closed too: u.Query silently drops
675// unparseable components, so the raw query is parsed explicitly and any
676// parse error alongside a v parameter rejects the request.
677func parseAssetVersion(u *url.URL) (v string, present, valid bool) {
678	if u == nil {
679		return "", false, false
680	}
681	vals, qerr := url.ParseQuery(u.RawQuery)
682	if qerr != nil {
683		// A malformed query carrying a v parameter is a version-gating
684		// error; without one it is an ordinary unversioned request.
685		return "", hasVersionParam(u.RawQuery), false
686	}
687	vs, ok := vals["v"]
688	if !ok || len(vs) == 0 {
689		return "", false, false
690	}
691	if len(vs) != 1 || vs[0] == "" {
692		return "", true, false
693	}
694	return vs[0], true, true
695}
696
697// hasVersionParam reports whether the raw query carries a v parameter,
698// used only to classify malformed queries fail-closed.
699func hasVersionParam(rawQuery string) bool {
700	for _, part := range strings.Split(rawQuery, "&") {
701		name, _, _ := strings.Cut(part, "=")
702		if name == "v" {
703			return true
704		}
705	}
706	return false
707}
708
709// serveVersionedStatic serves one allowlisted static file with byte-bound
710// cache policy: exactly one valid ?v= matching the SHA-256 of the exact
711// served bytes earns immutable headers. URL-to-manifest equality alone is
712// insufficient — the bytes are hashed and compared. Duplicate, malformed,
713// or mismatching versions, missing artifacts, and stale manifests all
714// return a noncacheable error; unversioned requests serve explicitly
715// noncacheable without verification.
716func (h *dumbSchemeHandler) serveVersionedStatic(
717	assetsFS fs.FS, bundle *systemviewAssetBundle, fullPath, relPath, v string, present, valid bool,
718) purecef.ResourceHandler {
719	if present && !valid {
720		return h.versionError(fullPath)
721	}
722	// Unversioned requests never consult the manifest: they serve
723	// explicitly noncacheable without verification, including on
724	// manifest-less bundles.
725	if !present {
726		return h.serveUnversionedStatic(assetsFS, bundle, fullPath, relPath)
727	}
728	m, err := bundle.Manifest()
729	if err != nil {
730		h.logger.Error().Err(err).Msg("asset manifest invalid; refusing versioned static")
731		return newStaticResourceHandler(http.StatusInternalServerError, "text/html; charset=utf-8",
732			errorPageBody(http.StatusInternalServerError, "Asset manifest invalid"),
733			map[string]string{"Cache-Control": noStoreCacheControl})
734	}
735	pin, ok := m.Files[relPath]
736	if !ok {
737		h.logger.Error().Str("path", fullPath).Msg("asset manifest missing pin; refusing versioned static")
738		return newStaticResourceHandler(http.StatusInternalServerError, "text/html; charset=utf-8",
739			errorPageBody(http.StatusInternalServerError, "Asset manifest invalid"),
740			map[string]string{"Cache-Control": noStoreCacheControl})
741	}
742	if v != pin.SHA256 {
743		return h.versionError(fullPath)
744	}
745	if relPath == "systemviews.wasm" {
746		return newSystemviewWASMResourceHandler(h.ctx, bundle, true, pin.SHA256)
747	}
748	data, err := readAssetWithEncoding(assetsFS, fullPath, relPath)
749	if err != nil {
750		return h.versionError(fullPath)
751	}
752	if sha256Hex(data) != pin.SHA256 {
753		h.logger.Debug().Str("path", fullPath).Msg("versioned asset bytes do not match manifest pin")
754		return h.versionError(fullPath)
755	}
756	h.logger.Debug().
757		Str("path", fullPath).
758		Str("content_type", getMimeType(relPath)).
759		Int("size", len(data)).
760		Msg("serving verified immutable asset")
761	return newStaticResourceHandler(http.StatusOK, getMimeType(relPath), data,
762		map[string]string{"Cache-Control": immutableCacheControl})
763}
764
765// serveUnversionedStatic serves an allowlisted file requested without a
766// version: readable, explicitly noncacheable, unverified.
767func (h *dumbSchemeHandler) serveUnversionedStatic(
768	assetsFS fs.FS, bundle *systemviewAssetBundle, fullPath, relPath string,
769) purecef.ResourceHandler {
770	if relPath == "systemviews.wasm" {
771		return newSystemviewWASMResourceHandler(h.ctx, bundle, false, "")
772	}
773	data, err := readAssetWithEncoding(assetsFS, fullPath, relPath)
774	if err != nil {
775		h.logger.Debug().Str("path", fullPath).Err(err).Msg("asset not found")
776		return h.newErrorResourceHandler(http.StatusNotFound, "Asset not found")
777	}
778	return newStaticResourceHandler(http.StatusOK, getMimeType(relPath), data,
779		map[string]string{"Cache-Control": noStoreCacheControl})
780}
781
782// versionError returns a noncacheable not-found for version-gating
783// failures: duplicates, malformed values, mismatches, and missing
784// versioned bytes all read as not found without inviting caching or
785// enumeration.
786func (h *dumbSchemeHandler) versionError(fullPath string) purecef.ResourceHandler {
787	h.logger.Debug().Str("path", fullPath).Msg("versioned asset rejected")
788	return newStaticResourceHandler(http.StatusNotFound, "text/html; charset=utf-8", errorPageBody(http.StatusNotFound, "Asset not found"),
789		map[string]string{"Cache-Control": noStoreCacheControl})
790}
791
792// sha256Hex digests exact served bytes for manifest comparison.
793func sha256Hex(data []byte) string {
794	sum := sha256.Sum256(data)
795	return hex.EncodeToString(sum[:])
796}
797
798// resolveAssetPath maps either a dumb:// URL or the actual internal HTTPS URL
799// to a relative asset path inside assets/systemviews.
800func resolveAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
801	if u == nil {
802		return "", "", false
803	}
804
805	if strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost) {
806		return resolveActualAssetPath(u)
807	}
808
809	return resolveConceptualAssetPath(u)
810}
811
812func resolveConceptualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
813	if u == nil {
814		return "", "", false
815	}
816
817	if root, ok := pageRootFiles[u.Host]; ok {
818		assetPath := strings.TrimPrefix(u.Path, "/")
819		if assetPath == "" {
820			return assetDirForPageHost(u.Host), root, true
821		}
822		// Don't serve API paths as assets.
823		if strings.HasPrefix(assetPath, "api/") {
824			return "", "", false
825		}
826		return assetDirForPageHost(u.Host), assetPath, true
827	}
828
829	// Handle opaque URLs like dumb:history.
830	if root, ok := pageRootFiles[u.Opaque]; ok {
831		return assetDirForPageHost(u.Opaque), root, true
832	}
833	return "", "", false
834}
835
836func resolveActualAssetPath(u *url.URL) (assetDir, relPath string, ok bool) {
837	if u == nil {
838		return "", "", false
839	}
840
841	assetPath := strings.Trim(u.Path, "/")
842	if assetPath == "" {
843		return "", "", false
844	}
845
846	parts := strings.SplitN(assetPath, "/", 2)
847	page := parts[0]
848	if root, ok := pageRootFiles[page]; ok {
849		if len(parts) == 1 {
850			return assetDirForPageHost(page), root, true
851		}
852		if page == historyPath && parts[1] == "crash" {
853			return "", "", false
854		}
855		return assetDirForPageHost(page), parts[1], true
856	}
857
858	if strings.HasPrefix(assetPath, "api/") {
859		return "", "", false
860	}
861
862	if !strings.Contains(assetPath, "/") && strings.Contains(assetPath, ".") {
863		return systemviewsAssetDir, assetPath, true
864	}
865
866	return "", "", false
867}
868
869func assetDirForPageHost(host string) string {
870	switch host {
871	case historyPath, favoritesPath, configPath, errorPath:
872		return systemviewsAssetDir
873	default:
874		return ""
875	}
876}
877
878func isCEFCrashPageURL(u *url.URL) bool {
879	if u == nil {
880		return false
881	}
882
883	switch {
884	case strings.EqualFold(u.Scheme, actualInternalScheme) && strings.EqualFold(u.Host, actualInternalHost):
885		return strings.Trim(u.Path, "/") == historyPath+"/crash"
886	case strings.EqualFold(u.Scheme, "dumb") && u.Host == historyPath:
887		return strings.Trim(u.Path, "/") == "crash"
888	default:
889		return false
890	}
891}
892
893func sanitizeCEFCrashPageOriginalURI(originalURI string) string {
894	return webutil.SanitizeCrashPageOriginalURI(originalURI)
895}
896
897func buildCEFCrashPageHTML(originalURI string) string {
898	return webutil.BuildCrashPageHTML(originalURI)
899}
900
901func getMimeType(filename string) string {
902	return webutil.GetMimeType(filename)
903}
904
905// splitMimeCharset splits "text/html; charset=utf-8" into ("text/html", "utf-8").
906// If no charset parameter is present, charset is empty.
907func splitMimeCharset(contentType string) (mimeType, charset string) {
908	parts := strings.SplitN(contentType, ";", 2)
909	mimeType = strings.TrimSpace(parts[0])
910	if len(parts) > 1 {
911		param := strings.TrimSpace(parts[1])
912		if strings.HasPrefix(strings.ToLower(param), "charset=") {
913			charset = strings.TrimSpace(param[len("charset="):])
914		}
915	}
916	return
917}
918
919// ---------------------------------------------------------------------------
920// ResourceHandler implementation
921// ---------------------------------------------------------------------------
922
923// staticResourceHandler serves a fixed byte buffer as a CEF ResourceHandler.
924type staticResourceHandler struct {
925	data        []byte
926	contentType string
927	statusCode  int
928	headers     map[string]string
929	offset      int
930}
931
932func apiResponseHeaders(extra map[string]string) map[string]string {
933	headers := map[string]string{
934		"Access-Control-Allow-Origin":  "*",
935		"Access-Control-Allow-Methods": "GET, POST, OPTIONS",
936		"Access-Control-Allow-Headers": strings.Join([]string{
937			"Content-Type",
938			"X-Dumber-Body",
939			"X-Dumber-Bridge-Action",
940			"X-Dumber-Bridge-Nonce",
941		}, ", "),
942		"Access-Control-Max-Age": "86400",
943		"Cache-Control":          "no-store",
944	}
945	maps.Copy(headers, extra)
946	return headers
947}
948
949func newStaticResourceHandler(status int, contentType string, data []byte, headers map[string]string) purecef.ResourceHandler {
950	return cefNewResourceHandler(&staticResourceHandler{
951		data:        data,
952		contentType: contentType,
953		statusCode:  status,
954		headers:     headers,
955	})
956}
957
958func (h *dumbSchemeHandler) newRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler {
959	return newStaticResourceHandler(status, contentType, data, nil)
960}
961
962func (h *dumbSchemeHandler) newAPIRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler {
963	return newStaticResourceHandler(status, contentType, data, apiResponseHeaders(nil))
964}
965
966func (h *dumbSchemeHandler) newPrivateAPIRawResourceHandler(status int, contentType string, data []byte) purecef.ResourceHandler {
967	return newStaticResourceHandler(status, contentType, data, map[string]string{"Cache-Control": "no-store"})
968}
969
970func (h *dumbSchemeHandler) newErrorResourceHandler(status int, msg string) purecef.ResourceHandler {
971	return h.newRawResourceHandler(status, "text/html; charset=utf-8", errorPageBody(status, msg))
972}
973
974// errorPageBody renders the shared plain error document. Deferred handlers
975// reuse it so failures stay byte-identical to the synchronous error path.
976func errorPageBody(status int, msg string) []byte {
977	escaped := html.EscapeString(msg)
978	return []byte(fmt.Sprintf(`<!DOCTYPE html><html><body><h1>%d</h1><p>%s</p></body></html>`, status, escaped))
979}
980
981func (h *dumbSchemeHandler) newAPIJSONResourceHandler(status int, v any) purecef.ResourceHandler {
982	data, err := json.Marshal(v)
983	if err != nil {
984		fallback, _ := json.Marshal(map[string]string{"error": "JSON encoding failed"})
985		return h.newAPIRawResourceHandler(http.StatusInternalServerError, "application/json", fallback)
986	}
987	return h.newAPIRawResourceHandler(status, "application/json", data)
988}
989
990func (h *dumbSchemeHandler) newPrivateAPIJSONResourceHandler(status int, v any) purecef.ResourceHandler {
991	data, err := json.Marshal(v)
992	if err != nil {
993		fallback, _ := json.Marshal(map[string]string{"error": "JSON encoding failed"})
994		return h.newPrivateAPIRawResourceHandler(http.StatusInternalServerError, "application/json", fallback)
995	}
996	return h.newPrivateAPIRawResourceHandler(status, "application/json", data)
997}
998
999func newFaviconResourceHandler(
1000	ctx context.Context,
1001	resolver port.FaviconSystemviewResolver,
1002	domain string,
1003	size int,
1004) purecef.ResourceHandler {
1005	reqCtx, cancel := context.WithCancel(ctx) //nolint:gosec // canceled by load completion or CEF Cancel callback
1006	return cefNewResourceHandler(&faviconResourceHandler{
1007		ctx:      reqCtx,
1008		cancel:   cancel,
1009		resolver: resolver,
1010		domain:   domain,
1011		size:     size,
1012		done:     make(chan struct{}),
1013		headers:  map[string]string{"Cache-Control": "no-store"},
1014	})
1015}
1016
1017type faviconResourceHandler struct {
1018	ctx         context.Context
1019	cancel      context.CancelFunc
1020	cancelOnce  sync.Once
1021	resolver    port.FaviconSystemviewResolver
1022	domain      string
1023	size        int
1024	headers     map[string]string
1025	once        sync.Once
1026	done        chan struct{}
1027	data        []byte
1028	contentType string
1029	statusCode  int
1030	offset      int
1031}
1032
1033func (rh *faviconResourceHandler) load() {
1034	defer close(rh.done)
1035	defer rh.cancelRequest()
1036	rh.statusCode = http.StatusNotFound
1037	rh.contentType = "application/json"
1038	rh.data = []byte(`{"error":"favicon not cached"}`)
1039	if rh.resolver == nil {
1040		return
1041	}
1042	resolved, err := rh.resolver.ResolveSystemviewIcon(rh.ctx, rh.domain, rh.size)
1043	if err != nil || resolved == nil || len(resolved.Bytes) == 0 {
1044		return
1045	}
1046	rh.statusCode = http.StatusOK
1047	rh.contentType = resolved.ContentType
1048	if rh.contentType == "" {
1049		rh.contentType = "image/png"
1050	}
1051	rh.data = resolved.Bytes
1052}
1053
1054func (rh *faviconResourceHandler) Open(_ purecef.Request, handleRequest *int32, callback purecef.Callback) int32 {
1055	if handleRequest != nil {
1056		*handleRequest = 0
1057	}
1058	rh.once.Do(func() {
1059		go func() {
1060			rh.load()
1061			if callback != nil {
1062				callback.Cont()
1063			}
1064		}()
1065	})
1066	return 1
1067}
1068
1069func (rh *faviconResourceHandler) ProcessRequest(_ purecef.Request, callback purecef.Callback) int32 {
1070	rh.once.Do(func() {
1071		go func() {
1072			rh.load()
1073			if callback != nil {
1074				callback.Cont()
1075			}
1076		}()
1077	})
1078	return 1
1079}
1080
1081func (rh *faviconResourceHandler) GetResponseHeaders(response purecef.Response, responseLength *int64, _ uintptr) {
1082	<-rh.done
1083	response.SetStatus(int32(rh.statusCode))
1084	if text := http.StatusText(rh.statusCode); text != "" {
1085		response.SetStatusText(text)
1086	}
1087	mimeType, charset := splitMimeCharset(rh.contentType)
1088	response.SetMimeType(mimeType)
1089	if charset != "" {
1090		response.SetCharset(charset)
1091	}
1092	for name, value := range rh.headers {
1093		response.SetHeaderByName(name, value, 1)
1094	}
1095	if responseLength != nil {
1096		*responseLength = int64(len(rh.data))
1097	}
1098}
1099
1100func (rh *faviconResourceHandler) Skip(_ int64, _ *int64, _ purecef.ResourceSkipCallback) int32 {
1101	return 0
1102}
1103
1104func (rh *faviconResourceHandler) Read(dataOut unsafe.Pointer, bytesToRead int32, bytesRead *int32, _ purecef.ResourceReadCallback) int32 {
1105	<-rh.done
1106	if rh.offset >= len(rh.data) {
1107		return 0
1108	}
1109	remaining := len(rh.data) - rh.offset
1110	toRead := min(int(bytesToRead), remaining)
1111	dst := unsafe.Slice((*byte)(dataOut), toRead)
1112	copy(dst, rh.data[rh.offset:rh.offset+toRead])
1113	rh.offset += toRead
1114	if bytesRead != nil {
1115		*bytesRead = int32(toRead)
1116	}
1117	return 1
1118}
1119
1120func (rh *faviconResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ *int32, _ purecef.Callback) int32 {
1121	return 0
1122}
1123
1124func (rh *faviconResourceHandler) Cancel() { rh.cancelRequest() }
1125
1126func (rh *faviconResourceHandler) cancelRequest() {
1127	if rh.cancel != nil {
1128		rh.cancelOnce.Do(rh.cancel)
1129	}
1130}
1131
1132// Open handles the request immediately (synchronous).
1133func (rh *staticResourceHandler) Open(_ purecef.Request, handleRequest *int32, _ purecef.Callback) int32 {
1134	// Set handleRequest = true (1) to indicate we handle it immediately.
1135	if handleRequest != nil {
1136		*handleRequest = 1
1137	}
1138	return 1
1139}
1140
1141// ProcessRequest is deprecated; Open is used instead.
1142func (rh *staticResourceHandler) ProcessRequest(_ purecef.Request, _ purecef.Callback) int32 {
1143	return 0
1144}
1145
1146// GetResponseHeaders sets status code, MIME type, and content length.
1147// CEF's SetMimeType expects the MIME type without charset parameters;
1148// the charset must be set separately via SetCharset.
1149func (rh *staticResourceHandler) GetResponseHeaders(response purecef.Response, responseLength *int64, _ uintptr) {
1150	response.SetStatus(int32(rh.statusCode))
1151	if text := http.StatusText(rh.statusCode); text != "" {
1152		response.SetStatusText(text)
1153	}
1154	mimeType, charset := splitMimeCharset(rh.contentType)
1155	response.SetMimeType(mimeType)
1156	if charset != "" {
1157		response.SetCharset(charset)
1158	}
1159	for name, value := range rh.headers {
1160		response.SetHeaderByName(name, value, 1)
1161	}
1162	if responseLength != nil {
1163		*responseLength = int64(len(rh.data))
1164	}
1165}
1166
1167// Skip is not used for static content.
1168func (rh *staticResourceHandler) Skip(_ int64, _ *int64, _ purecef.ResourceSkipCallback) int32 {
1169	return 0
1170}
1171
1172// Read copies data into the output buffer.
1173func (rh *staticResourceHandler) Read(
1174	dataOut unsafe.Pointer, bytesToRead int32,
1175	bytesRead *int32, _ purecef.ResourceReadCallback,
1176) int32 {
1177	if rh.offset >= len(rh.data) {
1178		return 0
1179	}
1180
1181	remaining := len(rh.data) - rh.offset
1182	toRead := min(int(bytesToRead), remaining)
1183
1184	// Copy data to the output buffer.
1185	dst := unsafe.Slice((*byte)(dataOut), toRead)
1186	copy(dst, rh.data[rh.offset:rh.offset+toRead])
1187	rh.offset += toRead
1188
1189	if bytesRead != nil {
1190		*bytesRead = int32(toRead)
1191	}
1192	return 1
1193}
1194
1195// ReadResponse is deprecated; Read is used instead.
1196func (rh *staticResourceHandler) ReadResponse(_ unsafe.Pointer, _ int32, _ *int32, _ purecef.Callback) int32 {
1197	return 0
1198}
1199
1200// Cancel is a no-op for static content.
1201func (rh *staticResourceHandler) Cancel() {}
1202
1203// ---------------------------------------------------------------------------
1204// Request body reader
1205// ---------------------------------------------------------------------------
1206
1207// readBodyFromHeader extracts the message body from the X-Dumber-Body header.
1208// The JS bridge base64-encodes the JSON body into this header to avoid the
1209// purego-cef PostData element wrapping limitation (wrapPostDataElement is unexported).
1210func readBodyFromHeader(request purecef.Request) []byte {
1211	encoded := request.GetHeaderByName(dumberBodyHeaderName)
1212	if encoded == "" {
1213		return nil
1214	}
1215	body, err := base64.StdEncoding.DecodeString(encoded)
1216	if err != nil {
1217		return nil
1218	}
1219	return body
1220}

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