webview_handlers.go RawHistoryFolder

1package cef
2
3import (
4	"context"
5	"strings"
6	"sync"
7	"sync/atomic"
8	"time"
9
10	purecef "github.com/bnema/purego-cef/cef"
11
12	"github.com/bnema/dumber/internal/application/dto"
13	"github.com/bnema/dumber/internal/application/port"
14	downloadutil "github.com/bnema/dumber/internal/domain/download"
15	"github.com/bnema/dumber/internal/logging"
16)
17
18// Console message markers used by injected JavaScript for log filtering.
19const (
20	consoleMarkerVideoDiag  = "[VIDEO-DIAG]"
21	consoleMarkerAutoCopy   = "[AUTO-COPY]"
22	consoleMarkerScaleProbe = "[SCALE-PROBE]"
23)
24
25// handlerSet implements all CEF handler interfaces and dispatches events to the
26// owning WebView. A single struct is used so that the Client's Get*Handler
27// methods can return the same receiver, avoiding extra allocations.
28type handlerSet struct {
29	wv                  *WebView
30	fileDialogPresenter fileDialogPresenter
31	renderHandlerOnce   sync.Once
32	renderHandler       purecef.RenderHandler
33}
34
35// Compile-time interface checks.
36var (
37	_ purecef.Client             = (*handlerSet)(nil)
38	_ purecef.DisplayHandler     = (*handlerSet)(nil)
39	_ purecef.LoadHandler        = (*handlerSet)(nil)
40	_ purecef.LifeSpanHandler    = (*handlerSet)(nil)
41	_ purecef.RequestHandler     = (*handlerSet)(nil)
42	_ purecef.AudioHandler       = (*handlerSet)(nil)
43	_ purecef.ContextMenuHandler = (*handlerSet)(nil)
44	_ purecef.DialogHandler      = (*handlerSet)(nil)
45	_ purecef.DownloadHandler    = (*handlerSet)(nil)
46	_ purecef.FindHandler        = (*handlerSet)(nil)
47)
48
49// ===========================================================================
50// Client
51// ===========================================================================
52
53func (h *handlerSet) GetAudioHandler() purecef.AudioHandler {
54	// Always return the audio handler — without it, CEF has no audio output
55	// path and refuses to start media decoding, breaking all video playback.
56	// The handler accepts audio but discards packets (no-op) until a real
57	// audio backend (PipeWire/PulseAudio) is wired up.
58	return h
59}
60func (h *handlerSet) GetCommandHandler() purecef.CommandHandler         { return nil }
61func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { return h }
62func (h *handlerSet) GetDialogHandler() purecef.DialogHandler           { return h }
63func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler         { return h }
64func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler {
65	if h.downloadHandler() != nil {
66		return h
67	}
68	return nil
69}
70func (h *handlerSet) GetDragHandler() purecef.DragHandler             { return nil }
71func (h *handlerSet) GetFindHandler() purecef.FindHandler             { return h }
72func (h *handlerSet) GetFocusHandler() purecef.FocusHandler           { return nil }
73func (h *handlerSet) GetFrameHandler() purecef.FrameHandler           { return nil }
74func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }
75func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler     { return nil }
76func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler     { return nil }
77func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler     { return h }
78func (h *handlerSet) GetLoadHandler() purecef.LoadHandler             { return h }
79func (h *handlerSet) GetPrintHandler() purecef.PrintHandler           { return nil }
80func (h *handlerSet) GetRenderHandler() purecef.RenderHandler {
81	if h == nil {
82		return nil
83	}
84	h.renderHandlerOnce.Do(func() {
85		h.renderHandler = newDumberRenderHandler(h.wv)
86	})
87	return h.renderHandler
88}
89func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h }
90
91func (h *handlerSet) OnProcessMessageReceived(
92	browser purecef.Browser,
93	_ purecef.Frame,
94	_ purecef.ProcessID,
95	message purecef.ProcessMessage,
96) int32 {
97	if h == nil || h.wv == nil || h.wv.engine == nil {
98		return 0
99	}
100	action, payload, ok := decodeRendererBridgeProcessMessage(message)
101	if !ok {
102		return 0
103	}
104
105	log := logging.FromContext(h.wv.ctx)
106	log.Debug().
107		Str("action", action).
108		Int("payload_len", len(payload)).
109		Msg("cef: renderer bridge message received")
110
111	switch action {
112	case rendererBridgeActionExplicitTextCopy:
113		req, err := decodeRendererBridgeExplicitTextCopyPayload([]byte(payload))
114		if err != nil {
115			log.Debug().Str("action", action).Msg("cef: invalid explicit copy payload")
116			return 1
117		}
118		h.wv.engine.handleExplicitClipboardBridgeText(h.wv.id, req.Action, req.Text)
119	case rendererBridgeActionEditableFocusChanged:
120		h.wv.setEditableFocus(payload == "1" || strings.EqualFold(payload, "true"))
121	case rendererBridgeActionFocusSync:
122		h.wv.engine.handleEditableFocusBridge(browser)
123	case rendererBridgeActionPopupOpen:
124		req, err := decodeRendererBridgePopupOpenPayload([]byte(payload))
125		if err != nil {
126			log.Debug().Str("action", action).Msg("cef: invalid popup_open payload")
127			return 1
128		}
129		h.wv.handleSyntheticPopupOpen(req.URL, req.FrameName, req.ProxyID, req.UserGesture, req.NoJavaScriptAccess)
130	case rendererBridgeActionPopupNavigate:
131		req, err := decodeRendererBridgePopupNavigatePayload([]byte(payload))
132		if err != nil {
133			log.Debug().Str("action", action).Msg("cef: invalid popup_navigate payload")
134			return 1
135		}
136		h.wv.handleSyntheticPopupNavigate(req.ProxyID, req.URL)
137	case rendererBridgeActionPopupClose:
138		req, err := decodeRendererBridgePopupClosePayload([]byte(payload))
139		if err != nil {
140			log.Debug().Str("action", action).Msg("cef: invalid popup_close payload")
141			return 1
142		}
143		h.wv.handleSyntheticPopupClose(req.ProxyID)
144	case rendererBridgeActionReady:
145		log.Debug().
146			Str("frame_url", logging.TruncateURL(payload, logging.PermissionLogURLMaxLen)).
147			Msg("cef: renderer bridge ready")
148	default:
149		log.Debug().Str("action", action).Msg("cef: unknown renderer bridge action")
150	}
151	return 1
152}
153
154// ===========================================================================
155// RenderHandler methods live in render_handler_adapter.go. handlerSet keeps
156// Dumber's non-render CEF handlers and returns a delegating render handler from
157// GetRenderHandler.
158// ===========================================================================
159
160// ===========================================================================
161// DisplayHandler (13 methods)
162// ===========================================================================
163
164// OnAddressChange updates the cached URI when the main frame navigates.
165func (h *handlerSet) OnAddressChange(_ purecef.Browser, frame purecef.Frame, url string) {
166	if frame != nil && frame.IsMain() {
167		if h.wv != nil && h.wv.ctx != nil {
168			logging.FromContext(h.wv.ctx).Debug().
169				Str("url", logging.TruncateURL(url, logging.PermissionLogURLMaxLen)).
170				Msg("cef: OnAddressChange")
171		}
172		h.wv.updateURI(url)
173	}
174}
175
176// OnTitleChange updates the cached title.
177func (h *handlerSet) OnTitleChange(_ purecef.Browser, title string) {
178	h.wv.updateTitle(title)
179}
180
181func (h *handlerSet) OnFaviconUrlchange(browser purecef.Browser, iconURLs purecef.StringList) {
182	if h == nil || h.wv == nil {
183		return
184	}
185	h.wv.mu.RLock()
186	pageURL, committedURL, cb, currentBrowser, documentSeq := h.wv.uri, h.wv.committedURL, h.wv.callbacks, h.wv.browser, h.wv.documentSeq
187	h.wv.mu.RUnlock()
188	if cb == nil || cb.OnFaviconURLChanged == nil || browser == nil || currentBrowser == nil ||
189		browser.GetIdentifier() != currentBrowser.GetIdentifier() {
190		return
191	}
192	if committedURL == "" {
193		committedURL = pageURL
194	}
195	candidates := resolveFaviconCandidates(committedURL, decodeCEFStringList(iconURLs))
196	if len(candidates) == 0 {
197		return
198	}
199	h.wv.mu.Lock()
200	if h.wv.documentSeq != documentSeq || h.wv.uri != pageURL {
201		h.wv.mu.Unlock()
202		return
203	}
204	h.wv.faviconEngineDocumentSeq = documentSeq
205	h.wv.mu.Unlock()
206	h.wv.runOnGTK(func() {
207		if !h.faviconCallbackCurrent(cb, browser, documentSeq, pageURL) {
208			return
209		}
210		cb.OnFaviconURLChanged(pageURL, candidates)
211	})
212}
213
214// faviconCallbackCurrent reports whether a queued favicon callback still
215// belongs to the current committed document, callback owner, and browser. The
216// GTK hop outlives the state it was queued for, so callbacks revalidate before
217// handing candidates to the UI.
218func (h *handlerSet) faviconCallbackCurrent(cb *port.WebViewCallbacks, browser purecef.Browser, documentSeq uint64, pageURL string) bool {
219	if h == nil || h.wv == nil || cb == nil {
220		return false
221	}
222	h.wv.mu.RLock()
223	currentBrowser := h.wv.browser
224	current := h.wv.callbacks == cb && h.wv.documentSeq == documentSeq && h.wv.uri == pageURL
225	h.wv.mu.RUnlock()
226	if !current {
227		return false
228	}
229	if browser == nil {
230		// No captured browser identity to compare; the document checks above
231		// still bound the callback.
232		return true
233	}
234	return currentBrowser != nil && currentBrowser.GetIdentifier() == browser.GetIdentifier()
235}
236
237// OnFullscreenModeChange toggles the fullscreen atomic and fires callbacks.
238func (h *handlerSet) OnFullscreenModeChange(_ purecef.Browser, fullscreen int32) {
239	entering := fullscreen != 0
240	h.wv.fullscreen.Store(entering)
241
242	h.wv.mu.RLock()
243	cb := h.wv.callbacks
244	h.wv.mu.RUnlock()
245	if cb == nil {
246		return
247	}
248	if entering && cb.OnEnterFullscreen != nil {
249		h.wv.runOnGTK(func() {
250			cb.OnEnterFullscreen()
251		})
252	}
253	if !entering && cb.OnLeaveFullscreen != nil {
254		h.wv.runOnGTK(func() {
255			cb.OnLeaveFullscreen()
256		})
257	}
258}
259
260func (h *handlerSet) OnTooltip(_ purecef.Browser, _ uintptr) int32 { return 0 }
261
262// OnStatusMessage fires the OnLinkHover callback with the status text
263// and caches the hover URI for middle-click interception.
264func (h *handlerSet) OnStatusMessage(_ purecef.Browser, value string) {
265	h.wv.updateHoverURI(value)
266
267	h.wv.mu.RLock()
268	cb := h.wv.callbacks
269	h.wv.mu.RUnlock()
270	if cb != nil && cb.OnLinkHover != nil {
271		h.wv.runOnGTK(func() {
272			cb.OnLinkHover(value)
273		})
274	}
275}
276
277func (h *handlerSet) OnConsoleMessage(
278	_ purecef.Browser,
279	level purecef.LogSeverity,
280	message, source string,
281	line int32,
282) int32 {
283	if h.wv != nil && h.wv.ctx != nil &&
284		(strings.Contains(message, consoleMarkerVideoDiag) ||
285			strings.Contains(message, consoleMarkerAutoCopy) ||
286			strings.Contains(message, consoleMarkerScaleProbe)) {
287		log := logging.FromContext(h.wv.ctx).With().
288			Str("component", "cef-console").
289			Str("source", source).
290			Int32("line", line).
291			Logger()
292		switch level {
293		case purecef.LogSeverityLogseverityError, purecef.LogSeverityLogseverityFatal:
294			log.Error().Str("message", message).Msg("cef: console message")
295		case purecef.LogSeverityLogseverityWarning:
296			log.Warn().Str("message", message).Msg("cef: console message")
297		default:
298			log.Info().Str("message", message).Msg("cef: console message")
299		}
300	}
301	return 0
302}
303
304func (h *handlerSet) OnAutoResize(_ purecef.Browser, _ *purecef.Size) int32 { return 0 }
305
306// OnLoadingProgressChange updates the cached progress value.
307func (h *handlerSet) OnLoadingProgressChange(_ purecef.Browser, progress float64) {
308	if h.wv != nil && h.wv.ctx != nil {
309		logging.FromContext(h.wv.ctx).Trace().
310			Float64("progress", progress).
311			Msg("cef: OnLoadingProgressChange")
312	}
313	h.wv.updateProgress(progress)
314}
315
316func (h *handlerSet) OnCursorChange(
317	_ purecef.Browser,
318	_ uintptr,
319	cursorType purecef.CursorType,
320	_ *purecef.CursorInfo,
321) int32 {
322	if h == nil || h.wv == nil {
323		return 0
324	}
325	name := cefCursorToGDKName(cursorType)
326	h.wv.runOnGTK(func() {
327		if h.wv.viewBridge != nil {
328			h.wv.viewBridge.SetCursorFromName(name)
329		}
330	})
331	return 1 // handled
332}
333
334func (h *handlerSet) OnMediaAccessChange(_ purecef.Browser, _, _ int32) {}
335
336func (h *handlerSet) OnContentsBoundsChange(_ purecef.Browser, _ *purecef.Rect) int32 { return 0 }
337
338func (h *handlerSet) GetRootWindowScreenRect(_ purecef.Browser, _ *purecef.Rect) int32 { return 0 }
339
340// ===========================================================================
341// LoadHandler (4 methods)
342// ===========================================================================
343
344type faviconSourceVisitorRetention struct {
345	id      uint64
346	visitor purecef.StringVisitor
347}
348
349type faviconSourceVisitor struct {
350	visit func(string)
351}
352
353func (v faviconSourceVisitor) Visit(source string) {
354	if v.visit != nil {
355		v.visit(source)
356	}
357}
358
359// OnLoadingStateChange updates the loading/navigation state cache and fires callbacks.
360func (h *handlerSet) OnLoadingStateChange(browser purecef.Browser, isloading, cangoback, cangoforward int32) {
361	loading := isloading != 0
362	h.wv.updateLoadState(loading, cangoback != 0, cangoforward != 0)
363	if h.wv != nil && h.wv.ctx != nil {
364		h.wv.mu.RLock()
365		uri := h.wv.uri
366		progress := h.wv.progress
367		pendingURI := h.wv.pendingURI
368		h.wv.mu.RUnlock()
369		logging.FromContext(h.wv.ctx).Debug().
370			Bool("loading", loading).
371			Bool("can_go_back", cangoback != 0).
372			Bool("can_go_forward", cangoforward != 0).
373			Float64("progress", progress).
374			Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
375			Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)).
376			Msg("cef: OnLoadingStateChange")
377	}
378
379	if !loading && h.wv.viewBridge != nil {
380		h.wv.runOnGTK(func() {
381			if h.wv.viewBridge != nil && h.wv.viewBridge.HasFocus() {
382				h.wv.mu.RLock()
383				host := h.wv.host
384				h.wv.mu.RUnlock()
385				if host != nil {
386					syncWindowlessBrowserFocus(host)
387				}
388			}
389		})
390	}
391
392	h.wv.mu.RLock()
393	cb := h.wv.callbacks
394	pageURL := h.wv.uri
395	committedURL := h.wv.committedURL
396	h.wv.mu.RUnlock()
397	if committedURL == "" {
398		committedURL = pageURL
399	}
400	if !loading && cb != nil && cb.OnFaviconURLChanged != nil {
401		h.emitDiscoveredFaviconCandidates(browser, pageURL, committedURL, cb)
402	}
403	if cb != nil && cb.OnLoadChanged != nil {
404		if loading {
405			h.wv.runOnGTK(func() {
406				cb.OnLoadChanged(port.LoadStarted)
407			})
408		} else {
409			h.wv.runOnGTK(func() {
410				cb.OnLoadChanged(port.LoadFinished)
411			})
412		}
413	}
414}
415
416var faviconSourceVisitorRetentionID atomic.Uint64
417
418func (wv *WebView) retainFaviconSourceVisitor(visitor purecef.StringVisitor) func() {
419	if wv == nil || visitor == nil {
420		return func() {}
421	}
422	id := faviconSourceVisitorRetentionID.Add(1)
423	wv.faviconSourceVisitorsMu.Lock()
424	wv.faviconSourceVisitors = append(wv.faviconSourceVisitors, faviconSourceVisitorRetention{id: id, visitor: visitor})
425	wv.faviconSourceVisitorsMu.Unlock()
426	return func() {
427		wv.faviconSourceVisitorsMu.Lock()
428		defer wv.faviconSourceVisitorsMu.Unlock()
429		for i, candidate := range wv.faviconSourceVisitors {
430			if candidate.id != id {
431				continue
432			}
433			last := len(wv.faviconSourceVisitors) - 1
434			copy(wv.faviconSourceVisitors[i:], wv.faviconSourceVisitors[i+1:])
435			wv.faviconSourceVisitors[last] = faviconSourceVisitorRetention{}
436			wv.faviconSourceVisitors = wv.faviconSourceVisitors[:last]
437			return
438		}
439	}
440}
441
442func (h *handlerSet) emitDiscoveredFaviconCandidates(browser purecef.Browser, pageURL, committedURL string, cb *port.WebViewCallbacks) {
443	if h == nil || h.wv == nil || cb == nil || cb.OnFaviconURLChanged == nil {
444		return
445	}
446	h.wv.mu.Lock()
447	documentSeq := h.wv.documentSeq
448	if h.wv.faviconEngineDocumentSeq == documentSeq || h.wv.faviconSourceCompletedDocumentSeq == documentSeq ||
449		h.wv.faviconSourcePendingToken != 0 || documentSeq == 0 {
450		h.wv.mu.Unlock()
451		return
452	}
453	h.wv.faviconSourceRequestSerial++
454	token := h.wv.faviconSourceRequestSerial
455	h.wv.faviconSourcePendingToken = token
456	h.wv.mu.Unlock()
457	finish := func() {
458		h.wv.mu.Lock()
459		if h.wv.documentSeq == documentSeq && h.wv.faviconSourcePendingToken == token {
460			h.wv.faviconSourcePendingToken = 0
461			h.wv.faviconSourceCompletedDocumentSeq = documentSeq
462		}
463		h.wv.mu.Unlock()
464	}
465	fallback := fallbackFaviconCandidates(committedURL)
466	emit := func(candidates []string) {
467		if len(candidates) == 0 {
468			return
469		}
470		h.wv.runOnGTK(func() {
471			if !h.faviconCallbackCurrent(cb, browser, documentSeq, pageURL) {
472				return
473			}
474			cb.OnFaviconURLChanged(pageURL, candidates)
475		})
476	}
477	if browser == nil {
478		finish()
479		emit(fallback)
480		return
481	}
482	frame := browser.GetMainFrame()
483	if frame == nil {
484		finish()
485		emit(fallback)
486		return
487	}
488	var releaseOnce sync.Once
489	var release func()
490	visitor := cefNewStringVisitor(faviconSourceVisitor{visit: func(source string) {
491		releaseOnce.Do(func() {
492			if release != nil {
493				release()
494			}
495		})
496		finish()
497		if !h.faviconCallbackCurrent(cb, browser, documentSeq, pageURL) {
498			return
499		}
500		h.wv.mu.RLock()
501		engineWon := h.wv.faviconEngineDocumentSeq == documentSeq
502		h.wv.mu.RUnlock()
503		if engineWon {
504			return
505		}
506		candidates := DiscoverFaviconCandidates(committedURL, source)
507		if len(candidates) == 0 {
508			candidates = fallback
509		}
510		emit(candidates)
511	}})
512	release = h.wv.retainFaviconSourceVisitor(visitor)
513	time.AfterFunc(5*time.Second, func() {
514		releaseOnce.Do(func() {
515			if release != nil {
516				release()
517			}
518		})
519		finish()
520	})
521	frame.GetSource(visitor)
522}
523
524// OnLoadStart fires LoadCommitted for main frame navigations.
525// In CEF this callback runs after navigation commit, so it is the closest
526// equivalent to WebKit's LoadCommitted event.
527func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purecef.TransitionType) {
528	if frame == nil || !frame.IsMain() {
529		return
530	}
531	// Post-commit fallback for navigation paths that bypass OnBeforeBrowse
532	// (same-page, error, and helper-driven commits). Main frame only.
533	if h.wv != nil {
534		h.wv.invalidateScrollMotion()
535		h.wv.mu.Lock()
536		h.wv.documentSeq++
537		h.wv.faviconSourcePendingToken = 0
538		h.wv.faviconSourceCompletedDocumentSeq = 0
539		h.wv.mu.Unlock()
540	}
541	bridgeNonce := h.wv.ensureBridgeNonce()
542	if openerBridgeScript := h.wv.popupOpenerBridgeScript(bridgeNonce); openerBridgeScript != "" {
543		if h.wv != nil && h.wv.ctx != nil {
544			parentURI, active, blocked := h.wv.popupOpenerBridgeState()
545			logging.FromContext(h.wv.ctx).Debug().
546				Uint64("webview_id", uint64(h.wv.id)).
547				Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)).
548				Str("parent_uri", logging.TruncateURL(parentURI, logging.PermissionLogURLMaxLen)).
549				Bool("bridge_active", active).
550				Bool("bridge_blocked", blocked).
551				Msg("cef: injecting popup opener bridge")
552		}
553		frame.ExecuteJavaScript(openerBridgeScript, frame.GetURL(), 0)
554	}
555	if h.wv != nil && h.wv.ctx != nil {
556		logging.FromContext(h.wv.ctx).Debug().
557			Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)).
558			Msg("cef: OnLoadStart")
559	}
560	h.wv.setEditableFocus(false)
561	h.wv.updateURI(frame.GetURL())
562	h.wv.mu.RLock()
563	cb := h.wv.callbacks
564	h.wv.mu.RUnlock()
565	if cb != nil && cb.OnLoadChanged != nil {
566		h.wv.runOnGTK(func() {
567			cb.OnLoadChanged(port.LoadCommitted)
568		})
569	}
570}
571
572// OnLoadEnd resets crash count and injects content scripts for the main frame.
573func (h *handlerSet) OnLoadEnd(browser purecef.Browser, frame purecef.Frame, httpStatusCode int32) {
574	log := logging.FromContext(h.wv.ctx)
575	log.Debug().
576		Bool("frame_nil", frame == nil).
577		Bool("is_main", frame != nil && frame.IsMain()).
578		Int32("http_status", httpStatusCode).
579		Msg("cef: OnLoadEnd")
580	if frame == nil || !frame.IsMain() {
581		return
582	}
583	// Successful load — reset the consecutive crash counter.
584	h.wv.crashCount.Store(0)
585
586	// If a queued startup navigation is still pending after about:blank finished,
587	// replay it now that the initial main-frame load completed.
588	frameURL := frame.GetURL()
589	pendingURI, pendingIntentID := h.wv.pendingNavigationSnapshot()
590	if pendingURI != "" && !pendingURIEquivalent(frameURL, pendingURI) {
591		if strings.EqualFold(strings.TrimSpace(frameURL), "about:blank") {
592			rearmed := h.wv.rearmPendingNavigationAfterBlankLoadEnd(pendingIntentID)
593			log.Info().
594				Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)).
595				Bool("rearmed", rearmed).
596				Msg("cef: replaying pending navigation after about:blank load end")
597			h.wv.schedulePendingNavigationReplayForIntent(0, pendingIntentID)
598		}
599	}
600
601	// Inject scripts and styles after page load.
602	// Must run on GTK thread — OnLoadEnd fires on the CEF IO thread,
603	// and JavaScript injection requires the main thread.
604	if cefScaleProbeEnabled() && shouldRunCEFScaleProbe(frameURL, httpStatusCode) {
605		// Do not retain/use CEF callback-scoped frame wrappers across the GTK idle hop.
606		// Re-read the current main frame through WebView.RunJavaScript instead.
607		h.wv.runOnGTK(func() {
608			snapshot := cefScaleProbeSnapshot(h.wv)
609			event := logging.FromContext(h.wv.ctx).Debug()
610			for key, value := range snapshot.logFields() {
611				event = event.Interface(key, value)
612			}
613			event.Msg("cef: injecting scale probe")
614			h.wv.RunJavaScript(context.Background(), cefScaleProbeScript)
615		})
616	}
617	if h.wv.engine != nil && h.wv.engine.contentInj != nil {
618		// Capture navigation identity for the GTK hop from the callback's
619		// browser: a process swap or a newer navigation can stale this
620		// event before it runs.
621		event := h.wv.captureInjectionEvent(browser, frameURL)
622		h.wv.runOnGTK(func() {
623			h.wv.engine.contentInj.onLoadEndForEvent(h.wv, event)
624		})
625	}
626
627	// NOTE: We intentionally do NOT dispatch LoadFinished or ProgressChanged(1.0)
628	// here. OnLoadEnd is a per-frame event, and during cross-site navigations
629	// (process swap), CEF fires OnLoadEnd for the OLD main frame before the new
630	// page finishes loading. Dispatching LoadFinished here caused the progress
631	// bar to hide prematurely while the new page was still loading.
632	//
633	// OnLoadingStateChange(isLoading=false) is the correct browser-level signal
634	// for load completion — CEF guarantees it fires AFTER all OnLoadEnd calls.
635	// CEF's OnLoadingProgressChange also provides progress=1.0 at completion.
636}
637
638const cefErrAborted purecef.Errorcode = -3
639
640// OnLoadError handles failed main-frame navigations that never reach
641// OnLoadStart. CEF still sends OnLoadingStateChange(false) after load errors,
642// so this callback only synthesizes the missing committed event needed to reveal
643// the pane; the normal browser-level loading-state callback remains responsible
644// for the terminal LoadFinished state.
645func (h *handlerSet) OnLoadError(_ purecef.Browser, frame purecef.Frame, errorCode purecef.Errorcode, errorText, failedURL string) {
646	if h == nil || h.wv == nil || frame == nil || !frame.IsMain() {
647		return
648	}
649	if errorCode == cefErrAborted {
650		return
651	}
652	if failedURL == "" {
653		failedURL = frame.GetURL()
654	}
655
656	h.wv.mu.RLock()
657	currentURI := h.wv.uri
658	cb := h.wv.callbacks
659	h.wv.mu.RUnlock()
660
661	if h.wv.ctx != nil {
662		logging.FromContext(h.wv.ctx).Debug().
663			Int32("error_code", errorCode).
664			Str("error_text", errorText).
665			Str("failed_url", logging.TruncateURL(failedURL, logging.PermissionLogURLMaxLen)).
666			Msg("cef: OnLoadError")
667	}
668
669	if failedURL != "" {
670		h.wv.updateURI(failedURL)
671	}
672
673	if cb == nil || cb.OnLoadChanged == nil {
674		return
675	}
676	if failedURL != "" && !pendingURIEquivalent(currentURI, failedURL) {
677		h.wv.runOnGTK(func() {
678			cb.OnLoadChanged(port.LoadCommitted)
679		})
680	}
681}
682
683func mapCEFPopupFeatures(in *purecef.PopupFeatures) dto.PopupFeatures {
684	if in == nil {
685		return dto.PopupFeatures{State: dto.PopupFeaturesNone}
686	}
687
688	state := dto.PopupFeaturesNone
689	if in.Xset != 0 || in.Yset != 0 || in.Widthset != 0 || in.Heightset != 0 || in.Ispopup != 0 {
690		state = dto.PopupFeaturesSpecified
691	}
692	return dto.PopupFeatures{
693		State:      state,
694		X:          int(in.X),
695		Y:          int(in.Y),
696		Width:      int(in.Width),
697		Height:     int(in.Height),
698		XSet:       in.Xset != 0,
699		YSet:       in.Yset != 0,
700		WidthSet:   in.Widthset != 0,
701		HeightSet:  in.Heightset != 0,
702		IsPopup:    in.Ispopup != 0,
703		IsPopupSet: true,
704	}
705}
706
707func mapCEFWindowDisposition(disposition purecef.WindowOpenDisposition) dto.WindowDisposition {
708	switch disposition {
709	case purecef.WindowOpenDispositionWodCurrentTab:
710		return dto.WindowDispositionCurrentTab
711	case purecef.WindowOpenDispositionWodNewPopup:
712		return dto.WindowDispositionNewPopup
713	case purecef.WindowOpenDispositionWodNewWindow:
714		return dto.WindowDispositionNewWindow
715	case purecef.WindowOpenDispositionWodNewForegroundTab,
716		purecef.WindowOpenDispositionWodNewBackgroundTab,
717		purecef.WindowOpenDispositionWodSingletonTab,
718		purecef.WindowOpenDispositionWodSwitchToTab:
719		return dto.WindowDispositionNewTab
720	default:
721		return ""
722	}
723}
724
725// ===========================================================================
726// LifeSpanHandler (6 methods)
727// ===========================================================================
728
729// OnBeforePopup intercepts popup requests (target="_blank", window.open).
730// When the coordinator returns a related CEF popup shell we hand that shell's
731// client back to CEF and allow native popup creation so opener semantics are
732// preserved. Otherwise we keep blocking and let the coordinator's fallback
733// pane handle the navigation.
734func (h *handlerSet) OnBeforePopup(
735	browser purecef.Browser, frame purecef.Frame, popupID int32, targetURL, targetFrameName string,
736	targetDisposition purecef.WindowOpenDisposition, userGesture int32, popupFeatures *purecef.PopupFeatures,
737	windowInfo *purecef.WindowInfo, clientSlot *purecef.RawClientWriteSlot, settings *purecef.BrowserSettings,
738	_ *purecef.DictionaryValue, noJavaScriptAccess *bool,
739) bool {
740	if targetURL == "" {
741		return true
742	}
743
744	h.wv.mu.RLock()
745	cb := h.wv.callbacks
746	h.wv.mu.RUnlock()
747	if cb == nil || cb.OnCreate == nil {
748		return true
749	}
750
751	requestNoJavaScriptAccess := false
752	if noJavaScriptAccess != nil {
753		requestNoJavaScriptAccess = *noJavaScriptAccess
754	}
755	var popup port.WebView
756	sourceBrowserID := int32(0)
757	if browser != nil {
758		sourceBrowserID = browser.GetIdentifier()
759	}
760	sourceFrameID := ""
761	sourceFrameURL := ""
762	if frame != nil {
763		sourceFrameID = frame.GetIdentifier()
764		sourceFrameURL = frame.GetURL()
765	}
766
767	dispatchResult := h.wv.runOnGTKSyncLabel("cef.on_before_popup", func() {
768		popup = cb.OnCreate(port.PopupRequest{
769			Engine:             dto.BrowserEngineCEF,
770			PopupFeatures:      mapCEFPopupFeatures(popupFeatures),
771			TargetURI:          targetURL,
772			FrameName:          targetFrameName,
773			IsUserGesture:      userGesture != 0,
774			NoJavaScriptAccess: requestNoJavaScriptAccess,
775			ParentViewID:       h.wv.id,
776			SourceBrowserID:    sourceBrowserID,
777			SourceFrameID:      sourceFrameID,
778			SourceFrameURL:     sourceFrameURL,
779			TargetDisposition:  mapCEFWindowDisposition(targetDisposition),
780		})
781	})
782	if !dispatchResult.Completed() {
783		logging.FromContext(h.currentContext()).Warn().
784			Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
785			Str("target_frame", targetFrameName).
786			Dur("elapsed", dispatchResult.Elapsed).
787			Str("dispatch_status", string(dispatchResult.Status)).
788			Msg("cef: blocked popup after GTK dispatch did not complete")
789		return true
790	}
791
792	cefPopup, ok := popup.(*WebView)
793	if !ok || cefPopup == nil {
794		return true
795	}
796
797	decision := dto.HostDecision{}
798	if carrier, ok := popup.(port.BrowsingContextHostDecisionCapable); ok {
799		if got, hasDecision := carrier.BrowsingContextHostDecision(); hasDecision {
800			decision = got
801		}
802	}
803
804	return h.handlePopupHostDecision(
805		cefPopup,
806		decision,
807		requestNoJavaScriptAccess,
808		popupID,
809		targetURL,
810		targetDisposition,
811		windowInfo,
812		clientSlot,
813		settings,
814	)
815}
816
817func (h *handlerSet) handlePopupHostDecision(
818	cefPopup *WebView,
819	decision dto.HostDecision,
820	requestNoJavaScriptAccess bool,
821	popupID int32,
822	targetURL string,
823	targetDisposition purecef.WindowOpenDisposition,
824	windowInfo *purecef.WindowInfo,
825	clientSlot *purecef.RawClientWriteSlot,
826	settings *purecef.BrowserSettings,
827) bool {
828	cefPopup.setPopupNoJavaScriptAccess(requestNoJavaScriptAccess)
829	switch decision.Kind {
830	case dto.HostDecisionCreateNativePopup:
831		if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) {
832			logging.FromContext(h.currentContext()).Debug().
833				Int32("popup_id", popupID).
834				Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
835				Msg("cef: native popup armed")
836			return false
837		}
838
839		fallbackEligible := !decision.RequiresNativeOpener && decision.ReasonCode != dto.HostDecisionReasonAuthNativePopup
840		if fallbackEligible {
841			// The Phase 3 abort callback may synchronously detach and adopt this
842			// shell. Arm direct creation first so the adopted shell can create a
843			// browser and retain the synthetic opener bridge when allowed.
844			cefPopup.preparePopupShellDirectBrowserCreation()
845		}
846		abortInvoked := cefPopup.abortNativePopupHost()
847		if !abortInvoked {
848			cefPopup.Destroy()
849		}
850		logging.FromContext(h.currentContext()).Warn().
851			Str("engine", string(dto.BrowserEngineCEF)).
852			Str("source_host", string(decision.SourceHost)).
853			Str("decision", string(decision.Kind)).
854			Str("target_disposition", string(mapCEFWindowDisposition(targetDisposition))).
855			Str("reason_code", string(dto.BrowsingContextFailureNativeArm)).
856			Int32("popup_id", popupID).
857			Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
858			Bool("fallback_eligible", fallbackEligible).
859			Bool("host_abort_invoked", abortInvoked).
860			Msg("cef: native popup arming failed")
861		return true
862	case dto.HostDecisionCreateBrowserWindow,
863		dto.HostDecisionCreatePane,
864		dto.HostDecisionReuseNamedPane:
865		// CEF's native popup is blocked. A related shell adopted by a Dumber
866		// host must create its browser directly; noopener controls whether the
867		// synthetic opener/postMessage bridge is installed.
868		directCreationPrepared := cefPopup.preparePopupShellDirectBrowserCreation()
869		if !directCreationPrepared && cefPopup.isNativePopupCandidate() {
870			// Clear only a still-unprepared candidate; an already-started fallback
871			// owns its opener state and must remain intact.
872			cefPopup.discardNativePopupCandidate()
873		}
874		logging.FromContext(h.currentContext()).Debug().
875			Int32("popup_id", popupID).
876			Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
877			Str("decision", string(decision.Kind)).
878			Bool("direct_creation_prepared", directCreationPrepared).
879			Msg("cef: blocking native popup for related shell browser creation")
880		return true
881	case dto.HostDecisionAwaitPopupFeatures, dto.HostDecisionDeny:
882		cefPopup.Destroy()
883		logging.FromContext(h.currentContext()).Warn().
884			Int32("popup_id", popupID).
885			Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
886			Str("decision", string(decision.Kind)).
887			Msg("cef: denied popup host decision")
888		return true
889	default:
890		if !cefPopup.preparePopupShellDirectBrowserCreation() {
891			cefPopup.discardNativePopupCandidate()
892		}
893		logging.FromContext(h.currentContext()).Debug().
894			Int32("popup_id", popupID).
895			Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
896			Msg("cef: missing host decision, blocking native popup and using popup shell browser creation")
897		return true
898	}
899}
900
901func (h *handlerSet) OnBeforePopupAborted(_ purecef.Browser, popupID int32) {
902	if popup := h.wv.takePendingNativePopup(popupID); popup != nil {
903		popup.handleNativePopupAborted()
904	}
905}
906
907func (h *handlerSet) OnBeforeDevToolsPopup(
908	_ purecef.Browser, _ *purecef.WindowInfo, _ *purecef.RawClientWriteSlot,
909	_ *purecef.BrowserSettings, _ *purecef.DictionaryValue, _ *bool,
910) {
911}
912
913// OnAfterCreated stores the browser and host references and enables input.
914func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
915	log := logging.FromContext(h.wv.ctx)
916	if browser == nil {
917		log.Warn().Msg("cef: OnAfterCreated called with nil browser")
918		if h.wv.engine != nil {
919			// No browser will ever complete this creation: resolve the
920			// pending count so quiescence cannot strand on it.
921			h.wv.engine.activity.NoteCreationResolved()
922		}
923		return
924	}
925	browserID := browser.GetIdentifier()
926	log.Debug().Int32("browser_id", browserID).Msg("cef: OnAfterCreated")
927
928	host := browser.GetHost()
929	if host == nil {
930		log.Warn().Msg("cef: OnAfterCreated returned nil host")
931		if h.wv.engine != nil {
932			// No view will ever attach to this browser: resolve the
933			// pending count so quiescence cannot strand on it.
934			h.wv.engine.activity.NoteCreationResolved()
935		}
936		return
937	}
938
939	state := h.attachAfterCreatedBrowser(browser, host, browserID)
940	if state.closeDuplicate {
941		log.Warn().
942			Int32("browser_id", browserID).
943			Int32("existing_browser_id", state.duplicateBrowserID).
944			Msg("cef: duplicate popup browser attached after shell already had a browser; closing duplicate")
945		if h.wv.engine != nil {
946			h.wv.engine.activity.NoteCreationResolved()
947		}
948		host.CloseBrowser(1)
949		return
950	}
951
952	h.finishAfterCreated(browser, host, state)
953}
954
955func (h *handlerSet) attachAfterCreatedBrowser(
956	browser purecef.Browser,
957	host purecef.BrowserHost,
958	browserID int32,
959) afterCreatedState {
960	state := afterCreatedState{}
961	h.wv.mu.Lock()
962
963	if existing := h.wv.browser; existing != nil {
964		existingID := existing.GetIdentifier()
965		if existingID != 0 && existingID != browserID {
966			state.closeDuplicate = true
967			state.duplicateBrowserID = existingID
968			h.wv.mu.Unlock()
969			return state
970		}
971	}
972
973	h.wv.browser = browser
974	h.wv.host = host
975	h.wv.clearAppliedZoomCompensation()
976	h.wv.pendingCreate = nil
977	bridge := h.wv.viewBridge
978	h.wv.inputAttached = bridge == nil
979	state.inputAttached = h.wv.inputAttached
980	if h.wv.findCtrl != nil {
981		h.wv.findCtrl.setHost(host)
982	}
983	state.nativePopupParent = h.wv.nativePopupParent
984	state.nativePopupID = h.wv.nativePopupID
985	h.wv.nativePopupParent = nil
986	h.wv.nativePopupID = 0
987	h.wv.nativePopupFallbackStarted = false
988	state.hasPendingNavigation = strings.TrimSpace(h.wv.pendingURI) != ""
989	h.wv.mu.Unlock()
990
991	if bridge != nil {
992		wv := h.wv
993		log := logging.FromContext(wv.ctx)
994		log.Debug().
995			Uint64("webview_id", uint64(wv.id)).
996			Int32("browser_id", browserID).
997			Bool("destroyed", wv.destroyed.Load()).
998			Bool("bridge_present", true).
999			Msg("cef: scheduling input bridge attachment")
1000		wv.runOnGTK(func() {
1001			currentBridge := wv.viewBridge
1002			log.Debug().
1003				Uint64("webview_id", uint64(wv.id)).
1004				Int32("browser_id", browserID).
1005				Bool("destroyed", wv.destroyed.Load()).
1006				Bool("bridge_present", currentBridge != nil).
1007				Bool("bridge_stale", currentBridge != bridge).
1008				Msg("cef: starting input bridge attachment")
1009			if wv.destroyed.Load() || currentBridge == nil || currentBridge != bridge || bridge.IsDestroyed() {
1010				wv.handleInputAttachFailure(ErrAdapterDestroyed, host)
1011				return
1012			}
1013			if err := bridge.AttachInputToWidget(host, wv.nativeWidget, wv.bridgeInputOptions()); err != nil {
1014				wv.handleInputAttachFailure(err, host)
1015				return
1016			}
1017			bridgeFocused := bridge.HasFocus()
1018			if bridgeFocused {
1019				syncWindowlessBrowserFocus(host)
1020			} else {
1021				host.Invalidate(purecef.PaintElementTypePetView)
1022			}
1023			wv.markInputAttached()
1024			log.Debug().
1025				Uint64("webview_id", uint64(wv.id)).
1026				Int32("browser_id", browserID).
1027				Bool("destroyed", wv.destroyed.Load()).
1028				Bool("bridge_present", wv.viewBridge != nil).
1029				Bool("bridge_focused", bridgeFocused).
1030				Msg("cef: input bridge attachment succeeded")
1031		})
1032	}
1033
1034	// CEF OSR starts hidden. SyncViewport reads GTK's mapped, ancestor-aware
1035	// effective state on the main context and reports that truthful initial state.
1036	if h.wv != nil {
1037		if h.wv.ctx != nil {
1038			logging.FromContext(h.wv.ctx).Info().
1039				Int32("windowless_frame_rate_configured", host.GetWindowlessFrameRate()).
1040				Msg("cef: browser initial/configured windowless frame rate")
1041		}
1042		h.wv.SyncViewport(h.wv.ctx, "after-created")
1043	}
1044	return state
1045}
1046
1047func (wv *WebView) handleInputAttachFailure(err error, host purecef.BrowserHost) {
1048	if wv == nil || err == nil {
1049		return
1050	}
1051	if wv.ctx != nil {
1052		browserID := int32(0)
1053		if host != nil {
1054			if browser := host.GetBrowser(); browser != nil {
1055				browserID = browser.GetIdentifier()
1056			}
1057		}
1058		logging.FromContext(wv.ctx).Warn().Err(err).
1059			Uint64("webview_id", uint64(wv.id)).
1060			Int32("browser_id", browserID).
1061			Bool("destroyed", wv.destroyed.Load()).
1062			Bool("bridge_present", wv.viewBridge != nil).
1063			Msg("cef: input bridge attachment failed")
1064	}
1065	if host != nil && !wv.destroyed.Load() {
1066		host.CloseBrowser(1)
1067		return
1068	}
1069	wv.runCloseCallbacks()
1070}
1071
1072func (h *handlerSet) finishAfterCreated(
1073	browser purecef.Browser,
1074	host purecef.BrowserHost,
1075	state afterCreatedState,
1076) {
1077	if h.wv.engine != nil {
1078		h.wv.engine.recordBrowserAfterCreated(browser)
1079		h.wv.engine.registerWebView(h.wv)
1080		h.wv.engine.bindBrowserWebView(browser, h.wv)
1081	}
1082	h.wv.stopNativePopupFallbackTimer()
1083	if state.inputAttached {
1084		// Request an initial OSR frame even before the first real navigation
1085		// commits. This restores the about:blank warm-up paint that the stable
1086		// startup path relied on and prevents the rendering bridge from staying idle.
1087		host.Invalidate(purecef.PaintElementTypePetView)
1088	}
1089	if state.hasPendingNavigation {
1090		h.wv.schedulePendingNavigationReplay()
1091	}
1092	if state.nativePopupParent != nil && state.nativePopupID != 0 {
1093		state.nativePopupParent.clearPendingNativePopup(state.nativePopupID, h.wv)
1094	}
1095	h.wv.scheduleStartAdaptiveFrameRatePolling()
1096	h.wv.scheduleStartBeginFrameLoop()
1097	h.wv.applyAccessibilityIfReady()
1098	h.wv.fireReadyToShow()
1099}
1100
1101type afterCreatedState struct {
1102	inputAttached        bool
1103	hasPendingNavigation bool
1104	nativePopupParent    *WebView
1105	nativePopupID        int32
1106	duplicateBrowserID   int32
1107	closeDuplicate       bool
1108}
1109
1110// DoClose returns false to allow the default close behavior.
1111func (h *handlerSet) DoClose(_ purecef.Browser) bool {
1112	return false
1113}
1114
1115// OnBeforeClose fires the OnClose callback.
1116func (h *handlerSet) OnBeforeClose(browser purecef.Browser) {
1117	if h.wv.engine != nil {
1118		browserID := int32(0)
1119		if browser != nil {
1120			browserID = browser.GetIdentifier()
1121		}
1122		h.wv.engine.unregisterWebView(h.wv, browserID)
1123	}
1124	h.wv.mu.Lock()
1125	h.wv.browser = nil
1126	h.wv.host = nil
1127	h.wv.clearAppliedZoomCompensation()
1128	h.wv.inputAttached = false
1129	if h.wv.findCtrl != nil {
1130		h.wv.findCtrl.setHost(nil)
1131	}
1132	h.wv.mu.Unlock()
1133	if h.wv.destroyed.Load() {
1134		h.wv.destroyViewBridgeOnGTKAsync()
1135	} else if h.wv.viewBridge != nil {
1136		wv := h.wv
1137		wv.runOnGTK(func() {
1138			if wv.viewBridge == nil {
1139				return
1140			}
1141			if err := wv.viewBridge.SetInputHost(nil); err != nil && wv.ctx != nil {
1142				logging.FromContext(wv.ctx).Warn().Err(err).Msg("cef: failed to clear cef2gtk input host")
1143			}
1144		})
1145	}
1146	h.wv.scheduleStopAdaptiveFrameRatePolling()
1147	h.wv.scheduleStopBeginFrameLoop()
1148
1149	h.wv.mu.RLock()
1150	cb := h.wv.callbacks
1151	h.wv.mu.RUnlock()
1152	if cb != nil && cb.OnClose != nil {
1153		h.wv.runOnGTK(func() {
1154			cb.OnClose()
1155		})
1156	}
1157	h.wv.runCloseCallbacks()
1158}
1159
1160// ===========================================================================
1161// RequestHandler (11 methods)
1162// ===========================================================================
1163
1164func (h *handlerSet) OnBeforeBrowse(browser purecef.Browser, frame purecef.Frame, request purecef.Request, _, _ int32) bool {
1165	if frame == nil || !frame.IsMain() || request == nil {
1166		return false
1167	}
1168	// Pre-commit invalidation for every main-frame navigation. The allow /
1169	// cancel decision below is unchanged; subframe navigations never reach
1170	// this point and do not cancel the main page's gesture.
1171	if h.wv != nil {
1172		h.wv.invalidateScrollMotion()
1173	}
1174
1175	handler := h.downloadHandler()
1176	if handler == nil || !strings.EqualFold(request.GetMethod(), "GET") {
1177		return false
1178	}
1179
1180	url := request.GetURL()
1181	if !downloadutil.ShouldForceDownloadForURI(url) || browser == nil {
1182		return false
1183	}
1184
1185	host := browser.GetHost()
1186	if host == nil {
1187		return false
1188	}
1189
1190	logging.FromContext(h.currentContext()).Debug().
1191		Str("url", logging.TruncateURL(url, maxSchemeTruncatedURLLength)).
1192		Msg("cef: forcing download for navigation")
1193
1194	host.StartDownload(url)
1195	return true
1196}
1197
1198func (h *handlerSet) CanDownload(browser purecef.Browser, url, requestMethod string) bool {
1199	handler := h.downloadHandler()
1200	if handler == nil {
1201		return false
1202	}
1203	return handler.canDownload(browser, url, requestMethod)
1204}
1205
1206func (h *handlerSet) OnBeforeDownload(
1207	browser purecef.Browser,
1208	downloadItem purecef.DownloadItem,
1209	suggestedName string,
1210	callback purecef.BeforeDownloadCallback,
1211) bool {
1212	handler := h.downloadHandler()
1213	if handler == nil {
1214		return false
1215	}
1216	return handler.onBeforeDownload(h.currentContext(), browser, downloadItem, suggestedName, callback)
1217}
1218
1219func (h *handlerSet) OnDownloadUpdated(
1220	browser purecef.Browser,
1221	downloadItem purecef.DownloadItem,
1222	callback purecef.DownloadItemCallback,
1223) {
1224	handler := h.downloadHandler()
1225	if handler == nil {
1226		return
1227	}
1228	handler.onDownloadUpdated(h.currentContext(), browser, downloadItem, callback)
1229}
1230
1231func (h *handlerSet) downloadHandler() *downloadHandler {
1232	if h == nil || h.wv == nil || h.wv.engine == nil {
1233		return nil
1234	}
1235	return h.wv.engine.currentDownloadHandler()
1236}
1237
1238func (h *handlerSet) currentContext() context.Context {
1239	if h == nil || h.wv == nil || h.wv.ctx == nil {
1240		return context.Background()
1241	}
1242	return h.wv.ctx
1243}
1244
1245func (h *handlerSet) OnOpenUrlfromTab(
1246	_ purecef.Browser,
1247	_ purecef.Frame,
1248	_ string,
1249	_ purecef.WindowOpenDisposition,
1250	_ int32,
1251) int32 {
1252	return 0
1253}
1254
1255func (h *handlerSet) GetResourceRequestHandler(
1256	_ purecef.Browser, _ purecef.Frame, _ purecef.Request,
1257	_, _ int32, _ string, _ *int32,
1258) purecef.ResourceRequestHandler {
1259	return nil
1260}
1261
1262func (h *handlerSet) GetAuthCredentials(
1263	_ purecef.Browser, _ string, _ int32, _ string, _ int32,
1264	_, _ string, _ purecef.AuthCallback,
1265) int32 {
1266	return 0
1267}
1268
1269func (h *handlerSet) OnCertificateError(
1270	_ purecef.Browser,
1271	_ purecef.Errorcode,
1272	_ string,
1273	_ purecef.Sslinfo,
1274	_ purecef.Callback,
1275) int32 {
1276	return 0
1277}
1278
1279func (h *handlerSet) OnSelectClientCertificate(
1280	_ purecef.Browser, _ int32, _ string, _ int32,
1281	_ []purecef.X509Certificate, _ purecef.SelectClientCertificateCallback,
1282) int32 {
1283	return 0
1284}
1285
1286func (h *handlerSet) OnRenderViewReady(_ purecef.Browser) {}
1287
1288func (h *handlerSet) OnRenderProcessUnresponsive(_ purecef.Browser, _ purecef.UnresponsiveProcessCallback) int32 {
1289	return 0
1290}
1291
1292func (h *handlerSet) OnRenderProcessResponsive(_ purecef.Browser) {}
1293
1294// maxConsecutiveCrashes caps how many times OnRenderProcessTerminated will
1295// forward the event before suppressing further notifications. This prevents
1296// infinite crash → redirect → crash loops.
1297const maxConsecutiveCrashes = 3
1298
1299// OnRenderProcessTerminated fires the OnWebProcessTerminated callback with a mapped reason.
1300func (h *handlerSet) OnRenderProcessTerminated(_ purecef.Browser, status purecef.TerminationStatus, _ int32, _ string) {
1301	if h.wv.crashCount.Add(1) > maxConsecutiveCrashes {
1302		return // suppress to break the loop
1303	}
1304
1305	var reason port.WebProcessTerminationReason
1306	var label string
1307	switch status {
1308	case purecef.TerminationStatusTsAbnormalTermination:
1309		reason = port.WebProcessTerminationCrashed
1310		label = "abnormal termination"
1311	case purecef.TerminationStatusTsProcessWasKilled:
1312		reason = port.WebProcessTerminationByAPI
1313		label = "killed"
1314	case purecef.TerminationStatusTsProcessCrashed:
1315		reason = port.WebProcessTerminationCrashed
1316		label = "crashed"
1317	case purecef.TerminationStatusTsProcessOom:
1318		reason = port.WebProcessTerminationExceededMemory
1319		label = "out of memory"
1320	default:
1321		reason = port.WebProcessTerminationUnknown
1322		label = "unknown"
1323	}
1324
1325	h.wv.mu.RLock()
1326	cb := h.wv.callbacks
1327	uri := h.wv.uri
1328	h.wv.mu.RUnlock()
1329	if cb != nil && cb.OnWebProcessTerminated != nil {
1330		h.wv.runOnGTK(func() {
1331			cb.OnWebProcessTerminated(reason, label, uri)
1332		})
1333	}
1334}
1335
1336func (h *handlerSet) OnDocumentAvailableInMainFrame(_ purecef.Browser) {}
1337
1338// ===========================================================================
1339// AudioHandler (5 methods)
1340// ===========================================================================
1341
1342func (h *handlerSet) GetAudioParameters(_ purecef.Browser, _ *purecef.AudioParameters) int32 {
1343	return 1 // proceed with defaults
1344}
1345
1346// OnAudioStreamStarted handles the start of an audio stream.
1347// The third callback argument is the channel count (not frames-per-buffer);
1348// frames-per-buffer comes from params.FramesPerBuffer.
1349func (h *handlerSet) OnAudioStreamStarted(_ purecef.Browser, params *purecef.AudioParameters, channels int32) {
1350	// Validate params and factory first
1351	if params == nil || h.wv.audioOutputFactory == nil {
1352		return
1353	}
1354
1355	// Build format from CEF parameters
1356	format := h.buildAudioStreamFormat(params, channels)
1357
1358	if h.wv.ctx != nil {
1359		logging.FromContext(h.wv.ctx).Info().
1360			Int("sample_rate", format.SampleRate).
1361			Int("channels", format.ChannelCount).
1362			Int("frames_per_buffer", format.FramesPerBuffer).
1363			Int32("channel_layout", params.ChannelLayout).
1364			Msg("cef: OnAudioStreamStarted")
1365	}
1366
1367	// Starting a stream invalidates both a previous publication and any older
1368	// creation already in flight. Close detached streams outside the mutex.
1369	generation, previous, accepted := h.wv.beginAudioStreamStart()
1370	if !accepted {
1371		return
1372	}
1373	h.wv.closeDetachedAudioStream(previous)
1374	h.wv.audioPacketCount.Store(0)
1375	h.wv.audioWriteCount.Store(0)
1376
1377	ctx := h.wv.ctx
1378	if ctx == nil {
1379		ctx = context.Background()
1380	}
1381	stream, err := h.wv.audioOutputFactory.NewStream(ctx, format)
1382	if err != nil {
1383		if h.wv.ctx != nil {
1384			logging.FromContext(h.wv.ctx).Warn().Err(err).
1385				Int("sample_rate", format.SampleRate).Int("channels", format.ChannelCount).
1386				Msg("cef: failed to create audio output stream")
1387		}
1388		return
1389	}
1390
1391	replaced, published := h.wv.publishAudioStream(generation, stream)
1392	if !published {
1393		// Stop, Destroy, or a newer Start won the race. This callback owns the
1394		// unpublishable stream and closes it exactly once.
1395		h.wv.closeDetachedAudioStream(&audioStreamHandle{stream: stream, generation: generation})
1396		return
1397	}
1398	h.wv.closeDetachedAudioStream(replaced)
1399	if h.wv.ctx != nil {
1400		logging.FromContext(h.wv.ctx).Info().
1401			Int("sample_rate", format.SampleRate).Int("channels", format.ChannelCount).
1402			Int("frames_per_buffer", format.FramesPerBuffer).
1403			Msg("cef: audio output stream created successfully")
1404	}
1405}
1406
1407// buildAudioStreamFormat converts CEF AudioParameters to port.AudioStreamFormat.
1408// channels is the authoritative channel count from the callback; params.FramesPerBuffer
1409// supplies the buffer size.
1410func (h *handlerSet) buildAudioStreamFormat(params *purecef.AudioParameters, channels int32) port.AudioStreamFormat {
1411	return port.AudioStreamFormat{
1412		SampleRate:      int(params.SampleRate),
1413		ChannelCount:    int(channels),
1414		FramesPerBuffer: int(params.FramesPerBuffer),
1415	}
1416}
1417
1418// OnAudioStreamPacket is a real-time callback: it atomically snapshots an
1419// immutable handle and performs only allocation-free atomic diagnostics. It
1420// never takes a lifecycle mutex or emits synchronous logs.
1421func (h *handlerSet) OnAudioStreamPacket(_ purecef.Browser, data [][]float32, frames int32, _ int64) {
1422	if len(data) == 0 || frames <= 0 {
1423		return
1424	}
1425	handle := h.wv.audioStream.Load()
1426	if handle == nil || handle.stream == nil {
1427		return
1428	}
1429	h.wv.audioPacketCount.Add(1)
1430	if handle.stream.Write(data) == nil {
1431		h.wv.audioWriteCount.Add(1)
1432	}
1433}
1434
1435// OnAudioStreamStopped handles stream stop.
1436func (h *handlerSet) OnAudioStreamStopped(_ purecef.Browser) {
1437	if h.wv.ctx != nil {
1438		logging.FromContext(h.wv.ctx).Info().
1439			Uint64("packets_received", h.wv.audioPacketCount.Load()).
1440			Uint64("writes_succeeded", h.wv.audioWriteCount.Load()).
1441			Msg("cef: OnAudioStreamStopped")
1442	}
1443	h.wv.setAudioPlaying(false)
1444	h.wv.closeAudioStream()
1445}
1446
1447// OnAudioStreamError handles stream errors.
1448func (h *handlerSet) OnAudioStreamError(_ purecef.Browser, message string) {
1449	if h.wv.ctx != nil {
1450		event := logging.FromContext(h.wv.ctx).Warn()
1451		logMsg := "cef: audio stream error"
1452		if h.wv.destroyed.Load() && message == "Socket closed unexpectedly" {
1453			event = logging.FromContext(h.wv.ctx).Debug()
1454			logMsg = "cef: audio stream closed during shutdown"
1455		}
1456		event.
1457			Str("error", message).
1458			Uint64("packets_received", h.wv.audioPacketCount.Load()).
1459			Uint64("writes_succeeded", h.wv.audioWriteCount.Load()).
1460			Msg(logMsg)
1461	}
1462	h.wv.setAudioPlaying(false)
1463	h.wv.closeAudioStream()
1464}
1465
1466// ===========================================================================
1467// FindHandler (1 method)
1468// ===========================================================================
1469
1470// OnFindResult dispatches CEF find results to the WebView's FindController.
1471func (h *handlerSet) OnFindResult(
1472	_ purecef.Browser,
1473	identifier, count int32,
1474	_ *purecef.Rect,
1475	activematchordinal, finalupdate int32,
1476) {
1477	if h.wv.findCtrl != nil {
1478		h.wv.findCtrl.handleFindResult(identifier, count, activematchordinal, finalupdate)
1479	}
1480}
1481
1482// cefCursorNames maps CEF cursor types to GDK/CSS cursor names.
1483var cefCursorNames = map[purecef.CursorType]string{
1484	purecef.CursorTypeCtPointer:                  "default",
1485	purecef.CursorTypeCtCross:                    "crosshair",
1486	purecef.CursorTypeCtHand:                     "pointer",
1487	purecef.CursorTypeCtIbeam:                    "text",
1488	purecef.CursorTypeCtWait:                     "wait",
1489	purecef.CursorTypeCtHelp:                     "help",
1490	purecef.CursorTypeCtEastresize:               "e-resize",
1491	purecef.CursorTypeCtNorthresize:              "n-resize",
1492	purecef.CursorTypeCtNortheastresize:          "ne-resize",
1493	purecef.CursorTypeCtNorthwestresize:          "nw-resize",
1494	purecef.CursorTypeCtSouthresize:              "s-resize",
1495	purecef.CursorTypeCtSoutheastresize:          "se-resize",
1496	purecef.CursorTypeCtSouthwestresize:          "sw-resize",
1497	purecef.CursorTypeCtWestresize:               "w-resize",
1498	purecef.CursorTypeCtNorthsouthresize:         "ns-resize",
1499	purecef.CursorTypeCtEastwestresize:           "ew-resize",
1500	purecef.CursorTypeCtNortheastsouthwestresize: "nesw-resize",
1501	purecef.CursorTypeCtNorthwestsoutheastresize: "nwse-resize",
1502	purecef.CursorTypeCtColumnresize:             "col-resize",
1503	purecef.CursorTypeCtRowresize:                "row-resize",
1504	purecef.CursorTypeCtMove:                     "move",
1505	purecef.CursorTypeCtProgress:                 "progress",
1506	purecef.CursorTypeCtNodrop:                   "no-drop",
1507	purecef.CursorTypeCtNotallowed:               "not-allowed",
1508	purecef.CursorTypeCtGrab:                     "grab",
1509	purecef.CursorTypeCtGrabbing:                 "grabbing",
1510	purecef.CursorTypeCtZoomin:                   "zoom-in",
1511	purecef.CursorTypeCtZoomout:                  "zoom-out",
1512	purecef.CursorTypeCtNone:                     "none",
1513}
1514
1515// cefCursorToGDKName maps a CEF cursor type to a GDK/CSS cursor name.
1516func cefCursorToGDKName(ct purecef.CursorType) string {
1517	if name, ok := cefCursorNames[ct]; ok {
1518		return name
1519	}
1520	return "default"
1521}

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