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