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

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