v0.30.0 eb3068f

webview.go RawHistoryFolder

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}

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close