v0.29.0 21b3150

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/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}

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