v0.32.0 0617278

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

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