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