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