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