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