v0.29.0 21b3150

webview_handlers.go RawHistoryFolder

1package cef
2
3import (
4	"context"
5	"strings"
6	"sync"
7
8	purecef "github.com/bnema/purego-cef/cef"
9
10	"github.com/bnema/dumber/internal/application/dto"
11	"github.com/bnema/dumber/internal/application/port"
12	downloadutil "github.com/bnema/dumber/internal/domain/download"
13	"github.com/bnema/dumber/internal/logging"
14)
15
16// Console message markers used by injected JavaScript for log filtering.
17const (
18	consoleMarkerVideoDiag  = "[VIDEO-DIAG]"
19	consoleMarkerAutoCopy   = "[AUTO-COPY]"
20	consoleMarkerScaleProbe = "[SCALE-PROBE]"
21)
22
23// handlerSet implements all CEF handler interfaces and dispatches events to the
24// owning WebView. A single struct is used so that the Client's Get*Handler
25// methods can return the same receiver, avoiding extra allocations.
26type handlerSet struct {
27	wv                  *WebView
28	fileDialogPresenter fileDialogPresenter
29	renderHandlerOnce   sync.Once
30	renderHandler       purecef.RenderHandler
31}
32
33// Compile-time interface checks.
34var (
35	_ purecef.Client             = (*handlerSet)(nil)
36	_ purecef.DisplayHandler     = (*handlerSet)(nil)
37	_ purecef.LoadHandler        = (*handlerSet)(nil)
38	_ purecef.LifeSpanHandler    = (*handlerSet)(nil)
39	_ purecef.RequestHandler     = (*handlerSet)(nil)
40	_ purecef.AudioHandler       = (*handlerSet)(nil)
41	_ purecef.ContextMenuHandler = (*handlerSet)(nil)
42	_ purecef.DialogHandler      = (*handlerSet)(nil)
43	_ purecef.DownloadHandler    = (*handlerSet)(nil)
44	_ purecef.FindHandler        = (*handlerSet)(nil)
45)
46
47// ===========================================================================
48// Client
49// ===========================================================================
50
51func (h *handlerSet) GetAudioHandler() purecef.AudioHandler {
52	// Always return the audio handler — without it, CEF has no audio output
53	// path and refuses to start media decoding, breaking all video playback.
54	// The handler accepts audio but discards packets (no-op) until a real
55	// audio backend (PipeWire/PulseAudio) is wired up.
56	return h
57}
58func (h *handlerSet) GetCommandHandler() purecef.CommandHandler         { return nil }
59func (h *handlerSet) GetContextMenuHandler() purecef.ContextMenuHandler { return h }
60func (h *handlerSet) GetDialogHandler() purecef.DialogHandler           { return h }
61func (h *handlerSet) GetDisplayHandler() purecef.DisplayHandler         { return h }
62func (h *handlerSet) GetDownloadHandler() purecef.DownloadHandler {
63	if h.downloadHandler() != nil {
64		return h
65	}
66	return nil
67}
68func (h *handlerSet) GetDragHandler() purecef.DragHandler             { return nil }
69func (h *handlerSet) GetFindHandler() purecef.FindHandler             { return h }
70func (h *handlerSet) GetFocusHandler() purecef.FocusHandler           { return nil }
71func (h *handlerSet) GetFrameHandler() purecef.FrameHandler           { return nil }
72func (h *handlerSet) GetPermissionHandler() purecef.PermissionHandler { return nil }
73func (h *handlerSet) GetJsdialogHandler() purecef.JsdialogHandler     { return nil }
74func (h *handlerSet) GetKeyboardHandler() purecef.KeyboardHandler     { return nil }
75func (h *handlerSet) GetLifeSpanHandler() purecef.LifeSpanHandler     { return h }
76func (h *handlerSet) GetLoadHandler() purecef.LoadHandler             { return h }
77func (h *handlerSet) GetPrintHandler() purecef.PrintHandler           { return nil }
78func (h *handlerSet) GetRenderHandler() purecef.RenderHandler {
79	if h == nil {
80		return nil
81	}
82	h.renderHandlerOnce.Do(func() {
83		h.renderHandler = newDumberRenderHandler(h.wv)
84	})
85	return h.renderHandler
86}
87func (h *handlerSet) GetRequestHandler() purecef.RequestHandler { return h }
88
89func (h *handlerSet) OnProcessMessageReceived(
90	browser purecef.Browser,
91	_ purecef.Frame,
92	_ purecef.ProcessID,
93	message purecef.ProcessMessage,
94) int32 {
95	if h == nil || h.wv == nil || h.wv.engine == nil {
96		return 0
97	}
98	action, payload, ok := decodeRendererBridgeProcessMessage(message)
99	if !ok {
100		return 0
101	}
102
103	log := logging.FromContext(h.wv.ctx)
104	log.Debug().
105		Str("action", action).
106		Int("payload_len", len(payload)).
107		Msg("cef: renderer bridge message received")
108
109	switch action {
110	case rendererBridgeActionExplicitTextCopy:
111		req, err := decodeRendererBridgeExplicitTextCopyPayload([]byte(payload))
112		if err != nil {
113			log.Debug().Str("action", action).Msg("cef: invalid explicit copy payload")
114			return 1
115		}
116		h.wv.engine.handleExplicitClipboardBridgeText(h.wv.id, req.Action, req.Text)
117	case rendererBridgeActionEditableFocusChanged:
118		h.wv.setEditableFocus(payload == "1" || strings.EqualFold(payload, "true"))
119	case rendererBridgeActionFocusSync:
120		h.wv.engine.handleEditableFocusBridge(browser)
121	case rendererBridgeActionPopupOpen:
122		req, err := decodeRendererBridgePopupOpenPayload([]byte(payload))
123		if err != nil {
124			log.Debug().Str("action", action).Msg("cef: invalid popup_open payload")
125			return 1
126		}
127		h.wv.handleSyntheticPopupOpen(req.URL, req.FrameName, req.ProxyID, req.UserGesture, req.NoJavaScriptAccess)
128	case rendererBridgeActionPopupNavigate:
129		req, err := decodeRendererBridgePopupNavigatePayload([]byte(payload))
130		if err != nil {
131			log.Debug().Str("action", action).Msg("cef: invalid popup_navigate payload")
132			return 1
133		}
134		h.wv.handleSyntheticPopupNavigate(req.ProxyID, req.URL)
135	case rendererBridgeActionPopupClose:
136		req, err := decodeRendererBridgePopupClosePayload([]byte(payload))
137		if err != nil {
138			log.Debug().Str("action", action).Msg("cef: invalid popup_close payload")
139			return 1
140		}
141		h.wv.handleSyntheticPopupClose(req.ProxyID)
142	case rendererBridgeActionReady:
143		log.Debug().
144			Str("frame_url", logging.TruncateURL(payload, logging.PermissionLogURLMaxLen)).
145			Msg("cef: renderer bridge ready")
146	default:
147		log.Debug().Str("action", action).Msg("cef: unknown renderer bridge action")
148	}
149	return 1
150}
151
152// ===========================================================================
153// RenderHandler methods live in render_handler_adapter.go. handlerSet keeps
154// Dumber's non-render CEF handlers and returns a delegating render handler from
155// GetRenderHandler.
156// ===========================================================================
157
158// ===========================================================================
159// DisplayHandler (13 methods)
160// ===========================================================================
161
162// OnAddressChange updates the cached URI when the main frame navigates.
163func (h *handlerSet) OnAddressChange(_ purecef.Browser, frame purecef.Frame, url string) {
164	if frame != nil && frame.IsMain() {
165		if h.wv != nil && h.wv.ctx != nil {
166			logging.FromContext(h.wv.ctx).Debug().
167				Str("url", logging.TruncateURL(url, logging.PermissionLogURLMaxLen)).
168				Msg("cef: OnAddressChange")
169		}
170		h.wv.updateURI(url)
171	}
172}
173
174// OnTitleChange updates the cached title.
175func (h *handlerSet) OnTitleChange(_ purecef.Browser, title string) {
176	h.wv.updateTitle(title)
177}
178
179func (h *handlerSet) OnFaviconUrlchange(_ purecef.Browser, _ purecef.StringList) {}
180
181// OnFullscreenModeChange toggles the fullscreen atomic and fires callbacks.
182func (h *handlerSet) OnFullscreenModeChange(_ purecef.Browser, fullscreen int32) {
183	entering := fullscreen != 0
184	h.wv.fullscreen.Store(entering)
185
186	h.wv.mu.RLock()
187	cb := h.wv.callbacks
188	h.wv.mu.RUnlock()
189	if cb == nil {
190		return
191	}
192	if entering && cb.OnEnterFullscreen != nil {
193		h.wv.runOnGTK(func() {
194			cb.OnEnterFullscreen()
195		})
196	}
197	if !entering && cb.OnLeaveFullscreen != nil {
198		h.wv.runOnGTK(func() {
199			cb.OnLeaveFullscreen()
200		})
201	}
202}
203
204func (h *handlerSet) OnTooltip(_ purecef.Browser, _ uintptr) int32 { return 0 }
205
206// OnStatusMessage fires the OnLinkHover callback with the status text
207// and caches the hover URI for middle-click interception.
208func (h *handlerSet) OnStatusMessage(_ purecef.Browser, value string) {
209	h.wv.updateHoverURI(value)
210
211	h.wv.mu.RLock()
212	cb := h.wv.callbacks
213	h.wv.mu.RUnlock()
214	if cb != nil && cb.OnLinkHover != nil {
215		h.wv.runOnGTK(func() {
216			cb.OnLinkHover(value)
217		})
218	}
219}
220
221func (h *handlerSet) OnConsoleMessage(
222	_ purecef.Browser,
223	level purecef.LogSeverity,
224	message, source string,
225	line int32,
226) int32 {
227	if h.wv != nil && h.wv.ctx != nil &&
228		(strings.Contains(message, consoleMarkerVideoDiag) ||
229			strings.Contains(message, consoleMarkerAutoCopy) ||
230			strings.Contains(message, consoleMarkerScaleProbe)) {
231		log := logging.FromContext(h.wv.ctx).With().
232			Str("component", "cef-console").
233			Str("source", source).
234			Int32("line", line).
235			Logger()
236		switch level {
237		case purecef.LogSeverityLogseverityError, purecef.LogSeverityLogseverityFatal:
238			log.Error().Str("message", message).Msg("cef: console message")
239		case purecef.LogSeverityLogseverityWarning:
240			log.Warn().Str("message", message).Msg("cef: console message")
241		default:
242			log.Info().Str("message", message).Msg("cef: console message")
243		}
244	}
245	return 0
246}
247
248func (h *handlerSet) OnAutoResize(_ purecef.Browser, _ *purecef.Size) int32 { return 0 }
249
250// OnLoadingProgressChange updates the cached progress value.
251func (h *handlerSet) OnLoadingProgressChange(_ purecef.Browser, progress float64) {
252	if h.wv != nil && h.wv.ctx != nil {
253		logging.FromContext(h.wv.ctx).Trace().
254			Float64("progress", progress).
255			Msg("cef: OnLoadingProgressChange")
256	}
257	h.wv.updateProgress(progress)
258}
259
260func (h *handlerSet) OnCursorChange(
261	_ purecef.Browser,
262	_ uintptr,
263	cursorType purecef.CursorType,
264	_ *purecef.CursorInfo,
265) int32 {
266	if h == nil || h.wv == nil {
267		return 0
268	}
269	name := cefCursorToGDKName(cursorType)
270	h.wv.runOnGTK(func() {
271		if h.wv.viewBridge != nil {
272			h.wv.viewBridge.SetCursorFromName(name)
273		}
274	})
275	return 1 // handled
276}
277
278func (h *handlerSet) OnMediaAccessChange(_ purecef.Browser, _, _ int32) {}
279
280func (h *handlerSet) OnContentsBoundsChange(_ purecef.Browser, _ *purecef.Rect) int32 { return 0 }
281
282func (h *handlerSet) GetRootWindowScreenRect(_ purecef.Browser, _ *purecef.Rect) int32 { return 0 }
283
284// ===========================================================================
285// LoadHandler (4 methods)
286// ===========================================================================
287
288// OnLoadingStateChange updates the loading/navigation state cache and fires callbacks.
289func (h *handlerSet) OnLoadingStateChange(_ purecef.Browser, isloading, cangoback, cangoforward int32) {
290	loading := isloading != 0
291	h.wv.updateLoadState(loading, cangoback != 0, cangoforward != 0)
292	if h.wv != nil && h.wv.ctx != nil {
293		h.wv.mu.RLock()
294		uri := h.wv.uri
295		progress := h.wv.progress
296		pendingURI := h.wv.pendingURI
297		h.wv.mu.RUnlock()
298		logging.FromContext(h.wv.ctx).Debug().
299			Bool("loading", loading).
300			Bool("can_go_back", cangoback != 0).
301			Bool("can_go_forward", cangoforward != 0).
302			Float64("progress", progress).
303			Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
304			Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)).
305			Msg("cef: OnLoadingStateChange")
306	}
307
308	if !loading && h.wv.viewBridge != nil {
309		h.wv.runOnGTK(func() {
310			if h.wv.viewBridge != nil && h.wv.viewBridge.HasFocus() {
311				h.wv.mu.RLock()
312				host := h.wv.host
313				h.wv.mu.RUnlock()
314				if host != nil {
315					syncWindowlessBrowserFocus(host)
316				}
317			}
318		})
319	}
320
321	h.wv.mu.RLock()
322	cb := h.wv.callbacks
323	h.wv.mu.RUnlock()
324	if cb != nil && cb.OnLoadChanged != nil {
325		if loading {
326			h.wv.runOnGTK(func() {
327				cb.OnLoadChanged(port.LoadStarted)
328			})
329		} else {
330			h.wv.runOnGTK(func() {
331				cb.OnLoadChanged(port.LoadFinished)
332			})
333		}
334	}
335}
336
337// OnLoadStart fires LoadCommitted for main frame navigations.
338// In CEF this callback runs after navigation commit, so it is the closest
339// equivalent to WebKit's LoadCommitted event.
340func (h *handlerSet) OnLoadStart(_ purecef.Browser, frame purecef.Frame, _ purecef.TransitionType) {
341	if frame == nil || !frame.IsMain() {
342		return
343	}
344	bridgeNonce := h.wv.ensureBridgeNonce()
345	if openerBridgeScript := h.wv.popupOpenerBridgeScript(bridgeNonce); openerBridgeScript != "" {
346		if h.wv != nil && h.wv.ctx != nil {
347			parentURI, active, blocked := h.wv.popupOpenerBridgeState()
348			logging.FromContext(h.wv.ctx).Debug().
349				Uint64("webview_id", uint64(h.wv.id)).
350				Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)).
351				Str("parent_uri", logging.TruncateURL(parentURI, logging.PermissionLogURLMaxLen)).
352				Bool("bridge_active", active).
353				Bool("bridge_blocked", blocked).
354				Msg("cef: injecting popup opener bridge")
355		}
356		frame.ExecuteJavaScript(openerBridgeScript, frame.GetURL(), 0)
357	}
358	if h.wv != nil && h.wv.ctx != nil {
359		logging.FromContext(h.wv.ctx).Debug().
360			Str("url", logging.TruncateURL(frame.GetURL(), logging.PermissionLogURLMaxLen)).
361			Msg("cef: OnLoadStart")
362	}
363	h.wv.updateURI(frame.GetURL())
364	h.wv.mu.RLock()
365	cb := h.wv.callbacks
366	h.wv.mu.RUnlock()
367	if cb != nil && cb.OnLoadChanged != nil {
368		h.wv.runOnGTK(func() {
369			cb.OnLoadChanged(port.LoadCommitted)
370		})
371	}
372}
373
374// OnLoadEnd resets crash count and injects content scripts for the main frame.
375func (h *handlerSet) OnLoadEnd(_ purecef.Browser, frame purecef.Frame, httpStatusCode int32) {
376	log := logging.FromContext(h.wv.ctx)
377	log.Debug().
378		Bool("frame_nil", frame == nil).
379		Bool("is_main", frame != nil && frame.IsMain()).
380		Int32("http_status", httpStatusCode).
381		Msg("cef: OnLoadEnd")
382	if frame == nil || !frame.IsMain() {
383		return
384	}
385	// Successful load — reset the consecutive crash counter.
386	h.wv.crashCount.Store(0)
387
388	// If a queued startup navigation is still pending after about:blank finished,
389	// replay it now that the initial main-frame load completed.
390	frameURL := frame.GetURL()
391	if pendingURI := h.wv.pendingNavigationURI(); pendingURI != "" && !pendingURIEquivalent(frameURL, pendingURI) {
392		if strings.EqualFold(strings.TrimSpace(frameURL), "about:blank") {
393			log.Debug().
394				Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)).
395				Msg("cef: replaying pending navigation after about:blank load end")
396			h.wv.schedulePendingNavigationReplay(0)
397		}
398	}
399
400	// Inject scripts and styles after page load.
401	// Must run on GTK thread — OnLoadEnd fires on the CEF IO thread,
402	// and JavaScript injection requires the main thread.
403	if cefScaleProbeEnabled() && httpStatusCode >= 0 && !strings.EqualFold(strings.TrimSpace(frameURL), "about:blank") {
404		// Do not retain/use CEF callback-scoped frame wrappers across the GTK idle hop.
405		// Re-read the current main frame through WebView.RunJavaScript instead.
406		h.wv.runOnGTK(func() {
407			logging.FromContext(h.wv.ctx).Debug().Msg("cef: injecting scale probe")
408			h.wv.RunJavaScript(context.Background(), cefScaleProbeScript)
409		})
410	}
411	if h.wv.engine != nil && h.wv.engine.contentInj != nil {
412		h.wv.runOnGTK(func() {
413			h.wv.engine.contentInj.onLoadEnd(h.wv)
414		})
415	}
416
417	// NOTE: We intentionally do NOT dispatch LoadFinished or ProgressChanged(1.0)
418	// here. OnLoadEnd is a per-frame event, and during cross-site navigations
419	// (process swap), CEF fires OnLoadEnd for the OLD main frame before the new
420	// page finishes loading. Dispatching LoadFinished here caused the progress
421	// bar to hide prematurely while the new page was still loading.
422	//
423	// OnLoadingStateChange(isLoading=false) is the correct browser-level signal
424	// for load completion — CEF guarantees it fires AFTER all OnLoadEnd calls.
425	// CEF's OnLoadingProgressChange also provides progress=1.0 at completion.
426}
427
428// OnLoadError is a no-op.
429func (h *handlerSet) OnLoadError(_ purecef.Browser, _ purecef.Frame, _ purecef.Errorcode, _, _ string) {
430}
431
432func mapCEFWindowDisposition(disposition purecef.WindowOpenDisposition) dto.WindowDisposition {
433	switch disposition {
434	case purecef.WindowOpenDispositionWodCurrentTab:
435		return dto.WindowDispositionCurrentTab
436	case purecef.WindowOpenDispositionWodNewPopup:
437		return dto.WindowDispositionNewPopup
438	case purecef.WindowOpenDispositionWodNewWindow:
439		return dto.WindowDispositionNewWindow
440	case purecef.WindowOpenDispositionWodNewForegroundTab,
441		purecef.WindowOpenDispositionWodNewBackgroundTab,
442		purecef.WindowOpenDispositionWodSingletonTab,
443		purecef.WindowOpenDispositionWodSwitchToTab:
444		return dto.WindowDispositionNewTab
445	default:
446		return ""
447	}
448}
449
450// ===========================================================================
451// LifeSpanHandler (6 methods)
452// ===========================================================================
453
454// OnBeforePopup intercepts popup requests (target="_blank", window.open).
455// When the coordinator returns a related CEF popup shell we hand that shell's
456// client back to CEF and allow native popup creation so opener semantics are
457// preserved. Otherwise we keep blocking and let the coordinator's fallback
458// pane handle the navigation.
459func (h *handlerSet) OnBeforePopup(
460	browser purecef.Browser, frame purecef.Frame, popupID int32, targetURL, targetFrameName string,
461	targetDisposition purecef.WindowOpenDisposition, userGesture int32, _ *purecef.PopupFeatures,
462	windowInfo *purecef.WindowInfo, clientSlot *purecef.RawClientWriteSlot, settings *purecef.BrowserSettings,
463	_ *purecef.DictionaryValue, noJavaScriptAccess *bool,
464) bool {
465	if targetURL == "" {
466		return true
467	}
468
469	h.wv.mu.RLock()
470	cb := h.wv.callbacks
471	h.wv.mu.RUnlock()
472	if cb == nil || cb.OnCreate == nil {
473		return true
474	}
475
476	requestNoJavaScriptAccess := false
477	if noJavaScriptAccess != nil {
478		requestNoJavaScriptAccess = *noJavaScriptAccess
479	}
480	var popup port.WebView
481	sourceBrowserID := int32(0)
482	if browser != nil {
483		sourceBrowserID = browser.GetIdentifier()
484	}
485	sourceFrameID := ""
486	sourceFrameURL := ""
487	if frame != nil {
488		sourceFrameID = frame.GetIdentifier()
489		sourceFrameURL = frame.GetURL()
490	}
491
492	h.wv.runOnGTKSync(func() {
493		popup = cb.OnCreate(port.PopupRequest{
494			TargetURI:          targetURL,
495			FrameName:          targetFrameName,
496			IsUserGesture:      userGesture != 0,
497			NoJavaScriptAccess: requestNoJavaScriptAccess,
498			ParentViewID:       h.wv.id,
499			SourceBrowserID:    sourceBrowserID,
500			SourceFrameID:      sourceFrameID,
501			SourceFrameURL:     sourceFrameURL,
502			TargetDisposition:  mapCEFWindowDisposition(targetDisposition),
503		})
504	})
505
506	cefPopup, ok := popup.(*WebView)
507	if !ok || cefPopup == nil {
508		return true
509	}
510
511	decision := dto.HostDecision{}
512	if carrier, ok := popup.(port.BrowsingContextHostDecisionCapable); ok {
513		if got, hasDecision := carrier.BrowsingContextHostDecision(); hasDecision {
514			decision = got
515		}
516	}
517
518	cefPopup.setPopupNoJavaScriptAccess(requestNoJavaScriptAccess)
519	switch decision.Kind {
520	case dto.HostDecisionCreateNativeWin:
521		if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) {
522			logging.FromContext(h.currentContext()).Debug().
523				Int32("popup_id", popupID).
524				Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
525				Msg("cef: native popup armed")
526			return false
527		}
528		if aborter, ok := popup.(port.NativePopupHostAbortCapable); ok {
529			aborter.AbortNativePopupHost()
530		}
531		cefPopup.discardNativePopupCandidate()
532		logging.FromContext(h.currentContext()).Warn().
533			Int32("popup_id", popupID).
534			Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
535			Msg("cef: native popup arming failed, denying without fallback")
536		return true
537	case dto.HostDecisionCreatePane, dto.HostDecisionReuseNamedPane:
538		logging.FromContext(h.currentContext()).Debug().
539			Int32("popup_id", popupID).
540			Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
541			Str("decision", string(decision.Kind)).
542			Msg("cef: pane-hosted browsing context, blocking native popup and using related shell browser creation")
543		return true
544	default:
545		if cefPopup.prepareNativePopup(popupID, targetURL, windowInfo, clientSlot, settings) {
546			return false
547		}
548
549		cefPopup.discardNativePopupCandidate()
550		logging.FromContext(h.currentContext()).Debug().
551			Int32("popup_id", popupID).
552			Str("target_url", logging.TruncateURL(targetURL, logging.PermissionLogURLMaxLen)).
553			Msg("cef: native popup bridge unavailable, blocking CEF popup and using popup shell browser creation")
554		return true
555	}
556}
557
558func (h *handlerSet) OnBeforePopupAborted(_ purecef.Browser, popupID int32) {
559	if popup := h.wv.takePendingNativePopup(popupID); popup != nil {
560		popup.handleNativePopupAborted()
561	}
562}
563
564func (h *handlerSet) OnBeforeDevToolsPopup(
565	_ purecef.Browser, _ *purecef.WindowInfo, _ *purecef.RawClientWriteSlot,
566	_ *purecef.BrowserSettings, _ *purecef.DictionaryValue, _ *bool,
567) {
568}
569
570// OnAfterCreated stores the browser and host references and enables input.
571func (h *handlerSet) OnAfterCreated(browser purecef.Browser) {
572	log := logging.FromContext(h.wv.ctx)
573	if browser == nil {
574		log.Warn().Msg("cef: OnAfterCreated called with nil browser")
575		return
576	}
577	browserID := browser.GetIdentifier()
578	log.Debug().Int32("browser_id", browserID).Msg("cef: OnAfterCreated")
579
580	host := browser.GetHost()
581	if host == nil {
582		log.Warn().Msg("cef: OnAfterCreated returned nil host")
583		return
584	}
585
586	state := h.attachAfterCreatedBrowser(browser, host, browserID)
587	if state.closeDuplicate {
588		log.Warn().
589			Int32("browser_id", browserID).
590			Int32("existing_browser_id", state.duplicateBrowserID).
591			Msg("cef: duplicate popup browser attached after shell already had a browser; closing duplicate")
592		host.CloseBrowser(1)
593		return
594	}
595
596	h.finishAfterCreated(browser, host, state)
597}
598
599func (h *handlerSet) attachAfterCreatedBrowser(
600	browser purecef.Browser,
601	host purecef.BrowserHost,
602	browserID int32,
603) afterCreatedState {
604	state := afterCreatedState{}
605	h.wv.mu.Lock()
606
607	if existing := h.wv.browser; existing != nil {
608		existingID := existing.GetIdentifier()
609		if existingID != 0 && existingID != browserID {
610			state.closeDuplicate = true
611			state.duplicateBrowserID = existingID
612			h.wv.mu.Unlock()
613			return state
614		}
615	}
616
617	h.wv.browser = browser
618	h.wv.host = host
619	h.wv.lastAppliedOSRBackingScaleBits.Store(0)
620	h.wv.pendingCreate = nil
621	bridge := h.wv.viewBridge
622	h.wv.inputAttached = bridge == nil
623	state.inputAttached = h.wv.inputAttached
624	if h.wv.findCtrl != nil {
625		h.wv.findCtrl.setHost(host)
626	}
627	state.nativePopupParent = h.wv.nativePopupParent
628	state.nativePopupID = h.wv.nativePopupID
629	h.wv.nativePopupParent = nil
630	h.wv.nativePopupID = 0
631	h.wv.nativePopupFallbackStarted = false
632	state.hasPendingNavigation = strings.TrimSpace(h.wv.pendingURI) != ""
633	h.wv.mu.Unlock()
634
635	if bridge != nil {
636		wv := h.wv
637		wv.runOnGTK(func() {
638			if wv.destroyed.Load() || wv.viewBridge == nil {
639				wv.handleInputAttachFailure(ErrAdapterDestroyed, host)
640				return
641			}
642			if err := wv.viewBridge.AttachInputToWidget(host, wv.nativeWidget, wv.bridgeInputOptions()); err != nil {
643				wv.handleInputAttachFailure(err, host)
644				return
645			}
646			if wv.viewBridge != nil && wv.viewBridge.HasFocus() {
647				syncWindowlessBrowserFocus(host)
648			} else {
649				host.Invalidate(purecef.PaintElementTypePetView)
650			}
651			wv.markInputAttached()
652		})
653	}
654
655	// Mark browser as visible — CEF OSR starts in hidden state and suppresses
656	// painting/caret updates until explicitly told the browser is shown.
657	host.WasHidden(0)
658	if h.wv != nil {
659		if h.wv.ctx != nil {
660			logging.FromContext(h.wv.ctx).Info().
661				Int32("windowless_frame_rate_configured", host.GetWindowlessFrameRate()).
662				Msg("cef: browser initial/configured windowless frame rate")
663		}
664		h.wv.SyncViewport(h.wv.ctx, "after-created")
665	}
666	return state
667}
668
669func (wv *WebView) handleInputAttachFailure(err error, host purecef.BrowserHost) {
670	if wv == nil || err == nil {
671		return
672	}
673	if wv.ctx != nil {
674		logging.FromContext(wv.ctx).Warn().Err(err).Msg("cef: failed to attach input to cef2gtk bridge")
675	}
676	if host != nil && !wv.destroyed.Load() {
677		host.CloseBrowser(1)
678		return
679	}
680	wv.runCloseCallbacks()
681}
682
683func (h *handlerSet) finishAfterCreated(
684	browser purecef.Browser,
685	host purecef.BrowserHost,
686	state afterCreatedState,
687) {
688	if h.wv.engine != nil {
689		h.wv.engine.recordBrowserAfterCreated(browser)
690		h.wv.engine.registerWebView(h.wv)
691		h.wv.engine.bindBrowserWebView(browser, h.wv)
692	}
693	h.wv.stopNativePopupFallbackTimer()
694	if state.inputAttached {
695		// Request an initial OSR frame even before the first real navigation
696		// commits. This restores the about:blank warm-up paint that the stable
697		// startup path relied on and prevents the rendering bridge from staying idle.
698		host.Invalidate(purecef.PaintElementTypePetView)
699	}
700	if state.hasPendingNavigation {
701		h.wv.schedulePendingNavigationReplay(0)
702	}
703	if state.nativePopupParent != nil && state.nativePopupID != 0 {
704		state.nativePopupParent.clearPendingNativePopup(state.nativePopupID, h.wv)
705	}
706	h.wv.scheduleStartAdaptiveFrameRatePolling()
707	h.wv.scheduleStartBeginFrameLoop()
708	h.wv.fireReadyToShow()
709}
710
711type afterCreatedState struct {
712	inputAttached        bool
713	hasPendingNavigation bool
714	nativePopupParent    *WebView
715	nativePopupID        int32
716	duplicateBrowserID   int32
717	closeDuplicate       bool
718}
719
720// DoClose returns false to allow the default close behavior.
721func (h *handlerSet) DoClose(_ purecef.Browser) bool {
722	return false
723}
724
725// OnBeforeClose fires the OnClose callback.
726func (h *handlerSet) OnBeforeClose(browser purecef.Browser) {
727	if h.wv.engine != nil {
728		browserID := int32(0)
729		if browser != nil {
730			browserID = browser.GetIdentifier()
731		}
732		h.wv.engine.unregisterWebView(h.wv, browserID)
733	}
734	h.wv.mu.Lock()
735	h.wv.browser = nil
736	h.wv.host = nil
737	h.wv.lastAppliedOSRBackingScaleBits.Store(0)
738	h.wv.inputAttached = false
739	if h.wv.findCtrl != nil {
740		h.wv.findCtrl.setHost(nil)
741	}
742	h.wv.mu.Unlock()
743	if h.wv.destroyed.Load() {
744		h.wv.destroyViewBridgeOnGTKAsync()
745	} else if h.wv.viewBridge != nil {
746		wv := h.wv
747		wv.runOnGTK(func() {
748			if wv.viewBridge == nil {
749				return
750			}
751			if err := wv.viewBridge.SetInputHost(nil); err != nil && wv.ctx != nil {
752				logging.FromContext(wv.ctx).Warn().Err(err).Msg("cef: failed to clear cef2gtk input host")
753			}
754		})
755	}
756	h.wv.scheduleStopAdaptiveFrameRatePolling()
757	h.wv.scheduleStopBeginFrameLoop()
758
759	h.wv.mu.RLock()
760	cb := h.wv.callbacks
761	h.wv.mu.RUnlock()
762	if cb != nil && cb.OnClose != nil {
763		h.wv.runOnGTK(func() {
764			cb.OnClose()
765		})
766	}
767	h.wv.runCloseCallbacks()
768}
769
770// ===========================================================================
771// RequestHandler (11 methods)
772// ===========================================================================
773
774func (h *handlerSet) OnBeforeBrowse(browser purecef.Browser, frame purecef.Frame, request purecef.Request, _, _ int32) bool {
775	if frame == nil || !frame.IsMain() || request == nil {
776		return false
777	}
778
779	handler := h.downloadHandler()
780	if handler == nil || !strings.EqualFold(request.GetMethod(), "GET") {
781		return false
782	}
783
784	url := request.GetURL()
785	if !downloadutil.ShouldForceDownloadForURI(url) || browser == nil {
786		return false
787	}
788
789	host := browser.GetHost()
790	if host == nil {
791		return false
792	}
793
794	logging.FromContext(h.currentContext()).Debug().
795		Str("url", logging.TruncateURL(url, maxSchemeTruncatedURLLength)).
796		Msg("cef: forcing download for navigation")
797
798	host.StartDownload(url)
799	return true
800}
801
802func (h *handlerSet) CanDownload(browser purecef.Browser, url, requestMethod string) bool {
803	handler := h.downloadHandler()
804	if handler == nil {
805		return false
806	}
807	return handler.canDownload(browser, url, requestMethod)
808}
809
810func (h *handlerSet) OnBeforeDownload(
811	browser purecef.Browser,
812	downloadItem purecef.DownloadItem,
813	suggestedName string,
814	callback purecef.BeforeDownloadCallback,
815) bool {
816	handler := h.downloadHandler()
817	if handler == nil {
818		return false
819	}
820	return handler.onBeforeDownload(h.currentContext(), browser, downloadItem, suggestedName, callback)
821}
822
823func (h *handlerSet) OnDownloadUpdated(
824	_ purecef.Browser,
825	downloadItem purecef.DownloadItem,
826	callback purecef.DownloadItemCallback,
827) {
828	handler := h.downloadHandler()
829	if handler == nil {
830		return
831	}
832	handler.onDownloadUpdated(h.currentContext(), downloadItem, callback)
833}
834
835func (h *handlerSet) downloadHandler() *downloadHandler {
836	if h == nil || h.wv == nil || h.wv.engine == nil {
837		return nil
838	}
839	return h.wv.engine.currentDownloadHandler()
840}
841
842func (h *handlerSet) currentContext() context.Context {
843	if h == nil || h.wv == nil || h.wv.ctx == nil {
844		return context.Background()
845	}
846	return h.wv.ctx
847}
848
849func (h *handlerSet) OnOpenUrlfromTab(
850	_ purecef.Browser,
851	_ purecef.Frame,
852	_ string,
853	_ purecef.WindowOpenDisposition,
854	_ int32,
855) int32 {
856	return 0
857}
858
859func (h *handlerSet) GetResourceRequestHandler(
860	_ purecef.Browser, _ purecef.Frame, _ purecef.Request,
861	_, _ int32, _ string, _ *int32,
862) purecef.ResourceRequestHandler {
863	return nil
864}
865
866func (h *handlerSet) GetAuthCredentials(
867	_ purecef.Browser, _ string, _ int32, _ string, _ int32,
868	_, _ string, _ purecef.AuthCallback,
869) int32 {
870	return 0
871}
872
873func (h *handlerSet) OnCertificateError(
874	_ purecef.Browser,
875	_ purecef.Errorcode,
876	_ string,
877	_ purecef.Sslinfo,
878	_ purecef.Callback,
879) int32 {
880	return 0
881}
882
883func (h *handlerSet) OnSelectClientCertificate(
884	_ purecef.Browser, _ int32, _ string, _ int32,
885	_ []purecef.X509Certificate, _ purecef.SelectClientCertificateCallback,
886) int32 {
887	return 0
888}
889
890func (h *handlerSet) OnRenderViewReady(_ purecef.Browser) {}
891
892func (h *handlerSet) OnRenderProcessUnresponsive(_ purecef.Browser, _ purecef.UnresponsiveProcessCallback) int32 {
893	return 0
894}
895
896func (h *handlerSet) OnRenderProcessResponsive(_ purecef.Browser) {}
897
898// maxConsecutiveCrashes caps how many times OnRenderProcessTerminated will
899// forward the event before suppressing further notifications. This prevents
900// infinite crash → redirect → crash loops.
901const maxConsecutiveCrashes = 3
902
903// OnRenderProcessTerminated fires the OnWebProcessTerminated callback with a mapped reason.
904func (h *handlerSet) OnRenderProcessTerminated(_ purecef.Browser, status purecef.TerminationStatus, _ int32, _ string) {
905	if h.wv.crashCount.Add(1) > maxConsecutiveCrashes {
906		return // suppress to break the loop
907	}
908
909	var reason port.WebProcessTerminationReason
910	var label string
911	switch status {
912	case purecef.TerminationStatusTsAbnormalTermination:
913		reason = port.WebProcessTerminationCrashed
914		label = "abnormal termination"
915	case purecef.TerminationStatusTsProcessWasKilled:
916		reason = port.WebProcessTerminationByAPI
917		label = "killed"
918	case purecef.TerminationStatusTsProcessCrashed:
919		reason = port.WebProcessTerminationCrashed
920		label = "crashed"
921	case purecef.TerminationStatusTsProcessOom:
922		reason = port.WebProcessTerminationExceededMemory
923		label = "out of memory"
924	default:
925		reason = port.WebProcessTerminationUnknown
926		label = "unknown"
927	}
928
929	h.wv.mu.RLock()
930	cb := h.wv.callbacks
931	uri := h.wv.uri
932	h.wv.mu.RUnlock()
933	if cb != nil && cb.OnWebProcessTerminated != nil {
934		h.wv.runOnGTK(func() {
935			cb.OnWebProcessTerminated(reason, label, uri)
936		})
937	}
938}
939
940func (h *handlerSet) OnDocumentAvailableInMainFrame(_ purecef.Browser) {}
941
942// ===========================================================================
943// AudioHandler (5 methods)
944// ===========================================================================
945
946func (h *handlerSet) GetAudioParameters(_ purecef.Browser, _ *purecef.AudioParameters) int32 {
947	return 1 // proceed with defaults
948}
949
950// OnAudioStreamStarted handles the start of an audio stream.
951// The third callback argument is the channel count (not frames-per-buffer);
952// frames-per-buffer comes from params.FramesPerBuffer.
953func (h *handlerSet) OnAudioStreamStarted(_ purecef.Browser, params *purecef.AudioParameters, channels int32) {
954	// Validate params and factory first
955	if params == nil || h.wv.audioOutputFactory == nil {
956		return
957	}
958
959	// Build format from CEF parameters
960	format := h.buildAudioStreamFormat(params, channels)
961
962	if h.wv.ctx != nil {
963		logging.FromContext(h.wv.ctx).Info().
964			Int("sample_rate", format.SampleRate).
965			Int("channels", format.ChannelCount).
966			Int("frames_per_buffer", format.FramesPerBuffer).
967			Int32("channel_layout", params.ChannelLayout).
968			Msg("cef: OnAudioStreamStarted")
969	}
970
971	// Reset packet counters for the new stream
972	h.wv.audioPacketCount.Store(0)
973	h.wv.audioWriteCount.Store(0)
974
975	// Close any existing stream first
976	h.wv.closeAudioStream()
977
978	// Create new stream
979	ctx := h.wv.ctx
980	if ctx == nil {
981		ctx = context.Background()
982	}
983
984	stream, err := h.wv.audioOutputFactory.NewStream(ctx, format)
985	if err != nil {
986		// Log error but don't panic - audio is non-critical.
987		// Do NOT set audioPlaying — the stream was never created.
988		if h.wv.ctx != nil {
989			logging.FromContext(h.wv.ctx).Warn().
990				Err(err).
991				Int("sample_rate", format.SampleRate).
992				Int("channels", format.ChannelCount).
993				Msg("cef: failed to create audio output stream")
994		}
995		return
996	}
997
998	// Set playing state only after stream creation succeeds
999	h.wv.setAudioPlaying(true)
1000
1001	if h.wv.ctx != nil {
1002		logging.FromContext(h.wv.ctx).Info().
1003			Int("sample_rate", format.SampleRate).
1004			Int("channels", format.ChannelCount).
1005			Int("frames_per_buffer", format.FramesPerBuffer).
1006			Msg("cef: audio output stream created successfully")
1007	}
1008
1009	// Store the new stream
1010	h.wv.audioStreamMu.Lock()
1011	h.wv.activeAudioStream = stream
1012	h.wv.audioStreamMu.Unlock()
1013}
1014
1015// buildAudioStreamFormat converts CEF AudioParameters to port.AudioStreamFormat.
1016// channels is the authoritative channel count from the callback; params.FramesPerBuffer
1017// supplies the buffer size.
1018func (h *handlerSet) buildAudioStreamFormat(params *purecef.AudioParameters, channels int32) port.AudioStreamFormat {
1019	return port.AudioStreamFormat{
1020		SampleRate:      int(params.SampleRate),
1021		ChannelCount:    int(channels),
1022		FramesPerBuffer: int(params.FramesPerBuffer),
1023	}
1024}
1025
1026// OnAudioStreamPacket receives audio packets and forwards them to the output stream.
1027// The mutex is held across both the stream snapshot and Write to prevent
1028// closeAudioStream from closing the stream mid-write.
1029func (h *handlerSet) OnAudioStreamPacket(_ purecef.Browser, data [][]float32, frames int32, pts int64) {
1030	if len(data) == 0 || frames <= 0 {
1031		return
1032	}
1033
1034	// Copy the data before acquiring the stream lock because CEF can reuse the
1035	// buffer as soon as this callback returns.
1036	copiedData := make([][]float32, len(data))
1037	for i, channel := range data {
1038		if len(channel) < int(frames) {
1039			continue
1040		}
1041		copiedData[i] = make([]float32, frames)
1042		copy(copiedData[i], channel[:frames])
1043	}
1044
1045	// Hold the lock across the stream read and Write so closeAudioStream
1046	// cannot close the stream between snapshot and write.
1047	h.wv.audioStreamMu.Lock()
1048	stream := h.wv.activeAudioStream
1049	if stream == nil {
1050		h.wv.audioStreamMu.Unlock()
1051		return
1052	}
1053
1054	pktNum := h.wv.audioPacketCount.Add(1)
1055	if pktNum == 1 && h.wv.ctx != nil {
1056		logging.FromContext(h.wv.ctx).Info().
1057			Int("channels", len(data)).
1058			Int32("frames", frames).
1059			Int64("pts", pts).
1060			Msg("cef: first audio packet received")
1061	}
1062
1063	err := stream.Write(copiedData)
1064	h.wv.audioStreamMu.Unlock()
1065
1066	if err != nil {
1067		if h.wv.ctx != nil {
1068			logging.FromContext(h.wv.ctx).Debug().
1069				Err(err).
1070				Uint64("packets_received", pktNum).
1071				Msg("cef: audio stream write failed")
1072		}
1073		return
1074	}
1075	h.wv.audioWriteCount.Add(1)
1076}
1077
1078// OnAudioStreamStopped handles stream stop.
1079func (h *handlerSet) OnAudioStreamStopped(_ purecef.Browser) {
1080	if h.wv.ctx != nil {
1081		logging.FromContext(h.wv.ctx).Info().
1082			Uint64("packets_received", h.wv.audioPacketCount.Load()).
1083			Uint64("writes_succeeded", h.wv.audioWriteCount.Load()).
1084			Msg("cef: OnAudioStreamStopped")
1085	}
1086	h.wv.setAudioPlaying(false)
1087	h.wv.closeAudioStream()
1088}
1089
1090// OnAudioStreamError handles stream errors.
1091func (h *handlerSet) OnAudioStreamError(_ purecef.Browser, message string) {
1092	if h.wv.ctx != nil {
1093		event := logging.FromContext(h.wv.ctx).Warn()
1094		logMsg := "cef: audio stream error"
1095		if h.wv.destroyed.Load() && message == "Socket closed unexpectedly" {
1096			event = logging.FromContext(h.wv.ctx).Debug()
1097			logMsg = "cef: audio stream closed during shutdown"
1098		}
1099		event.
1100			Str("error", message).
1101			Uint64("packets_received", h.wv.audioPacketCount.Load()).
1102			Uint64("writes_succeeded", h.wv.audioWriteCount.Load()).
1103			Msg(logMsg)
1104	}
1105	h.wv.setAudioPlaying(false)
1106	h.wv.closeAudioStream()
1107}
1108
1109// ===========================================================================
1110// FindHandler (1 method)
1111// ===========================================================================
1112
1113// OnFindResult dispatches CEF find results to the WebView's FindController.
1114func (h *handlerSet) OnFindResult(
1115	_ purecef.Browser,
1116	identifier, count int32,
1117	_ *purecef.Rect,
1118	activematchordinal, finalupdate int32,
1119) {
1120	if h.wv.findCtrl != nil {
1121		h.wv.findCtrl.handleFindResult(identifier, count, activematchordinal, finalupdate)
1122	}
1123}
1124
1125// cefCursorNames maps CEF cursor types to GDK/CSS cursor names.
1126var cefCursorNames = map[purecef.CursorType]string{
1127	purecef.CursorTypeCtPointer:                  "default",
1128	purecef.CursorTypeCtCross:                    "crosshair",
1129	purecef.CursorTypeCtHand:                     "pointer",
1130	purecef.CursorTypeCtIbeam:                    "text",
1131	purecef.CursorTypeCtWait:                     "wait",
1132	purecef.CursorTypeCtHelp:                     "help",
1133	purecef.CursorTypeCtEastresize:               "e-resize",
1134	purecef.CursorTypeCtNorthresize:              "n-resize",
1135	purecef.CursorTypeCtNortheastresize:          "ne-resize",
1136	purecef.CursorTypeCtNorthwestresize:          "nw-resize",
1137	purecef.CursorTypeCtSouthresize:              "s-resize",
1138	purecef.CursorTypeCtSoutheastresize:          "se-resize",
1139	purecef.CursorTypeCtSouthwestresize:          "sw-resize",
1140	purecef.CursorTypeCtWestresize:               "w-resize",
1141	purecef.CursorTypeCtNorthsouthresize:         "ns-resize",
1142	purecef.CursorTypeCtEastwestresize:           "ew-resize",
1143	purecef.CursorTypeCtNortheastsouthwestresize: "nesw-resize",
1144	purecef.CursorTypeCtNorthwestsoutheastresize: "nwse-resize",
1145	purecef.CursorTypeCtColumnresize:             "col-resize",
1146	purecef.CursorTypeCtRowresize:                "row-resize",
1147	purecef.CursorTypeCtMove:                     "move",
1148	purecef.CursorTypeCtProgress:                 "progress",
1149	purecef.CursorTypeCtNodrop:                   "no-drop",
1150	purecef.CursorTypeCtNotallowed:               "not-allowed",
1151	purecef.CursorTypeCtGrab:                     "grab",
1152	purecef.CursorTypeCtGrabbing:                 "grabbing",
1153	purecef.CursorTypeCtZoomin:                   "zoom-in",
1154	purecef.CursorTypeCtZoomout:                  "zoom-out",
1155}
1156
1157// cefCursorToGDKName maps a CEF cursor type to a GDK/CSS cursor name.
1158func cefCursorToGDKName(ct purecef.CursorType) string {
1159	if name, ok := cefCursorNames[ct]; ok {
1160		return name
1161	}
1162	return "default"
1163}

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