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