v0.31.0 bf0756b

webview_handlers.go RawHistoryFolder

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

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