v0.30.0 eb3068f

webview.go RawHistoryFolder

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

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