v0.33.2 57f21d6

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

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