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