1package webkit
2
3import (
4 "context"
5 "fmt"
6 "net/url"
7 "strings"
8 "sync"
9 "sync/atomic"
10 "time"
11
12 "github.com/bnema/dumber/internal/application/port"
13 "github.com/bnema/dumber/internal/logging"
14 "github.com/bnema/puregotk-webkit/webkit"
15 "github.com/jwijenbergh/puregotk/v4/gdk"
16 "github.com/jwijenbergh/puregotk/v4/gio"
17 "github.com/jwijenbergh/puregotk/v4/glib"
18 "github.com/jwijenbergh/puregotk/v4/gobject"
19 "github.com/jwijenbergh/puregotk/v4/gtk"
20 "github.com/rs/zerolog"
21)
22
23// Compile-time interface check: WebView must implement port.WebView.
24var _ port.WebView = (*WebView)(nil)
25
26// WebViewID is an alias to port.WebViewID for clean architecture compliance.
27// Infrastructure layer uses the type defined in the application port.
28type WebViewID = port.WebViewID
29
30// LoadEvent represents WebKit load events.
31type LoadEvent int
32
33const (
34 LoadStarted LoadEvent = LoadEvent(webkit.LoadStartedValue)
35 LoadRedirected LoadEvent = LoadEvent(webkit.LoadRedirectedValue)
36 LoadCommitted LoadEvent = LoadEvent(webkit.LoadCommittedValue)
37 LoadFinished LoadEvent = LoadEvent(webkit.LoadFinishedValue)
38)
39
40// PopupRequest contains information about a popup window request from the create signal.
41type PopupRequest struct {
42 TargetURI string
43 FrameName string // e.g., "_blank", custom name, or empty
44 IsUserGesture bool
45 ParentID WebViewID
46}
47
48// webViewRegistry tracks all active WebViews.
49type webViewRegistry struct {
50 views map[WebViewID]*WebView
51 byUCM map[uintptr]WebViewID
52 counter atomic.Uint64
53 mu sync.RWMutex
54}
55
56var globalRegistry = &webViewRegistry{
57 views: make(map[WebViewID]*WebView),
58 byUCM: make(map[uintptr]WebViewID),
59}
60
61func (r *webViewRegistry) register(wv *WebView) WebViewID {
62 id := WebViewID(r.counter.Add(1))
63 r.mu.Lock()
64 r.views[id] = wv
65 if wv != nil && wv.ucm != nil {
66 r.byUCM[wv.ucm.GoPointer()] = id
67 }
68 r.mu.Unlock()
69 return id
70}
71
72func (r *webViewRegistry) unregister(id WebViewID) {
73 r.mu.Lock()
74 wv := r.views[id]
75 delete(r.views, id)
76 if wv != nil && wv.ucm != nil {
77 delete(r.byUCM, wv.ucm.GoPointer())
78 }
79 r.mu.Unlock()
80}
81
82// Lookup returns a WebView by ID, or nil if not found.
83func (r *webViewRegistry) Lookup(id WebViewID) *WebView {
84 r.mu.RLock()
85 defer r.mu.RUnlock()
86 return r.views[id]
87}
88
89func (r *webViewRegistry) LookupByUCMPointer(ptr uintptr) *WebView {
90 if ptr == 0 {
91 return nil
92 }
93 r.mu.RLock()
94 defer r.mu.RUnlock()
95 id, ok := r.byUCM[ptr]
96 if !ok {
97 return nil
98 }
99 return r.views[id]
100}
101
102// LookupWebView returns a WebView by ID from the global registry.
103func LookupWebView(id WebViewID) *WebView {
104 return globalRegistry.Lookup(id)
105}
106
107// LookupWebViewByUCMPointer returns a WebView by its UserContentManager pointer.
108func LookupWebViewByUCMPointer(ptr uintptr) *WebView {
109 return globalRegistry.LookupByUCMPointer(ptr)
110}
111
112// WebView wraps webkit.WebView with Go-level state tracking and callbacks.
113type WebView struct {
114 id WebViewID
115 inner *webkit.WebView
116 ucm *webkit.UserContentManager
117
118 // State (protected by mutex)
119 destroyed atomic.Bool
120 isRelated bool // true if created via NewWebViewWithRelated (shares web process with parent)
121 uri string
122 title string
123 progress float64
124 canGoBack bool
125 canGoFwd bool
126 isLoading bool
127
128 // Media/fullscreen state for idle inhibition cleanup
129 isFullscreen atomic.Bool
130 isPlayingAudio atomic.Bool
131
132 // Progress throttling (~60fps)
133 lastProgressUpdate atomic.Int64 // Unix nanoseconds
134
135 // Signal handler IDs for disconnection
136 signalIDs []uint32
137
138 // Callbacks (set by UI layer)
139 OnLoadChanged func(LoadEvent)
140 OnTitleChanged func(string)
141 OnURIChanged func(string)
142 OnProgressChanged func(float64)
143 OnFaviconChanged func(*gdk.Texture) // Called when page favicon changes
144 OnClose func()
145 OnCreate func(PopupRequest) *WebView // Return new WebView or nil to block popup
146 OnReadyToShow func() // Called when popup is ready to display
147 OnLinkMiddleClick func(uri string) bool // Return true if handled (blocks navigation)
148 OnEnterFullscreen func() bool // Return true to prevent fullscreen
149 OnLeaveFullscreen func() bool // Return true to prevent leaving fullscreen
150 OnAudioStateChanged func(playing bool) // Called when audio playback starts/stops
151 OnLinkHover func(uri string) // Called when hovering over a link/image/media (empty string when leaving)
152
153 logger zerolog.Logger
154 mu sync.RWMutex
155
156 frontendAttached atomic.Bool
157
158 // asyncCallbacks keeps references to async JS callbacks to prevent GC
159 asyncCallbacks []interface{}
160
161 // findController is cached to prevent GC from collecting the Go wrapper
162 findController *findControllerAdapter
163 findControllerOnce sync.Once
164}
165
166type findControllerAdapter struct {
167 fc *webkit.FindController
168}
169
170func (a *findControllerAdapter) Search(text string, opts port.FindOptions, maxMatches uint) {
171 if a == nil || a.fc == nil {
172 return
173 }
174 var flags uint32
175 if opts.WrapAround {
176 flags |= uint32(webkit.FindOptionsWrapAroundValue)
177 }
178 if opts.CaseInsensitive {
179 flags |= uint32(webkit.FindOptionsCaseInsensitiveValue)
180 }
181 if opts.AtWordStarts {
182 flags |= uint32(webkit.FindOptionsAtWordStartsValue)
183 }
184 a.fc.Search(text, flags, maxMatches)
185}
186
187func (a *findControllerAdapter) CountMatches(text string, opts port.FindOptions, maxMatches uint) {
188 if a == nil || a.fc == nil {
189 return
190 }
191 var flags uint32
192 if opts.WrapAround {
193 flags |= uint32(webkit.FindOptionsWrapAroundValue)
194 }
195 if opts.CaseInsensitive {
196 flags |= uint32(webkit.FindOptionsCaseInsensitiveValue)
197 }
198 if opts.AtWordStarts {
199 flags |= uint32(webkit.FindOptionsAtWordStartsValue)
200 }
201 a.fc.CountMatches(text, flags, maxMatches)
202}
203
204func (a *findControllerAdapter) SearchNext() {
205 if a == nil || a.fc == nil {
206 return
207 }
208 a.fc.SearchNext()
209}
210
211func (a *findControllerAdapter) SearchPrevious() {
212 if a == nil || a.fc == nil {
213 return
214 }
215 a.fc.SearchPrevious()
216}
217
218func (a *findControllerAdapter) SearchFinish() {
219 if a == nil || a.fc == nil {
220 return
221 }
222 a.fc.SearchFinish()
223}
224
225func (a *findControllerAdapter) GetSearchText() string {
226 if a == nil || a.fc == nil {
227 return ""
228 }
229 return a.fc.GetSearchText()
230}
231
232func (a *findControllerAdapter) OnFoundText(callback func(matchCount uint)) uint32 {
233 if a == nil || a.fc == nil || callback == nil {
234 return 0
235 }
236 cb := func(_ webkit.FindController, matchCount uint) {
237 callback(matchCount)
238 }
239 return a.fc.ConnectFoundText(&cb)
240}
241
242func (a *findControllerAdapter) OnFailedToFindText(callback func()) uint32 {
243 if a == nil || a.fc == nil || callback == nil {
244 return 0
245 }
246 cb := func(_ webkit.FindController) {
247 callback()
248 }
249 return a.fc.ConnectFailedToFindText(&cb)
250}
251
252func (a *findControllerAdapter) OnCountedMatches(callback func(matchCount uint)) uint32 {
253 if a == nil || a.fc == nil || callback == nil {
254 return 0
255 }
256 cb := func(_ webkit.FindController, matchCount uint) {
257 callback(matchCount)
258 }
259 return a.fc.ConnectCountedMatches(&cb)
260}
261
262func (a *findControllerAdapter) DisconnectSignal(id uint32) {
263 if a == nil || a.fc == nil || id == 0 {
264 return
265 }
266 obj := gobject.ObjectNewFromInternalPtr(a.fc.GoPointer())
267 gobject.SignalHandlerDisconnect(obj, id)
268}
269
270// NewWebView creates a new WebView with the given context and settings.
271// Uses the persistent NetworkSession from wkCtx for cookie/data persistence.
272// bgColor is optional - if provided, sets background immediately to prevent white flash.
273func NewWebView(ctx context.Context, wkCtx *WebKitContext, settings *SettingsManager, bgColor *gdk.RGBA) (*WebView, error) {
274 log := logging.FromContext(ctx)
275
276 if wkCtx == nil || !wkCtx.IsInitialized() {
277 return nil, fmt.Errorf("webkit context not initialized")
278 }
279
280 // Use options constructor to pass both WebContext and NetworkSession.
281 // This ensures WebViews use our custom WebContext (with memory pressure settings)
282 // and the persistent NetworkSession for cookie/storage.
283 inner := webkit.NewWebViewWithOptions(&webkit.WebViewOptions{
284 WebContext: wkCtx.Context(),
285 NetworkSession: wkCtx.NetworkSession(),
286 })
287 if inner == nil {
288 return nil, fmt.Errorf("failed to create webkit webview with options")
289 }
290
291 // Set background color IMMEDIATELY after creation, before any other operations.
292 // This prevents white flash by ensuring WebKit's renderer starts with correct color.
293 if bgColor != nil {
294 inner.SetBackgroundColor(bgColor)
295 }
296
297 wv := &WebView{
298 inner: inner,
299 ucm: inner.GetUserContentManager(),
300 logger: log.With().Str("component", "webview").Logger(),
301 signalIDs: make([]uint32, 0, 4),
302 }
303
304 // Register in global registry
305 wv.id = globalRegistry.register(wv)
306
307 // Apply settings if provided
308 if settings != nil {
309 settings.ApplyToWebView(ctx, inner)
310 }
311
312 // Connect signals
313 wv.connectSignals()
314
315 wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("webview created")
316
317 return wv, nil
318}
319
320// NewWebViewWithRelated creates a WebView that shares session/cookies with parent.
321// This is required for popup windows to maintain authentication state.
322func NewWebViewWithRelated(ctx context.Context, parent *WebView, settings *SettingsManager) (*WebView, error) {
323 log := logging.FromContext(ctx)
324
325 if parent == nil {
326 return nil, fmt.Errorf("parent webview is nil")
327 }
328 if parent.IsDestroyed() {
329 return nil, fmt.Errorf("parent webview %d is destroyed", parent.id)
330 }
331
332 log.Debug().
333 Uint64("parent_ptr", uint64(parent.inner.GoPointer())).
334 Msg("creating related webview with parent pointer")
335
336 inner := webkit.NewWebViewWithRelatedView(parent.inner)
337 if inner == nil {
338 return nil, fmt.Errorf("failed to create related webkit webview")
339 }
340
341 log.Debug().
342 Uint64("new_ptr", uint64(inner.GoPointer())).
343 Uint64("new_widget_ptr", uint64(inner.Widget.GoPointer())).
344 Msg("related webview created, checking pointers")
345
346 wv := &WebView{
347 inner: inner,
348 isRelated: true, // Shares web process with parent - must not terminate process on destroy
349 ucm: inner.GetUserContentManager(),
350 logger: log.With().Str("component", "webview-popup").Logger(),
351 signalIDs: make([]uint32, 0, 6),
352 }
353
354 wv.id = globalRegistry.register(wv)
355
356 if settings != nil {
357 settings.ApplyToWebView(ctx, inner)
358 }
359
360 wv.connectSignals()
361
362 wv.logger.Debug().
363 Uint64("id", uint64(wv.id)).
364 Uint64("parent_id", uint64(parent.id)).
365 Msg("related webview created for popup")
366
367 return wv, nil
368}
369
370// connectSignals sets up signal handlers for the WebView.
371func (wv *WebView) connectSignals() {
372 wv.connectLoadChangedSignal()
373 wv.connectCloseSignal()
374 wv.connectCreateSignal()
375 wv.connectReadyToShowSignal()
376 wv.connectTitleSignal()
377 wv.connectURISignal()
378 wv.connectFaviconSignal()
379 wv.connectProgressSignal()
380 wv.connectDecidePolicySignal()
381 wv.connectEnterFullscreenSignal()
382 wv.connectLeaveFullscreenSignal()
383 wv.connectAudioStateSignal()
384 wv.connectMouseTargetChangedSignal()
385}
386
387func (wv *WebView) connectLoadChangedSignal() {
388 loadChangedCb := func(inner webkit.WebView, event webkit.LoadEvent) {
389 uri := inner.GetUri()
390 title := inner.GetTitle()
391
392 wv.mu.Lock()
393 wv.uri = uri
394 wv.title = title
395 wv.canGoBack = inner.CanGoBack()
396 wv.canGoFwd = inner.CanGoForward()
397 wv.progress = inner.GetEstimatedLoadProgress()
398
399 switch event {
400 case webkit.LoadStartedValue:
401 wv.isLoading = true
402 wv.logger.Debug().Str("uri", uri).Msg("load started")
403 case webkit.LoadRedirectedValue:
404 wv.logger.Debug().Str("uri", uri).Msg("load redirected")
405 case webkit.LoadCommittedValue:
406 wv.logger.Debug().Str("uri", uri).Msg("load committed")
407 case webkit.LoadFinishedValue:
408 wv.isLoading = false
409 wv.logger.Debug().Str("uri", uri).Str("title", title).Msg("load finished")
410 }
411 wv.mu.Unlock()
412
413 if wv.OnLoadChanged != nil {
414 wv.OnLoadChanged(LoadEvent(event))
415 }
416 }
417 sigID := wv.inner.ConnectLoadChanged(&loadChangedCb)
418 wv.signalIDs = append(wv.signalIDs, sigID)
419}
420
421func (wv *WebView) connectCloseSignal() {
422 closeCb := func(_ webkit.WebView) {
423 if wv.OnClose != nil {
424 wv.OnClose()
425 }
426 }
427 sigID := wv.inner.ConnectClose(&closeCb)
428 wv.signalIDs = append(wv.signalIDs, sigID)
429}
430
431func (wv *WebView) connectCreateSignal() {
432 createCb := func(_ webkit.WebView, navActionPtr uintptr) gtk.Widget {
433 if wv.OnCreate == nil {
434 return gtk.Widget{} // Block popup
435 }
436
437 navAction := webkit.NavigationActionFromPointer(navActionPtr)
438 if navAction == nil {
439 return gtk.Widget{} // Block popup
440 }
441 defer navAction.Free()
442
443 var targetURI string
444 if req := navAction.GetRequest(); req != nil {
445 targetURI = req.GetUri()
446 }
447
448 popupReq := PopupRequest{
449 TargetURI: targetURI,
450 FrameName: navAction.GetFrameName(),
451 IsUserGesture: navAction.IsUserGesture(),
452 ParentID: wv.id,
453 }
454
455 wv.logger.Debug().Msg("create signal: invoking OnCreate handler")
456 newWV := wv.OnCreate(popupReq)
457 wv.logger.Debug().Bool("nil", newWV == nil).Msg("create signal: OnCreate returned")
458 if newWV == nil {
459 return gtk.Widget{} // Block popup
460 }
461
462 wv.logger.Debug().
463 Uint64("parent_id", uint64(wv.id)).
464 Uint64("popup_id", uint64(newWV.id)).
465 Msg("create signal: returning webview widget to WebKit")
466
467 return newWV.inner.Widget
468 }
469 sigID := wv.inner.ConnectCreate(&createCb)
470 wv.signalIDs = append(wv.signalIDs, sigID)
471}
472
473func (wv *WebView) connectReadyToShowSignal() {
474 readyToShowCb := func(_ webkit.WebView) {
475 if wv.OnReadyToShow != nil {
476 wv.OnReadyToShow()
477 }
478 }
479 sigID := wv.inner.ConnectReadyToShow(&readyToShowCb)
480 wv.signalIDs = append(wv.signalIDs, sigID)
481}
482
483func (wv *WebView) connectTitleSignal() {
484 titleCb := func() {
485 title := wv.inner.GetTitle()
486 wv.mu.Lock()
487 wv.title = title
488 wv.mu.Unlock()
489
490 if wv.OnTitleChanged != nil {
491 wv.OnTitleChanged(title)
492 }
493 }
494 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::title", glib.NewCallback(&titleCb))
495 wv.signalIDs = append(wv.signalIDs, sigID)
496}
497
498func (wv *WebView) connectURISignal() {
499 uriCb := func() {
500 uri := wv.inner.GetUri()
501 wv.mu.Lock()
502 oldUri := wv.uri
503 wv.uri = uri
504 wv.mu.Unlock()
505
506 if wv.OnURIChanged != nil && uri != oldUri {
507 wv.OnURIChanged(uri)
508 }
509 }
510 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::uri", glib.NewCallback(&uriCb))
511 wv.signalIDs = append(wv.signalIDs, sigID)
512}
513
514func (wv *WebView) connectFaviconSignal() {
515 faviconCb := func() {
516 favicon := wv.inner.GetFavicon()
517 if wv.OnFaviconChanged != nil {
518 wv.OnFaviconChanged(favicon)
519 }
520 }
521 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::favicon", glib.NewCallback(&faviconCb))
522 wv.signalIDs = append(wv.signalIDs, sigID)
523}
524
525// progressThrottleInterval limits progress callbacks to ~60fps to reduce UI overhead.
526const progressThrottleInterval = 16 * time.Millisecond
527
528func (wv *WebView) connectProgressSignal() {
529 progressCb := func() {
530 progress := wv.inner.GetEstimatedLoadProgress()
531
532 // Throttle progress updates to ~60fps (16ms interval)
533 // Always allow 0.0 (start) and 1.0 (complete) through
534 now := time.Now().UnixNano()
535 last := wv.lastProgressUpdate.Load()
536 if progress > 0.0 && progress < 1.0 {
537 if now-last < int64(progressThrottleInterval) {
538 return // Skip this update
539 }
540 }
541 wv.lastProgressUpdate.Store(now)
542
543 wv.mu.Lock()
544 wv.progress = progress
545 wv.mu.Unlock()
546
547 if wv.OnProgressChanged != nil {
548 wv.OnProgressChanged(progress)
549 }
550 }
551 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::estimated-load-progress", glib.NewCallback(&progressCb))
552 wv.signalIDs = append(wv.signalIDs, sigID)
553}
554
555func (wv *WebView) connectDecidePolicySignal() {
556 decidePolicyCb := func(_ webkit.WebView, decisionPtr uintptr, decisionType webkit.PolicyDecisionType) bool {
557 switch decisionType {
558 case webkit.PolicyDecisionTypeResponseValue:
559 return wv.handleResponsePolicyDecision(decisionPtr)
560 case webkit.PolicyDecisionTypeNavigationActionValue:
561 return wv.handleNavigationPolicyDecision(decisionPtr)
562 default:
563 return false
564 }
565 }
566 sigID := wv.inner.ConnectDecidePolicy(&decidePolicyCb)
567 wv.signalIDs = append(wv.signalIDs, sigID)
568}
569
570// handleResponsePolicyDecision handles response policy decisions (e.g., forcing downloads).
571func (wv *WebView) handleResponsePolicyDecision(decisionPtr uintptr) bool {
572 responseDecision := webkit.ResponsePolicyDecisionNewFromInternalPtr(decisionPtr)
573 if responseDecision == nil {
574 return false
575 }
576
577 if !responseDecision.IsMainFrameMainResource() {
578 return false
579 }
580
581 if !shouldForceDownload(responseDecision) {
582 return false
583 }
584
585 policyDecision := webkit.PolicyDecisionNewFromInternalPtr(decisionPtr)
586 if policyDecision == nil {
587 return false
588 }
589
590 response := responseDecision.GetResponse()
591 mimeType := ""
592 uri := ""
593 if response != nil {
594 mimeType = response.GetMimeType()
595 uri = response.GetUri()
596 }
597
598 wv.logger.Debug().
599 Str("mime_type", mimeType).
600 Str("uri", uri).
601 Msg("forcing download for response")
602
603 // Start a fresh download instead of converting the in-progress response.
604 // Using policyDecision.Download() can fail due to race conditions where
605 // the network connection is released before the download can use it.
606 // By ignoring the policy and starting a new download, we avoid this issue.
607 // We defer the download to the next idle cycle to let WebKit clean up
608 // the ignored navigation first.
609 if uri != "" {
610 policyDecision.Ignore()
611 downloadURI := uri
612 inner := wv.inner
613 cb := glib.SourceFunc(func(_ uintptr) bool {
614 inner.DownloadUri(downloadURI)
615 return false // Don't repeat
616 })
617 // Store callback reference to prevent GC before GTK calls it
618 wv.mu.Lock()
619 wv.asyncCallbacks = append(wv.asyncCallbacks, &cb)
620 wv.mu.Unlock()
621 glib.IdleAdd(&cb, 0)
622 return true
623 }
624
625 // Fallback to old behavior if URI is not available
626 policyDecision.Download()
627 return true
628}
629
630// handleNavigationPolicyDecision handles navigation policy decisions (e.g., middle-click).
631func (wv *WebView) handleNavigationPolicyDecision(decisionPtr uintptr) bool {
632 navDecision := webkit.NavigationPolicyDecisionNewFromInternalPtr(decisionPtr)
633 if navDecision == nil {
634 return false
635 }
636
637 navAction := navDecision.GetNavigationAction()
638 if navAction == nil {
639 return false
640 }
641
642 if navAction.GetNavigationType() != webkit.NavigationTypeLinkClickedValue {
643 return false
644 }
645
646 mouseButton := navAction.GetMouseButton()
647 modifiers := navAction.GetModifiers()
648 isMiddleClick := mouseButton == 2
649 isCtrlClick := mouseButton == 1 && (gdk.ModifierType(modifiers)&gdk.ControlMaskValue) != 0
650
651 if !isMiddleClick && !isCtrlClick {
652 return false
653 }
654
655 request := navAction.GetRequest()
656 if request == nil {
657 return false
658 }
659
660 linkURI := request.GetUri()
661 if linkURI == "" {
662 return false
663 }
664
665 wv.logger.Debug().
666 Str("uri", linkURI).
667 Uint("button", mouseButton).
668 Bool("ctrl", isCtrlClick).
669 Msg("middle-click/ctrl+click on link detected")
670
671 if wv.OnLinkMiddleClick != nil {
672 if wv.OnLinkMiddleClick(linkURI) {
673 navDecision.Ignore()
674 return true
675 }
676 }
677
678 return false
679}
680
681func shouldForceDownload(responseDecision *webkit.ResponsePolicyDecision) bool {
682 if responseDecision == nil {
683 return false
684 }
685
686 response := responseDecision.GetResponse()
687 if response == nil {
688 return false
689 }
690
691 mimeType := strings.ToLower(response.GetMimeType())
692 if strings.HasPrefix(mimeType, "application/pdf") {
693 return true
694 }
695
696 uri := response.GetUri()
697 if uri == "" {
698 return false
699 }
700
701 parsed, err := url.Parse(uri)
702 if err != nil {
703 return strings.Contains(strings.ToLower(uri), ".pdf")
704 }
705
706 return strings.HasSuffix(strings.ToLower(parsed.Path), ".pdf")
707}
708
709func (wv *WebView) connectEnterFullscreenSignal() {
710 enterFullscreenCb := func(_ webkit.WebView) bool {
711 wv.isFullscreen.Store(true)
712 wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("enter fullscreen")
713 if wv.OnEnterFullscreen != nil {
714 return wv.OnEnterFullscreen()
715 }
716 return false // Allow fullscreen
717 }
718 sigID := wv.inner.ConnectEnterFullscreen(&enterFullscreenCb)
719 wv.signalIDs = append(wv.signalIDs, sigID)
720}
721
722func (wv *WebView) connectLeaveFullscreenSignal() {
723 leaveFullscreenCb := func(_ webkit.WebView) bool {
724 wv.isFullscreen.Store(false)
725 wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("leave fullscreen")
726 if wv.OnLeaveFullscreen != nil {
727 return wv.OnLeaveFullscreen()
728 }
729 return false // Allow leaving fullscreen
730 }
731 sigID := wv.inner.ConnectLeaveFullscreen(&leaveFullscreenCb)
732 wv.signalIDs = append(wv.signalIDs, sigID)
733}
734
735func (wv *WebView) connectAudioStateSignal() {
736 audioCb := func() {
737 playing := wv.inner.IsPlayingAudio()
738 oldState := wv.isPlayingAudio.Swap(playing)
739 if oldState != playing {
740 wv.logger.Debug().
741 Uint64("id", uint64(wv.id)).
742 Bool("playing", playing).
743 Msg("audio state changed")
744 if wv.OnAudioStateChanged != nil {
745 wv.OnAudioStateChanged(playing)
746 }
747 }
748 }
749 sigID := gobject.SignalConnect(wv.inner.GoPointer(), "notify::is-playing-audio", glib.NewCallback(&audioCb))
750 wv.signalIDs = append(wv.signalIDs, sigID)
751}
752
753func (wv *WebView) connectMouseTargetChangedSignal() {
754 mouseTargetCb := func(_ webkit.WebView, hitTestPtr uintptr, _ uint) {
755 if wv.OnLinkHover == nil {
756 return
757 }
758
759 hitResult := webkit.HitTestResultNewFromInternalPtr(hitTestPtr)
760 if hitResult == nil {
761 wv.OnLinkHover("")
762 return
763 }
764
765 var uri string
766 switch {
767 case hitResult.ContextIsLink():
768 uri = hitResult.GetLinkUri()
769 case hitResult.ContextIsImage():
770 uri = hitResult.GetImageUri()
771 case hitResult.ContextIsMedia():
772 uri = hitResult.GetMediaUri()
773 }
774
775 wv.OnLinkHover(uri)
776 }
777 sigID := wv.inner.ConnectMouseTargetChanged(&mouseTargetCb)
778 wv.signalIDs = append(wv.signalIDs, sigID)
779}
780
781// ID returns the unique identifier for this WebView.
782func (wv *WebView) ID() WebViewID {
783 return wv.id
784}
785
786// UserContentManager returns the content manager associated with this WebView.
787func (wv *WebView) UserContentManager() *webkit.UserContentManager {
788 return wv.ucm
789}
790
791// Widget returns the underlying webkit.WebView for GTK embedding.
792func (wv *WebView) Widget() *webkit.WebView {
793 return wv.inner
794}
795
796// IsFullscreen returns true if the WebView is currently in fullscreen mode.
797func (wv *WebView) IsFullscreen() bool {
798 return wv.isFullscreen.Load()
799}
800
801// IsPlayingAudio returns true if the WebView is currently playing audio.
802func (wv *WebView) IsPlayingAudio() bool {
803 return wv.isPlayingAudio.Load()
804}
805
806// GetFindController returns the WebKit FindController wrapped in the port interface.
807// The adapter is cached to prevent the Go wrapper from being garbage collected.
808func (wv *WebView) GetFindController() port.FindController {
809 if wv == nil || wv.inner == nil {
810 return nil
811 }
812
813 wv.findControllerOnce.Do(func() {
814 fc := wv.inner.GetFindController()
815 if fc != nil {
816 wv.findController = &findControllerAdapter{fc: fc}
817 }
818 })
819
820 return wv.findController
821}
822
823// LoadURI loads the given URI.
824func (wv *WebView) LoadURI(ctx context.Context, uri string) error {
825 if wv.destroyed.Load() {
826 return fmt.Errorf("webview %d is destroyed", wv.id)
827 }
828 wv.inner.LoadUri(uri)
829 logging.FromContext(ctx).Debug().Str("uri", uri).Msg("loading URI")
830 return nil
831}
832
833// LoadHTML loads HTML content with an optional base URI.
834func (wv *WebView) LoadHTML(ctx context.Context, content, baseURI string) error {
835 if wv.destroyed.Load() {
836 return fmt.Errorf("webview %d is destroyed", wv.id)
837 }
838 var baseURIPtr *string
839 if baseURI != "" {
840 baseURIPtr = &baseURI
841 }
842 wv.inner.LoadHtml(content, baseURIPtr)
843 logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("loading HTML content")
844 return nil
845}
846
847// Reload reloads the current page.
848func (wv *WebView) Reload(ctx context.Context) error {
849 if wv.destroyed.Load() {
850 return fmt.Errorf("webview %d is destroyed", wv.id)
851 }
852 wv.inner.Reload()
853 logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("reloading webview")
854 return nil
855}
856
857// ReloadBypassCache reloads the current page, bypassing the cache.
858func (wv *WebView) ReloadBypassCache(ctx context.Context) error {
859 if wv.destroyed.Load() {
860 return fmt.Errorf("webview %d is destroyed", wv.id)
861 }
862 wv.inner.ReloadBypassCache()
863 logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("reloading webview bypassing cache")
864 return nil
865}
866
867// Stop stops the current load operation.
868func (wv *WebView) Stop(ctx context.Context) error {
869 if wv.destroyed.Load() {
870 return fmt.Errorf("webview %d is destroyed", wv.id)
871 }
872 wv.inner.StopLoading()
873 logging.FromContext(ctx).Debug().Int("webview_id", int(wv.id)).Msg("stopping webview load")
874 return nil
875}
876
877// GoBack navigates back in history.
878// Uses WebKit native navigation first, falls back to JavaScript history.back() for SPA compatibility.
879func (wv *WebView) GoBack(ctx context.Context) error {
880 if wv.destroyed.Load() {
881 return fmt.Errorf("webview %d is destroyed", wv.id)
882 }
883 log := logging.FromContext(ctx)
884
885 // Try WebKit native navigation first if available (for non-SPA pages)
886 if wv.inner.CanGoBack() {
887 wv.inner.GoBack()
888 log.Debug().Int("webview_id", int(wv.id)).Msg("webview go back (native)")
889 return nil
890 }
891
892 // Fall back to JavaScript history.back() for SPA navigation
893 // This handles pushState/replaceState history that WebKit's BackForwardList doesn't track
894 wv.RunJavaScript(ctx, "history.back()", "")
895 log.Debug().Int("webview_id", int(wv.id)).Msg("webview go back (js fallback)")
896 return nil
897}
898
899// GoForward navigates forward in history.
900// Uses WebKit native navigation first, falls back to JavaScript history.forward() for SPA compatibility.
901func (wv *WebView) GoForward(ctx context.Context) error {
902 if wv.destroyed.Load() {
903 return fmt.Errorf("webview %d is destroyed", wv.id)
904 }
905 log := logging.FromContext(ctx)
906
907 // Try WebKit native navigation first if available (for non-SPA pages)
908 if wv.inner.CanGoForward() {
909 wv.inner.GoForward()
910 log.Debug().Int("webview_id", int(wv.id)).Msg("webview go forward (native)")
911 return nil
912 }
913
914 // Fall back to JavaScript history.forward() for SPA navigation
915 // This handles pushState/replaceState history that WebKit's BackForwardList doesn't track
916 wv.RunJavaScript(ctx, "history.forward()", "")
917 log.Debug().Int("webview_id", int(wv.id)).Msg("webview go forward (js fallback)")
918 return nil
919}
920
921// CanGoBack returns true if back navigation is possible.
922func (wv *WebView) CanGoBack() bool {
923 wv.mu.RLock()
924 defer wv.mu.RUnlock()
925 return wv.canGoBack
926}
927
928// CanGoForward returns true if forward navigation is possible.
929func (wv *WebView) CanGoForward() bool {
930 wv.mu.RLock()
931 defer wv.mu.RUnlock()
932 return wv.canGoFwd
933}
934
935// URI returns the current URI.
936func (wv *WebView) URI() string {
937 wv.mu.RLock()
938 defer wv.mu.RUnlock()
939 return wv.uri
940}
941
942// Title returns the current page title.
943func (wv *WebView) Title() string {
944 wv.mu.RLock()
945 defer wv.mu.RUnlock()
946 return wv.title
947}
948
949// Favicon returns the current page favicon as a GdkTexture.
950// Returns nil if no favicon is available.
951func (wv *WebView) Favicon() *gdk.Texture {
952 if wv.destroyed.Load() {
953 return nil
954 }
955 return wv.inner.GetFavicon()
956}
957
958// IsLoading returns true if a page is currently loading.
959func (wv *WebView) IsLoading() bool {
960 wv.mu.RLock()
961 defer wv.mu.RUnlock()
962 return wv.isLoading
963}
964
965// EstimatedProgress returns the estimated load progress (0.0 to 1.0).
966func (wv *WebView) EstimatedProgress() float64 {
967 wv.mu.RLock()
968 defer wv.mu.RUnlock()
969 return wv.progress
970}
971
972// SetZoomLevel sets the zoom level (1.0 = 100%).
973func (wv *WebView) SetZoomLevel(ctx context.Context, level float64) error {
974 if wv.destroyed.Load() {
975 return fmt.Errorf("webview %d is destroyed", wv.id)
976 }
977 wv.inner.SetZoomLevel(level)
978 logging.FromContext(ctx).Debug().Float64("factor", level).Int("webview_id", int(wv.id)).Msg("set webview zoom level")
979 return nil
980}
981
982// GetZoomLevel returns the current zoom level.
983func (wv *WebView) GetZoomLevel() float64 {
984 if wv.destroyed.Load() {
985 return 1.0
986 }
987 return wv.inner.GetZoomLevel()
988}
989
990// SetBackgroundColor sets the WebView background color.
991// This color is shown before content is painted, eliminating white flash.
992// Values are in range 0.0-1.0 for red, green, blue, alpha.
993func (wv *WebView) SetBackgroundColor(r, g, b, a float32) {
994 if wv.destroyed.Load() {
995 return
996 }
997 rgba := &gdk.RGBA{
998 Red: r,
999 Green: g,
1000 Blue: b,
1001 Alpha: a,
1002 }
1003 wv.inner.SetBackgroundColor(rgba)
1004}
1005
1006// ResetBackgroundToDefault sets WebView background to white (browser default).
1007// Used for external pages to prevent dark background from bleeding through.
1008func (wv *WebView) ResetBackgroundToDefault() {
1009 wv.SetBackgroundColor(1.0, 1.0, 1.0, 1.0)
1010}
1011
1012// Show makes the WebView widget visible.
1013// This should be called after the WebView is ready to be displayed.
1014func (wv *WebView) Show() {
1015 if wv.destroyed.Load() {
1016 return
1017 }
1018 wv.inner.SetVisible(true)
1019}
1020
1021// State returns the current WebView state as a snapshot.
1022func (wv *WebView) State() port.WebViewState {
1023 return port.WebViewState{
1024 URI: wv.uri,
1025 Title: wv.title,
1026 IsLoading: wv.isLoading,
1027 Progress: wv.progress,
1028 CanGoBack: wv.canGoBack,
1029 CanGoFwd: wv.canGoFwd,
1030 ZoomLevel: wv.GetZoomLevel(),
1031 }
1032}
1033
1034// SetCallbacks registers callback handlers for WebView events.
1035// Pass nil to clear all callbacks.
1036func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
1037 if callbacks == nil {
1038 wv.OnLoadChanged = nil
1039 wv.OnTitleChanged = nil
1040 wv.OnURIChanged = nil
1041 wv.OnProgressChanged = nil
1042 wv.OnFaviconChanged = nil
1043 wv.OnClose = nil
1044 wv.OnCreate = nil
1045 return
1046 }
1047
1048 // Map port callbacks to webkit callbacks
1049 if callbacks.OnLoadChanged != nil {
1050 wv.OnLoadChanged = func(e LoadEvent) {
1051 callbacks.OnLoadChanged(port.LoadEvent(e))
1052 }
1053 }
1054 wv.OnTitleChanged = callbacks.OnTitleChanged
1055 wv.OnURIChanged = callbacks.OnURIChanged
1056 wv.OnProgressChanged = callbacks.OnProgressChanged
1057 if callbacks.OnFaviconChanged != nil {
1058 wv.OnFaviconChanged = func(texture *gdk.Texture) {
1059 callbacks.OnFaviconChanged(texture)
1060 }
1061 }
1062 wv.OnClose = callbacks.OnClose
1063 if callbacks.OnCreate != nil {
1064 wv.OnCreate = func(req PopupRequest) *WebView {
1065 portReq := port.PopupRequest{
1066 TargetURI: req.TargetURI,
1067 FrameName: req.FrameName,
1068 IsUserGesture: req.IsUserGesture,
1069 ParentViewID: req.ParentID,
1070 }
1071 result := callbacks.OnCreate(portReq)
1072 if result == nil {
1073 return nil
1074 }
1075 // The callback returns port.WebView, we need to convert back
1076 // This assumes the returned WebView is actually a *webkit.WebView
1077 if wkView, ok := result.(*WebView); ok {
1078 return wkView
1079 }
1080 return nil
1081 }
1082 }
1083 wv.OnLinkHover = callbacks.OnLinkHover
1084}
1085
1086// ShowDevTools opens the WebKit inspector/developer tools.
1087func (wv *WebView) ShowDevTools() error {
1088 if wv.destroyed.Load() {
1089 return fmt.Errorf("webview %d is destroyed", wv.id)
1090 }
1091 inspector := wv.inner.GetInspector()
1092 if inspector == nil {
1093 return fmt.Errorf("failed to get inspector for webview %d", wv.id)
1094 }
1095 inspector.Show()
1096 wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("devtools shown")
1097 return nil
1098}
1099
1100// Print opens the print dialog for the current page.
1101func (wv *WebView) Print() error {
1102 if wv.destroyed.Load() {
1103 return fmt.Errorf("webview %d is destroyed", wv.id)
1104 }
1105 printOp := webkit.NewPrintOperation(wv.inner)
1106 if printOp == nil {
1107 return fmt.Errorf("failed to create print operation for webview %d", wv.id)
1108 }
1109 // RunDialog with nil parent window - GTK will use the widget's toplevel
1110 printOp.RunDialog(nil)
1111 wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("print dialog opened")
1112 return nil
1113}
1114
1115// IsDestroyed returns true if the WebView has been destroyed.
1116func (wv *WebView) IsDestroyed() bool {
1117 return wv.destroyed.Load()
1118}
1119
1120// Close triggers the close callback as if window.close() was called.
1121// This is used for programmatic popup closure (e.g., OAuth auto-close).
1122func (wv *WebView) Close() {
1123 if wv.destroyed.Load() {
1124 return
1125 }
1126 if wv.OnClose != nil {
1127 wv.OnClose()
1128 }
1129}
1130
1131// DisconnectSignals disconnects all GLib signal handlers from the WebView.
1132// This must be called before releasing the WebView to the pool or destroying it
1133// to prevent callbacks from firing on freed/pooled WebViews.
1134func (wv *WebView) DisconnectSignals() {
1135 if wv.inner == nil {
1136 return
1137 }
1138
1139 obj := gobject.ObjectNewFromInternalPtr(wv.inner.GoPointer())
1140 for _, sigID := range wv.signalIDs {
1141 gobject.SignalHandlerDisconnect(obj, sigID)
1142 }
1143 wv.signalIDs = wv.signalIDs[:0] // Clear the slice
1144
1145 wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("signals disconnected")
1146}
1147
1148// Destroy cleans up the WebView resources and terminates the web process.
1149// This must be called when a WebView is no longer needed to free GPU resources,
1150// VA-API decoder contexts, and DMA-BUF buffers held by the web process.
1151func (wv *WebView) Destroy() {
1152 if wv.destroyed.Swap(true) {
1153 return // Already destroyed
1154 }
1155
1156 // 1. Disconnect all signal handlers first to prevent callbacks during cleanup
1157 wv.DisconnectSignals()
1158
1159 // 2. Clear all callbacks to prevent use-after-free
1160 wv.OnLoadChanged = nil
1161 wv.OnTitleChanged = nil
1162 wv.OnURIChanged = nil
1163 wv.OnProgressChanged = nil
1164 wv.OnFaviconChanged = nil
1165 wv.OnClose = nil
1166 wv.OnCreate = nil
1167 wv.OnReadyToShow = nil
1168 wv.OnLinkMiddleClick = nil
1169 wv.OnEnterFullscreen = nil
1170 wv.OnLeaveFullscreen = nil
1171 wv.OnAudioStateChanged = nil
1172 wv.OnLinkHover = nil
1173
1174 // 3. Clear async callback references
1175 wv.mu.Lock()
1176 wv.asyncCallbacks = nil
1177 wv.mu.Unlock()
1178
1179 // 4. Unparent from GTK hierarchy (must happen before process termination)
1180 // This releases the widget from any parent container.
1181 if wv.inner != nil {
1182 wv.inner.Unparent()
1183 }
1184
1185 // 5. Terminate the web process to free GPU resources (VA-API, DMA-BUF, GL contexts)
1186 // This is critical to prevent zombie processes that hold video decoder resources.
1187 // IMPORTANT: Skip for related views (popups) - they share the web process with their
1188 // parent. Terminating the shared process would kill the parent WebView too!
1189 if !wv.isRelated && wv.inner != nil {
1190 wv.inner.TerminateWebProcess()
1191 }
1192
1193 // 6. Unregister from global registry
1194 globalRegistry.unregister(wv.id)
1195
1196 // 7. Clear internal references to allow GC
1197 wv.inner = nil
1198 wv.ucm = nil
1199 wv.findController = nil
1200
1201 if wv.isRelated {
1202 wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("related webview destroyed (process shared with parent)")
1203 } else {
1204 wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("webview destroyed and web process terminated")
1205 }
1206}
1207
1208// RunJavaScript executes script in the specified world (empty for main world).
1209// This is fire-and-forget: it does not block and errors are logged asynchronously.
1210// Safe to call from any context including GTK signal handlers.
1211func (wv *WebView) RunJavaScript(ctx context.Context, script, worldName string) {
1212 if wv.destroyed.Load() {
1213 return
1214 }
1215 if ctx == nil {
1216 ctx = context.Background()
1217 }
1218
1219 log := logging.FromContext(ctx)
1220
1221 cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) {
1222 if resPtr == 0 {
1223 log.Warn().Uint64("webview_id", uint64(wv.id)).Msg("RunJavaScript: nil async result")
1224 return
1225 }
1226
1227 res := &gio.AsyncResultBase{Ptr: resPtr}
1228 value, err := wv.inner.EvaluateJavascriptFinish(res)
1229 if err != nil {
1230 log.Warn().Err(err).Uint64("webview_id", uint64(wv.id)).Msg("RunJavaScript: failed")
1231 return
1232 }
1233
1234 if value != nil {
1235 if jscCtx := value.GetContext(); jscCtx != nil {
1236 if exc := jscCtx.GetException(); exc != nil {
1237 log.Warn().
1238 Str("exception", exc.GetMessage()).
1239 Uint64("webview_id", uint64(wv.id)).
1240 Msg("RunJavaScript: JS exception")
1241 }
1242 }
1243 }
1244 })
1245
1246 // prevent callback from being GC'd before it's called
1247 wv.mu.Lock()
1248 wv.asyncCallbacks = append(wv.asyncCallbacks, cb)
1249 wv.mu.Unlock()
1250
1251 // worldName: nil for main world, &worldName for specific world
1252 // sourceUri: nil (not used)
1253 var worldNamePtr *string
1254 if worldName != "" {
1255 worldNamePtr = &worldName
1256 }
1257 wv.inner.EvaluateJavascript(script, -1, worldNamePtr, nil, nil, &cb, 0)
1258}
1259
1260// AttachFrontend injects scripts/styles and wires the message router once per WebView.
1261func (wv *WebView) AttachFrontend(ctx context.Context, injector *ContentInjector, router *MessageRouter) error {
1262 if ctx == nil {
1263 ctx = context.Background()
1264 }
1265
1266 log := logging.FromContext(ctx).With().
1267 Str("component", "webview").
1268 Uint64("webview_id", uint64(wv.id)).
1269 Logger()
1270
1271 log.Debug().
1272 Bool("injector_nil", injector == nil).
1273 Bool("router_nil", router == nil).
1274 Msg("AttachFrontend called")
1275
1276 if wv.destroyed.Load() {
1277 log.Debug().Msg("AttachFrontend: webview is destroyed")
1278 return fmt.Errorf("webview %d is destroyed", wv.id)
1279 }
1280 if injector == nil && router == nil {
1281 log.Debug().Msg("AttachFrontend: both injector and router are nil, skipping")
1282 return nil
1283 }
1284
1285 if !wv.frontendAttached.CompareAndSwap(false, true) {
1286 log.Debug().Msg("AttachFrontend: already attached, skipping")
1287 return nil // already attached
1288 }
1289
1290 var attachErr error
1291
1292 defer func() {
1293 if attachErr != nil {
1294 // allow retry on next call
1295 wv.frontendAttached.Store(false)
1296 }
1297 }()
1298
1299 if router != nil {
1300 log.Debug().Msg("AttachFrontend: setting up message router")
1301 if _, err := router.SetupMessageHandler(wv.ucm, ScriptWorldName); err != nil {
1302 attachErr = fmt.Errorf("setup message router: %w", err)
1303 log.Warn().Err(err).Msg("failed to attach message router")
1304 return attachErr
1305 }
1306 log.Debug().Msg("AttachFrontend: message router setup complete")
1307 }
1308
1309 if injector != nil {
1310 log.Debug().Msg("AttachFrontend: injecting scripts")
1311 injector.InjectScripts(ctx, wv.ucm, wv.id)
1312 }
1313
1314 log.Debug().Msg("frontend assets attached to webview")
1315 return nil
1316}