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

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