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}