v0.33.2 57f21d6

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

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