v0.28.0 3cb3770

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

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