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