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