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