v0.32.0 0617278

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

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