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