webview.go RawHistoryFolder

1package webkit
2
3import (
4	"context"
5	"crypto/rand"
6	"crypto/subtle"
7	"encoding/hex"
8	"fmt"
9	"net/url"
10	"os"
11	"strings"
12	"sync"
13	"sync/atomic"
14	"time"
15
16	"github.com/bnema/dumber/internal/application/dto"
17	"github.com/bnema/dumber/internal/application/port"
18	downloadutil "github.com/bnema/dumber/internal/domain/download"
19	"github.com/bnema/dumber/internal/domain/entity"
20	urlutil "github.com/bnema/dumber/internal/domain/url"
21	"github.com/bnema/dumber/internal/infrastructure/desktop"
22	"github.com/bnema/dumber/internal/infrastructure/webutil"
23	"github.com/bnema/dumber/internal/logging"
24	"github.com/bnema/puregotk/v4/gdk"
25	"github.com/bnema/puregotk/v4/gio"
26	"github.com/bnema/puregotk/v4/glib"
27	"github.com/bnema/puregotk/v4/gobject"
28	gtypes "github.com/bnema/puregotk/v4/gobject/types"
29	"github.com/bnema/puregotk/v4/gtk"
30	"github.com/bnema/puregotk/v4/webkit"
31	"github.com/rs/zerolog"
32)
33
34// Compile-time interface checks.
35var _ port.WebView = (*WebView)(nil)
36var _ port.DevToolsOpener = (*WebView)(nil)
37var _ port.Printer = (*WebView)(nil)
38var _ port.PopupLifecycleCapable = (*WebView)(nil)
39var _ port.PopupFeatureResolver = (*WebView)(nil)
40var _ port.OAuthCallbackCapable = (*WebView)(nil)
41var _ port.PageScrollable = (*WebView)(nil)
42
43var buildPageScrollFallbackJS = webutil.BuildPageScrollByJS
44var runPageScrollFallbackJS = func(ctx context.Context, wv *WebView, script string) {
45	wv.RunJavaScript(ctx, script)
46}
47
48// editableFocusTokenRand is the injectable CSPRNG seam for bridge tokens.
49// Tests may override it; production uses crypto/rand.Read.
50var editableFocusTokenRand = rand.Read
51
52const editableFocusBridgeTokenBytes = 16
53
54func newEditableFocusBridgeToken() string {
55	buf := make([]byte, editableFocusBridgeTokenBytes)
56	if _, err := editableFocusTokenRand(buf); err != nil {
57		return ""
58	}
59	return hex.EncodeToString(buf)
60}
61
62// WebViewID is an alias to port.WebViewID for clean architecture compliance.
63// Infrastructure layer uses the type defined in the application port.
64type WebViewID = port.WebViewID
65
66// LoadEvent represents WebKit load events.
67type LoadEvent int
68
69const (
70	LoadStarted    LoadEvent = LoadEvent(webkit.LoadStartedValue)
71	LoadRedirected LoadEvent = LoadEvent(webkit.LoadRedirectedValue)
72	LoadCommitted  LoadEvent = LoadEvent(webkit.LoadCommittedValue)
73	LoadFinished   LoadEvent = LoadEvent(webkit.LoadFinishedValue)
74)
75
76// PopupRequest contains information about a popup window request from the create signal.
77type PopupRequest struct {
78	TargetURI      string
79	FrameName      string // e.g., "_blank", custom name, or empty
80	NavigationType webkit.NavigationType
81	IsUserGesture  bool
82	ParentID       WebViewID
83}
84
85// webViewRegistry tracks all active WebViews.
86type webViewRegistry struct {
87	views   map[WebViewID]*WebView
88	byUCM   map[uintptr]WebViewID
89	counter atomic.Uint64
90	mu      sync.RWMutex
91}
92
93var globalRegistry = &webViewRegistry{
94	views: make(map[WebViewID]*WebView),
95	byUCM: make(map[uintptr]WebViewID),
96}
97
98func (r *webViewRegistry) register(wv *WebView) WebViewID {
99	id := WebViewID(r.counter.Add(1))
100	r.mu.Lock()
101	r.views[id] = wv
102	if wv != nil && wv.ucm != nil {
103		r.byUCM[wv.ucm.GoPointer()] = id
104	}
105	r.mu.Unlock()
106	return id
107}
108
109func (r *webViewRegistry) unregister(id WebViewID) {
110	r.mu.Lock()
111	wv := r.views[id]
112	delete(r.views, id)
113	if wv != nil && wv.ucm != nil {
114		delete(r.byUCM, wv.ucm.GoPointer())
115	}
116	r.mu.Unlock()
117}
118
119// Lookup returns a WebView by ID, or nil if not found.
120func (r *webViewRegistry) Lookup(id WebViewID) *WebView {
121	r.mu.RLock()
122	defer r.mu.RUnlock()
123	return r.views[id]
124}
125
126func (r *webViewRegistry) LookupByUCMPointer(ptr uintptr) *WebView {
127	if ptr == 0 {
128		return nil
129	}
130	r.mu.RLock()
131	defer r.mu.RUnlock()
132	id, ok := r.byUCM[ptr]
133	if !ok {
134		return nil
135	}
136	return r.views[id]
137}
138
139// LookupWebView returns a WebView by ID from the global registry.
140func LookupWebView(id WebViewID) *WebView {
141	return globalRegistry.Lookup(id)
142}
143
144// LookupWebViewByUCMPointer returns a WebView by its UserContentManager pointer.
145func LookupWebViewByUCMPointer(ptr uintptr) *WebView {
146	return globalRegistry.LookupByUCMPointer(ptr)
147}
148
149// WebView wraps webkit.WebView with Go-level state tracking and callbacks.
150type WebView struct {
151	id    WebViewID
152	inner *webkit.WebView
153	ucm   *webkit.UserContentManager
154
155	// State (protected by mutex)
156	destroyed atomic.Bool
157	isRelated bool // true if created via NewWebViewWithRelated (shares web process with parent)
158	uri       string
159	title     string
160	progress  float64
161	canGoBack bool
162	canGoFwd  bool
163	isLoading bool
164
165	// Media/fullscreen state for idle inhibition cleanup
166	isFullscreen   atomic.Bool
167	isPlayingAudio atomic.Bool
168
169	// Progress throttling (~60fps)
170	lastProgressUpdate atomic.Int64 // Unix nanoseconds
171
172	// Signal handler IDs for disconnection
173	signalIDs  []uintptr
174	generation atomic.Uint64
175
176	// Callbacks (set by UI layer)
177	OnLoadChanged              func(LoadEvent)
178	OnTitleChanged             func(string)
179	OnURIChanged               func(string)
180	OnProgressChanged          func(float64)
181	OnFaviconChanged           func(*gdk.Texture) // Called when page favicon changes
182	OnClose                    func()
183	popupLifecycleClose        func()
184	OnCreate                   func(PopupRequest) *WebView // Return new WebView or nil to block popup
185	OnReadyToShow              func()                      // Called when popup is ready to display
186	OnLinkMiddleClick          func(uri string) bool       // Return true if handled (blocks navigation)
187	OnEnterFullscreen          func() bool                 // Return true to prevent fullscreen
188	OnLeaveFullscreen          func() bool                 // Return true to prevent leaving fullscreen
189	OnAudioStateChanged        func(playing bool)          // Called when audio playback starts/stops
190	OnEditableFocusChanged     func(editable bool)         // Called when page editable focus changes
191	OnLinkHover                func(uri string)            // Called when hovering over a link/image/media (empty string when leaving)
192	OnWebProcessTerminated     func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string)
193	browsingContextDecision    dto.HostDecision
194	hasBrowsingContextDecision bool
195	nativePopupHostAbort       func()
196
197	// PermissionRequest is called when a site requests permission (mic, camera, screen sharing).
198	// Return true to indicate the request was handled. Call allow()/deny() to respond.
199	// The permission types are determined from the request object.
200	// The metadata map carries permission-type-specific context; for website_data_access both
201	// entity.PermissionMetadataKeyRequestingDomain and entity.PermissionMetadataKeyCurrentDomain are populated.
202	OnPermissionRequest func(origin string, permTypes []string, metadata map[string]string, allow, deny func()) bool
203
204	logger zerolog.Logger
205	mu     sync.RWMutex
206
207	frontendAttached         atomic.Bool
208	navigationActive         atomic.Bool
209	editableFocusBridgeToken string
210
211	// asyncCallbacks keeps references to async JS callbacks to prevent GC
212	asyncCallbacks []any
213
214	// runJSErrorStats aggregates repeated non-fatal RunJavaScript errors by domain+signature.
215	runJSErrorStats map[string]runJSErrorStat
216
217	// findController is cached to prevent GC from collecting the Go wrapper
218	findController     *findControllerAdapter
219	findControllerOnce sync.Once
220
221	backForwardList         *webkit.BackForwardList
222	backForwardListSignalID uintptr
223
224	// contextMenu holds the optional context menu pipeline for reconnection.
225	contextMenu *contextMenuPipeline
226}
227
228type runJSErrorStat struct {
229	count   uint64
230	lastLog time.Time
231}
232
233const (
234	terminatePolicyAuto   = "auto"
235	terminatePolicyAlways = "always"
236	terminatePolicyNever  = "never"
237
238	runJSNonFatalLogInterval = 30 * time.Second
239	runJSAggregateLogEvery   = 20
240	runJSUnknown             = "unknown"
241)
242
243type findControllerAdapter struct {
244	fc *webkit.FindController
245}
246
247func (a *findControllerAdapter) Search(text string, opts port.FindOptions, maxMatches uint) {
248	if a == nil || a.fc == nil {
249		return
250	}
251	var flags uint32
252	if opts.WrapAround {
253		flags |= uint32(webkit.FindOptionsWrapAroundValue)
254	}
255	if opts.CaseInsensitive {
256		flags |= uint32(webkit.FindOptionsCaseInsensitiveValue)
257	}
258	if opts.AtWordStarts {
259		flags |= uint32(webkit.FindOptionsAtWordStartsValue)
260	}
261	a.fc.Search(text, flags, maxMatches)
262}
263
264func (a *findControllerAdapter) CountMatches(text string, opts port.FindOptions, maxMatches uint) {
265	if a == nil || a.fc == nil {
266		return
267	}
268	var flags uint32
269	if opts.WrapAround {
270		flags |= uint32(webkit.FindOptionsWrapAroundValue)
271	}
272	if opts.CaseInsensitive {
273		flags |= uint32(webkit.FindOptionsCaseInsensitiveValue)
274	}
275	if opts.AtWordStarts {
276		flags |= uint32(webkit.FindOptionsAtWordStartsValue)
277	}
278	a.fc.CountMatches(text, flags, maxMatches)
279}
280
281func (a *findControllerAdapter) SearchNext() {
282	if a == nil || a.fc == nil {
283		return
284	}
285	a.fc.SearchNext()
286}
287
288func (a *findControllerAdapter) SearchPrevious() {
289	if a == nil || a.fc == nil {
290		return
291	}
292	a.fc.SearchPrevious()
293}
294
295func (a *findControllerAdapter) SearchFinish() {
296	if a == nil || a.fc == nil {
297		return
298	}
299	a.fc.SearchFinish()
300}
301
302func (a *findControllerAdapter) GetSearchText() string {
303	if a == nil || a.fc == nil {
304		return ""
305	}
306	return a.fc.GetSearchText()
307}
308
309func (a *findControllerAdapter) OnFoundText(callback func(matchCount uint)) uint {
310	if a == nil || a.fc == nil || callback == nil {
311		return 0
312	}
313	cb := func(_ webkit.FindController, matchCount uint) {
314		callback(matchCount)
315	}
316	return a.fc.ConnectFoundText(&cb)
317}
318
319func (a *findControllerAdapter) OnFailedToFindText(callback func()) uint {
320	if a == nil || a.fc == nil || callback == nil {
321		return 0
322	}
323	cb := func(_ webkit.FindController) {
324		callback()
325	}
326	return a.fc.ConnectFailedToFindText(&cb)
327}
328
329func (a *findControllerAdapter) OnCountedMatches(callback func(matchCount uint)) uint {
330	if a == nil || a.fc == nil || callback == nil {
331		return 0
332	}
333	cb := func(_ webkit.FindController, matchCount uint) {
334		callback(matchCount)
335	}
336	return a.fc.ConnectCountedMatches(&cb)
337}
338
339func (a *findControllerAdapter) DisconnectSignal(id uint) {
340	if a == nil || a.fc == nil || id == 0 {
341		return
342	}
343	obj := gobject.ObjectNewFromInternalPtr(a.fc.GoPointer())
344	gobject.SignalHandlerDisconnect(obj, id)
345}
346
347// NewWebView creates a new WebView with the given context and settings.
348// Uses the persistent NetworkSession from wkCtx for cookie/data persistence.
349// bgColor is optional - if provided, sets background immediately to prevent white flash.
350func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsManager, bgColor *gdk.RGBA) (*WebView, error) {
351	log := logging.FromContext(ctx)
352
353	if wkCtx == nil || !wkCtx.IsInitialized() {
354		return nil, fmt.Errorf("webkit context not initialized")
355	}
356
357	// Use options constructor to pass both WebContext and NetworkSession.
358	// This ensures WebViews use our custom WebContext (with memory pressure settings)
359	// and the persistent NetworkSession for cookie/storage.
360	inner := webkit.NewWebViewWithOptions(&webkit.WebViewOptions{
361		WebContext:     wkCtx.Context(),
362		NetworkSession: wkCtx.NetworkSession(),
363	})
364	if inner == nil {
365		return nil, fmt.Errorf("failed to create webkit webview with options")
366	}
367
368	// Set background color IMMEDIATELY after creation, before any other operations.
369	// This prevents white flash by ensuring WebKit's renderer starts with correct color.
370	if bgColor != nil {
371		inner.SetBackgroundColor(bgColor)
372	}
373
374	wv := &WebView{
375		inner:                    inner,
376		ucm:                      inner.GetUserContentManager(),
377		logger:                   log.With().Str("component", "webview").Logger(),
378		signalIDs:                make([]uintptr, 0, 4),
379		runJSErrorStats:          make(map[string]runJSErrorStat),
380		editableFocusBridgeToken: newEditableFocusBridgeToken(),
381	}
382
383	// Register in global registry
384	wv.id = globalRegistry.register(wv)
385
386	// Apply settings if provided
387	if settings != nil {
388		settings.ApplyToWebView(ctx, inner)
389	}
390
391	// Connect signals
392	wv.connectSignals()
393
394	wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("webview created")
395
396	return wv, nil
397}
398
399// NewWebViewWithRelated creates a WebView that shares session/cookies with parent.
400// This is required for popup windows to maintain authentication state.
401func NewWebViewWithRelated(ctx context.Context, parent *WebView, settings *SettingsManager) (*WebView, error) {
402	log := logging.FromContext(ctx)
403
404	if parent == nil {
405		return nil, fmt.Errorf("parent webview is nil")
406	}
407	if parent.IsDestroyed() {
408		return nil, fmt.Errorf("parent webview %d is destroyed", parent.id)
409	}
410
411	log.Debug().
412		Uint64("parent_ptr", uint64(parent.inner.GoPointer())).
413		Msg("creating related webview with parent pointer")
414
415	inner := webkit.NewWebViewWithRelatedView(parent.inner)
416	if inner == nil {
417		return nil, fmt.Errorf("failed to create related webkit webview")
418	}
419
420	log.Debug().
421		Uint64("new_ptr", uint64(inner.GoPointer())).
422		Uint64("new_widget_ptr", uint64(inner.Widget.GoPointer())).
423		Msg("related webview created, checking pointers")
424
425	wv := &WebView{
426		inner:                    inner,
427		isRelated:                true, // Shares web process with parent - must not terminate process on destroy
428		ucm:                      inner.GetUserContentManager(),
429		logger:                   log.With().Str("component", "webview-popup").Logger(),
430		signalIDs:                make([]uintptr, 0, 6),
431		runJSErrorStats:          make(map[string]runJSErrorStat),
432		editableFocusBridgeToken: newEditableFocusBridgeToken(),
433	}
434
435	wv.id = globalRegistry.register(wv)
436
437	if settings != nil {
438		settings.ApplyToWebView(ctx, inner)
439	}
440
441	wv.connectSignals()
442
443	wv.logger.Debug().
444		Uint64("id", uint64(wv.id)).
445		Uint64("parent_id", uint64(parent.id)).
446		Msg("related webview created for popup")
447
448	return wv, nil
449}
450
451// connectSignals sets up signal handlers for the WebView.
452func (wv *WebView) connectSignals() {
453	wv.connectLoadChangedSignal()
454	wv.connectCloseSignal()
455	wv.connectCreateSignal()
456	wv.connectReadyToShowSignal()
457	wv.connectTitleSignal()
458	wv.connectURISignal()
459	wv.connectFaviconSignal()
460	wv.connectProgressSignal()
461	wv.connectLoadFailedSignal()
462	wv.connectWebProcessResponsiveSignal()
463	wv.connectDecidePolicySignal()
464	wv.connectEnterFullscreenSignal()
465	wv.connectLeaveFullscreenSignal()
466	wv.connectAudioStateSignal()
467	wv.connectMediaCaptureStateSignals()
468	wv.connectMouseTargetChangedSignal()
469	wv.connectBackForwardListChangedSignal()
470	wv.connectWebProcessTerminatedSignal()
471	wv.connectPermissionRequestSignal()
472	wv.connectContextMenuSignal(wv.contextMenu)
473}
474
475func (wv *WebView) connectLoadChangedSignal() {
476	loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) {
477		uri := inner.GetUri()
478		title := inner.GetTitle()
479		progress := inner.GetEstimatedLoadProgress()
480		wv.handleLoadChanged(LoadEvent(event), uri, title, progress)
481	}
482	sigID := wv.inner.ConnectLoadChanged(&loadChangedCb)
483	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
484}
485
486// handleLoadChanged updates load state then notifies outside wv.mu so callbacks
487// may re-enter URI()/State() without deadlocking.
488func (wv *WebView) handleLoadChanged(event LoadEvent, uri, title string, progress float64) {
489	wv.mu.Lock()
490	wv.uri = uri
491	wv.title = title
492	// Note: canGoBack/canGoFwd are updated via back-forward-list::changed signal
493	// which fires for both traditional navigation and SPA history.pushState()
494	wv.progress = progress
495
496	notifyEditableBlur := false
497	switch event {
498	case LoadStarted:
499		wv.navigationActive.Store(true)
500		wv.isLoading = true
501		wv.logger.Debug().Str("uri", uri).Msg("load started")
502		notifyEditableBlur = true
503	case LoadRedirected:
504		wv.logger.Debug().Str("uri", uri).Msg("load redirected")
505	case LoadCommitted:
506		wv.logger.Debug().Str("uri", uri).Msg("load committed")
507	case LoadFinished:
508		wv.isLoading = false
509		wv.logger.Debug().Str("uri", uri).Str("title", title).Msg("load finished")
510	}
511	wv.mu.Unlock()
512
513	if notifyEditableBlur {
514		wv.dispatchEditableFocusChanged(false)
515	}
516
517	if wv.OnLoadChanged != nil {
518		wv.OnLoadChanged(event)
519	}
520}
521
522func (wv *WebView) connectLoadFailedSignal() {
523	loadFailedCb := func(_ webkit.WebView, event webkit.LoadEvent, failingURI string, gerr *glib.Error) bool {
524		wv.logger.Warn().
525			Str("component", "webview").
526			Str("uri", failingURI).
527			Int("load_event", int(event)).
528			Str("error", gerr.MessageGo()).
529			Msg("load failed")
530		return false
531	}
532	sigID := wv.inner.ConnectLoadFailed(&loadFailedCb)
533	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
534}
535
536func (wv *WebView) connectWebProcessResponsiveSignal() {
537	responsiveCb := func() {
538		responsive := wv.inner.GetPropertyIsWebProcessResponsive()
539		event := wv.logger.With().
540			Uint64("id", uint64(wv.id)).
541			Str("uri", wv.inner.GetUri()).
542			Bool("responsive", responsive).
543			Logger()
544		if responsive {
545			event.Info().Msg("web process responsiveness changed")
546			return
547		}
548		event.Warn().Msg("web process became unresponsive")
549	}
550	sigID := gobject.SignalConnect(
551		wv.inner.GoPointer(),
552		"notify::is-web-process-responsive",
553		glib.NewCallback(&responsiveCb),
554	)
555	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
556}
557
558func (wv *WebView) connectCloseSignal() {
559	closeCb := func(_ webkit.WebView) {
560		wv.runCloseCallbacks()
561	}
562	sigID := wv.inner.ConnectClose(&closeCb)
563	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
564}
565
566func popupFeaturesFromWindowProperties(
567	x, y, width, height int,
568	toolbarVisible, locationbarVisible, resizable bool,
569) dto.PopupFeatures {
570	chromeRestricted := !toolbarVisible || !locationbarVisible || !resizable
571	features := dto.PopupFeatures{
572		X: x, Y: y, Width: width, Height: height,
573		XSet: x != 0, YSet: y != 0, WidthSet: width > 0, HeightSet: height > 0,
574		ToolbarVisible: toolbarVisible, LocationbarVisible: locationbarVisible,
575		ToolbarVisibilitySet: true, LocationbarVisibilitySet: true,
576		Resizable: resizable, ResizableSet: true,
577		IsPopup: chromeRestricted, IsPopupSet: true,
578		State: dto.PopupFeaturesNone,
579	}
580	if width > 0 || height > 0 || chromeRestricted {
581		features.State = dto.PopupFeaturesSpecified
582	}
583	return features
584}
585
586// ResolvePopupFeatures reads WebKit's late window metadata. WebKit documents
587// these properties as reliable when ready-to-show is emitted.
588func (wv *WebView) ResolvePopupFeatures() dto.PopupFeatures {
589	if wv == nil || wv.inner == nil || wv.destroyed.Load() {
590		return dto.PopupFeatures{State: dto.PopupFeaturesUnknown}
591	}
592	props := wv.inner.GetWindowProperties()
593	if props == nil {
594		return dto.PopupFeatures{State: dto.PopupFeaturesUnknown}
595	}
596	geometry := &gdk.Rectangle{}
597	props.GetGeometry(geometry)
598	return popupFeaturesFromWindowProperties(
599		geometry.X, geometry.Y, geometry.Width, geometry.Height,
600		props.GetToolbarVisible(), props.GetLocationbarVisible(), props.GetResizable(),
601	)
602}
603
604func (wv *WebView) SetBrowsingContextHostDecision(decision dto.HostDecision) {
605	wv.mu.Lock()
606	wv.browsingContextDecision = decision
607	wv.hasBrowsingContextDecision = true
608	wv.mu.Unlock()
609}
610
611func (wv *WebView) BrowsingContextHostDecision() (dto.HostDecision, bool) {
612	wv.mu.RLock()
613	defer wv.mu.RUnlock()
614	return wv.browsingContextDecision, wv.hasBrowsingContextDecision
615}
616
617func (wv *WebView) SetNativePopupHostAbort(fn func()) {
618	wv.mu.Lock()
619	wv.nativePopupHostAbort = fn
620	wv.mu.Unlock()
621}
622
623func (wv *WebView) AbortNativePopupHost() {
624	wv.mu.RLock()
625	fn := wv.nativePopupHostAbort
626	wv.mu.RUnlock()
627	if fn != nil {
628		fn()
629	}
630}
631
632func (wv *WebView) connectCreateSignal() {
633	createCb := func(_ webkit.WebView, navActionPtr uintptr) gtk.Widget {
634		if wv.OnCreate == nil {
635			return gtk.Widget{} // Block popup
636		}
637
638		navAction := webkit.NavigationActionFromPointer(navActionPtr)
639		if navAction == nil {
640			return gtk.Widget{} // Block popup
641		}
642		defer navAction.Free()
643
644		var targetURI string
645		if req := navAction.GetRequest(); req != nil {
646			targetURI = req.GetUri()
647		}
648
649		popupReq := PopupRequest{
650			TargetURI:      targetURI,
651			FrameName:      navAction.GetFrameName(),
652			NavigationType: navAction.GetNavigationType(),
653			IsUserGesture:  navAction.IsUserGesture(),
654			ParentID:       wv.id,
655		}
656
657		wv.logger.Debug().Msg("create signal: invoking OnCreate handler")
658		newWV := wv.OnCreate(popupReq)
659		wv.logger.Debug().Bool("nil", newWV == nil).Msg("create signal: OnCreate returned")
660		if newWV == nil {
661			return gtk.Widget{} // Block popup
662		}
663
664		wv.logger.Debug().
665			Uint64("parent_id", uint64(wv.id)).
666			Uint64("popup_id", uint64(newWV.id)).
667			Msg("create signal: returning webview widget to WebKit")
668
669		return newWV.inner.Widget
670	}
671	sigID := wv.inner.ConnectCreate(&createCb)
672	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
673}
674
675func (wv *WebView) connectReadyToShowSignal() {
676	readyToShowCb := func(_ webkit.WebView) {
677		wv.fireReadyToShow()
678	}
679	sigID := wv.inner.ConnectReadyToShow(&readyToShowCb)
680	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
681}
682
683func (wv *WebView) connectTitleSignal() {
684	titleCb := func() {
685		title := wv.inner.GetTitle()
686		wv.mu.Lock()
687		wv.title = title
688		wv.mu.Unlock()
689
690		if wv.OnTitleChanged != nil {
691			wv.OnTitleChanged(title)
692		}
693	}
694	sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::title", glib.NewCallback(&titleCb))
695	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
696}
697
698func (wv *WebView) connectURISignal() {
699	uriCb := func() {
700		uri := wv.inner.GetUri()
701		wv.mu.Lock()
702		oldUri := wv.uri
703		wv.uri = uri
704		wv.mu.Unlock()
705
706		if wv.OnURIChanged != nil && uri != oldUri {
707			wv.OnURIChanged(uri)
708		}
709	}
710	sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::uri", glib.NewCallback(&uriCb))
711	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
712}
713
714func (wv *WebView) connectFaviconSignal() {
715	faviconCb := func() {
716		favicon := wv.inner.GetFavicon()
717		if wv.OnFaviconChanged != nil {
718			wv.OnFaviconChanged(favicon)
719		}
720	}
721	sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::favicon", glib.NewCallback(&faviconCb))
722	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
723}
724
725// progressThrottleInterval limits progress callbacks to ~60fps to reduce UI overhead.
726const progressThrottleInterval = 16 * time.Millisecond
727
728func (wv *WebView) connectProgressSignal() {
729	progressCb := func() {
730		progress := wv.inner.GetEstimatedLoadProgress()
731
732		// Throttle progress updates to ~60fps (16ms interval)
733		// Always allow 0.0 (start) and 1.0 (complete) through
734		now := time.Now().UnixNano()
735		last := wv.lastProgressUpdate.Load()
736		if progress > 0.0 && progress < 1.0 {
737			if now-last < int64(progressThrottleInterval) {
738				return // Skip this update
739			}
740		}
741		wv.lastProgressUpdate.Store(now)
742
743		wv.mu.Lock()
744		wv.progress = progress
745		wv.mu.Unlock()
746
747		if wv.OnProgressChanged != nil {
748			wv.OnProgressChanged(progress)
749		}
750	}
751	sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::estimated-load-progress", glib.NewCallback(&progressCb))
752	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
753}
754
755func (wv *WebView) connectDecidePolicySignal() {
756	decidePolicyCb := func(_ webkit.WebView, decisionPtr uintptr, decisionType webkit.PolicyDecisionType) bool {
757		switch decisionType {
758		case webkit.PolicyDecisionTypeResponseValue:
759			return wv.handleResponsePolicyDecision(decisionPtr)
760		case webkit.PolicyDecisionTypeNavigationActionValue, webkit.PolicyDecisionTypeNewWindowActionValue:
761			// Both navigation and new window actions use NavigationPolicyDecision
762			return wv.handleNavigationPolicyDecision(decisionPtr)
763		default:
764			return false
765		}
766	}
767	sigID := wv.inner.ConnectDecidePolicy(&decidePolicyCb)
768	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
769}
770
771// handleResponsePolicyDecision handles response policy decisions (e.g., forcing downloads).
772func (wv *WebView) handleResponsePolicyDecision(decisionPtr uintptr) bool {
773	responseDecision := webkit.ResponsePolicyDecisionNewFromInternalPtr(decisionPtr)
774	if responseDecision == nil {
775		return false
776	}
777
778	if !responseDecision.IsMainFrameMainResource() {
779		return false
780	}
781
782	if !shouldForceDownload(responseDecision) {
783		return false
784	}
785
786	policyDecision := webkit.PolicyDecisionNewFromInternalPtr(decisionPtr)
787	if policyDecision == nil {
788		return false
789	}
790
791	response := responseDecision.GetResponse()
792	mimeType := ""
793	uri := ""
794	if response != nil {
795		mimeType = response.GetMimeType()
796		uri = response.GetUri()
797	}
798
799	wv.logger.Debug().
800		Str("mime_type", mimeType).
801		Str("uri", uri).
802		Msg("forcing download for response")
803
804	// Start a fresh download instead of converting the in-progress response.
805	// Using policyDecision.Download() can fail due to race conditions where
806	// the network connection is released before the download can use it.
807	// By ignoring the policy and starting a new download, we avoid this issue.
808	// We defer the download to the next idle cycle to let WebKit clean up
809	// the ignored navigation first.
810	if uri != "" {
811		policyDecision.Ignore()
812		downloadURI := uri
813		inner := wv.inner
814		cb := glib.SourceFunc(func(_ uintptr) bool {
815			inner.DownloadUri(downloadURI)
816			return false // Don't repeat
817		})
818		// Store callback reference to prevent GC before GTK calls it
819		wv.mu.Lock()
820		wv.asyncCallbacks = append(wv.asyncCallbacks, &cb)
821		wv.mu.Unlock()
822		glib.IdleAdd(&cb, 0)
823		return true
824	}
825
826	// Fallback to old behavior if URI is not available
827	policyDecision.Download()
828	return true
829}
830
831// handleNavigationPolicyDecision handles navigation policy decisions (e.g., middle-click, external schemes).
832func (wv *WebView) handleNavigationPolicyDecision(decisionPtr uintptr) bool {
833	navDecision := webkit.NavigationPolicyDecisionNewFromInternalPtr(decisionPtr)
834	if navDecision == nil {
835		return false
836	}
837
838	navAction := navDecision.GetNavigationAction()
839	if navAction == nil {
840		return false
841	}
842
843	request := navAction.GetRequest()
844	if request == nil {
845		return false
846	}
847
848	linkURI := request.GetUri()
849	if linkURI == "" {
850		return false
851	}
852
853	// Debug logging to trace navigation decisions
854	wv.logger.Debug().
855		Str("uri", linkURI).
856		Int("nav_type", int(navAction.GetNavigationType())).
857		Bool("user_gesture", navAction.IsUserGesture()).
858		Msg("navigation policy decision")
859
860	// Check for external URL schemes (e.g., vscode://, vscode-insiders://, spotify://)
861	// These need to be launched via xdg-open rather than handled by WebKit
862	// Only launch for user-initiated actions to prevent automatic redirects
863	// from silently opening external applications
864	if urlutil.IsExternalScheme(linkURI) {
865		if !navAction.IsUserGesture() {
866			// For non-user-initiated redirects, ignore silently
867			navDecision.Ignore()
868			return true
869		}
870
871		wv.logger.Info().
872			Str("uri", linkURI).
873			Msg("launching external URL scheme via xdg-open")
874
875		// Launch asynchronously to avoid blocking WebKit
876		go desktop.LaunchExternalURL(linkURI)
877
878		// Ignore the navigation to prevent WebKit from showing an error
879		navDecision.Ignore()
880
881		// Go back to the previous page to avoid showing WebKit's error page.
882		// This handles OAuth flows where after successful auth, the callback
883		// redirects to a custom scheme (vscode://, vscode-insiders://, etc.)
884		// Schedule on idle to let WebKit process the ignored decision first
885		cb := glib.SourceFunc(func(_ uintptr) bool {
886			if wv.inner != nil && !wv.destroyed.Load() && wv.inner.CanGoBack() {
887				wv.inner.GoBack()
888			}
889			return false // Don't repeat
890		})
891		wv.mu.Lock()
892		wv.asyncCallbacks = append(wv.asyncCallbacks, &cb)
893		wv.mu.Unlock()
894		glib.IdleAdd(&cb, 0)
895
896		return true
897	}
898
899	// Only handle link clicks for middle-click/ctrl-click (open in new tab)
900	if navAction.GetNavigationType() != webkit.NavigationTypeLinkClickedValue {
901		return false
902	}
903
904	mouseButton := navAction.GetMouseButton()
905	modifiers := navAction.GetModifiers()
906	isMiddleClick := mouseButton == 2
907	isCtrlClick := mouseButton == 1 && (gdk.ModifierType(modifiers)&gdk.ControlMaskValue) != 0
908
909	if !isMiddleClick && !isCtrlClick {
910		return false
911	}
912
913	wv.logger.Debug().
914		Str("uri", linkURI).
915		Uint("button", mouseButton).
916		Bool("ctrl", isCtrlClick).
917		Msg("middle-click/ctrl+click on link detected")
918
919	if wv.OnLinkMiddleClick != nil {
920		if wv.OnLinkMiddleClick(linkURI) {
921			navDecision.Ignore()
922			return true
923		}
924	}
925
926	return false
927}
928
929func shouldForceDownload(responseDecision *webkit.ResponsePolicyDecision) bool {
930	if responseDecision == nil {
931		return false
932	}
933
934	response := responseDecision.GetResponse()
935	if response == nil {
936		return false
937	}
938
939	return downloadutil.ShouldForceDownload(response.GetUri(), response.GetMimeType())
940}
941
942func (wv *WebView) connectEnterFullscreenSignal() {
943	enterFullscreenCb := func(_ webkit.WebView) bool {
944		wv.isFullscreen.Store(true)
945		wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("enter fullscreen")
946		if wv.OnEnterFullscreen != nil {
947			return wv.OnEnterFullscreen()
948		}
949		return false // Allow fullscreen
950	}
951	sigID := wv.inner.ConnectEnterFullscreen(&enterFullscreenCb)
952	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
953}
954
955func (wv *WebView) connectLeaveFullscreenSignal() {
956	leaveFullscreenCb := func(_ webkit.WebView) bool {
957		wv.isFullscreen.Store(false)
958		wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("leave fullscreen")
959		if wv.OnLeaveFullscreen != nil {
960			return wv.OnLeaveFullscreen()
961		}
962		return false // Allow leaving fullscreen
963	}
964	sigID := wv.inner.ConnectLeaveFullscreen(&leaveFullscreenCb)
965	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
966}
967
968func (wv *WebView) connectAudioStateSignal() {
969	audioCb := func() {
970		playing := wv.inner.IsPlayingAudio()
971		oldState := wv.isPlayingAudio.Swap(playing)
972		if oldState != playing {
973			wv.logger.Debug().
974				Uint64("id", uint64(wv.id)).
975				Bool("playing", playing).
976				Msg("audio state changed")
977			if wv.OnAudioStateChanged != nil {
978				wv.OnAudioStateChanged(playing)
979			}
980		}
981	}
982	sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::is-playing-audio", glib.NewCallback(&audioCb))
983	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
984}
985
986func (wv *WebView) connectMediaCaptureStateSignals() {
987	connect := func(signal string, readState func() webkit.MediaCaptureState, kind string) {
988		cb := func() {
989			state := readState()
990			wv.logger.Debug().
991				Uint64("id", uint64(wv.id)).
992				Str("kind", kind).
993				Str("state", mediaCaptureStateString(state)).
994				Int("state_code", int(state)).
995				Msg("media capture state changed")
996		}
997		sigID := gobject.SignalConnect(wv.inner.GoPointer(), signal, glib.NewCallback(&cb))
998		wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
999	}
1000
1001	connect("notify::camera-capture-state", wv.inner.GetCameraCaptureState, "camera")
1002	connect("notify::microphone-capture-state", wv.inner.GetMicrophoneCaptureState, "microphone")
1003	connect("notify::display-capture-state", wv.inner.GetDisplayCaptureState, "display")
1004}
1005
1006func mediaCaptureStateString(state webkit.MediaCaptureState) string {
1007	switch state {
1008	case webkit.MediaCaptureStateNoneValue:
1009		return "none"
1010	case webkit.MediaCaptureStateActiveValue:
1011		return "active"
1012	case webkit.MediaCaptureStateMutedValue:
1013		return "muted"
1014	default:
1015		return "unknown"
1016	}
1017}
1018
1019func (wv *WebView) connectMouseTargetChangedSignal() {
1020	mouseTargetCb := func(_ webkit.WebView, hitTestPtr uintptr, _ uint) {
1021		if wv.OnLinkHover == nil {
1022			return
1023		}
1024
1025		hitResult := webkit.HitTestResultNewFromInternalPtr(hitTestPtr)
1026		if hitResult == nil {
1027			wv.OnLinkHover("")
1028			return
1029		}
1030
1031		var uri string
1032		switch {
1033		case hitResult.ContextIsLink():
1034			uri = hitResult.GetLinkUri()
1035		case hitResult.ContextIsImage():
1036			uri = hitResult.GetImageUri()
1037		case hitResult.ContextIsMedia():
1038			uri = hitResult.GetMediaUri()
1039		}
1040
1041		wv.OnLinkHover(uri)
1042	}
1043	sigID := wv.inner.ConnectMouseTargetChanged(&mouseTargetCb)
1044	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
1045}
1046
1047func (wv *WebView) connectBackForwardListChangedSignal() {
1048	backForwardList := wv.inner.GetBackForwardList()
1049	if backForwardList == nil {
1050		wv.logger.Warn().Msg("failed to get back-forward-list")
1051		return
1052	}
1053
1054	// The "changed" signal is emitted when the back-forward list changes,
1055	// including for SPA navigation via history.pushState/replaceState.
1056	// Parameters: (item_added, items_removed) - we ignore them and just refresh state.
1057	changedCb := func(_ webkit.BackForwardList, _ uintptr, _ uintptr) {
1058		canBack := wv.inner.CanGoBack()
1059		canFwd := wv.inner.CanGoForward()
1060		wv.logger.Debug().
1061			Bool("can_go_back", canBack).
1062			Bool("can_go_forward", canFwd).
1063			Uint("list_length", backForwardList.GetLength()).
1064			Msg("back-forward-list changed")
1065		wv.mu.Lock()
1066		wv.canGoBack = canBack
1067		wv.canGoFwd = canFwd
1068		wv.mu.Unlock()
1069	}
1070	sigID := backForwardList.ConnectChanged(&changedCb)
1071	wv.backForwardList = backForwardList
1072	wv.backForwardListSignalID = uintptr(sigID)
1073}
1074
1075func webProcessTerminationReasonString(reason webkit.WebProcessTerminationReason) string {
1076	switch reason {
1077	case webkit.WebProcessCrashedValue:
1078		return "crashed"
1079	case webkit.WebProcessExceededMemoryLimitValue:
1080		return "exceeded_memory"
1081	case webkit.WebProcessTerminatedByApiValue:
1082		return "terminated_by_api"
1083	default:
1084		return "unknown"
1085	}
1086}
1087
1088func mapWebProcessTerminationReason(reason webkit.WebProcessTerminationReason) port.WebProcessTerminationReason {
1089	switch reason {
1090	case webkit.WebProcessCrashedValue:
1091		return port.WebProcessTerminationCrashed
1092	case webkit.WebProcessExceededMemoryLimitValue:
1093		return port.WebProcessTerminationExceededMemory
1094	case webkit.WebProcessTerminatedByApiValue:
1095		return port.WebProcessTerminationByAPI
1096	default:
1097		return port.WebProcessTerminationUnknown
1098	}
1099}
1100
1101func (wv *WebView) connectWebProcessTerminatedSignal() {
1102	terminatedCb := func(_ webkit.WebView, reason webkit.WebProcessTerminationReason) {
1103		uri := wv.URI()
1104		reasonLabel := webProcessTerminationReasonString(reason)
1105		wv.logger.Warn().
1106			Uint64("id", uint64(wv.id)).
1107			Str("reason", reasonLabel).
1108			Int("reason_code", int(reason)).
1109			Str("uri", uri).
1110			Msg("web process terminated")
1111
1112		if wv.OnWebProcessTerminated != nil {
1113			wv.OnWebProcessTerminated(reason, reasonLabel, uri)
1114		}
1115	}
1116	sigID := wv.inner.ConnectWebProcessTerminated(&terminatedCb)
1117	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
1118}
1119
1120// connectPermissionRequestSignal sets up the permission-request signal handler.
1121// This is emitted when a site calls getUserMedia() or getDisplayMedia().
1122func (wv *WebView) connectPermissionRequestSignal() {
1123	permissionCb := func(inner webkit.WebView, requestPtr uintptr) bool {
1124		ctx := logging.WithContext(context.Background(), wv.logger)
1125
1126		if wv.OnPermissionRequest == nil {
1127			return false // Not handled, WebKit will deny by default
1128		}
1129
1130		// Use the live URI from the signal's WebView rather than the cached wv.URI(),
1131		// so permission lookups use the current page origin even if navigation has
1132		// occurred since the last load-changed signal.
1133		uri := inner.GetUri()
1134		if uri == "" {
1135			wv.logger.Debug().Msg("permission request with empty origin, denying")
1136			return false
1137		}
1138		origin, err := urlutil.ExtractOrigin(uri)
1139		if err != nil {
1140			wv.logger.Debug().Str("uri", uri).Err(err).Msg("permission request: failed to extract origin, denying")
1141			return false
1142		}
1143
1144		// Determine permission types from the request
1145		permTypes, metadata := wv.determinePermissionTypes(ctx, requestPtr)
1146		if len(permTypes) == 0 {
1147			wv.logger.Warn().Msg("permission request with unknown type, denying")
1148			return false
1149		}
1150		if len(permTypes) == 1 && permTypes[0] == string(entity.PermissionTypeWebsiteDataAccess) {
1151			wv.logger.Info().
1152				Str("origin", origin).
1153				Str("requesting_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyRequestingDomain], logging.PermissionLogURLMaxLen)).
1154				Str("current_domain", logging.TruncateURL(metadata[entity.PermissionMetadataKeyCurrentDomain], logging.PermissionLogURLMaxLen)).
1155				Msg("website data access permission requested")
1156		}
1157
1158		// Ref the request object to prevent use-after-free
1159		// The request may outlive the signal handler if we show a dialog
1160		requestObj := gobject.ObjectNewFromInternalPtr(requestPtr)
1161		if requestObj == nil {
1162			wv.logger.Warn().Msg("permission request: failed to wrap request object")
1163			return false
1164		}
1165		requestObj.Ref()
1166
1167		// Create allow/deny callbacks that wrap the WebKit permission request
1168		allowCalled := false
1169		denyCalled := false
1170
1171		allow := func() {
1172			if allowCalled || denyCalled {
1173				return
1174			}
1175			allowCalled = true
1176			wv.allowPermissionRequest(requestPtr)
1177			requestObj.Unref()
1178		}
1179
1180		deny := func() {
1181			if allowCalled || denyCalled {
1182				return
1183			}
1184			denyCalled = true
1185			wv.denyPermissionRequest(requestPtr)
1186			requestObj.Unref()
1187		}
1188
1189		// Call the handler
1190		return wv.OnPermissionRequest(origin, permTypes, metadata, allow, deny)
1191	}
1192
1193	sigID := wv.inner.ConnectPermissionRequest(&permissionCb)
1194	wv.signalIDs = append(wv.signalIDs, uintptr(sigID))
1195}
1196
1197// determinePermissionTypes extracts permission types from a WebKit permission request.
1198// This uses type checking to identify UserMediaPermissionRequest and its specific types.
1199// We use GObject property accessors for audio/video request flags. Display detection
1200// uses the dedicated WebKit API because this WebKit build does not expose an
1201// "is-for-display-device" GObject property.
1202func (wv *WebView) determinePermissionTypes(ctx context.Context, requestPtr uintptr) (types []string, metadata map[string]string) {
1203	requestKind := detectPermissionRequestKind(ctx, requestPtr)
1204	switch requestKind {
1205	case permissionRequestKindUserMedia:
1206		userMediaReq := webkit.UserMediaPermissionRequestNewFromInternalPtr(requestPtr)
1207		if userMediaReq == nil {
1208			return nil, nil
1209		}
1210
1211		// Use GObject property accessors — more reliable than the C function wrappers
1212		// which can hit the purego bool return value bug.
1213		isAudio := userMediaReq.GetPropertyIsForAudioDevice()
1214		isVideo := userMediaReq.GetPropertyIsForVideoDevice()
1215		isDisplay := webkit.UserMediaPermissionIsForDisplayDevice(userMediaReq)
1216
1217		wv.logger.Debug().
1218			Bool("is_audio", isAudio).
1219			Bool("is_video", isVideo).
1220			Bool("is_display", isDisplay).
1221			Msg("permission request type detection")
1222
1223		return classifyPermissionRequestTypes(ctx, requestKind, isAudio, isVideo, isDisplay), nil
1224	case permissionRequestKindDeviceInfo:
1225		return classifyPermissionRequestTypes(ctx, requestKind, false, false, false), nil
1226	case permissionRequestKindWebsiteDataAccess:
1227		wdaReq := webkit.WebsiteDataAccessPermissionRequestNewFromInternalPtr(requestPtr)
1228		if wdaReq == nil {
1229			return nil, nil
1230		}
1231		currentDomain := wdaReq.GetCurrentDomain()
1232		requestingDomain := wdaReq.GetRequestingDomain()
1233		wv.logger.Debug().
1234			Str("current_domain", currentDomain).
1235			Str("requesting_domain", requestingDomain).
1236			Msg("website data access permission request")
1237		meta := map[string]string{
1238			entity.PermissionMetadataKeyRequestingDomain: requestingDomain,
1239			entity.PermissionMetadataKeyCurrentDomain:    currentDomain,
1240		}
1241		return classifyPermissionRequestTypes(ctx, requestKind, false, false, false), meta
1242	default:
1243		if requestPtr != 0 {
1244			typeName := permissionRequestTypeName(ctx, requestPtr)
1245			if typeName != "" {
1246				wv.logger.Warn().Str("request_type", typeName).Msg("unknown permission request type")
1247			} else {
1248				wv.logger.Warn().Msg("unknown permission request type")
1249			}
1250		}
1251		// Unknown permission type - could be clipboard, notifications, geolocation, etc.
1252		// For now, return empty to trigger denial. Future phases will add these types.
1253		return nil, nil
1254	}
1255}
1256
1257type permissionRequestKind int
1258
1259const (
1260	permissionRequestKindUnknown permissionRequestKind = iota
1261	permissionRequestKindUserMedia
1262	permissionRequestKindDeviceInfo
1263	permissionRequestKindWebsiteDataAccess
1264)
1265
1266func detectPermissionRequestKind(ctx context.Context, requestPtr uintptr) permissionRequestKind {
1267	if gtype, ok := safeGLibType(ctx, webkit.UserMediaPermissionRequestGLibType); ok {
1268		if isPermissionRequestType(ctx, requestPtr, gtype) {
1269			return permissionRequestKindUserMedia
1270		}
1271	}
1272	if gtype, ok := safeGLibType(ctx, webkit.DeviceInfoPermissionRequestGLibType); ok {
1273		if isPermissionRequestType(ctx, requestPtr, gtype) {
1274			return permissionRequestKindDeviceInfo
1275		}
1276	}
1277	if gtype, ok := safeGLibType(ctx, webkit.WebsiteDataAccessPermissionRequestGLibType); ok {
1278		if isPermissionRequestType(ctx, requestPtr, gtype) {
1279			return permissionRequestKindWebsiteDataAccess
1280		}
1281	}
1282	return permissionRequestKindUnknown
1283}
1284
1285// safeGLibType calls a GType accessor function, recovering from panics caused
1286// by unregistered (nil) puregotk function pointers. Returns the GType and true
1287// on success, or zero and false if the call panicked.
1288func safeGLibType(ctx context.Context, fn func() gtypes.GType) (gtype gtypes.GType, ok bool) {
1289	defer func() {
1290		if r := recover(); r != nil {
1291			logging.FromContext(ctx).Warn().
1292				Str("component", "permission").
1293				Interface("panic", r).
1294				Msg("GType accessor panicked (unregistered puregotk binding?)")
1295			gtype = 0
1296			ok = false
1297		}
1298	}()
1299	return fn(), true
1300}
1301
1302func isPermissionRequestType(ctx context.Context, requestPtr uintptr, requestType gtypes.GType) bool {
1303	return gobjectTypeCheckInstanceIsAByPtr(ctx, requestPtr, requestType)
1304}
1305
1306func classifyPermissionRequestTypes(
1307	ctx context.Context,
1308	kind permissionRequestKind,
1309	isAudio, isVideo, isDisplay bool,
1310) []string {
1311	switch kind {
1312	case permissionRequestKindUserMedia:
1313		return classifyUserMediaPermissionTypes(ctx, isAudio, isVideo, isDisplay)
1314	case permissionRequestKindDeviceInfo:
1315		return []string{"device_info"}
1316	case permissionRequestKindWebsiteDataAccess:
1317		return []string{"website_data_access"}
1318	default:
1319		return nil
1320	}
1321}
1322
1323func classifyUserMediaPermissionTypes(ctx context.Context, isAudio, isVideo, isDisplay bool) []string {
1324	types := make([]string, 0, 2)
1325
1326	if isAudio {
1327		types = append(types, "microphone")
1328	}
1329
1330	if isDisplay {
1331		types = append(types, "display")
1332	} else if isVideo {
1333		types = append(types, "camera")
1334	}
1335
1336	if len(types) == 0 {
1337		// On some Wayland/WebKit paths, getDisplayMedia() can arrive with all
1338		// flags false even though the request is a screen-capture request.
1339		logging.FromContext(ctx).Debug().
1340			Bool("is_audio", isAudio).
1341			Bool("is_video", isVideo).
1342			Bool("is_display", isDisplay).
1343			Msg("user media request had no flags; using display fallback")
1344		return []string{"display"}
1345	}
1346
1347	return types
1348}
1349
1350// allowPermissionRequest calls Allow() on the WebKit permission request.
1351func (wv *WebView) allowPermissionRequest(requestPtr uintptr) {
1352	// Try UserMediaPermissionRequest first
1353	userMediaReq := webkit.UserMediaPermissionRequestNewFromInternalPtr(requestPtr)
1354	if userMediaReq != nil {
1355		userMediaReq.Allow()
1356		wv.logger.Debug().Msg("permission request allowed")
1357		return
1358	}
1359
1360	// Try DeviceInfoPermissionRequest
1361	deviceInfoReq := webkit.DeviceInfoPermissionRequestNewFromInternalPtr(requestPtr)
1362	if deviceInfoReq != nil {
1363		deviceInfoReq.Allow()
1364		wv.logger.Debug().Msg("permission request allowed")
1365		return
1366	}
1367
1368	// Try WebsiteDataAccessPermissionRequest
1369	wdaReq := webkit.WebsiteDataAccessPermissionRequestNewFromInternalPtr(requestPtr)
1370	if wdaReq != nil {
1371		wdaReq.Allow()
1372		wv.logger.Debug().Msg("website data access permission request allowed")
1373		return
1374	}
1375
1376	wv.logger.Warn().Uint64("request_ptr", uint64(requestPtr)).Msg("permission request: unknown type, cannot allow")
1377}
1378
1379// denyPermissionRequest calls Deny() on the WebKit permission request.
1380func (wv *WebView) denyPermissionRequest(requestPtr uintptr) {
1381	// Try UserMediaPermissionRequest first
1382	userMediaReq := webkit.UserMediaPermissionRequestNewFromInternalPtr(requestPtr)
1383	if userMediaReq != nil {
1384		userMediaReq.Deny()
1385		wv.logger.Debug().Msg("permission request denied")
1386		return
1387	}
1388
1389	// Try DeviceInfoPermissionRequest
1390	deviceInfoReq := webkit.DeviceInfoPermissionRequestNewFromInternalPtr(requestPtr)
1391	if deviceInfoReq != nil {
1392		deviceInfoReq.Deny()
1393		wv.logger.Debug().Msg("permission request denied")
1394		return
1395	}
1396
1397	// Try WebsiteDataAccessPermissionRequest
1398	wdaReq := webkit.WebsiteDataAccessPermissionRequestNewFromInternalPtr(requestPtr)
1399	if wdaReq != nil {
1400		wdaReq.Deny()
1401		wv.logger.Debug().Msg("website data access permission request denied")
1402		return
1403	}
1404
1405	wv.logger.Warn().Uint64("request_ptr", uint64(requestPtr)).Msg("permission request: unknown type, cannot deny")
1406}
1407
1408// ID returns the unique identifier for this WebView.
1409func (wv *WebView) ID() WebViewID {
1410	return wv.id
1411}
1412
1413// UserContentManager returns the content manager associated with this WebView.
1414func (wv *WebView) UserContentManager() *webkit.UserContentManager {
1415	return wv.ucm
1416}
1417
1418// Widget returns the underlying webkit.WebView for GTK embedding.
1419func (wv *WebView) Widget() *webkit.WebView {
1420	return wv.inner
1421}
1422
1423// IsFullscreen returns true if the WebView is currently in fullscreen mode.
1424func (wv *WebView) IsFullscreen() bool {
1425	return wv.isFullscreen.Load()
1426}
1427
1428// IsPlayingAudio returns true if the WebView is currently playing audio.
1429func (wv *WebView) IsPlayingAudio() bool {
1430	return wv.isPlayingAudio.Load()
1431}
1432
1433// GetFindController returns the WebKit FindController wrapped in the port interface.
1434// The adapter is cached to prevent the Go wrapper from being garbage collected.
1435func (wv *WebView) GetFindController() port.FindController {
1436	if wv == nil || wv.inner == nil {
1437		return nil
1438	}
1439
1440	wv.findControllerOnce.Do(func() {
1441		fc := wv.inner.GetFindController()
1442		if fc != nil {
1443			wv.findController = &findControllerAdapter{fc: fc}
1444		}
1445	})
1446
1447	return wv.findController
1448}
1449
1450// LoadURI loads the given URI.
1451func (wv *WebView) LoadURI(ctx context.Context, uri string) error {
1452	if wv.destroyed.Load() {
1453		return fmt.Errorf("webview %d is destroyed", wv.id)
1454	}
1455	wv.navigationActive.Store(true)
1456	wv.inner.LoadUri(uri)
1457	logging.FromContext(ctx).Debug().Str("uri", uri).Msg("loading URI")
1458	return nil
1459}
1460
1461// LoadHTML loads HTML content with an optional base URI.
1462func (wv *WebView) LoadHTML(ctx context.Context, content, baseURI string) error {
1463	if wv.destroyed.Load() {
1464		return fmt.Errorf("webview %d is destroyed", wv.id)
1465	}
1466	wv.navigationActive.Store(true)
1467	var baseURIPtr *string
1468	if baseURI != "" {
1469		baseURIPtr = &baseURI
1470	}
1471	wv.inner.LoadHtml(content, baseURIPtr)
1472	logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("loading HTML content")
1473	return nil
1474}
1475
1476// Reload reloads the current page.
1477func (wv *WebView) Reload(ctx context.Context) error {
1478	if wv.destroyed.Load() {
1479		return fmt.Errorf("webview %d is destroyed", wv.id)
1480	}
1481	wv.inner.Reload()
1482	logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("reloading webview")
1483	return nil
1484}
1485
1486// ReloadBypassCache reloads the current page, bypassing the cache.
1487func (wv *WebView) ReloadBypassCache(ctx context.Context) error {
1488	if wv.destroyed.Load() {
1489		return fmt.Errorf("webview %d is destroyed", wv.id)
1490	}
1491	wv.inner.ReloadBypassCache()
1492	logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("reloading webview bypassing cache")
1493	return nil
1494}
1495
1496// Stop stops the current load operation.
1497func (wv *WebView) Stop(ctx context.Context) error {
1498	if wv.destroyed.Load() {
1499		return fmt.Errorf("webview %d is destroyed", wv.id)
1500	}
1501	wv.inner.StopLoading()
1502	logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("stopping webview load")
1503	return nil
1504}
1505
1506// GoBack navigates back in history.
1507// Uses WebKit's native history navigation.
1508func (wv *WebView) GoBack(ctx context.Context) error {
1509	if wv.destroyed.Load() {
1510		return fmt.Errorf("webview %d is destroyed", wv.id)
1511	}
1512
1513	logging.FromContext(ctx).Debug().
1514		Int("webview_id", int(wv.id)).
1515		Bool("can_go_back", wv.inner.CanGoBack()).
1516		Str("current_uri", wv.inner.GetUri()).
1517		Msg("webview go back")
1518
1519	wv.inner.GoBack()
1520
1521	// Note: We don't dispatch popstate manually anymore.
1522	// WebKit's go_back() should handle SPA navigation correctly.
1523	// If issues persist, the problem is likely elsewhere.
1524
1525	return nil
1526}
1527
1528// GoForward navigates forward in history.
1529func (wv *WebView) GoForward(ctx context.Context) error {
1530	if wv.destroyed.Load() {
1531		return fmt.Errorf("webview %d is destroyed", wv.id)
1532	}
1533
1534	logging.FromContext(ctx).Debug().
1535		Int("webview_id", int(wv.id)).
1536		Bool("can_go_forward", wv.inner.CanGoForward()).
1537		Str("current_uri", wv.inner.GetUri()).
1538		Msg("webview go forward")
1539
1540	wv.inner.GoForward()
1541
1542	return nil
1543}
1544
1545// GoBackDirect calls WebKit's go_back directly without any wrappers.
1546// This is intended for use from gesture handlers where preserving user
1547// gesture context is critical for SPA popstate handling.
1548// Matches Epiphany: webkit_web_view_go_back(web_view) directly in gesture callback.
1549func (wv *WebView) GoBackDirect() {
1550	if wv.destroyed.Load() {
1551		return
1552	}
1553	wv.inner.GoBack()
1554}
1555
1556// GoForwardDirect calls WebKit's go_forward directly without any wrappers.
1557// This is intended for use from gesture handlers where preserving user
1558// gesture context is critical for SPA popstate handling.
1559func (wv *WebView) GoForwardDirect() {
1560	if wv.destroyed.Load() {
1561		return
1562	}
1563	wv.inner.GoForward()
1564}
1565
1566// CanGoBack returns true if back navigation is possible.
1567func (wv *WebView) CanGoBack() bool {
1568	wv.mu.RLock()
1569	defer wv.mu.RUnlock()
1570	return wv.canGoBack
1571}
1572
1573// CanGoForward returns true if forward navigation is possible.
1574func (wv *WebView) CanGoForward() bool {
1575	wv.mu.RLock()
1576	defer wv.mu.RUnlock()
1577	return wv.canGoFwd
1578}
1579
1580// URI returns the current URI.
1581func (wv *WebView) URI() string {
1582	wv.mu.RLock()
1583	defer wv.mu.RUnlock()
1584	return wv.uri
1585}
1586
1587// Title returns the current page title.
1588func (wv *WebView) Title() string {
1589	wv.mu.RLock()
1590	defer wv.mu.RUnlock()
1591	return wv.title
1592}
1593
1594// Favicon returns the current page favicon as a Texture.
1595// Returns nil if no favicon is available.
1596func (wv *WebView) Favicon() port.Texture {
1597	if wv.destroyed.Load() {
1598		return nil
1599	}
1600	tex := wv.inner.GetFavicon()
1601	if tex == nil {
1602		return nil
1603	}
1604	return tex
1605}
1606
1607// FaviconGdk returns the current page favicon as a *gdk.Texture.
1608// Used by internal WebKit code that needs the concrete type.
1609func (wv *WebView) FaviconGdk() *gdk.Texture {
1610	if wv.destroyed.Load() {
1611		return nil
1612	}
1613	return wv.inner.GetFavicon()
1614}
1615
1616// Generation returns the current reuse generation of this WebView.
1617// Used by callback holders to detect stale closures after pool reuse.
1618func (wv *WebView) Generation() uint64 {
1619	return wv.generation.Load()
1620}
1621
1622// IsLoading returns true if a page is currently loading.
1623func (wv *WebView) IsLoading() bool {
1624	wv.mu.RLock()
1625	defer wv.mu.RUnlock()
1626	return wv.isLoading
1627}
1628
1629// EstimatedProgress returns the estimated load progress (0.0 to 1.0).
1630func (wv *WebView) EstimatedProgress() float64 {
1631	wv.mu.RLock()
1632	defer wv.mu.RUnlock()
1633	return wv.progress
1634}
1635
1636// SetZoomLevel sets the zoom level (1.0 = 100%).
1637func (wv *WebView) SetZoomLevel(ctx context.Context, level float64) error {
1638	if wv.destroyed.Load() {
1639		return fmt.Errorf("webview %d is destroyed", wv.id)
1640	}
1641	wv.inner.SetZoomLevel(level)
1642	logging.FromContext(ctx).Debug().Float64("factor", level).Int("webview_id", int(wv.id)).Msg("set webview zoom level")
1643	return nil
1644}
1645
1646// GetZoomLevel returns the current zoom level.
1647func (wv *WebView) GetZoomLevel() float64 {
1648	if wv.destroyed.Load() {
1649		return 1.0
1650	}
1651	return wv.inner.GetZoomLevel()
1652}
1653
1654// SetBackgroundColor sets the WebView background color (port.WebView interface).
1655// This color is shown before content is painted, eliminating white flash.
1656// Values are in range 0.0-1.0 for red, green, blue, alpha.
1657func (wv *WebView) SetBackgroundColor(r, g, b, a float64) {
1658	if wv.destroyed.Load() {
1659		return
1660	}
1661	rgba := &gdk.RGBA{
1662		Red:   float32(r),
1663		Green: float32(g),
1664		Blue:  float32(b),
1665		Alpha: float32(a),
1666	}
1667	wv.inner.SetBackgroundColor(rgba)
1668}
1669
1670// ResetBackgroundToDefault sets WebView background to white (browser default).
1671// Used for external pages to prevent dark background from bleeding through.
1672func (wv *WebView) ResetBackgroundToDefault() {
1673	wv.SetBackgroundColor(1.0, 1.0, 1.0, 1.0)
1674}
1675
1676// NativeWidget returns the underlying GTK widget pointer for UI embedding.
1677// Implements port.NativeWidgetProvider.
1678func (wv *WebView) NativeWidget() uintptr {
1679	if wv.destroyed.Load() {
1680		return 0
1681	}
1682	w := wv.Widget()
1683	if w == nil {
1684		return 0
1685	}
1686	return w.Widget.GoPointer()
1687}
1688
1689// Show makes the WebView widget visible.
1690// This should be called after the WebView is ready to be displayed.
1691func (wv *WebView) Show() {
1692	if wv.destroyed.Load() {
1693		return
1694	}
1695	wv.inner.SetVisible(true)
1696}
1697
1698// PrimePopupNavigation is a no-op for WebKit, which already has a native popup
1699// browser at the time the coordinator receives popup lifecycle callbacks.
1700func (*WebView) PrimePopupNavigation(string) {}
1701
1702// SetOnReadyToShow sets the callback invoked when the popup WebView is ready to display.
1703// It implements port.PopupLifecycleCapable.
1704func (wv *WebView) SetOnReadyToShow(fn func()) {
1705	if wv == nil {
1706		return
1707	}
1708	wv.mu.Lock()
1709	wv.OnReadyToShow = fn
1710	wv.mu.Unlock()
1711}
1712
1713// SetOnClose replaces the coordinator-owned popup lifecycle callback without
1714// disturbing WebView callbacks or additive OAuth close callbacks.
1715func (wv *WebView) SetOnClose(fn func()) {
1716	if wv == nil {
1717		return
1718	}
1719	wv.mu.Lock()
1720	wv.popupLifecycleClose = fn
1721	wv.mu.Unlock()
1722}
1723
1724func (wv *WebView) fireReadyToShow() {
1725	if wv == nil {
1726		return
1727	}
1728	wv.mu.RLock()
1729	fn := wv.OnReadyToShow
1730	wv.mu.RUnlock()
1731	if fn != nil {
1732		fn()
1733	}
1734}
1735
1736func (wv *WebView) runCloseCallbacks() {
1737	if wv == nil {
1738		return
1739	}
1740	wv.mu.Lock()
1741	base := wv.OnClose
1742	lifecycle := wv.popupLifecycleClose
1743	wv.OnClose = nil
1744	wv.popupLifecycleClose = nil
1745	wv.mu.Unlock()
1746	if base != nil {
1747		base()
1748	}
1749	if lifecycle != nil {
1750		lifecycle()
1751	}
1752}
1753
1754// State returns the current WebView state as a snapshot.
1755func (wv *WebView) State() port.WebViewState {
1756	return port.WebViewState{
1757		URI:       wv.uri,
1758		Title:     wv.title,
1759		IsLoading: wv.isLoading,
1760		Progress:  wv.progress,
1761		CanGoBack: wv.canGoBack,
1762		CanGoFwd:  wv.canGoFwd,
1763		ZoomLevel: wv.GetZoomLevel(),
1764	}
1765}
1766
1767func mapPopupRequest(req PopupRequest) port.PopupRequest {
1768	mapped := port.PopupRequest{
1769		Engine:        dto.BrowserEngineWebKit,
1770		TargetURI:     req.TargetURI,
1771		FrameName:     req.FrameName,
1772		IsUserGesture: req.IsUserGesture,
1773		ParentViewID:  req.ParentID,
1774		PopupFeatures: dto.PopupFeatures{State: dto.PopupFeaturesUnknown},
1775	}
1776	if strings.EqualFold(strings.TrimSpace(req.FrameName), "_blank") {
1777		if req.NavigationType == webkit.NavigationTypeLinkClickedValue {
1778			mapped.TargetDisposition = dto.WindowDispositionNewTab
1779			mapped.PopupFeatures.State = dto.PopupFeaturesNone
1780		} else {
1781			mapped.TargetDisposition = dto.WindowDispositionNewPopup
1782		}
1783	}
1784	return mapped
1785}
1786
1787// SetCallbacks registers callback handlers for WebView events.
1788// Pass nil to clear all callbacks.
1789func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
1790	if callbacks == nil {
1791		wv.OnLoadChanged = nil
1792		wv.OnTitleChanged = nil
1793		wv.OnURIChanged = nil
1794		wv.OnProgressChanged = nil
1795		wv.OnFaviconChanged = nil
1796		wv.OnClose = nil
1797		wv.OnCreate = nil
1798		wv.OnLinkHover = nil
1799		wv.OnWebProcessTerminated = nil
1800		wv.OnPermissionRequest = nil
1801		wv.OnLinkMiddleClick = nil
1802		wv.OnEnterFullscreen = nil
1803		wv.OnLeaveFullscreen = nil
1804		wv.OnAudioStateChanged = nil
1805		wv.OnEditableFocusChanged = nil
1806		return
1807	}
1808
1809	// Map port callbacks to webkit callbacks
1810	if callbacks.OnLoadChanged != nil {
1811		wv.OnLoadChanged = func(e LoadEvent) {
1812			callbacks.OnLoadChanged(port.LoadEvent(e))
1813		}
1814	}
1815	wv.OnTitleChanged = callbacks.OnTitleChanged
1816	wv.OnURIChanged = callbacks.OnURIChanged
1817	wv.OnProgressChanged = callbacks.OnProgressChanged
1818	if callbacks.OnFaviconChanged != nil {
1819		wv.OnFaviconChanged = func(texture *gdk.Texture) {
1820			callbacks.OnFaviconChanged(texture)
1821		}
1822	}
1823	wv.OnClose = callbacks.OnClose
1824	if callbacks.OnCreate != nil {
1825		wv.OnCreate = func(req PopupRequest) *WebView {
1826			result := callbacks.OnCreate(mapPopupRequest(req))
1827			if result == nil {
1828				return nil
1829			}
1830			// The callback returns port.WebView, we need to convert back
1831			// This assumes the returned WebView is actually a *webkit.WebView
1832			if wkView, ok := result.(*WebView); ok {
1833				return wkView
1834			}
1835			return nil
1836		}
1837	}
1838	wv.OnLinkHover = callbacks.OnLinkHover
1839	if callbacks.OnWebProcessTerminated != nil {
1840		wv.OnWebProcessTerminated = func(reason webkit.WebProcessTerminationReason, reasonLabel string, uri string) {
1841			callbacks.OnWebProcessTerminated(mapWebProcessTerminationReason(reason), reasonLabel, uri)
1842		}
1843	} else {
1844		wv.OnWebProcessTerminated = nil
1845	}
1846	wv.OnPermissionRequest = callbacks.OnPermissionRequest
1847	wv.OnLinkMiddleClick = callbacks.OnLinkMiddleClick
1848	wv.OnEnterFullscreen = callbacks.OnEnterFullscreen
1849	wv.OnLeaveFullscreen = callbacks.OnLeaveFullscreen
1850	wv.OnAudioStateChanged = callbacks.OnAudioStateChanged
1851	wv.OnEditableFocusChanged = callbacks.OnEditableFocusChanged
1852}
1853
1854func (wv *WebView) dispatchEditableFocusChanged(editable bool) {
1855	if wv == nil || wv.destroyed.Load() {
1856		return
1857	}
1858	if wv.OnEditableFocusChanged != nil {
1859		wv.OnEditableFocusChanged(editable)
1860	}
1861}
1862
1863// ShowDevTools opens the WebKit inspector/developer tools.
1864func (wv *WebView) ShowDevTools() error {
1865	if wv.destroyed.Load() {
1866		return fmt.Errorf("webview %d is destroyed", wv.id)
1867	}
1868	inspector := wv.inner.GetInspector()
1869	if inspector == nil {
1870		return fmt.Errorf("failed to get inspector for webview %d", wv.id)
1871	}
1872	inspector.Show()
1873	wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("devtools shown")
1874	return nil
1875}
1876
1877// Print opens the print dialog for the current page.
1878func (wv *WebView) Print() error {
1879	if wv.destroyed.Load() {
1880		return fmt.Errorf("webview %d is destroyed", wv.id)
1881	}
1882	printOp := webkit.NewPrintOperation(wv.inner)
1883	if printOp == nil {
1884		return fmt.Errorf("failed to create print operation for webview %d", wv.id)
1885	}
1886	// RunDialog with nil parent window - GTK will use the widget's toplevel
1887	printOp.RunDialog(nil)
1888	wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("print dialog opened")
1889	return nil
1890}
1891
1892// OpenDevTools implements port.DevToolsOpener.
1893func (wv *WebView) OpenDevTools() {
1894	if err := wv.ShowDevTools(); err != nil {
1895		wv.logger.Error().Err(err).Uint64("id", uint64(wv.id)).Msg("failed to open dev tools")
1896	}
1897}
1898
1899// PrintPage implements port.Printer.
1900func (wv *WebView) PrintPage() {
1901	if err := wv.Print(); err != nil {
1902		wv.logger.Error().Err(err).Uint64("id", uint64(wv.id)).Msg("failed to open print dialog")
1903	}
1904}
1905
1906// IsDestroyed returns true if the WebView has been destroyed.
1907func (wv *WebView) IsDestroyed() bool {
1908	return wv.destroyed.Load()
1909}
1910
1911// IsRelated returns true when this WebView shares process/session with a parent popup.
1912func (wv *WebView) IsRelated() bool {
1913	return wv.isRelated
1914}
1915
1916// HasNavigationActivity reports whether this WebView has been used for content navigation.
1917func (wv *WebView) HasNavigationActivity() bool {
1918	return wv.navigationActive.Load()
1919}
1920
1921// Close triggers the close callback as if window.close() was called.
1922// This is used for programmatic popup closure (e.g., OAuth auto-close).
1923func (wv *WebView) Close() {
1924	if wv.destroyed.Load() {
1925		return
1926	}
1927	wv.runCloseCallbacks()
1928}
1929
1930// AddCloseCallback implements port.OAuthCallbackCapable.
1931// It composes fn with any existing OnClose handler so multiple callers can register
1932// close callbacks without overwriting one another.
1933func (wv *WebView) AddCloseCallback(fn func()) {
1934	if fn == nil {
1935		return
1936	}
1937	if wv.OnClose == nil {
1938		wv.OnClose = fn
1939		return
1940	}
1941	existing := wv.OnClose
1942	wv.OnClose = func() {
1943		existing()
1944		fn()
1945	}
1946}
1947
1948// AddNavigationCallback implements port.OAuthCallbackCapable.
1949// fn is invoked when the URI changes (OnURIChanged) and when a page load is committed
1950// (OnLoadChanged with LoadCommitted), covering both redirect-based and postMessage OAuth flows.
1951func (wv *WebView) AddNavigationCallback(fn func(uri string)) {
1952	if fn == nil {
1953		return
1954	}
1955	// Compose with existing OnURIChanged.
1956	if wv.OnURIChanged == nil {
1957		wv.OnURIChanged = fn
1958	} else {
1959		existing := wv.OnURIChanged
1960		wv.OnURIChanged = func(uri string) {
1961			existing(uri)
1962			fn(uri)
1963		}
1964	}
1965
1966	// Compose with existing OnLoadChanged to also fire on committed loads.
1967	if wv.OnLoadChanged == nil {
1968		wv.OnLoadChanged = func(event LoadEvent) {
1969			if event == LoadCommitted {
1970				fn(wv.URI())
1971			}
1972		}
1973	} else {
1974		existing := wv.OnLoadChanged
1975		wv.OnLoadChanged = func(event LoadEvent) {
1976			existing(event)
1977			if event == LoadCommitted {
1978				fn(wv.URI())
1979			}
1980		}
1981	}
1982}
1983
1984// DisconnectSignals disconnects all GLib signal handlers from the WebView.
1985// This must be called before releasing the WebView to the pool or destroying it
1986// to prevent callbacks from firing on freed/pooled WebViews.
1987func (wv *WebView) DisconnectSignals() {
1988	if wv.inner == nil {
1989		return
1990	}
1991
1992	obj := gobject.ObjectNewFromInternalPtr(wv.inner.GoPointer())
1993	for _, sigID := range wv.signalIDs {
1994		gobject.SignalHandlerDisconnect(obj, uint(sigID))
1995	}
1996	wv.signalIDs = wv.signalIDs[:0] // Clear the slice
1997
1998	if wv.backForwardList != nil && wv.backForwardListSignalID != 0 {
1999		bfObj := gobject.ObjectNewFromInternalPtr(wv.backForwardList.GoPointer())
2000		gobject.SignalHandlerDisconnect(bfObj, uint(wv.backForwardListSignalID))
2001		wv.backForwardListSignalID = 0
2002		wv.backForwardList = nil
2003	}
2004
2005	wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("signals disconnected")
2006}
2007
2008// ReconnectSignals re-establishes GLib signal handlers after pool reuse.
2009// Must be called after acquiring a WebView from the pool and before setting up callbacks.
2010func (wv *WebView) ReconnectSignals() {
2011	if wv == nil || wv.destroyed.Load() || wv.inner == nil {
2012		return
2013	}
2014	// Only reconnect if signals were previously disconnected
2015	if len(wv.signalIDs) > 0 {
2016		return // Already connected
2017	}
2018	wv.connectSignals()
2019	wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("signals reconnected after pool reuse")
2020}
2021
2022// Destroy cleans up the WebView resources and terminates the web process.
2023// This must be called when a WebView is no longer needed to free GPU resources,
2024// VA-API decoder contexts, and DMA-BUF buffers held by the web process.
2025func (wv *WebView) Destroy() {
2026	wv.DestroyWithPolicy("")
2027}
2028
2029// DestroyWithPolicy cleans up the WebView resources with explicit process policy.
2030// Valid policies: auto, always, never.
2031func (wv *WebView) DestroyWithPolicy(policy string) {
2032	if wv.destroyed.Swap(true) {
2033		return // Already destroyed
2034	}
2035	policy = resolveTerminatePolicy(policy)
2036
2037	// 1. Disconnect all signal handlers first to prevent callbacks during cleanup
2038	wv.DisconnectSignals()
2039
2040	// 2. Clear all callbacks to prevent use-after-free
2041	wv.OnLoadChanged = nil
2042	wv.OnTitleChanged = nil
2043	wv.OnURIChanged = nil
2044	wv.OnProgressChanged = nil
2045	wv.OnFaviconChanged = nil
2046	wv.OnClose = nil
2047	wv.OnCreate = nil
2048	wv.OnReadyToShow = nil
2049	wv.OnLinkMiddleClick = nil
2050	wv.OnEnterFullscreen = nil
2051	wv.OnLeaveFullscreen = nil
2052	wv.OnAudioStateChanged = nil
2053	wv.OnEditableFocusChanged = nil
2054	wv.OnLinkHover = nil
2055	wv.OnWebProcessTerminated = nil
2056	wv.OnPermissionRequest = nil
2057
2058	// 3. Clear async callback references and popup-hosting state
2059	wv.mu.Lock()
2060	wv.asyncCallbacks = nil
2061	wv.browsingContextDecision = dto.HostDecision{}
2062	wv.hasBrowsingContextDecision = false
2063	wv.nativePopupHostAbort = nil
2064	wv.popupLifecycleClose = nil
2065	wv.mu.Unlock()
2066
2067	// 4. Unparent from GTK hierarchy (must happen before process termination)
2068	// This releases the widget from any parent container.
2069	if wv.inner != nil {
2070		wv.inner.Unparent()
2071	}
2072
2073	// 5. Optionally terminate web process (skip for related popup views sharing parent process).
2074	terminate := shouldTerminateWebProcess(policy, wv.isRelated, wv.navigationActive.Load())
2075	if terminate && wv.inner != nil {
2076		wv.inner.TerminateWebProcess()
2077	}
2078
2079	// 6. Unregister from global registry
2080	globalRegistry.unregister(wv.id)
2081
2082	// 7. Clear internal references to allow GC
2083	wv.inner = nil
2084	wv.ucm = nil
2085	wv.findController = nil
2086
2087	wv.logger.Debug().
2088		Uint64("id", uint64(wv.id)).
2089		Bool("related", wv.isRelated).
2090		Bool("navigation_active", wv.navigationActive.Load()).
2091		Bool("terminated_process", terminate).
2092		Str("terminate_policy", policy).
2093		Msg("webview destroyed")
2094}
2095
2096// ResetForPoolReuse sanitizes callbacks/state so the WebView can be safely reused.
2097func (wv *WebView) ResetForPoolReuse() {
2098	if wv == nil || wv.destroyed.Load() {
2099		return
2100	}
2101	wv.generation.Add(1)
2102	wv.setEditableFocusBridgeToken(newEditableFocusBridgeToken())
2103
2104	// Disconnect GLib signals to prevent stale callbacks from firing on reused WebView.
2105	// They will be reconnected when the WebView is re-acquired from the pool.
2106	wv.DisconnectSignals()
2107
2108	wv.OnLoadChanged = nil
2109	wv.OnTitleChanged = nil
2110	wv.OnURIChanged = nil
2111	wv.OnProgressChanged = nil
2112	wv.OnFaviconChanged = nil
2113	wv.OnClose = nil
2114	wv.OnCreate = nil
2115	wv.OnReadyToShow = nil
2116	wv.OnLinkMiddleClick = nil
2117	wv.OnEnterFullscreen = nil
2118	wv.OnLeaveFullscreen = nil
2119	wv.OnAudioStateChanged = nil
2120	wv.OnEditableFocusChanged = nil
2121	wv.OnLinkHover = nil
2122	wv.OnWebProcessTerminated = nil
2123	wv.OnPermissionRequest = nil
2124
2125	wv.mu.Lock()
2126	wv.uri = ""
2127	wv.title = ""
2128	wv.progress = 0
2129	wv.canGoBack = false
2130	wv.canGoFwd = false
2131	wv.isLoading = false
2132	wv.asyncCallbacks = nil
2133	wv.runJSErrorStats = make(map[string]runJSErrorStat)
2134	wv.browsingContextDecision = dto.HostDecision{}
2135	wv.hasBrowsingContextDecision = false
2136	wv.nativePopupHostAbort = nil
2137	wv.popupLifecycleClose = nil
2138	wv.lastProgressUpdate.Store(0)
2139	wv.mu.Unlock()
2140
2141	wv.isFullscreen.Store(false)
2142	wv.isPlayingAudio.Store(false)
2143	wv.navigationActive.Store(false)
2144
2145	if wv.inner != nil {
2146		wv.inner.StopLoading()
2147		wv.inner.SetVisible(false)
2148	}
2149}
2150
2151func resolveTerminatePolicy(raw string) string {
2152	value := strings.ToLower(strings.TrimSpace(raw))
2153	if value == "" {
2154		value = strings.ToLower(strings.TrimSpace(os.Getenv("DUMBER_WEBVIEW_TERMINATE_POLICY")))
2155	}
2156	switch value {
2157	case terminatePolicyAlways:
2158		return terminatePolicyAlways
2159	case terminatePolicyNever:
2160		return terminatePolicyNever
2161	case terminatePolicyAuto, "":
2162		return terminatePolicyAuto
2163	default:
2164		return terminatePolicyAuto
2165	}
2166}
2167
2168func shouldTerminateWebProcess(policy string, isRelated, navigationActive bool) bool {
2169	if isRelated {
2170		return false
2171	}
2172	switch resolveTerminatePolicy(policy) {
2173	case terminatePolicyNever:
2174		return false
2175	case terminatePolicyAlways:
2176		return true
2177	case terminatePolicyAuto:
2178		return navigationActive
2179	default:
2180		return navigationActive
2181	}
2182}
2183
2184func classifyRunJSEvaluateError(err error) (nonFatal bool, signature string) {
2185	// Defensive guard: callers should only pass non-nil errors, but tolerate nil safely.
2186	if err == nil {
2187		return true, ""
2188	}
2189	msg := strings.ToLower(strings.TrimSpace(err.Error()))
2190	signature = "evaluate_error:" + normalizeRunJSErrorSignature(msg)
2191	switch {
2192	case strings.Contains(msg, "canceled"),
2193		strings.Contains(msg, "cancel"),
2194		strings.Contains(msg, "javascript execution context"),
2195		strings.Contains(msg, "execution context was destroyed"),
2196		strings.Contains(msg, "document unloaded"):
2197		return true, signature
2198	default:
2199		return false, signature
2200	}
2201}
2202
2203func normalizeRunJSErrorSignature(msg string) string {
2204	msg = strings.ToLower(strings.TrimSpace(msg))
2205	if msg == "" {
2206		return "empty"
2207	}
2208	fields := strings.Fields(msg)
2209	if len(fields) == 0 {
2210		return "empty"
2211	}
2212	return strings.Join(fields, " ")
2213}
2214
2215func (wv *WebView) runJSDomain() string {
2216	wv.mu.RLock()
2217	currentURI := strings.TrimSpace(wv.uri)
2218	wv.mu.RUnlock()
2219	if currentURI == "" {
2220		return runJSUnknown
2221	}
2222	parsed, err := url.Parse(currentURI)
2223	if err != nil {
2224		return runJSUnknown
2225	}
2226	host := strings.ToLower(strings.TrimSpace(parsed.Hostname()))
2227	if host == "" {
2228		return runJSUnknown
2229	}
2230	return host
2231}
2232
2233func (wv *WebView) shouldLogRunJSError(domain, signature string, nonFatal bool, now time.Time) (bool, uint64) {
2234	if wv == nil {
2235		return true, 1
2236	}
2237	if now.IsZero() {
2238		now = time.Now()
2239	}
2240	if domain == "" {
2241		domain = runJSUnknown
2242	}
2243	if signature == "" {
2244		signature = runJSUnknown
2245	}
2246	key := domain + "|" + signature
2247
2248	wv.mu.Lock()
2249	if wv.runJSErrorStats == nil {
2250		wv.runJSErrorStats = make(map[string]runJSErrorStat)
2251	}
2252	stat := wv.runJSErrorStats[key]
2253	stat.count++
2254	count := stat.count
2255
2256	shouldLog := true
2257	if nonFatal {
2258		shouldLog = count == 1 || count%runJSAggregateLogEvery == 0 || now.Sub(stat.lastLog) >= runJSNonFatalLogInterval
2259	}
2260	if shouldLog {
2261		stat.lastLog = now
2262	}
2263	wv.runJSErrorStats[key] = stat
2264	wv.mu.Unlock()
2265
2266	return shouldLog, count
2267}
2268
2269// RunJavaScript executes script in the main world (port.WebView interface).
2270// This is fire-and-forget: it does not block and errors are logged asynchronously.
2271func (wv *WebView) RunJavaScript(ctx context.Context, script string) {
2272	wv.RunJavaScriptInWorld(ctx, script, "")
2273}
2274
2275// RunJavaScriptInWorld executes script in the specified world (empty for main world).
2276// This is fire-and-forget: it does not block and errors are logged asynchronously.
2277// Safe to call from any context including GTK signal handlers.
2278func (wv *WebView) RunJavaScriptInWorld(ctx context.Context, script, worldName string) {
2279	if wv.destroyed.Load() {
2280		return
2281	}
2282	if ctx == nil {
2283		ctx = context.Background()
2284	}
2285
2286	log := logging.FromContext(ctx)
2287
2288	cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) {
2289		if wv.destroyed.Load() {
2290			return
2291		}
2292		wv.mu.RLock()
2293		inner := wv.inner
2294		wv.mu.RUnlock()
2295		if inner == nil {
2296			return
2297		}
2298
2299		domain := wv.runJSDomain()
2300		if resPtr == 0 {
2301			signature := "nil_async_result"
2302			shouldLog, count := wv.shouldLogRunJSError(domain, signature, true, time.Now())
2303			if shouldLog {
2304				log.Debug().
2305					Uint64("webview_id", uint64(wv.id)).
2306					Str("domain", domain).
2307					Str("signature", signature).
2308					Uint64("repeat_count", count).
2309					Msg("RunJavaScript: non-fatal nil async result")
2310			}
2311			return
2312		}
2313
2314		res := &gio.AsyncResultBase{Ptr: resPtr}
2315		value, err := inner.EvaluateJavascriptFinish(res)
2316		if err != nil {
2317			nonFatal, signature := classifyRunJSEvaluateError(err)
2318			shouldLog, count := wv.shouldLogRunJSError(domain, signature, nonFatal, time.Now())
2319			if shouldLog {
2320				ev := log.Warn()
2321				msg := "RunJavaScript: failed"
2322				if nonFatal {
2323					ev = log.Debug()
2324					msg = "RunJavaScript: non-fatal failure"
2325				}
2326				ev.Err(err).
2327					Uint64("webview_id", uint64(wv.id)).
2328					Str("domain", domain).
2329					Str("signature", signature).
2330					Uint64("repeat_count", count).
2331					Msg(msg)
2332			}
2333			return
2334		}
2335
2336		if value != nil {
2337			if jscCtx := value.GetContext(); jscCtx != nil {
2338				if exc := jscCtx.GetException(); exc != nil {
2339					exceptionMessage := strings.TrimSpace(exc.GetMessage())
2340					signature := "js_exception:" + normalizeRunJSErrorSignature(exceptionMessage)
2341					shouldLog, count := wv.shouldLogRunJSError(domain, signature, true, time.Now())
2342					if shouldLog {
2343						log.Debug().
2344							Str("exception", exceptionMessage).
2345							Uint64("webview_id", uint64(wv.id)).
2346							Str("domain", domain).
2347							Str("signature", signature).
2348							Uint64("repeat_count", count).
2349							Msg("RunJavaScript: JS exception")
2350					}
2351				}
2352			}
2353		}
2354	})
2355
2356	// prevent callback from being GC'd before it's called
2357	wv.mu.Lock()
2358	wv.asyncCallbacks = append(wv.asyncCallbacks, cb)
2359	wv.mu.Unlock()
2360
2361	// worldName: nil for main world, &worldName for specific world
2362	// sourceUri: nil (not used)
2363	var worldNamePtr *string
2364	if worldName != "" {
2365		worldNamePtr = &worldName
2366	}
2367	wv.mu.RLock()
2368	inner := wv.inner
2369	wv.mu.RUnlock()
2370	if inner == nil || wv.destroyed.Load() {
2371		return
2372	}
2373	inner.EvaluateJavascript(script, -1, worldNamePtr, nil, nil, &cb, 0)
2374}
2375
2376// AttachFrontend injects scripts/styles and wires the message router once per WebView.
2377func (wv *WebView) AttachFrontend(ctx context.Context, injector *ContentInjector, router *MessageRouter) error {
2378	if ctx == nil {
2379		ctx = context.Background()
2380	}
2381
2382	log := logging.FromContext(ctx).With().
2383		Str("component", "webview").
2384		Uint64("webview_id", uint64(wv.id)).
2385		Logger()
2386
2387	log.Debug().
2388		Bool("injector_nil", injector == nil).
2389		Bool("router_nil", router == nil).
2390		Msg("AttachFrontend called")
2391
2392	if wv.destroyed.Load() {
2393		log.Debug().Msg("AttachFrontend: webview is destroyed")
2394		return fmt.Errorf("webview %d is destroyed", wv.id)
2395	}
2396	if injector == nil && router == nil {
2397		log.Debug().Msg("AttachFrontend: both injector and router are nil, skipping")
2398		return nil
2399	}
2400
2401	if !wv.frontendAttached.CompareAndSwap(false, true) {
2402		log.Debug().Msg("AttachFrontend: already attached, skipping")
2403		return nil // already attached
2404	}
2405
2406	var attachErr error
2407
2408	defer func() {
2409		if attachErr != nil {
2410			// allow retry on next call
2411			wv.frontendAttached.Store(false)
2412		}
2413	}()
2414
2415	if router != nil {
2416		log.Debug().Msg("AttachFrontend: setting up message router")
2417		if _, err := router.SetupMessageHandler(wv.ucm, ScriptWorldName); err != nil {
2418			attachErr = fmt.Errorf("setup message router: %w", err)
2419			log.Warn().Err(err).Msg("failed to attach message router")
2420			return attachErr
2421		}
2422		log.Debug().Msg("AttachFrontend: message router setup complete")
2423	}
2424
2425	if injector != nil {
2426		log.Debug().Msg("AttachFrontend: injecting scripts")
2427		injector.InjectScripts(ctx, wv.ucm, wv.id)
2428	}
2429
2430	log.Debug().Msg("frontend assets attached to webview")
2431	return nil
2432}
2433
2434// ScrollPage scrolls from the viewport center, handing exhausted nested
2435// scrollers off to their ancestors and then the document. CEF uses the same
2436// shared directional DOM-target resolution once its browser is ready.
2437// Implements port.PageScrollable.
2438func (wv *WebView) ScrollPage(ctx context.Context, request port.PageScrollRequest) error {
2439	if err := ctx.Err(); err != nil {
2440		return err
2441	}
2442	if wv == nil {
2443		return fmt.Errorf("webkit: webview is nil")
2444	}
2445	if err := request.Validate(); err != nil {
2446		return err
2447	}
2448	if wv.destroyed.Load() {
2449		return fmt.Errorf("webview %d is destroyed", wv.id)
2450	}
2451	js := buildPageScrollFallbackJS(request.FallbackDX, request.FallbackDY)
2452	runPageScrollFallbackJS(ctx, wv, js)
2453	return nil
2454}
2455
2456// FrontendAttached reports whether AttachFrontend has already wired scripts/router.
2457func (wv *WebView) FrontendAttached() bool {
2458	return wv != nil && wv.frontendAttached.Load()
2459}
2460
2461// EditableFocusBridgeToken returns the current allowlisted bridge auth token.
2462func (wv *WebView) EditableFocusBridgeToken() string {
2463	if wv == nil {
2464		return ""
2465	}
2466	wv.mu.RLock()
2467	defer wv.mu.RUnlock()
2468	return wv.editableFocusBridgeToken
2469}
2470
2471func (wv *WebView) setEditableFocusBridgeToken(token string) {
2472	if wv == nil {
2473		return
2474	}
2475	wv.mu.Lock()
2476	wv.editableFocusBridgeToken = token
2477	wv.mu.Unlock()
2478}
2479
2480// matchesEditableFocusBridgeToken compares a candidate token in constant time.
2481func (wv *WebView) matchesEditableFocusBridgeToken(candidate string) bool {
2482	if wv == nil || candidate == "" {
2483		return false
2484	}
2485	token := wv.EditableFocusBridgeToken()
2486	if token == "" {
2487		return false
2488	}
2489	return subtle.ConstantTimeCompare([]byte(candidate), []byte(token)) == 1
2490}

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