v0.32.0 0617278

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

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