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