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}