v0.31.0 bf0756b

webview.go RawHistoryFolder

1package cef
2
3import (
4	"context"
5	"crypto/rand"
6	"encoding/base64"
7	"encoding/hex"
8	"errors"
9	"fmt"
10	"math"
11	"strings"
12	"sync"
13	"sync/atomic"
14	"time"
15
16	purecef "github.com/bnema/purego-cef/cef"
17	cef2gtk "github.com/bnema/purego-cef2gtk"
18	"github.com/bnema/puregotk/v4/glib"
19	"github.com/bnema/puregotk/v4/gtk"
20
21	"github.com/bnema/dumber/internal/application/dto"
22	"github.com/bnema/dumber/internal/application/port"
23	"github.com/bnema/dumber/internal/domain/entity"
24	"github.com/bnema/dumber/internal/infrastructure/webutil"
25	"github.com/bnema/dumber/internal/logging"
26	"github.com/bnema/dumber/internal/shared/syncdispatch"
27)
28
29// Compile-time interface checks.
30var (
31	_ port.WebView               = (*WebView)(nil)
32	_ port.NativeWidgetProvider  = (*WebView)(nil)
33	_ port.DevToolsOpener        = (*WebView)(nil)
34	_ port.PopupLifecycleCapable = (*WebView)(nil)
35	_ port.PopupOpenerCapable    = (*WebView)(nil)
36	_ port.ViewportSyncCapable   = (*WebView)(nil)
37	_ port.OAuthCallbackCapable  = (*WebView)(nil)
38	_ port.PageScrollable        = (*WebView)(nil)
39	_ port.PageScrollCanceler    = (*WebView)(nil)
40	_ port.AccessibilityEnabler  = (*WebView)(nil)
41)
42
43// errDestroyed is returned when an operation is attempted on a destroyed WebView.
44var errDestroyed = errors.New("cef: webview is destroyed")
45
46// unrefTickCallback is the canonical puregotk/purego callback-slot release.
47// It remains injectable so lifecycle tests can prove exactly-once ownership.
48var unrefTickCallback = glib.UnrefCallback
49
50// errNoBrowser is returned when the browser has not been created yet.
51var errNoBrowser = errors.New("cef: browser not yet created")
52
53const (
54	clipboardSelectionDebounceDelay = 300 * time.Millisecond
55	pendingNavigationRetryDelay     = 50 * time.Millisecond
56	pendingNavigationMaxRetries     = 80
57	nativePopupAttachFallbackDelay  = 250 * time.Millisecond
58)
59
60type stoppableTimer interface {
61	Stop() bool
62}
63
64var cefLoadWatchdogDelays = []time.Duration{
65	250 * time.Millisecond,
66	1 * time.Second,
67	2 * time.Second,
68	5 * time.Second,
69	15 * time.Second,
70	30 * time.Second,
71}
72
73const (
74	cefGTKSyncDispatchTimeout       = 2 * time.Second
75	cefGTKSyncDispatchSlowThreshold = 250 * time.Millisecond
76	maxPendingPageScroll            = 3200
77)
78
79type pageScrollQueue struct {
80	mu           sync.Mutex
81	commitMu     sync.Mutex
82	tapDX        int
83	tapDY        int
84	heldDX       int
85	heldDY       int
86	flushPending bool
87	generation   uint64
88
89	// beforeExecute is a deterministic test seam for the drain-to-commit
90	// boundary. Production leaves it nil.
91	beforeExecute func()
92}
93
94var (
95	cefNewTask          = purecef.NewTask
96	cefNewStringVisitor = purecef.NewStringVisitor
97	cefPostTask         = purecef.PostTask
98	cefPostDelayedTask  = purecef.PostDelayedTask
99	cefScheduleAfter    = func(delay time.Duration, fn func()) { time.AfterFunc(delay, fn) }
100)
101
102// WebView implements port.WebView using a CEF off-screen browser rendered and
103// driven through purego-cef2gtk. Dumber owns browser state and callbacks; the
104// bridge owns GTK rendering and input forwarding.
105type WebView struct {
106	id                        port.WebViewID
107	ctx                       context.Context
108	engine                    *Engine
109	factory                   *WebViewFactory
110	browser                   purecef.Browser
111	host                      purecef.BrowserHost
112	client                    purecef.RawClient // prevent GC from collecting the client before CEF AddRef's it
113	viewBridge                *Cef2gtkAdapter
114	popupSurface              *popupBridgeSurface
115	nativeWidget              *gtk.Widget
116	handlers                  *handlerSet
117	findCtrl                  *cefFindController
118	profileCleanup            func()
119	profileMu                 sync.Mutex
120	renderStallMu             sync.Mutex
121	renderStallStop           chan struct{}
122	renderStallDone           chan struct{}
123	renderStallRecoveryLastAt time.Time
124	latestProfileSnapshot     cef2gtk.ProfileSnapshot
125	previousProfileSnapshot   cef2gtk.ProfileSnapshot
126	latestProfileSnapshotAt   time.Time
127
128	removeSizeObserver      func()
129	viewportSyncMu          sync.Mutex
130	viewportSyncPending     bool
131	viewportSyncReason      string
132	viewportMapFunc         func(gtk.Widget)
133	viewportShowFunc        func(gtk.Widget)
134	viewportHideFunc        func(gtk.Widget)
135	viewportUnmapFunc       func(gtk.Widget)
136	viewportRealizeFunc     func(gtk.Widget)
137	viewportMapSignalID     uint
138	viewportShowSignalID    uint
139	viewportHideSignalID    uint
140	viewportUnmapSignalID   uint
141	viewportRealizeSignalID uint
142	viewportResizePulseSeq  atomic.Uint64
143	// effectiveVisibility records the last CEF WasHidden state dispatched. It
144	// is guarded by mu because GTK and CEF UI work cross threads.
145	effectiveVisibilityKnown bool
146	effectiveVisible         bool
147
148	adaptiveWindowlessFrameRate bool
149	windowlessFrameRateMax      int32
150	adaptiveFrameRatePoll       *glib.SourceFunc
151	adaptiveFrameRatePollID     uint
152	lastAdaptiveFrameRate       int32
153
154	pageScrollQueue pageScrollQueue
155
156	// a11yWorker receives serialized accessibility payloads off the CEF UI
157	// thread. Production leaves it nil unless DUMBER_A11Y_CAPTURE=1.
158	// After publication, a11yWorker/a11yCapture stay immutable; teardown uses
159	// a11yCaptureFinalized so abort/shutdown close the worker once without
160	// racing CEF callback sinks that still read these pointers.
161	a11yWorker           *accessibilityCaptureWorker
162	a11yCapture          *accessibilityCapture
163	a11yCaptureFinalized atomic.Bool
164	a11yStats            accessibilityStats
165	// a11yRequested records EnableAccessibility before the browser host exists.
166	a11yRequested atomic.Bool
167	a11yEnabled   atomic.Bool
168
169	// beginFrameTick drives CEF external BeginFrame requests while the GTK
170	// widget is visible. Access is guarded by mu.
171	beginFrameTick   *gtk.TickCallback
172	beginFrameTickID uint
173
174	// pendingCreate holds browser creation params until the GL area is realized.
175	pendingCreate *pendingBrowserCreate
176	// initialBrowserCreateResizeHandled gates the one-shot onFirstResize path so
177	// later size observer events fall through to normal viewport sync even when
178	// browser creation started from another GTK lifecycle path.
179	initialBrowserCreateResizeHandled bool
180
181	// pendingURI is set when LoadURI is called before the browser exists.
182	pendingURI          string
183	pendingURISetAt     time.Time
184	pendingURIStartedAt time.Time
185
186	// GTK sync dispatch hooks are injectable for tests. Production uses the GTK
187	// default main context through runOnGTK and isOnGTKThread.
188	gtkSyncDispatch func(func())
189	gtkSyncIsOwner  func() bool
190	gtkSyncTimeout  time.Duration
191
192	// crashCount tracks consecutive renderer crashes to prevent infinite
193	// crash → redirect → crash loops.
194	crashCount atomic.Int32
195
196	// Callbacks and browsing-context state set by the UI layer.
197	mu                         sync.RWMutex
198	callbacks                  *port.WebViewCallbacks
199	browsingContextDecision    dto.HostDecision
200	hasBrowsingContextDecision bool
201	nativePopupHostAbort       func()
202
203	// Synthetic popup proxies created by the renderer bridge's window.open shim.
204	syntheticPopupMu sync.Mutex
205	syntheticPopups  map[string]*syntheticPopupState
206
207	// Programmatic popup lifecycle callbacks used for OAuth auto-close and
208	// synthetic window.open() proxy support on CEF.
209	closeCallbacks            []func()
210	popupLifecycleClose       func()
211	navigationCallbacks       []func(string)
212	openerMessageCallbacks    []func()
213	openerNavigationCallbacks []func(string)
214	popupReadyToShow          func()
215	popupReadyShown           bool
216
217	// Native popup bookkeeping. CreateRelated() returns a popup shell with no
218	// browser attached yet; OnBeforePopup may wire CEF's real popup browser into
219	// that shell. If native attachment is unavailable or stalls, the shell can
220	// still create its own browser directly while preserving the same popup pane
221	// lifecycle from the coordinator's perspective.
222	nativePopupCandidate        bool
223	nativePopupParent           *WebView
224	nativePopupID               int32
225	nativePopupFallbackStarted  bool
226	nativePopupFallbackTimer    stoppableTimer
227	nativePopupFallbackSchedule func(time.Duration, func()) stoppableTimer
228	pendingNativePopups         map[int32]*WebView
229	popupNoJavaScriptAccess     bool
230	popupOpenerBridgeParent     *WebView
231	popupOpenerBridgeParentURI  string
232
233	// State cache (mutex-protected).
234	uri                       string
235	title                     string
236	progress                  float64
237	canGoBack                 bool
238	canGoFwd                  bool
239	isLoading                 bool
240	selectedText              string
241	focusedEditable           bool
242	inputAttached             bool
243	bridgeNonce               string
244	selectionDebounceTimer    stoppableTimer
245	selectionDebounceSeq      uint64
246	selectionDebounceDelay    *time.Duration
247	selectionDebounceSchedule func(time.Duration, func()) stoppableTimer
248
249	// Last known hover URI for middle-click → new tab.
250	lastHoverURI string
251
252	// Favicon source visitors are retained until CEF calls them back because
253	// GetSource delivers asynchronously through a C callback pointer.
254	faviconSourceVisitorsMu sync.Mutex
255	faviconSourceVisitors   []faviconSourceVisitorRetention
256
257	// Load diagnostics state (mutex-protected).
258	loadDiagSeq             uint64
259	loadDiagStartedAt       time.Time
260	loadDiagLastProgressAt  time.Time
261	loadDiagLastAddressAt   time.Time
262	loadDiagLastLoadStateAt time.Time
263
264	// Atomic state.
265	destroyed                     atomic.Bool
266	fullscreen                    atomic.Bool
267	generation                    atomic.Uint64
268	audioPlaying                  atomic.Bool
269	zoomFactor                    atomic.Value // float64, initialized to 1.0
270	lastAppliedZoomScaleRatioBits atomic.Uint64
271
272	// Browser creation defaults copied from the factory so native popup shells
273	// can apply the same settings in OnBeforePopup.
274	windowlessFrameRate int32
275	backgroundColor     uint32
276	inputConfig         RuntimeInputConfig
277
278	// Background flash guard: a theme-colored CSS provider painted behind the
279	// render widget until the first CEF frame is presented. It replaces the
280	// former dark BrowserSettings.BackgroundColor anti-white-flash, whose canvas
281	// is now always opaque white. State is guarded by bgGuardMu; firstFramePainted
282	// gates the one-shot teardown and resets naturally when a fresh WebView is
283	// created after a browser recreation.
284	firstFramePainted atomic.Bool
285	bgGuardMu         sync.Mutex
286	bgGuardProvider   *gtk.CssProvider
287	bgGuardWidget     *gtk.Widget
288	bgGuardClass      string
289
290	// Touchpad input diagnostics are debounced to avoid flooding logs while
291	// debugging continuous scroll streams.
292	touchpadNavigation *touchpadNavigationRecognizer
293	inputDiagMu        sync.Mutex
294	inputDiagLastLog   time.Time
295	inputDiagEvents    int
296	inputDiagDX        float64
297	inputDiagDY        float64
298	inputDiagDeltaXSum int64
299	inputDiagDeltaYSum int64
300
301	// Audio output lifecycle. Packet callbacks load audioStream directly: its
302	// immutable handle is atomically published and never requires a mutex.
303	audioOutputFactory    port.AudioOutputFactory
304	audioStreamMu         sync.Mutex
305	audioStreamGeneration atomic.Uint64
306	audioStream           atomic.Pointer[audioStreamHandle]
307
308	// Audio instrumentation counters (diagnostic only).
309	audioPacketCount atomic.Uint64 // total OnAudioStreamPacket calls
310	audioWriteCount  atomic.Uint64 // successful Write calls to stream
311}
312
313// audioStreamHandle is immutable after publication so packet callbacks can
314// safely snapshot it without coordinating with lifecycle operations.
315type audioStreamHandle struct {
316	stream     port.AudioOutputStream
317	generation uint64
318}
319
320// pendingBrowserCreate holds the parameters needed to create a CEF browser,
321// deferred until the GL area has a non-zero size.
322type pendingBrowserCreate struct {
323	windowInfo                 *purecef.WindowInfo
324	client                     purecef.RawClient
325	settings                   *purecef.BrowserSettings
326	extraInfo                  purecef.DictionaryValue
327	postTaskRetries            int
328	observedSizeRetries        int
329	observedSizeRetryScheduled bool
330}
331
332type cefTaskFunc func()
333
334func (fn cefTaskFunc) Execute() {
335	if fn != nil {
336		fn()
337	}
338}
339
340// ---------------------------------------------------------------------------
341// Identity
342// ---------------------------------------------------------------------------
343
344func (wv *WebView) ID() port.WebViewID {
345	return wv.id
346}
347
348// ---------------------------------------------------------------------------
349// Navigation
350// ---------------------------------------------------------------------------
351
352func (wv *WebView) LoadURI(_ context.Context, uri string) error {
353	if wv.destroyed.Load() {
354		return errDestroyed
355	}
356	actualURI := toActualInternalURL(uri)
357	wv.mu.Lock()
358	browser := wv.browser
359	// Always remember the latest requested URI so a browser/main-frame race
360	// cannot strand startup on about:blank.
361	wv.setPendingNavigationLocked(actualURI, time.Now())
362	wv.mu.Unlock()
363	if browser == nil {
364		return nil
365	}
366	wv.schedulePendingNavigationReplay(0)
367	return nil
368}
369
370// LoadHTML loads HTML content with an optional base URI (ignored in Phase 1).
371func (wv *WebView) LoadHTML(ctx context.Context, content, _ string) error {
372	if wv.destroyed.Load() {
373		return errDestroyed
374	}
375	const maxDataURLSize = 1 << 20 // 1MB
376	if len(content) > maxDataURLSize {
377		logging.FromContext(ctx).Warn().
378			Int("content_len", len(content)).
379			Msg("cef: LoadHTML content exceeds 1MB, data URL may fail")
380	}
381	wv.mu.RLock()
382	browser := wv.browser
383	wv.mu.RUnlock()
384	if browser == nil {
385		return errNoBrowser
386	}
387	dataURL := "data:text/html;base64," + base64.StdEncoding.EncodeToString([]byte(content))
388	if frame := browser.GetMainFrame(); frame != nil {
389		frame.LoadURL(dataURL)
390	}
391	return nil
392}
393
394func (wv *WebView) Reload(_ context.Context) error {
395	if wv.destroyed.Load() {
396		return errDestroyed
397	}
398	wv.mu.RLock()
399	browser := wv.browser
400	wv.mu.RUnlock()
401	if browser == nil {
402		return errNoBrowser
403	}
404	browser.Reload()
405	return nil
406}
407
408// ReloadBypassCache reloads the current page, bypassing cache.
409func (wv *WebView) ReloadBypassCache(_ context.Context) error {
410	if wv.destroyed.Load() {
411		return errDestroyed
412	}
413	wv.mu.RLock()
414	browser := wv.browser
415	wv.mu.RUnlock()
416	if browser == nil {
417		return errNoBrowser
418	}
419	browser.ReloadIgnoreCache()
420	return nil
421}
422
423// Stop stops the current page load.
424func (wv *WebView) Stop(_ context.Context) error {
425	if wv.destroyed.Load() {
426		return errDestroyed
427	}
428	wv.mu.RLock()
429	browser := wv.browser
430	wv.mu.RUnlock()
431	if browser == nil {
432		return errNoBrowser
433	}
434	browser.StopLoad()
435	return nil
436}
437
438// GoBack navigates back in history.
439func (wv *WebView) GoBack(_ context.Context) error {
440	if wv.destroyed.Load() {
441		return errDestroyed
442	}
443	wv.mu.RLock()
444	browser := wv.browser
445	wv.mu.RUnlock()
446	if browser == nil {
447		return errNoBrowser
448	}
449	browser.GoBack()
450	return nil
451}
452
453// GoForward navigates forward in history.
454func (wv *WebView) GoForward(_ context.Context) error {
455	if wv.destroyed.Load() {
456		return errDestroyed
457	}
458	wv.mu.RLock()
459	browser := wv.browser
460	wv.mu.RUnlock()
461	if browser == nil {
462		return errNoBrowser
463	}
464	browser.GoForward()
465	return nil
466}
467
468// ---------------------------------------------------------------------------
469// State queries (read from cache under RLock)
470// ---------------------------------------------------------------------------
471
472func (wv *WebView) URI() string {
473	wv.mu.RLock()
474	defer wv.mu.RUnlock()
475	return wv.uri
476}
477
478func (wv *WebView) Title() string {
479	wv.mu.RLock()
480	defer wv.mu.RUnlock()
481	return wv.title
482}
483
484func (wv *WebView) IsLoading() bool {
485	wv.mu.RLock()
486	defer wv.mu.RUnlock()
487	return wv.isLoading
488}
489
490// EstimatedProgress returns the load progress (0.0 to 1.0).
491func (wv *WebView) EstimatedProgress() float64 {
492	wv.mu.RLock()
493	defer wv.mu.RUnlock()
494	return wv.progress
495}
496
497// CanGoBack returns true if back navigation is available.
498func (wv *WebView) CanGoBack() bool {
499	wv.mu.RLock()
500	defer wv.mu.RUnlock()
501	return wv.canGoBack
502}
503
504// CanGoForward returns true if forward navigation is available.
505func (wv *WebView) CanGoForward() bool {
506	wv.mu.RLock()
507	defer wv.mu.RUnlock()
508	return wv.canGoFwd
509}
510
511// State returns the current WebView state as a snapshot.
512func (wv *WebView) State() port.WebViewState {
513	wv.mu.RLock()
514	defer wv.mu.RUnlock()
515	return port.WebViewState{
516		URI:       wv.uri,
517		Title:     wv.title,
518		IsLoading: wv.isLoading,
519		Progress:  wv.progress,
520		CanGoBack: wv.canGoBack,
521		CanGoFwd:  wv.canGoFwd,
522		ZoomLevel: wv.GetZoomLevel(),
523	}
524}
525
526// IsFullscreen returns true if the WebView is currently in fullscreen mode.
527func (wv *WebView) IsFullscreen() bool {
528	return wv.fullscreen.Load()
529}
530
531// IsPlayingAudio returns true if any audio stream is active.
532func (wv *WebView) IsPlayingAudio() bool {
533	return wv.audioPlaying.Load()
534}
535
536// Generation returns a monotonic counter incremented on pool reuse.
537func (wv *WebView) Generation() uint64 {
538	return wv.generation.Load()
539}
540
541// Favicon returns nil (Phase 1 stub).
542func (wv *WebView) Favicon() port.Texture {
543	return nil
544}
545
546// ---------------------------------------------------------------------------
547// Zoom
548// ---------------------------------------------------------------------------
549
550// chromiumZoomBase is the base used by Chromium's logarithmic zoom level.
551// CEF zoom level = log(factor) / log(1.2), where factor is the linear multiplier.
552const chromiumZoomBase = 1.2
553
554// cefZoomFromFactor converts a linear zoom factor (1.0 = 100%) to CEF's
555// logarithmic zoom level.
556func cefZoomFromFactor(factor float64) float64 {
557	if factor <= 0 {
558		return 0
559	}
560	return math.Log(factor) / math.Log(chromiumZoomBase)
561}
562
563func zoomScaleRatio(surfaceScale, backingScale float64) float64 {
564	backingScale = normalizeScale(backingScale)
565	if backingScale <= 1 {
566		return 1
567	}
568	return normalizeScale(surfaceScale) / backingScale
569}
570
571func cefZoomFromPageAndScaleFactors(pageZoom, surfaceScale, backingScale float64) float64 {
572	return cefZoomFromFactor(pageZoom * zoomScaleRatio(surfaceScale, backingScale))
573}
574
575func pageZoomFromCEFAndScaleLevel(level, surfaceScale, backingScale float64) float64 {
576	return factorFromCEFZoom(level) / zoomScaleRatio(surfaceScale, backingScale)
577}
578
579func (wv *WebView) applyCEFZoomLevel(host purecef.BrowserHost, factor, cefLevel, surfaceScale, backingScale float64) {
580	host.SetZoomLevel(cefLevel)
581	// Force CEF to produce a new frame at the new zoom level. In OSR mode,
582	// SetZoomLevel changes the Blink layout zoom but doesn't guarantee a
583	// repaint. WasResized is a no-op when view dimensions haven't changed.
584	// NotifyScreenInfoChanged forces surface ID invalidation + a full
585	// SynchronizeVisualProperties cycle, which makes the renderer produce
586	// a new compositor frame at the new zoom level.
587	host.NotifyScreenInfoChanged()
588	wv.recordAppliedZoomScaleRatio(surfaceScale, backingScale)
589	// Zoom is applied asynchronously in the renderer process. Request a couple
590	// of follow-up refreshes on the CEF UI thread so OSR captures the updated
591	// compositor frame after the zoom IPC has been processed.
592	wv.scheduleZoomRefresh()
593	wv.scheduleZoomReadback(factor, cefLevel)
594}
595
596// factorFromCEFZoom converts a Chromium/CEF logarithmic zoom level back to a
597// linear zoom factor.
598func factorFromCEFZoom(level float64) float64 {
599	return math.Pow(chromiumZoomBase, level)
600}
601
602// GetZoomLevel returns the current linear zoom factor.
603func (wv *WebView) GetZoomLevel() float64 {
604	if v := wv.zoomFactor.Load(); v != nil {
605		return v.(float64)
606	}
607	return 1.0
608}
609
610// SetZoomLevel sets the zoom level using a linear factor (1.0 = 100%).
611func (wv *WebView) SetZoomLevel(_ context.Context, factor float64) error {
612	if wv.destroyed.Load() {
613		return errDestroyed
614	}
615	wv.mu.RLock()
616	host := wv.host
617	wv.mu.RUnlock()
618	if host == nil {
619		return errNoBrowser
620	}
621	surfaceScale := wv.viewBridgeScale()
622	backingScale := wv.osrBackingScaleFactor()
623	cefLevel := cefZoomFromPageAndScaleFactors(factor, surfaceScale, backingScale)
624	logging.FromContext(wv.ctx).Debug().
625		Float64("factor", factor).
626		Float64("cef_level", cefLevel).
627		Float64("osr_backing_scale", backingScale).
628		Float64("surface_scale", surfaceScale).
629		Msg("cef: SetZoomLevel")
630	wv.applyCEFZoomLevel(host, factor, cefLevel, surfaceScale, backingScale)
631	wv.zoomFactor.Store(factor)
632	return nil
633}
634
635// ---------------------------------------------------------------------------
636// DevTools
637// ---------------------------------------------------------------------------
638
639// OpenDevTools opens the Chromium DevTools in a separate window.
640func (wv *WebView) OpenDevTools() {
641	if wv.destroyed.Load() {
642		return
643	}
644	wv.mu.RLock()
645	host := wv.host
646	wv.mu.RUnlock()
647	if host == nil {
648		return
649	}
650	windowInfo := purecef.NewWindowInfo()
651	settings := purecef.NewBrowserSettings()
652	host.ShowDevTools(&windowInfo, nil, &settings, nil)
653}
654
655// ---------------------------------------------------------------------------
656// Find
657// ---------------------------------------------------------------------------
658
659// GetFindController returns the find-in-page controller.
660func (wv *WebView) GetFindController() port.FindController {
661	if wv.findCtrl == nil {
662		return nil
663	}
664	return wv.findCtrl
665}
666
667// ---------------------------------------------------------------------------
668// Callbacks
669// ---------------------------------------------------------------------------
670
671// SetCallbacks registers callback handlers for WebView events.
672func (wv *WebView) SetCallbacks(cb *port.WebViewCallbacks) {
673	wv.mu.Lock()
674	defer wv.mu.Unlock()
675	wv.callbacks = cb
676}
677
678// SetOnReadyToShow implements port.PopupLifecycleCapable.
679func (wv *WebView) SetOnReadyToShow(fn func()) {
680	if wv == nil {
681		return
682	}
683	wv.mu.Lock()
684	wv.popupReadyToShow = fn
685	shouldTryReady := wv.browser != nil && fn != nil
686	wv.mu.Unlock()
687	if shouldTryReady {
688		wv.fireReadyToShow()
689	}
690}
691
692// SetOnClose replaces the coordinator-owned popup lifecycle callback without
693// disturbing OAuth and other additive close callbacks.
694func (wv *WebView) SetOnClose(fn func()) {
695	if wv == nil {
696		return
697	}
698	wv.mu.Lock()
699	wv.popupLifecycleClose = fn
700	wv.mu.Unlock()
701}
702
703// Show implements port.PopupLifecycleCapable.
704func (wv *WebView) Show() {
705	if wv == nil || wv.destroyed.Load() {
706		return
707	}
708	wv.SyncViewport(wv.ctx, "popup-show")
709}
710
711// PrimePopupNavigation implements port.PopupLifecycleCapable.
712func (wv *WebView) PrimePopupNavigation(uri string) {
713	if wv == nil || wv.destroyed.Load() {
714		return
715	}
716	actualURI := toActualInternalURL(strings.TrimSpace(uri))
717	if actualURI == "" {
718		return
719	}
720	wv.mu.Lock()
721	browser := wv.browser
722	wv.setPendingNavigationLocked(actualURI, time.Now())
723	wv.mu.Unlock()
724	if browser != nil {
725		wv.schedulePendingNavigationReplay(0)
726	}
727}
728
729// AddCloseCallback implements port.OAuthCallbackCapable.
730func (wv *WebView) AddCloseCallback(fn func()) {
731	if wv == nil || fn == nil {
732		return
733	}
734	wv.mu.Lock()
735	wv.closeCallbacks = append(wv.closeCallbacks, fn)
736	wv.mu.Unlock()
737}
738
739// AddNavigationCallback implements port.OAuthCallbackCapable.
740func (wv *WebView) AddNavigationCallback(fn func(uri string)) {
741	if wv == nil || fn == nil {
742		return
743	}
744	wv.mu.Lock()
745	wv.navigationCallbacks = append(wv.navigationCallbacks, fn)
746	wv.mu.Unlock()
747}
748
749// Close implements port.OAuthCallbackCapable.
750func (wv *WebView) Close() {
751	if wv == nil || wv.destroyed.Load() {
752		return
753	}
754	wv.mu.RLock()
755	host := wv.host
756	wv.mu.RUnlock()
757	if host != nil {
758		host.CloseBrowser(1)
759		return
760	}
761	wv.runCloseCallbacks()
762}
763
764func (wv *WebView) fireReadyToShow() {
765	if wv == nil {
766		return
767	}
768	wv.mu.Lock()
769	if wv.popupReadyShown || wv.popupReadyToShow == nil || !wv.inputAttached {
770		wv.mu.Unlock()
771		return
772	}
773	fn := wv.popupReadyToShow
774	wv.popupReadyShown = true
775	wv.mu.Unlock()
776	wv.runOnGTK(fn)
777}
778
779func (wv *WebView) markInputAttached() {
780	if wv == nil || wv.destroyed.Load() {
781		return
782	}
783	wv.mu.Lock()
784	wv.inputAttached = true
785	wv.mu.Unlock()
786	wv.fireReadyToShow()
787}
788
789func (wv *WebView) markNativePopupCandidate(parent *WebView) {
790	if wv == nil {
791		return
792	}
793	wv.stopNativePopupFallbackTimer()
794	wv.mu.Lock()
795	wv.nativePopupCandidate = true
796	wv.nativePopupParent = parent
797	wv.nativePopupID = 0
798	wv.nativePopupFallbackStarted = false
799	wv.mu.Unlock()
800}
801
802func (wv *WebView) isNativePopupCandidate() bool {
803	if wv == nil {
804		return false
805	}
806	wv.mu.RLock()
807	defer wv.mu.RUnlock()
808	return wv.nativePopupCandidate
809}
810
811func (wv *WebView) discardNativePopupCandidate() {
812	if wv == nil {
813		return
814	}
815	var timer stoppableTimer
816	wv.mu.Lock()
817	timer = wv.nativePopupFallbackTimer
818	wv.nativePopupFallbackTimer = nil
819	wv.nativePopupCandidate = false
820	wv.nativePopupParent = nil
821	wv.nativePopupID = 0
822	wv.nativePopupFallbackStarted = false
823	wv.popupOpenerBridgeParent = nil
824	wv.popupOpenerBridgeParentURI = ""
825	wv.syncPopupOpenerBridgeExtraInfoLocked()
826	wv.mu.Unlock()
827	if timer != nil {
828		timer.Stop()
829	}
830}
831
832func (wv *WebView) PreparePaneHostedBrowsingContext() {
833	if wv == nil {
834		return
835	}
836	wv.discardNativePopupCandidate()
837}
838
839func (wv *WebView) SetBrowsingContextHostDecision(decision dto.HostDecision) {
840	if wv == nil {
841		return
842	}
843	wv.mu.Lock()
844	wv.browsingContextDecision = decision
845	wv.hasBrowsingContextDecision = true
846	wv.mu.Unlock()
847}
848
849func (wv *WebView) BrowsingContextHostDecision() (dto.HostDecision, bool) {
850	if wv == nil {
851		return dto.HostDecision{}, false
852	}
853	wv.mu.RLock()
854	defer wv.mu.RUnlock()
855	return wv.browsingContextDecision, wv.hasBrowsingContextDecision
856}
857
858func (wv *WebView) SetNativePopupHostAbort(fn func()) {
859	if wv == nil {
860		return
861	}
862	wv.mu.Lock()
863	wv.nativePopupHostAbort = fn
864	wv.mu.Unlock()
865}
866
867func (wv *WebView) AbortNativePopupHost() {
868	wv.abortNativePopupHost()
869}
870
871// abortNativePopupHost reports whether an installed host callback took
872// responsibility for either transferring or destroying the popup WebView.
873func (wv *WebView) abortNativePopupHost() bool {
874	if wv == nil {
875		return false
876	}
877	wv.mu.RLock()
878	fn := wv.nativePopupHostAbort
879	wv.mu.RUnlock()
880	if fn == nil {
881		return false
882	}
883	fn()
884	return true
885}
886
887func (wv *WebView) awaitsNativePopupAttachment() bool {
888	if wv == nil {
889		return false
890	}
891	wv.mu.RLock()
892	defer wv.mu.RUnlock()
893	return wv.browser == nil && !wv.nativePopupFallbackStarted && (wv.nativePopupCandidate || wv.nativePopupID != 0)
894}
895
896func (wv *WebView) awaitsBrowserCreateFromNativePopupFallback() bool {
897	if wv == nil {
898		return false
899	}
900	wv.mu.RLock()
901	defer wv.mu.RUnlock()
902	return wv.browser == nil && wv.nativePopupFallbackStarted && wv.pendingCreate != nil
903}
904
905func (wv *WebView) nativePopupFallbackScheduler() func(time.Duration, func()) stoppableTimer {
906	if wv != nil && wv.nativePopupFallbackSchedule != nil {
907		return wv.nativePopupFallbackSchedule
908	}
909	return func(delay time.Duration, fn func()) stoppableTimer {
910		return time.AfterFunc(delay, fn)
911	}
912}
913
914func (wv *WebView) stopNativePopupFallbackTimer() {
915	if wv == nil {
916		return
917	}
918	wv.mu.Lock()
919	timer := wv.nativePopupFallbackTimer
920	wv.nativePopupFallbackTimer = nil
921	wv.mu.Unlock()
922	if timer != nil {
923		timer.Stop()
924	}
925}
926
927func (wv *WebView) scheduleNativePopupFallback(delay time.Duration, fn func()) {
928	if wv == nil || fn == nil || wv.destroyed.Load() {
929		return
930	}
931	wv.stopNativePopupFallbackTimer()
932	var timer stoppableTimer
933	timer = wv.nativePopupFallbackScheduler()(delay, func() {
934		wv.mu.Lock()
935		if wv.destroyed.Load() || wv.nativePopupFallbackTimer != timer {
936			wv.mu.Unlock()
937			return
938		}
939		wv.nativePopupFallbackTimer = nil
940		wv.mu.Unlock()
941		fn()
942	})
943	wv.mu.Lock()
944	if wv.destroyed.Load() {
945		wv.mu.Unlock()
946		if timer != nil {
947			timer.Stop()
948		}
949		return
950	}
951	wv.nativePopupFallbackTimer = timer
952	wv.mu.Unlock()
953}
954
955func (wv *WebView) preparePopupShellDirectBrowserCreation() bool {
956	if wv == nil {
957		return false
958	}
959	wv.stopNativePopupFallbackTimer()
960	wv.mu.Lock()
961	if wv.destroyed.Load() || wv.browser != nil || wv.nativePopupFallbackStarted || wv.pendingCreate == nil {
962		wv.mu.Unlock()
963		return false
964	}
965	parent := wv.nativePopupParent
966	popupID := wv.nativePopupID
967	wv.nativePopupFallbackStarted = true
968	wv.nativePopupCandidate = false
969	wv.nativePopupParent = nil
970	wv.nativePopupID = 0
971	if !wv.popupNoJavaScriptAccess && parent != nil && !parent.destroyed.Load() {
972		wv.popupOpenerBridgeParent = parent
973		wv.popupOpenerBridgeParentURI = parent.URI()
974	} else {
975		wv.popupOpenerBridgeParent = nil
976		wv.popupOpenerBridgeParentURI = ""
977	}
978	wv.syncPopupOpenerBridgeExtraInfoLocked()
979	wv.mu.Unlock()
980	if parent != nil && popupID != 0 {
981		parent.clearPendingNativePopup(popupID, wv)
982	}
983	return true
984}
985
986func (wv *WebView) startNativePopupFallback() bool {
987	if wv == nil {
988		return false
989	}
990	wv.mu.RLock()
991	destroyed := wv.destroyed.Load()
992	alreadyStarted := wv.nativePopupFallbackStarted
993	wv.mu.RUnlock()
994	if destroyed || alreadyStarted {
995		return false
996	}
997	return wv.preparePopupShellDirectBrowserCreation()
998}
999
1000func (wv *WebView) trackPendingNativePopup(popupID int32, popup *WebView) {
1001	if wv == nil || popupID == 0 || popup == nil {
1002		return
1003	}
1004	wv.mu.Lock()
1005	if wv.pendingNativePopups == nil {
1006		wv.pendingNativePopups = make(map[int32]*WebView)
1007	}
1008	wv.pendingNativePopups[popupID] = popup
1009	wv.mu.Unlock()
1010}
1011
1012func (wv *WebView) takePendingNativePopup(popupID int32) *WebView {
1013	if wv == nil || popupID == 0 {
1014		return nil
1015	}
1016	wv.mu.Lock()
1017	defer wv.mu.Unlock()
1018	popup := wv.pendingNativePopups[popupID]
1019	delete(wv.pendingNativePopups, popupID)
1020	return popup
1021}
1022
1023func (wv *WebView) clearPendingNativePopup(popupID int32, popup *WebView) {
1024	if wv == nil || popupID == 0 || popup == nil {
1025		return
1026	}
1027	wv.mu.Lock()
1028	defer wv.mu.Unlock()
1029	if current := wv.pendingNativePopups[popupID]; current == popup {
1030		delete(wv.pendingNativePopups, popupID)
1031	}
1032}
1033
1034func (wv *WebView) prepareNativePopup(
1035	popupID int32,
1036	targetURL string,
1037	windowInfo *purecef.WindowInfo,
1038	clientSlot *purecef.RawClientWriteSlot,
1039	settings *purecef.BrowserSettings,
1040) bool {
1041	if wv == nil || wv.destroyed.Load() || popupID == 0 || windowInfo == nil || clientSlot == nil {
1042		return false
1043	}
1044
1045	prep, ok := wv.nativePopupPreparationSnapshot()
1046	if !ok {
1047		return false
1048	}
1049	client, ok := wv.activateNativePopup(popupID, targetURL)
1050	if !ok {
1051		return false
1052	}
1053	if prep.parent != nil {
1054		prep.parent.trackPendingNativePopup(popupID, wv)
1055	}
1056
1057	configureNativePopupWindow(windowInfo, settings, prep.frameRate)
1058	clientSlot.Set(client)
1059	return true
1060}
1061
1062func (wv *WebView) nativePopupPreparationSnapshot() (nativePopupPreparation, bool) {
1063	if wv == nil {
1064		return nativePopupPreparation{}, false
1065	}
1066	wv.mu.RLock()
1067	defer wv.mu.RUnlock()
1068	if !wv.canPrepareNativePopupLocked() {
1069		return nativePopupPreparation{}, false
1070	}
1071	return nativePopupPreparation{
1072		parent:    wv.nativePopupParent,
1073		frameRate: wv.windowlessFrameRate,
1074	}, true
1075}
1076
1077func (wv *WebView) activateNativePopup(popupID int32, targetURL string) (purecef.RawClient, bool) {
1078	if wv == nil {
1079		return nil, false
1080	}
1081	wv.mu.Lock()
1082	defer wv.mu.Unlock()
1083	if !wv.canPrepareNativePopupLocked() {
1084		return nil, false
1085	}
1086	wv.nativePopupCandidate = false
1087	wv.nativePopupID = popupID
1088	wv.nativePopupFallbackStarted = false
1089	if strings.TrimSpace(wv.pendingURI) == "" {
1090		wv.setPendingNavigationLocked(toActualInternalURL(targetURL), time.Now())
1091	}
1092	if strings.TrimSpace(wv.pendingURI) != "" {
1093		wv.uri = toConceptualInternalURL(wv.pendingURI)
1094	} else {
1095		wv.uri = toConceptualInternalURL(targetURL)
1096	}
1097	wv.isLoading = true
1098	return wv.client, true
1099}
1100
1101func (wv *WebView) canPrepareNativePopupLocked() bool {
1102	return wv.nativePopupCandidate && wv.client != nil && !wv.nativePopupFallbackStarted && wv.browser == nil
1103}
1104
1105func configureNativePopupWindow(
1106	windowInfo *purecef.WindowInfo,
1107	settings *purecef.BrowserSettings,
1108	frameRate int32,
1109) {
1110	cef2gtk.ConfigureWindowInfo(windowInfo, cef2gtk.WindowInfoOptions{})
1111	if externalBeginFrameEnabled() {
1112		windowInfo.ExternalBeginFrameEnabled = 1
1113	}
1114	if settings == nil {
1115		return
1116	}
1117	cef2gtk.ConfigureBrowserSettings(settings, cef2gtk.BrowserSettingsOptions{WindowlessFrameRate: frameRate})
1118	settings.LocalStorage = 1
1119	// Always opaque white; the popup shell WebView carries the theme flash guard.
1120	settings.BackgroundColor = opaqueWhiteBackground
1121}
1122
1123type nativePopupPreparation struct {
1124	parent    *WebView
1125	frameRate int32
1126}
1127
1128func (wv *WebView) handleNativePopupAborted() {
1129	if wv == nil {
1130		return
1131	}
1132	wv.mu.Lock()
1133	wv.nativePopupCandidate = false
1134	wv.nativePopupID = 0
1135	wv.mu.Unlock()
1136}
1137
1138// ---------------------------------------------------------------------------
1139// JavaScript / Appearance
1140// ---------------------------------------------------------------------------
1141
1142// RunJavaScript executes a script in the main world. Fire-and-forget.
1143func (wv *WebView) RunJavaScript(_ context.Context, script string) {
1144	if wv.destroyed.Load() {
1145		return
1146	}
1147	if wv.engine == nil {
1148		wv.executeJavaScriptNow(script)
1149		return
1150	}
1151	task := cefNewTask(cefTaskFunc(func() {
1152		wv.executeJavaScriptNow(script)
1153	}))
1154	if task == nil {
1155		return
1156	}
1157	cefPostTask(purecef.ThreadIDTidUi, task)
1158}
1159
1160func (wv *WebView) executeJavaScriptNow(script string) {
1161	if wv == nil || wv.destroyed.Load() {
1162		return
1163	}
1164	wv.mu.RLock()
1165	browser := wv.browser
1166	wv.mu.RUnlock()
1167	if browser == nil {
1168		return
1169	}
1170	if frame := browser.GetMainFrame(); frame != nil {
1171		frame.ExecuteJavaScript(script, "", 0)
1172	}
1173}
1174
1175// colorScale converts a 0.0–1.0 color component to an 8-bit integer.
1176const colorScale = 255
1177
1178var bridgeNonceRandom = rand.Read
1179
1180func newBridgeNonce() string {
1181	buf := make([]byte, 16)
1182	if _, err := bridgeNonceRandom(buf); err != nil {
1183		return ""
1184	}
1185	return hex.EncodeToString(buf)
1186}
1187
1188func (wv *WebView) rotateBridgeNonce() string {
1189	nonce := newBridgeNonce()
1190	if nonce == "" {
1191		return ""
1192	}
1193	wv.mu.Lock()
1194	wv.bridgeNonce = nonce
1195	wv.mu.Unlock()
1196	return nonce
1197}
1198
1199func (wv *WebView) ensureBridgeNonce() string {
1200	if wv == nil {
1201		return ""
1202	}
1203	wv.mu.RLock()
1204	nonce := wv.bridgeNonce
1205	wv.mu.RUnlock()
1206	if nonce != "" {
1207		return nonce
1208	}
1209	return wv.rotateBridgeNonce()
1210}
1211
1212// SetBackgroundColor sets the background via JS injection (CEF has no runtime API).
1213func (wv *WebView) SetBackgroundColor(r, g, b, a float64) {
1214	script := fmt.Sprintf(
1215		`(function(){ if (document.documentElement) { document.documentElement.style.backgroundColor='rgba(%d,%d,%d,%.2f)'; } })()`,
1216		int(r*colorScale), int(g*colorScale), int(b*colorScale), a,
1217	)
1218	wv.RunJavaScript(context.Background(), script)
1219}
1220
1221// ResetBackgroundToDefault clears the injected background color.
1222func (wv *WebView) ResetBackgroundToDefault() {
1223	wv.RunJavaScript(context.Background(),
1224		`(function(){ if (document.documentElement) { document.documentElement.style.backgroundColor=''; } })()`)
1225}
1226
1227// ---------------------------------------------------------------------------
1228// Lifecycle
1229// ---------------------------------------------------------------------------
1230
1231func (wv *WebView) IsDestroyed() bool {
1232	return wv.destroyed.Load()
1233}
1234
1235func (wv *WebView) Destroy() {
1236	if !wv.destroyed.CompareAndSwap(false, true) {
1237		return
1238	}
1239	wv.shutdownAccessibilityCapture()
1240	wv.resetPageScrollQueue()
1241	wv.syntheticPopupMu.Lock()
1242	wv.syntheticPopups = nil
1243	wv.syntheticPopupMu.Unlock()
1244	wv.mu.Lock()
1245	wv.navigationCallbacks = nil
1246	wv.openerMessageCallbacks = nil
1247	wv.openerNavigationCallbacks = nil
1248	wv.popupReadyToShow = nil
1249	wv.touchpadNavigation = nil
1250	wv.inputAttached = false
1251	wv.pendingNativePopups = nil
1252	wv.nativePopupParent = nil
1253	wv.nativePopupCandidate = false
1254	wv.nativePopupID = 0
1255	wv.nativePopupFallbackStarted = false
1256	wv.popupOpenerBridgeParent = nil
1257	wv.popupOpenerBridgeParentURI = ""
1258	wv.mu.Unlock()
1259	wv.stopNativePopupFallbackTimer()
1260	wv.stopRenderStallWatchdog()
1261	wv.cancelSelectionDebounce()
1262	wv.closeAudioStream()
1263	wv.scheduleStopAdaptiveFrameRatePolling()
1264	wv.scheduleStopBeginFrameLoop()
1265	wv.mu.RLock()
1266	host := wv.host
1267	wv.mu.RUnlock()
1268	if host != nil {
1269		host.CloseBrowser(1)
1270	} else {
1271		wv.runCloseCallbacks()
1272		wv.destroyViewBridgeOnGTKSync()
1273	}
1274	if wv.profileCleanup != nil {
1275		wv.profileCleanup()
1276		wv.profileCleanup = nil
1277	}
1278}
1279
1280// destroyViewBridgeOnGTKSync, destroyViewBridgeOnGTKAsync, and
1281// destroyViewBridgeOnGTKThread may race with one another during teardown. The
1282// early viewBridge nil checks avoid scheduling unnecessary GTK work; the final
1283// nil guard on the GTK thread is authoritative and makes duplicate scheduling
1284// harmless.
1285func (wv *WebView) destroyViewBridgeOnGTKSync() {
1286	if wv == nil || wv.viewBridge == nil {
1287		return
1288	}
1289	wv.runOnGTKSyncLabelAllowLateStart("cef.destroy_view_bridge", true, func() {
1290		wv.destroyViewBridgeOnGTKThread()
1291	})
1292}
1293
1294func (wv *WebView) destroyViewBridgeOnGTKAsync() {
1295	if wv == nil || wv.viewBridge == nil {
1296		return
1297	}
1298	wv.runOnGTK(func() {
1299		wv.destroyViewBridgeOnGTKThread()
1300	})
1301}
1302
1303func (wv *WebView) destroyViewBridgeOnGTKThread() {
1304	if wv == nil || wv.viewBridge == nil {
1305		return
1306	}
1307	wv.removeBackgroundFlashGuard()
1308	if wv.removeSizeObserver != nil {
1309		wv.removeSizeObserver()
1310		wv.removeSizeObserver = nil
1311	}
1312	wv.disconnectViewportSyncHooksOnGTKThread()
1313	if wv.popupSurface != nil {
1314		wv.popupSurface.DestroyOnGTKThread()
1315		wv.popupSurface = nil
1316	}
1317	_ = wv.viewBridge.DetachInput()
1318	_ = wv.viewBridge.Destroy()
1319	wv.viewBridge = nil
1320	wv.nativeWidget = nil
1321}
1322
1323// ---------------------------------------------------------------------------
1324// NativeWidgetProvider
1325// ---------------------------------------------------------------------------
1326
1327// NativeWidget returns the uintptr for embedding the bridge GTK widget.
1328func (wv *WebView) NativeWidget() uintptr {
1329	if wv == nil {
1330		return 0
1331	}
1332	var ptr uintptr
1333	wv.runOnGTKSync(func() {
1334		switch {
1335		case wv.nativeWidget != nil:
1336			ptr = wv.nativeWidget.GoPointer()
1337		case wv.viewBridge != nil:
1338			ptr = wv.viewBridge.NativeWidget()
1339		}
1340	})
1341	return ptr
1342}
1343
1344// ---------------------------------------------------------------------------
1345// State update helpers (called from handlers)
1346// ---------------------------------------------------------------------------
1347
1348func (wv *WebView) updateURI(uri string) {
1349	uri = toConceptualInternalURL(uri)
1350	now := time.Now()
1351	wv.mu.Lock()
1352	wv.uri = uri
1353	wv.loadDiagLastAddressAt = now
1354	cb := wv.callbacks
1355	pendingMatched := wv.clearPendingNavigationIfEquivalentLocked(uri)
1356	wv.mu.Unlock()
1357
1358	if pendingMatched && wv.ctx != nil {
1359		logging.FromContext(wv.ctx).Debug().
1360			Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
1361			Msg("cef: pending navigation acknowledged by address change")
1362	}
1363
1364	if cb != nil && cb.OnURIChanged != nil {
1365		wv.runOnGTK(func() {
1366			cb.OnURIChanged(uri)
1367		})
1368	}
1369	wv.runNavigationCallbacks(uri)
1370}
1371
1372func (wv *WebView) updateTitle(title string) {
1373	wv.mu.Lock()
1374	wv.title = title
1375	cb := wv.callbacks
1376	wv.mu.Unlock()
1377
1378	if cb != nil && cb.OnTitleChanged != nil {
1379		wv.runOnGTK(func() {
1380			cb.OnTitleChanged(title)
1381		})
1382	}
1383}
1384
1385func (wv *WebView) updateProgress(progress float64) {
1386	now := time.Now()
1387	wv.mu.Lock()
1388	wv.progress = progress
1389	wv.loadDiagLastProgressAt = now
1390	cb := wv.callbacks
1391	wv.mu.Unlock()
1392
1393	if cb != nil && cb.OnProgressChanged != nil {
1394		wv.runOnGTK(func() {
1395			cb.OnProgressChanged(progress)
1396		})
1397	}
1398}
1399
1400func (wv *WebView) updateLoadState(loading, back, fwd bool) {
1401	now := time.Now()
1402	wv.mu.Lock()
1403	wasLoading := wv.isLoading
1404	wv.isLoading = loading
1405	wv.canGoBack = back
1406	wv.canGoFwd = fwd
1407	wv.loadDiagLastLoadStateAt = now
1408	var loadDiagSeq uint64
1409	if loading && !wasLoading {
1410		wv.loadDiagSeq++
1411		wv.loadDiagStartedAt = now
1412		wv.loadDiagLastProgressAt = time.Time{}
1413		wv.loadDiagLastAddressAt = time.Time{}
1414		loadDiagSeq = wv.loadDiagSeq
1415	}
1416	clearedPendingURI := ""
1417	currentURI := ""
1418	if !loading && wv.hasObservedAddressForPendingNavigationLocked() {
1419		clearedPendingURI = wv.clearPendingNavigationLocked()
1420		currentURI = wv.uri
1421	}
1422	wv.mu.Unlock()
1423
1424	if clearedPendingURI != "" && wv.ctx != nil {
1425		logging.FromContext(wv.ctx).Debug().
1426			Str("pending_uri", logging.TruncateURL(clearedPendingURI, logging.PermissionLogURLMaxLen)).
1427			Str("uri", logging.TruncateURL(currentURI, logging.PermissionLogURLMaxLen)).
1428			Msg("cef: cleared pending navigation after load finished at final address")
1429	}
1430	if loadDiagSeq != 0 {
1431		wv.scheduleLoadWatchdogs(loadDiagSeq)
1432	}
1433}
1434
1435func (wv *WebView) setAudioPlaying(playing bool) {
1436	wv.audioPlaying.Store(playing)
1437	wv.mu.RLock()
1438	cb := wv.callbacks
1439	wv.mu.RUnlock()
1440	if cb != nil && cb.OnAudioStateChanged != nil {
1441		wv.runOnGTK(func() {
1442			cb.OnAudioStateChanged(playing)
1443		})
1444	}
1445}
1446
1447func (wv *WebView) setSelectedText(text string) (string, bool) {
1448	wv.mu.Lock()
1449	previous := wv.selectedText
1450	changed := previous != text
1451	wv.selectedText = text
1452	wv.mu.Unlock()
1453	return previous, changed
1454}
1455
1456func (wv *WebView) setEditableFocus(editable bool) {
1457	if wv == nil {
1458		return
1459	}
1460	wv.mu.Lock()
1461	previous := wv.focusedEditable
1462	wv.focusedEditable = editable
1463	cb := wv.callbacks
1464	var timer stoppableTimer
1465	if editable {
1466		wv.selectionDebounceSeq++
1467		timer = wv.selectionDebounceTimer
1468		wv.selectionDebounceTimer = nil
1469	}
1470	wv.mu.Unlock()
1471	if timer != nil {
1472		timer.Stop()
1473	}
1474	if previous == editable {
1475		return
1476	}
1477	if wv.ctx != nil {
1478		logging.FromContext(wv.ctx).Debug().Bool("editable", editable).Msg("cef: editable focus changed")
1479	}
1480	if cb != nil && cb.OnEditableFocusChanged != nil {
1481		wv.runOnGTK(func() {
1482			cb.OnEditableFocusChanged(editable)
1483		})
1484	}
1485}
1486
1487func (wv *WebView) cancelSelectionDebounce() {
1488	if wv == nil {
1489		return
1490	}
1491	wv.mu.Lock()
1492	wv.selectionDebounceSeq++
1493	timer := wv.selectionDebounceTimer
1494	wv.selectionDebounceTimer = nil
1495	wv.mu.Unlock()
1496	if timer != nil {
1497		timer.Stop()
1498	}
1499}
1500
1501func (wv *WebView) selectionDebounceInterval() time.Duration {
1502	if wv == nil || wv.selectionDebounceDelay == nil {
1503		return clipboardSelectionDebounceDelay
1504	}
1505	return *wv.selectionDebounceDelay
1506}
1507
1508func (wv *WebView) selectionDebounceScheduler() func(time.Duration, func()) stoppableTimer {
1509	if wv != nil && wv.selectionDebounceSchedule != nil {
1510		return wv.selectionDebounceSchedule
1511	}
1512	return func(delay time.Duration, fn func()) stoppableTimer {
1513		return time.AfterFunc(delay, fn)
1514	}
1515}
1516
1517func (wv *WebView) scheduleSelectionUpdate(text string) {
1518	if wv == nil || wv.destroyed.Load() {
1519		return
1520	}
1521	wv.mu.Lock()
1522	if wv.destroyed.Load() || wv.engine == nil {
1523		wv.mu.Unlock()
1524		return
1525	}
1526	if wv.focusedEditable {
1527		wv.mu.Unlock()
1528		if wv.ctx != nil {
1529			logging.FromContext(wv.ctx).Debug().
1530				Int("text_len", len(text)).
1531				Msg("cef: selection auto-copy skipped while editable focused")
1532		}
1533		return
1534	}
1535	wv.selectionDebounceSeq++
1536	seq := wv.selectionDebounceSeq
1537	if timer := wv.selectionDebounceTimer; timer != nil {
1538		timer.Stop()
1539	}
1540	delay := wv.selectionDebounceInterval()
1541	if delay <= 0 {
1542		wv.selectionDebounceTimer = nil
1543		wv.mu.Unlock()
1544		wv.flushSelectionUpdate(seq, text)
1545		return
1546	}
1547	scheduler := wv.selectionDebounceScheduler()
1548	wv.selectionDebounceTimer = scheduler(delay, func() {
1549		wv.flushSelectionUpdate(seq, text)
1550	})
1551	wv.mu.Unlock()
1552}
1553
1554func (wv *WebView) flushSelectionUpdate(seq uint64, text string) {
1555	if wv == nil || wv.destroyed.Load() {
1556		return
1557	}
1558	wv.mu.Lock()
1559	if wv.destroyed.Load() || wv.focusedEditable || seq != wv.selectionDebounceSeq {
1560		wv.mu.Unlock()
1561		return
1562	}
1563	engine := wv.engine
1564	viewID := wv.id
1565	wv.mu.Unlock()
1566	if engine != nil {
1567		if wv.ctx != nil {
1568			logging.FromContext(wv.ctx).Debug().
1569				Int("text_len", len(text)).
1570				Msg("cef: debounced selection update flushed")
1571		}
1572		engine.handleClipboardSelectionUpdate(viewID, text)
1573	}
1574}
1575
1576func (wv *WebView) selectedTextSnapshot() string {
1577	wv.mu.RLock()
1578	defer wv.mu.RUnlock()
1579	return wv.selectedText
1580}
1581
1582func (wv *WebView) bridgeInputOptions() cef2gtk.InputOptions {
1583	if wv == nil {
1584		return cef2gtk.InputOptions{}
1585	}
1586	return cef2gtk.InputOptions{
1587		Scale: 0,
1588		Scroll: cef2gtk.ScrollOptions{
1589			WheelMultiplier:      wv.inputConfig.ScrollWheelMultiplier,
1590			PreciseMultiplier:    wv.inputConfig.ScrollPreciseMultiplier,
1591			HorizontalMultiplier: wv.inputConfig.ScrollHorizontalMultiplier,
1592			VerticalMultiplier:   wv.inputConfig.ScrollVerticalMultiplier,
1593			MaxDelta:             wv.inputConfig.ScrollMaxDelta,
1594		},
1595		OnMiddleClick: func(_, _ float64) bool {
1596			return wv.handleMiddleClickFromBridge()
1597		},
1598		OnScroll: wv.handleScrollInput,
1599		NavigationSwipe: cef2gtk.NavigationSwipeOptions{
1600			// Dumber handles thresholding and progress UI locally from OnScroll so
1601			// horizontal scrolling continues to reach CEF while history navigation
1602			// waits for a deliberate release past the visual threshold.
1603			Enabled: false,
1604		},
1605		SelectionText: wv.selectedTextSnapshot,
1606		OnClipboardShortcut: func(action, text string) {
1607			if wv.engine != nil {
1608				wv.engine.handleExplicitClipboardBridgeText(wv.id, action, text)
1609			}
1610		},
1611	}
1612}
1613
1614// handleScrollInput is invoked by cef2gtk from GTK scroll event callbacks on
1615// the GTK main thread, which keeps lazy touchpad recognizer ownership
1616// single-threaded while every scroll event is still forwarded to CEF.
1617func (wv *WebView) handleScrollInput(event cef2gtk.ScrollEvent) cef2gtk.ScrollDecision {
1618	wv.handleTouchpadNavigationScroll(event)
1619	return wv.handleScrollInputDiagnostic(event)
1620}
1621
1622func (wv *WebView) handleTouchpadNavigationScroll(event cef2gtk.ScrollEvent) {
1623	if wv == nil || wv.destroyed.Load() {
1624		return
1625	}
1626	if !wv.inputConfig.TouchpadNavigationEnabled {
1627		return
1628	}
1629	wv.mu.Lock()
1630	if wv.destroyed.Load() {
1631		wv.mu.Unlock()
1632		return
1633	}
1634	if wv.touchpadNavigation == nil {
1635		wv.touchpadNavigation = newTouchpadNavigationRecognizer()
1636	}
1637	result := wv.touchpadNavigation.Handle(touchpadNavigationInput{
1638		Event:        event,
1639		Config:       wv.inputConfig,
1640		CanGoBack:    wv.canGoBack,
1641		CanGoForward: wv.canGoFwd,
1642		ViewWidth:    wv.touchpadNavigationViewWidth(),
1643	})
1644	wv.mu.Unlock()
1645	if result.HasIndicator {
1646		wv.emitTouchpadNavigationGesture(result.Indicator)
1647	}
1648	if result.HasAction {
1649		wv.handleNavigationSwipeAction(result.Action)
1650	}
1651}
1652
1653func (wv *WebView) touchpadNavigationViewWidth() float64 {
1654	if wv == nil || wv.nativeWidget == nil {
1655		return 0
1656	}
1657	if width := wv.nativeWidget.GetAllocatedWidth(); width > 0 {
1658		return float64(width)
1659	}
1660	return float64(wv.nativeWidget.GetWidth())
1661}
1662
1663func (wv *WebView) emitTouchpadNavigationGesture(gesture entity.TouchpadNavigationGesture) {
1664	if wv == nil {
1665		return
1666	}
1667	wv.mu.RLock()
1668	cb := wv.callbacks
1669	wv.mu.RUnlock()
1670	if cb != nil && cb.OnTouchpadNavigationGesture != nil {
1671		// Scroll input is delivered from GTK callbacks, so invoking directly keeps
1672		// progress and final indicators ordered before any navigation action.
1673		cb.OnTouchpadNavigationGesture(gesture)
1674	}
1675}
1676
1677func (wv *WebView) handleScrollInputDiagnostic(event cef2gtk.ScrollEvent) cef2gtk.ScrollDecision {
1678	if event.Phase != cef2gtk.ScrollPhaseUpdate || (math.Abs(event.DX) == 0 && math.Abs(event.DY) == 0) {
1679		return cef2gtk.ScrollForwardToCEF
1680	}
1681
1682	wv.inputDiagMu.Lock()
1683	wv.inputDiagEvents++
1684	wv.inputDiagDX += event.DX
1685	wv.inputDiagDY += event.DY
1686	wv.inputDiagDeltaXSum += int64(event.DeltaX)
1687	wv.inputDiagDeltaYSum += int64(event.DeltaY)
1688	now := time.Now()
1689	if !wv.inputDiagLastLog.IsZero() && now.Sub(wv.inputDiagLastLog) < time.Second {
1690		wv.inputDiagMu.Unlock()
1691		return cef2gtk.ScrollForwardToCEF
1692	}
1693	count := wv.inputDiagEvents
1694	dx, dy := wv.inputDiagDX, wv.inputDiagDY
1695	deltaX, deltaY := wv.inputDiagDeltaXSum, wv.inputDiagDeltaYSum
1696	wv.inputDiagEvents = 0
1697	wv.inputDiagDX = 0
1698	wv.inputDiagDY = 0
1699	wv.inputDiagDeltaXSum = 0
1700	wv.inputDiagDeltaYSum = 0
1701	wv.inputDiagLastLog = now
1702	wv.inputDiagMu.Unlock()
1703
1704	logging.FromContext(wv.ctx).Trace().
1705		Uint64("webview_id", uint64(wv.id)).
1706		Int("events", count).
1707		Str("phase", fmt.Sprint(event.Phase)).
1708		Str("unit", fmt.Sprint(event.Unit)).
1709		Bool("unit_known", event.UnitKnown).
1710		Float64("dx_sum", dx).
1711		Float64("dy_sum", dy).
1712		Int64("cef_delta_x_sum", deltaX).
1713		Int64("cef_delta_y_sum", deltaY).
1714		Float64("scroll_wheel_multiplier", wv.inputConfig.ScrollWheelMultiplier).
1715		Float64("scroll_precise_multiplier", wv.inputConfig.ScrollPreciseMultiplier).
1716		Float64("scroll_horizontal_multiplier", wv.inputConfig.ScrollHorizontalMultiplier).
1717		Float64("scroll_vertical_multiplier", wv.inputConfig.ScrollVerticalMultiplier).
1718		Int32("scroll_max_delta", wv.inputConfig.ScrollMaxDelta).
1719		Bool("nav_enabled", wv.inputConfig.TouchpadNavigationEnabled).
1720		Float64("nav_min_delta", wv.inputConfig.TouchpadNavigationMinDelta).
1721		Float64("nav_max_vertical_ratio", wv.inputConfig.TouchpadNavigationMaxVerticalRatio).
1722		Bool("can_go_back", wv.CanGoBack()).
1723		Bool("can_go_forward", wv.CanGoForward()).
1724		Msg("cef: touchpad scroll diagnostic")
1725
1726	return cef2gtk.ScrollForwardToCEF
1727}
1728
1729func (wv *WebView) handleNavigationSwipeAction(action cef2gtk.NavigationSwipeAction) {
1730	if wv.ctx != nil {
1731		logging.FromContext(wv.ctx).Debug().
1732			Uint64("webview_id", uint64(wv.id)).
1733			Str("action", fmt.Sprint(action)).
1734			Bool("can_go_back", wv.CanGoBack()).
1735			Bool("can_go_forward", wv.CanGoForward()).
1736			Msg("cef: touchpad navigation swipe recognized")
1737	}
1738
1739	switch action {
1740	case cef2gtk.NavigationSwipeBack:
1741		if err := wv.GoBack(wv.ctx); err != nil && wv.ctx != nil {
1742			logging.FromContext(wv.ctx).Warn().Err(err).Msg("touchpad back navigation failed")
1743		}
1744	case cef2gtk.NavigationSwipeForward:
1745		if err := wv.GoForward(wv.ctx); err != nil && wv.ctx != nil {
1746			logging.FromContext(wv.ctx).Warn().Err(err).Msg("touchpad forward navigation failed")
1747		}
1748	}
1749}
1750
1751func (wv *WebView) viewBridgeScale() float64 {
1752	if wv == nil || wv.viewBridge == nil {
1753		return 1
1754	}
1755	return normalizeScale(float64(wv.viewBridge.DeviceScaleFactor()))
1756}
1757
1758func (wv *WebView) osrBackingScaleFactor() float64 {
1759	if wv == nil || wv.viewBridge == nil {
1760		return 1
1761	}
1762	return normalizeScale(wv.viewBridge.OSRBackingScaleFactor())
1763}
1764
1765func (wv *WebView) recordAppliedZoomScaleRatio(surfaceScale, backingScale float64) {
1766	if wv == nil {
1767		return
1768	}
1769	wv.lastAppliedZoomScaleRatioBits.Store(math.Float64bits(zoomScaleRatio(surfaceScale, backingScale)))
1770}
1771
1772func (wv *WebView) shouldReapplyZoomForScaleRatio(surfaceScale, backingScale float64) bool {
1773	if wv == nil {
1774		return false
1775	}
1776	ratio := zoomScaleRatio(surfaceScale, backingScale)
1777	bits := wv.lastAppliedZoomScaleRatioBits.Load()
1778	if bits == 0 {
1779		return true
1780	}
1781	return math.Float64frombits(bits) != ratio
1782}
1783
1784func (wv *WebView) handleMiddleClickFromBridge() bool {
1785	if wv == nil {
1786		return false
1787	}
1788	wv.mu.RLock()
1789	hoverURI := wv.lastHoverURI
1790	cb := wv.callbacks
1791	wv.mu.RUnlock()
1792	if hoverURI == "" || cb == nil || cb.OnLinkMiddleClick == nil {
1793		return false
1794	}
1795	uri := hoverURI
1796	wv.runOnGTK(func() {
1797		cb.OnLinkMiddleClick(uri)
1798	})
1799	return true
1800}
1801
1802func (wv *WebView) scheduleLoadWatchdogs(loadSeq uint64) {
1803	for _, delay := range cefLoadWatchdogDelays {
1804		d := delay
1805		time.AfterFunc(d, func() {
1806			wv.logLoadWatchdog(loadSeq, d)
1807		})
1808	}
1809}
1810
1811func (wv *WebView) logLoadWatchdog(loadSeq uint64, delay time.Duration) {
1812	if wv == nil || wv.destroyed.Load() || wv.ctx == nil {
1813		return
1814	}
1815
1816	wv.mu.RLock()
1817	currentSeq := wv.loadDiagSeq
1818	loading := wv.isLoading
1819	uri := wv.uri
1820	title := wv.title
1821	progress := wv.progress
1822	pendingURI := wv.pendingURI
1823	startedAt := wv.loadDiagStartedAt
1824	lastProgressAt := wv.loadDiagLastProgressAt
1825	lastAddressAt := wv.loadDiagLastAddressAt
1826	lastLoadStateAt := wv.loadDiagLastLoadStateAt
1827	wv.mu.RUnlock()
1828
1829	if loadSeq != currentSeq || !loading {
1830		return
1831	}
1832
1833	var acceleratedPaints, unsupportedPaints, acceleratedPaintErrors, importFailures, renderFailures int
1834	var surfaceWidth, surfaceHeight int32 = 1, 1
1835	var surfaceScale float32 = 1
1836	if wv.viewBridge != nil {
1837		diag := wv.viewBridge.Diagnostics()
1838		acceleratedPaints = diag.AcceleratedPaints
1839		unsupportedPaints = diag.UnsupportedPaints
1840		acceleratedPaintErrors = diag.AcceleratedPaintErrors
1841		importFailures = diag.ImportFailures
1842		renderFailures = diag.RenderFailures
1843		surfaceWidth, surfaceHeight = wv.viewBridge.Size()
1844		surfaceScale = wv.viewBridge.DeviceScaleFactor()
1845	}
1846
1847	now := time.Now()
1848	logging.FromContext(wv.ctx).Debug().
1849		Uint64("load_seq", loadSeq).
1850		Int64("watch_delay_ms", delay.Milliseconds()).
1851		Int64("since_start_ms", sinceTimestampMs(startedAt, now)).
1852		Int64("since_progress_ms", sinceTimestampMs(lastProgressAt, now)).
1853		Int64("since_address_ms", sinceTimestampMs(lastAddressAt, now)).
1854		Int64("since_load_state_ms", sinceTimestampMs(lastLoadStateAt, now)).
1855		Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
1856		Str("title", title).
1857		Float64("progress", progress).
1858		Str("pending_uri", logging.TruncateURL(pendingURI, logging.PermissionLogURLMaxLen)).
1859		Int32("surface_width", surfaceWidth).
1860		Int32("surface_height", surfaceHeight).
1861		Float32("surface_scale", surfaceScale).
1862		Int("accelerated_paints", acceleratedPaints).
1863		Int("unsupported_paints", unsupportedPaints).
1864		Int("accelerated_paint_errors", acceleratedPaintErrors).
1865		Int("import_failures", importFailures).
1866		Int("render_failures", renderFailures).
1867		Msg("cef: loading watchdog")
1868}
1869
1870func (wv *WebView) pendingNavigationURI() string {
1871	if wv == nil {
1872		return ""
1873	}
1874	wv.mu.RLock()
1875	defer wv.mu.RUnlock()
1876	return wv.pendingURI
1877}
1878
1879func (wv *WebView) setPendingNavigationLocked(uri string, at time.Time) {
1880	wv.pendingURI = uri
1881	wv.pendingURIStartedAt = time.Time{}
1882	if uri == "" {
1883		wv.pendingURISetAt = time.Time{}
1884		return
1885	}
1886	wv.pendingURISetAt = at
1887}
1888
1889func (wv *WebView) markPendingNavigationStartedLocked(uri string, at time.Time) {
1890	if !pendingURIEquivalent(wv.pendingURI, uri) {
1891		return
1892	}
1893	wv.pendingURIStartedAt = at
1894}
1895
1896func (wv *WebView) clearPendingNavigationLocked() string {
1897	cleared := wv.pendingURI
1898	wv.pendingURI = ""
1899	wv.pendingURISetAt = time.Time{}
1900	wv.pendingURIStartedAt = time.Time{}
1901	return cleared
1902}
1903
1904func (wv *WebView) clearPendingNavigationIfEquivalentLocked(uri string) bool {
1905	if !pendingURIEquivalent(wv.pendingURI, uri) {
1906		return false
1907	}
1908	wv.clearPendingNavigationLocked()
1909	return true
1910}
1911
1912func (wv *WebView) hasObservedAddressForPendingNavigationLocked() bool {
1913	if strings.TrimSpace(wv.pendingURI) == "" || strings.TrimSpace(wv.uri) == "" {
1914		return false
1915	}
1916	if wv.loadDiagLastAddressAt.IsZero() || wv.pendingURIStartedAt.IsZero() {
1917		return false
1918	}
1919	return !wv.loadDiagLastAddressAt.Before(wv.pendingURIStartedAt)
1920}
1921
1922func (wv *WebView) schedulePendingNavigationReplay(attempt int) {
1923	if wv == nil || wv.destroyed.Load() {
1924		return
1925	}
1926	task := cefNewTask(cefTaskFunc(func() {
1927		wv.replayPendingNavigation(attempt)
1928	}))
1929	if task == nil {
1930		return
1931	}
1932	var result int32
1933	if attempt <= 0 {
1934		result = cefPostTask(purecef.ThreadIDTidUi, task)
1935	} else {
1936		result = cefPostDelayedTask(purecef.ThreadIDTidUi, task, int64(pendingNavigationRetryDelay/time.Millisecond))
1937	}
1938	if result == 1 {
1939		return
1940	}
1941	if wv.ctx != nil {
1942		log := logging.FromContext(wv.ctx).Warn().
1943			Int("attempt", attempt).
1944			Int32("result", result)
1945		if attempt >= pendingNavigationMaxRetries {
1946			log.Msg("cef: failed to schedule pending navigation replay; retries exhausted")
1947		} else {
1948			log.Msg("cef: failed to schedule pending navigation replay; retrying")
1949		}
1950	}
1951	if attempt >= pendingNavigationMaxRetries {
1952		return
1953	}
1954	cefScheduleAfter(pendingNavigationRetryDelay, func() {
1955		wv.schedulePendingNavigationReplay(attempt + 1)
1956	})
1957}
1958
1959func (wv *WebView) replayPendingNavigation(attempt int) {
1960	if wv == nil || wv.destroyed.Load() {
1961		return
1962	}
1963	wv.mu.RLock()
1964	uri := wv.pendingURI
1965	browser := wv.browser
1966	wv.mu.RUnlock()
1967	if uri == "" || browser == nil {
1968		return
1969	}
1970	frame := browser.GetMainFrame()
1971	if frame == nil {
1972		if attempt >= pendingNavigationMaxRetries {
1973			if wv.ctx != nil {
1974				logging.FromContext(wv.ctx).Warn().
1975					Int("attempt", attempt).
1976					Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
1977					Msg("cef: pending navigation replay exhausted without main frame")
1978			}
1979			return
1980		}
1981		if wv.ctx != nil {
1982			logging.FromContext(wv.ctx).Debug().
1983				Int("attempt", attempt).
1984				Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
1985				Msg("cef: pending navigation replay waiting for main frame")
1986		}
1987		wv.schedulePendingNavigationReplay(attempt + 1)
1988		return
1989	}
1990	currentURL := frame.GetURL()
1991	if pendingURIEquivalent(currentURL, uri) {
1992		wv.mu.Lock()
1993		wv.clearPendingNavigationIfEquivalentLocked(uri)
1994		wv.mu.Unlock()
1995		if wv.ctx != nil {
1996			logging.FromContext(wv.ctx).Debug().
1997				Int("attempt", attempt).
1998				Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
1999				Msg("cef: pending navigation already active")
2000		}
2001		return
2002	}
2003	wv.mu.Lock()
2004	wv.markPendingNavigationStartedLocked(uri, time.Now())
2005	wv.mu.Unlock()
2006	frame.LoadURL(uri)
2007	if wv.ctx != nil {
2008		logging.FromContext(wv.ctx).Debug().
2009			Int("attempt", attempt).
2010			Str("uri", logging.TruncateURL(uri, logging.PermissionLogURLMaxLen)).
2011			Msg("cef: replayed pending navigation")
2012	}
2013}
2014
2015func pendingURIEquivalent(a, b string) bool {
2016	if a == "" || b == "" {
2017		return false
2018	}
2019	return toConceptualInternalURL(strings.TrimSpace(a)) == toConceptualInternalURL(strings.TrimSpace(b))
2020}
2021
2022func sinceTimestampMs(ts, now time.Time) int64 {
2023	if ts.IsZero() {
2024		return -1
2025	}
2026	return now.Sub(ts).Milliseconds()
2027}
2028
2029func (wv *WebView) updateHoverURI(uri string) {
2030	wv.mu.Lock()
2031	wv.lastHoverURI = uri
2032	wv.mu.Unlock()
2033}
2034
2035// scheduleZoomRefresh posts two invalidation requests to the CEF UI thread:
2036//   - 16ms delay: one frame at 60fps, gives the renderer time to process
2037//     the zoom IPC before we request the first repaint.
2038//   - 48ms delay: three frames at 60fps, a second invalidation to catch
2039//     any compositor frame that was still in-flight during the first.
2040//
2041// These values are empirically tuned for CEF's async zoom application.
2042func (wv *WebView) scheduleZoomRefresh() {
2043	for _, delayMs := range [...]int64{16, 48} {
2044		task := cefNewTask(cefTaskFunc(func() {
2045			if wv.destroyed.Load() {
2046				return
2047			}
2048			wv.mu.RLock()
2049			host := wv.host
2050			wv.mu.RUnlock()
2051			if host == nil {
2052				return
2053			}
2054			host.Invalidate(purecef.PaintElementTypePetView)
2055		}))
2056		if task == nil {
2057			continue
2058		}
2059		cefPostDelayedTask(purecef.ThreadIDTidUi, task, delayMs)
2060	}
2061}
2062
2063// scheduleZoomReadback posts two diagnostic readbacks to verify zoom applied:
2064//   - 0ms delay: immediate check on the CEF UI thread to log the zoom level
2065//     right after SetZoomLevel returns (usually still the old value).
2066//   - 64ms delay: four frames at 60fps, enough time for the renderer to
2067//     process the zoom IPC and reflect the new level back to the browser.
2068//
2069// These values are empirically tuned for CEF's async zoom application.
2070func (wv *WebView) scheduleZoomReadback(expectedFactor, expectedLevel float64) {
2071	for _, delayMs := range [...]int64{0, 64} {
2072		task := cefNewTask(cefTaskFunc(func() {
2073			if wv.destroyed.Load() {
2074				return
2075			}
2076			wv.mu.RLock()
2077			host := wv.host
2078			wv.mu.RUnlock()
2079			if host == nil {
2080				return
2081			}
2082			surfaceScale := wv.viewBridgeScale()
2083			backingScale := wv.osrBackingScaleFactor()
2084			actualLevel := host.GetZoomLevel()
2085			logging.FromContext(wv.ctx).Debug().
2086				Int64("delay_ms", delayMs).
2087				Float64("expected_factor", expectedFactor).
2088				Float64("expected_cef_level", expectedLevel).
2089				Float64("actual_factor", pageZoomFromCEFAndScaleLevel(actualLevel, surfaceScale, backingScale)).
2090				Float64("actual_cef_factor", factorFromCEFZoom(actualLevel)).
2091				Float64("actual_cef_level", actualLevel).
2092				Float64("osr_backing_scale", backingScale).
2093				Float64("surface_scale", surfaceScale).
2094				Msg("cef: zoom level readback")
2095		}))
2096		if task == nil {
2097			continue
2098		}
2099		cefPostDelayedTask(purecef.ThreadIDTidUi, task, delayMs)
2100	}
2101}
2102
2103func (wv *WebView) reapplyCurrentZoomForBackingScale(reason string) {
2104	if wv == nil || wv.destroyed.Load() {
2105		return
2106	}
2107	wv.mu.RLock()
2108	host := wv.host
2109	wv.mu.RUnlock()
2110	if host == nil {
2111		return
2112	}
2113	factor := wv.GetZoomLevel()
2114	surfaceScale := wv.viewBridgeScale()
2115	backingScale := wv.osrBackingScaleFactor()
2116	cefLevel := cefZoomFromPageAndScaleFactors(factor, surfaceScale, backingScale)
2117	wv.applyCEFZoomLevel(host, factor, cefLevel, surfaceScale, backingScale)
2118	logging.FromContext(wv.ctx).Debug().
2119		Str("reason", reason).
2120		Float64("factor", factor).
2121		Float64("cef_level", cefLevel).
2122		Float64("osr_backing_scale", backingScale).
2123		Float64("surface_scale", surfaceScale).
2124		Msg("cef: reapplied zoom after OSR backing scale change")
2125}
2126
2127func (wv *WebView) scheduleStartBeginFrameLoop() {
2128	if !externalBeginFrameEnabled() {
2129		return
2130	}
2131	wv.runOnGTK(func() {
2132		wv.startBeginFrameLoop()
2133	})
2134}
2135
2136func (wv *WebView) scheduleStopBeginFrameLoop() {
2137	wv.runOnGTK(func() {
2138		wv.stopBeginFrameLoop()
2139	})
2140}
2141
2142func (wv *WebView) startBeginFrameLoop() {
2143	if wv.destroyed.Load() || wv.viewBridge == nil || wv.viewBridge.Widget() == nil {
2144		return
2145	}
2146
2147	wv.mu.Lock()
2148	if wv.beginFrameTickID != 0 || wv.host == nil {
2149		wv.mu.Unlock()
2150		return
2151	}
2152
2153	widget := wv.viewBridge.Widget()
2154	cb := new(gtk.TickCallback)
2155	*cb = func(_, _, _ uintptr) bool {
2156		if wv.destroyed.Load() {
2157			wv.releaseBeginFrameTickCallback()
2158			return false
2159		}
2160		wv.mu.RLock()
2161		host := wv.host
2162		wv.mu.RUnlock()
2163		if host == nil {
2164			wv.releaseBeginFrameTickCallback()
2165			return false
2166		}
2167		host.SendExternalBeginFrame()
2168		if wv.viewBridge != nil {
2169			wv.viewBridge.RecordExternalBeginFrameSent()
2170		}
2171		return true
2172	}
2173	wv.beginFrameTick = cb
2174	wv.beginFrameTickID = widget.AddTickCallback(cb, 0, nil)
2175	host := wv.host
2176	wv.mu.Unlock()
2177
2178	if host != nil {
2179		host.SendExternalBeginFrame()
2180		if wv.viewBridge != nil {
2181			wv.viewBridge.RecordExternalBeginFrameSent()
2182		}
2183	}
2184}
2185
2186func (wv *WebView) stopBeginFrameLoop() {
2187	wv.mu.Lock()
2188	tickID := wv.beginFrameTickID
2189	callback := wv.beginFrameTick
2190	wv.beginFrameTickID = 0
2191	wv.beginFrameTick = nil
2192	bridge := wv.viewBridge
2193	wv.mu.Unlock()
2194
2195	if tickID != 0 && bridge != nil {
2196		if widget := bridge.Widget(); widget != nil {
2197			widget.RemoveTickCallback(tickID)
2198		}
2199	}
2200	if callback != nil {
2201		_ = unrefTickCallback(callback)
2202	}
2203}
2204
2205// releaseBeginFrameTickCallback releases the purego callback slot after GTK
2206// removes a source because its callback returned false.
2207func (wv *WebView) releaseBeginFrameTickCallback() {
2208	wv.mu.Lock()
2209	callback := wv.beginFrameTick
2210	wv.beginFrameTickID = 0
2211	wv.beginFrameTick = nil
2212	wv.mu.Unlock()
2213	if callback != nil {
2214		_ = unrefTickCallback(callback)
2215	}
2216}
2217
2218func (wv *WebView) runOnGTK(fn func()) {
2219	if fn == nil {
2220		return
2221	}
2222	// When engine is nil (test/bootstrap), call directly.
2223	if wv.engine == nil {
2224		fn()
2225		return
2226	}
2227
2228	// Heap-allocate the callback so it survives until glib invokes it.
2229	cb := new(glib.SourceOnceFunc)
2230	*cb = func(_ uintptr) {
2231		fn()
2232	}
2233	glib.IdleAddOnce(cb, 0)
2234}
2235
2236func (wv *WebView) isOnGTKThread() bool {
2237	mainContext := glib.MainContextDefault()
2238	return mainContext != nil && mainContext.IsOwner()
2239}
2240
2241// runOnGTKSync executes fn on the GTK main context and waits for completion.
2242// Callers already running on the GTK thread execute inline to avoid
2243// self-deadlocking while waiting for an IdleAddOnce callback. Calls are bounded
2244// so a wedged GTK loop cannot block CEF callbacks forever.
2245func (wv *WebView) runOnGTKSync(fn func()) syncdispatch.SyncDispatchResult {
2246	return wv.runOnGTKSyncLabel("cef.gtk_sync", fn)
2247}
2248
2249func (wv *WebView) runOnGTKSyncLabel(label string, fn func()) syncdispatch.SyncDispatchResult {
2250	return wv.runOnGTKSyncLabelAllowLateStart(label, false, fn)
2251}
2252
2253func (wv *WebView) runOnGTKSyncLabelAllowLateStart(
2254	label string,
2255	allowLateStartAfterTimeout bool,
2256	fn func(),
2257) syncdispatch.SyncDispatchResult {
2258	if wv == nil {
2259		if fn != nil {
2260			fn()
2261		}
2262		return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchInline}
2263	}
2264	if wv.engine == nil && wv.gtkSyncDispatch == nil && wv.gtkSyncIsOwner == nil {
2265		if fn != nil {
2266			fn()
2267		}
2268		return syncdispatch.SyncDispatchResult{Label: label, Status: syncdispatch.SyncDispatchInline}
2269	}
2270
2271	isOwner := wv.gtkSyncIsOwner
2272	if isOwner == nil {
2273		isOwner = wv.isOnGTKThread
2274	}
2275	dispatch := wv.gtkSyncDispatch
2276	if dispatch == nil {
2277		dispatch = wv.runOnGTK
2278	}
2279	timeout := wv.gtkSyncTimeout
2280	if timeout <= 0 {
2281		timeout = cefGTKSyncDispatchTimeout
2282	}
2283
2284	result := syncdispatch.RunSynchronousDispatch(syncdispatch.SyncDispatchOptions{
2285		Label:                      label,
2286		Timeout:                    timeout,
2287		IsOwner:                    isOwner,
2288		Dispatch:                   dispatch,
2289		AllowLateStartAfterTimeout: allowLateStartAfterTimeout,
2290	}, fn)
2291	wv.logGTKSyncDispatchResult(result)
2292	return result
2293}
2294
2295func (wv *WebView) logGTKSyncDispatchResult(result syncdispatch.SyncDispatchResult) {
2296	if wv == nil {
2297		return
2298	}
2299	logger := logging.FromContext(wv.ctx)
2300	switch result.Status {
2301	case syncdispatch.SyncDispatchTimedOut:
2302		logger.Warn().
2303			Str("dispatch_label", result.Label).
2304			Dur("elapsed", result.Elapsed).
2305			Dur("timeout", wv.effectiveGTKSyncTimeout()).
2306			Uint64("webview_id", uint64(wv.id)).
2307			Msg("cef: GTK synchronous dispatch timed out before callback started")
2308	case syncdispatch.SyncDispatchCompletedAfterTimeout:
2309		logger.Warn().
2310			Str("dispatch_label", result.Label).
2311			Dur("elapsed", result.Elapsed).
2312			Dur("timeout", wv.effectiveGTKSyncTimeout()).
2313			Uint64("webview_id", uint64(wv.id)).
2314			Msg("cef: GTK synchronous dispatch completed after timeout")
2315	case syncdispatch.SyncDispatchQueuedAfterTimeout:
2316		logger.Warn().
2317			Str("dispatch_label", result.Label).
2318			Dur("elapsed", result.Elapsed).
2319			Dur("timeout", wv.effectiveGTKSyncTimeout()).
2320			Uint64("webview_id", uint64(wv.id)).
2321			Msg("cef: GTK synchronous dispatch left queued after timeout")
2322	case syncdispatch.SyncDispatchCompleted:
2323		if result.Elapsed >= cefGTKSyncDispatchSlowThreshold {
2324			logger.Debug().
2325				Str("dispatch_label", result.Label).
2326				Dur("elapsed", result.Elapsed).
2327				Uint64("webview_id", uint64(wv.id)).
2328				Msg("cef: GTK synchronous dispatch completed slowly")
2329		}
2330	}
2331}
2332
2333func (wv *WebView) effectiveGTKSyncTimeout() time.Duration {
2334	if wv != nil && wv.gtkSyncTimeout > 0 {
2335		return wv.gtkSyncTimeout
2336	}
2337	return cefGTKSyncDispatchTimeout
2338}
2339
2340func errGTKSyncDispatchIncomplete(operation string, result syncdispatch.SyncDispatchResult) error {
2341	return fmt.Errorf("%s: GTK dispatch did not complete: %s", operation, result.Status)
2342}
2343
2344func (wv *WebView) runCloseCallbacks() {
2345	if wv == nil {
2346		return
2347	}
2348	wv.mu.Lock()
2349	callbacks := append([]func(){}, wv.closeCallbacks...)
2350	wv.closeCallbacks = nil
2351	if wv.popupLifecycleClose != nil {
2352		callbacks = append(callbacks, wv.popupLifecycleClose)
2353		wv.popupLifecycleClose = nil
2354	}
2355	wv.mu.Unlock()
2356	if len(callbacks) == 0 {
2357		return
2358	}
2359	wv.runOnGTK(func() {
2360		for _, fn := range callbacks {
2361			if fn != nil {
2362				fn()
2363			}
2364		}
2365	})
2366}
2367
2368func (wv *WebView) runNavigationCallbacks(uri string) {
2369	if wv == nil {
2370		return
2371	}
2372	wv.mu.RLock()
2373	callbacks := append([]func(string){}, wv.navigationCallbacks...)
2374	wv.mu.RUnlock()
2375	if len(callbacks) == 0 {
2376		return
2377	}
2378	wv.runOnGTK(func() {
2379		for _, fn := range callbacks {
2380			if fn != nil {
2381				fn(uri)
2382			}
2383		}
2384	})
2385}
2386
2387func (wv *WebView) shouldStartBrowserCreateFromSizeObserver() bool {
2388	if wv == nil {
2389		return false
2390	}
2391	wv.mu.RLock()
2392	defer wv.mu.RUnlock()
2393	return wv.pendingCreate != nil && !wv.initialBrowserCreateResizeHandled
2394}
2395
2396func (wv *WebView) markInitialBrowserCreateResizeHandled() {
2397	if wv == nil {
2398		return
2399	}
2400	wv.mu.Lock()
2401	defer wv.mu.Unlock()
2402	wv.initialBrowserCreateResizeHandled = true
2403}
2404
2405func (wv *WebView) takePendingCreate() *pendingBrowserCreate {
2406	wv.mu.Lock()
2407	defer wv.mu.Unlock()
2408	pc := wv.pendingCreate
2409	wv.pendingCreate = nil
2410	return pc
2411}
2412
2413// pageScrollWheelEvent builds the CEF mouse wheel event used for keyboard-driven
2414// Vim Mode scrolling. It targets the center of the current OSR view so the
2415// scroll goes through Chromium's compositor/input pipeline instead of a series
2416// of synthetic key taps.
2417func (wv *WebView) pageScrollWheelEvent() purecef.MouseEvent {
2418	width, height := int32(1), int32(1)
2419	if wv != nil && wv.viewBridge != nil {
2420		width, height = wv.viewBridge.Size()
2421	}
2422	scale := normalizeScale(wv.osrBackingScaleFactor())
2423	return purecef.MouseEvent{
2424		X:         int32(math.Floor(float64(width) * scale / 2)),
2425		Y:         int32(math.Floor(float64(height) * scale / 2)),
2426		Modifiers: uint32(purecef.EventFlagsEventflagPrecisionScrollingDelta),
2427	}
2428}
2429
2430func pageScrollWheelDeltas(request port.PageScrollRequest) (deltaX, deltaY int32) {
2431	return int32(-request.FallbackDX), int32(-request.FallbackDY)
2432}
2433
2434// ScrollPage resolves a DOM scroll target when the browser is ready so an
2435// exhausted nested scroller can hand off automatically to its ancestor or the
2436// document. Native precision-wheel input remains the pre-frame fallback.
2437func (wv *WebView) ScrollPage(ctx context.Context, request port.PageScrollRequest) error {
2438	if wv == nil {
2439		return errors.New("cef: webview is nil")
2440	}
2441	if err := ctx.Err(); err != nil {
2442		return err
2443	}
2444	if err := request.Validate(); err != nil {
2445		return err
2446	}
2447	if wv.destroyed.Load() {
2448		return errDestroyed
2449	}
2450
2451	wv.mu.RLock()
2452	host := wv.host
2453	browser := wv.browser
2454	wv.mu.RUnlock()
2455	if browser != nil {
2456		wv.enqueuePageScroll(request)
2457		return nil
2458	}
2459	if host != nil {
2460		deltaX, deltaY := pageScrollWheelDeltas(request)
2461		if deltaX != 0 || deltaY != 0 {
2462			event := wv.pageScrollWheelEvent()
2463			host.SendMouseWheelEvent(&event, deltaX, deltaY)
2464		}
2465		return nil
2466	}
2467
2468	wv.RunJavaScript(ctx, webutil.BuildPageScrollByJS(request.FallbackDX, request.FallbackDY))
2469	return nil
2470}
2471
2472func (wv *WebView) enqueuePageScroll(request port.PageScrollRequest) {
2473	q := &wv.pageScrollQueue
2474	q.mu.Lock()
2475	if request.Continuous {
2476		q.heldDX = saturatingPageScrollAdd(q.heldDX, request.FallbackDX)
2477		q.heldDY = saturatingPageScrollAdd(q.heldDY, request.FallbackDY)
2478	} else {
2479		q.tapDX = saturatingPageScrollAdd(q.tapDX, request.FallbackDX)
2480		q.tapDY = saturatingPageScrollAdd(q.tapDY, request.FallbackDY)
2481	}
2482	q.mu.Unlock()
2483	wv.ensurePageScrollFlush()
2484}
2485
2486func (wv *WebView) ensurePageScrollFlush() {
2487	q := &wv.pageScrollQueue
2488	q.mu.Lock()
2489	if q.flushPending || (q.tapDX == 0 && q.tapDY == 0 && q.heldDX == 0 && q.heldDY == 0) {
2490		q.mu.Unlock()
2491		return
2492	}
2493	q.flushPending = true
2494	q.mu.Unlock()
2495
2496	if wv.engine == nil {
2497		wv.drainPageScrollSynchronously()
2498		return
2499	}
2500	task := cefNewTask(cefTaskFunc(wv.flushPageScroll))
2501	if task == nil || cefPostTask(purecef.ThreadIDTidUi, task) != 1 {
2502		// Schedule failed: drop held (cancel semantics) and synchronously drain
2503		// any queued tap so it cannot remain stranded with flushPending cleared.
2504		q.mu.Lock()
2505		q.heldDX = 0
2506		q.heldDY = 0
2507		q.mu.Unlock()
2508		wv.drainPageScrollSynchronously()
2509	}
2510}
2511
2512func (wv *WebView) drainPageScrollSynchronously() {
2513	q := &wv.pageScrollQueue
2514	for {
2515		wv.drainPageScrollOnce()
2516		q.mu.Lock()
2517		hasWork := q.tapDX != 0 || q.tapDY != 0 || q.heldDX != 0 || q.heldDY != 0
2518		if !hasWork {
2519			q.flushPending = false
2520			q.mu.Unlock()
2521			return
2522		}
2523		q.mu.Unlock()
2524	}
2525}
2526
2527func (wv *WebView) flushPageScroll() {
2528	wv.drainPageScrollOnce()
2529
2530	q := &wv.pageScrollQueue
2531	q.mu.Lock()
2532	q.flushPending = false
2533	hasWork := q.tapDX != 0 || q.tapDY != 0 || q.heldDX != 0 || q.heldDY != 0
2534	q.mu.Unlock()
2535	if hasWork {
2536		wv.ensurePageScrollFlush()
2537	}
2538}
2539
2540func (wv *WebView) drainPageScrollOnce() {
2541	q := &wv.pageScrollQueue
2542	q.mu.Lock()
2543	tapDX, tapDY := q.tapDX, q.tapDY
2544	heldDX, heldDY := q.heldDX, q.heldDY
2545	heldGeneration := q.generation
2546	beforeExecute := q.beforeExecute
2547	if tapDX == 0 && tapDY == 0 && heldDX == 0 && heldDY == 0 {
2548		q.mu.Unlock()
2549		return
2550	}
2551	q.tapDX, q.tapDY, q.heldDX, q.heldDY = 0, 0, 0, 0
2552	q.mu.Unlock()
2553
2554	if beforeExecute != nil {
2555		beforeExecute()
2556	}
2557
2558	// commitMu makes the generation check and the start of ExecuteJavaScript
2559	// linearizable with CancelPageScroll. The queue mutex is released before
2560	// taking it and before CEF execution, so JavaScript callbacks may enqueue a
2561	// successor without deadlocking; flushPending still bounds that to one task.
2562	q.commitMu.Lock()
2563	q.mu.Lock()
2564	if heldGeneration != q.generation {
2565		heldDX, heldDY = 0, 0
2566	}
2567	q.mu.Unlock()
2568	if dx, dy := tapDX+heldDX, tapDY+heldDY; (dx != 0 || dy != 0) && !wv.destroyed.Load() {
2569		wv.executeJavaScriptNow(webutil.BuildPageScrollByJS(dx, dy))
2570	}
2571	q.commitMu.Unlock()
2572}
2573
2574// CancelPageScroll invalidates all accepted but unconsumed held-key deltas.
2575// Tap deltas remain queued so a quick press always retains its 80/320 px step.
2576// Returning is also a barrier: an old held batch cannot start afterward.
2577func (wv *WebView) CancelPageScroll(_ context.Context) {
2578	q := &wv.pageScrollQueue
2579	q.commitMu.Lock()
2580	q.mu.Lock()
2581	q.generation++
2582	q.heldDX = 0
2583	q.heldDY = 0
2584	q.mu.Unlock()
2585	q.commitMu.Unlock()
2586}
2587
2588// EnableAccessibility records a request to enable CEF accessibility and applies
2589// it once the browser host exists. Destroyed views never enable. Calls before
2590// host attachment remain pending until OnAfterCreated applies them.
2591func (wv *WebView) EnableAccessibility() {
2592	if wv == nil || wv.destroyed.Load() {
2593		return
2594	}
2595	wv.a11yRequested.Store(true)
2596	wv.applyAccessibilityIfReady()
2597}
2598
2599// applyAccessibilityIfReady performs exactly one SetAccessibilityState after a
2600// pending request and a live host are both present.
2601func (wv *WebView) applyAccessibilityIfReady() {
2602	if wv == nil || wv.destroyed.Load() || !wv.a11yRequested.Load() || wv.a11yEnabled.Load() {
2603		return
2604	}
2605	wv.mu.RLock()
2606	host := wv.host
2607	wv.mu.RUnlock()
2608	if host == nil || !wv.a11yEnabled.CompareAndSwap(false, true) {
2609		return
2610	}
2611	host.SetAccessibilityState(purecef.StateStateEnabled)
2612	if wv.ctx != nil {
2613		logging.FromContext(wv.ctx).Debug().
2614			Uint64("webview_id", uint64(wv.id)).
2615			Msg("cef: accessibility enabled")
2616	}
2617}
2618
2619func (wv *WebView) resetPageScrollQueue() {
2620	q := &wv.pageScrollQueue
2621	q.mu.Lock()
2622	q.generation++
2623	q.tapDX, q.tapDY, q.heldDX, q.heldDY = 0, 0, 0, 0
2624	q.mu.Unlock()
2625}
2626
2627func saturatingPageScrollAdd(current, delta int) int {
2628	result := current + delta
2629	if result > maxPendingPageScroll {
2630		return maxPendingPageScroll
2631	}
2632	if result < -maxPendingPageScroll {
2633		return -maxPendingPageScroll
2634	}
2635	return result
2636}
2637
2638// beginAudioStreamStart invalidates any prior stream before creating a new
2639// one. A stream created for the returned generation may only be published if
2640// that generation is still current.
2641func (wv *WebView) beginAudioStreamStart() (uint64, *audioStreamHandle, bool) {
2642	wv.audioStreamMu.Lock()
2643	defer wv.audioStreamMu.Unlock()
2644	if wv.destroyed.Load() {
2645		return 0, nil, false
2646	}
2647	generation := wv.audioStreamGeneration.Add(1)
2648	previous := wv.audioStream.Swap(nil)
2649	wv.audioPlaying.Store(false)
2650	return generation, previous, true
2651}
2652
2653// publishAudioStream publishes stream only if no Stop, Destroy, or newer Start
2654// invalidated its creation generation. A rejected stream is owned by the
2655// caller and must be closed by it.
2656func (wv *WebView) publishAudioStream(generation uint64, stream port.AudioOutputStream) (*audioStreamHandle, bool) {
2657	wv.audioStreamMu.Lock()
2658	defer wv.audioStreamMu.Unlock()
2659	if wv.destroyed.Load() || wv.audioStreamGeneration.Load() != generation {
2660		return nil, false
2661	}
2662	previous := wv.audioStream.Swap(&audioStreamHandle{stream: stream, generation: generation})
2663	wv.audioPlaying.Store(true)
2664	return previous, true
2665}
2666
2667// closeAudioStream invalidates creation in progress, atomically detaches the
2668// published handle, then closes it outside the lifecycle mutex. The port
2669// contract permits Close concurrently with a packet Write.
2670func (wv *WebView) closeAudioStream() {
2671	wv.audioStreamMu.Lock()
2672	wv.audioStreamGeneration.Add(1)
2673	stream := wv.audioStream.Swap(nil)
2674	wv.audioPlaying.Store(false)
2675	wv.audioStreamMu.Unlock()
2676	wv.closeDetachedAudioStream(stream)
2677}
2678
2679func (wv *WebView) closeDetachedAudioStream(handle *audioStreamHandle) {
2680	if handle != nil && handle.stream != nil {
2681		if err := handle.stream.Close(); err != nil && wv.ctx != nil {
2682			logging.FromContext(wv.ctx).Debug().Err(err).Msg("cef: error closing audio stream")
2683		}
2684	}
2685}

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