v0.33.0 62a09f5

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

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