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

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