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.
764// Uses WebKit native navigation first, falls back to JavaScript history.back() for SPA compatibility.
765func (wv *WebView) GoBack(ctx context.Context) error {
766 if wv.destroyed.Load() {
767 return fmt.Errorf("webview %d is destroyed", wv.id)
768 }
769 log := logging.FromContext(ctx)
770
771 // Try WebKit native navigation first if available (for non-SPA pages)
772 if wv.inner.CanGoBack() {
773 wv.inner.GoBack()
774 log.Debug().Int("webview_id", int(wv.id)).Msg("webview go back (native)")
775 return nil
776 }
777
778 // Fall back to JavaScript history.back() for SPA navigation
779 // This handles pushState/replaceState history that WebKit's BackForwardList doesn't track
780 wv.RunJavaScript(ctx, "history.back()", "")
781 log.Debug().Int("webview_id", int(wv.id)).Msg("webview go back (js fallback)")
782 return nil
783}
784
785// GoForward navigates forward in history.
786// Uses WebKit native navigation first, falls back to JavaScript history.forward() for SPA compatibility.
787func (wv *WebView) GoForward(ctx context.Context) error {
788 if wv.destroyed.Load() {
789 return fmt.Errorf("webview %d is destroyed", wv.id)
790 }
791 log := logging.FromContext(ctx)
792
793 // Try WebKit native navigation first if available (for non-SPA pages)
794 if wv.inner.CanGoForward() {
795 wv.inner.GoForward()
796 log.Debug().Int("webview_id", int(wv.id)).Msg("webview go forward (native)")
797 return nil
798 }
799
800 // Fall back to JavaScript history.forward() for SPA navigation
801 // This handles pushState/replaceState history that WebKit's BackForwardList doesn't track
802 wv.RunJavaScript(ctx, "history.forward()", "")
803 log.Debug().Int("webview_id", int(wv.id)).Msg("webview go forward (js fallback)")
804 return nil
805}
806
807// CanGoBack returns true if back navigation is possible.
808func (wv *WebView) CanGoBack() bool {
809 wv.mu.RLock()
810 defer wv.mu.RUnlock()
811 return wv.canGoBack
812}
813
814// CanGoForward returns true if forward navigation is possible.
815func (wv *WebView) CanGoForward() bool {
816 wv.mu.RLock()
817 defer wv.mu.RUnlock()
818 return wv.canGoFwd
819}
820
821// URI returns the current URI.
822func (wv *WebView) URI() string {
823 wv.mu.RLock()
824 defer wv.mu.RUnlock()
825 return wv.uri
826}
827
828// Title returns the current page title.
829func (wv *WebView) Title() string {
830 wv.mu.RLock()
831 defer wv.mu.RUnlock()
832 return wv.title
833}
834
835// Favicon returns the current page favicon as a GdkTexture.
836// Returns nil if no favicon is available.
837func (wv *WebView) Favicon() *gdk.Texture {
838 if wv.destroyed.Load() {
839 return nil
840 }
841 return wv.inner.GetFavicon()
842}
843
844// IsLoading returns true if a page is currently loading.
845func (wv *WebView) IsLoading() bool {
846 wv.mu.RLock()
847 defer wv.mu.RUnlock()
848 return wv.isLoading
849}
850
851// EstimatedProgress returns the estimated load progress (0.0 to 1.0).
852func (wv *WebView) EstimatedProgress() float64 {
853 wv.mu.RLock()
854 defer wv.mu.RUnlock()
855 return wv.progress
856}
857
858// SetZoomLevel sets the zoom level (1.0 = 100%).
859func (wv *WebView) SetZoomLevel(ctx context.Context, level float64) error {
860 if wv.destroyed.Load() {
861 return fmt.Errorf("webview %d is destroyed", wv.id)
862 }
863 wv.inner.SetZoomLevel(level)
864 logging.FromContext(ctx).Debug().Float64("factor", level).Int("webview_id", int(wv.id)).Msg("set webview zoom level")
865 return nil
866}
867
868// GetZoomLevel returns the current zoom level.
869func (wv *WebView) GetZoomLevel() float64 {
870 if wv.destroyed.Load() {
871 return 1.0
872 }
873 return wv.inner.GetZoomLevel()
874}
875
876// SetBackgroundColor sets the WebView background color.
877// This color is shown before content is painted, eliminating white flash.
878// Values are in range 0.0-1.0 for red, green, blue, alpha.
879func (wv *WebView) SetBackgroundColor(r, g, b, a float32) {
880 if wv.destroyed.Load() {
881 return
882 }
883 rgba := &gdk.RGBA{
884 Red: r,
885 Green: g,
886 Blue: b,
887 Alpha: a,
888 }
889 wv.inner.SetBackgroundColor(rgba)
890}
891
892// State returns the current WebView state as a snapshot.
893func (wv *WebView) State() port.WebViewState {
894 return port.WebViewState{
895 URI: wv.uri,
896 Title: wv.title,
897 IsLoading: wv.isLoading,
898 Progress: wv.progress,
899 CanGoBack: wv.canGoBack,
900 CanGoFwd: wv.canGoFwd,
901 ZoomLevel: wv.GetZoomLevel(),
902 }
903}
904
905// SetCallbacks registers callback handlers for WebView events.
906// Pass nil to clear all callbacks.
907func (wv *WebView) SetCallbacks(callbacks *port.WebViewCallbacks) {
908 if callbacks == nil {
909 wv.OnLoadChanged = nil
910 wv.OnTitleChanged = nil
911 wv.OnURIChanged = nil
912 wv.OnProgressChanged = nil
913 wv.OnFaviconChanged = nil
914 wv.OnClose = nil
915 wv.OnCreate = nil
916 return
917 }
918
919 // Map port callbacks to webkit callbacks
920 if callbacks.OnLoadChanged != nil {
921 wv.OnLoadChanged = func(e LoadEvent) {
922 callbacks.OnLoadChanged(port.LoadEvent(e))
923 }
924 }
925 wv.OnTitleChanged = callbacks.OnTitleChanged
926 wv.OnURIChanged = callbacks.OnURIChanged
927 wv.OnProgressChanged = callbacks.OnProgressChanged
928 if callbacks.OnFaviconChanged != nil {
929 wv.OnFaviconChanged = func(texture *gdk.Texture) {
930 callbacks.OnFaviconChanged(texture)
931 }
932 }
933 wv.OnClose = callbacks.OnClose
934 if callbacks.OnCreate != nil {
935 wv.OnCreate = func(req PopupRequest) *WebView {
936 portReq := port.PopupRequest{
937 TargetURI: req.TargetURI,
938 FrameName: req.FrameName,
939 IsUserGesture: req.IsUserGesture,
940 ParentViewID: req.ParentID,
941 }
942 result := callbacks.OnCreate(portReq)
943 if result == nil {
944 return nil
945 }
946 // The callback returns port.WebView, we need to convert back
947 // This assumes the returned WebView is actually a *webkit.WebView
948 if wkView, ok := result.(*WebView); ok {
949 return wkView
950 }
951 return nil
952 }
953 }
954 wv.OnLinkHover = callbacks.OnLinkHover
955}
956
957// ShowDevTools opens the WebKit inspector/developer tools.
958func (wv *WebView) ShowDevTools() error {
959 if wv.destroyed.Load() {
960 return fmt.Errorf("webview %d is destroyed", wv.id)
961 }
962 inspector := wv.inner.GetInspector()
963 if inspector == nil {
964 return fmt.Errorf("failed to get inspector for webview %d", wv.id)
965 }
966 inspector.Show()
967 wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("devtools shown")
968 return nil
969}
970
971// Print opens the print dialog for the current page.
972func (wv *WebView) Print() error {
973 if wv.destroyed.Load() {
974 return fmt.Errorf("webview %d is destroyed", wv.id)
975 }
976 printOp := webkit.NewPrintOperation(wv.inner)
977 if printOp == nil {
978 return fmt.Errorf("failed to create print operation for webview %d", wv.id)
979 }
980 // RunDialog with nil parent window - GTK will use the widget's toplevel
981 printOp.RunDialog(nil)
982 wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("print dialog opened")
983 return nil
984}
985
986// IsDestroyed returns true if the WebView has been destroyed.
987func (wv *WebView) IsDestroyed() bool {
988 return wv.destroyed.Load()
989}
990
991// Close triggers the close callback as if window.close() was called.
992// This is used for programmatic popup closure (e.g., OAuth auto-close).
993func (wv *WebView) Close() {
994 if wv.destroyed.Load() {
995 return
996 }
997 if wv.OnClose != nil {
998 wv.OnClose()
999 }
1000}
1001
1002// DisconnectSignals disconnects all GLib signal handlers from the WebView.
1003// This must be called before releasing the WebView to the pool or destroying it
1004// to prevent callbacks from firing on freed/pooled WebViews.
1005func (wv *WebView) DisconnectSignals() {
1006 if wv.inner == nil {
1007 return
1008 }
1009
1010 obj := gobject.ObjectNewFromInternalPtr(wv.inner.GoPointer())
1011 for _, sigID := range wv.signalIDs {
1012 gobject.SignalHandlerDisconnect(obj, sigID)
1013 }
1014 wv.signalIDs = wv.signalIDs[:0] // Clear the slice
1015
1016 wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("signals disconnected")
1017}
1018
1019// Destroy cleans up the WebView resources and terminates the web process.
1020// This must be called when a WebView is no longer needed to free GPU resources,
1021// VA-API decoder contexts, and DMA-BUF buffers held by the web process.
1022func (wv *WebView) Destroy() {
1023 if wv.destroyed.Swap(true) {
1024 return // Already destroyed
1025 }
1026
1027 // 1. Disconnect all signal handlers first to prevent callbacks during cleanup
1028 wv.DisconnectSignals()
1029
1030 // 2. Clear all callbacks to prevent use-after-free
1031 wv.OnLoadChanged = nil
1032 wv.OnTitleChanged = nil
1033 wv.OnURIChanged = nil
1034 wv.OnProgressChanged = nil
1035 wv.OnFaviconChanged = nil
1036 wv.OnClose = nil
1037 wv.OnCreate = nil
1038 wv.OnReadyToShow = nil
1039 wv.OnLinkMiddleClick = nil
1040 wv.OnEnterFullscreen = nil
1041 wv.OnLeaveFullscreen = nil
1042 wv.OnAudioStateChanged = nil
1043 wv.OnLinkHover = nil
1044
1045 // 3. Clear async callback references
1046 wv.mu.Lock()
1047 wv.asyncCallbacks = nil
1048 wv.mu.Unlock()
1049
1050 // 4. Unparent from GTK hierarchy (must happen before process termination)
1051 // This releases the widget from any parent container.
1052 if wv.inner != nil {
1053 wv.inner.Unparent()
1054 }
1055
1056 // 5. Terminate the web process to free GPU resources (VA-API, DMA-BUF, GL contexts)
1057 // This is critical to prevent zombie processes that hold video decoder resources.
1058 // IMPORTANT: Skip for related views (popups) - they share the web process with their
1059 // parent. Terminating the shared process would kill the parent WebView too!
1060 if !wv.isRelated && wv.inner != nil {
1061 wv.inner.TerminateWebProcess()
1062 }
1063
1064 // 6. Unregister from global registry
1065 globalRegistry.unregister(wv.id)
1066
1067 // 7. Clear internal references to allow GC
1068 wv.inner = nil
1069 wv.ucm = nil
1070 wv.findController = nil
1071
1072 if wv.isRelated {
1073 wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("related webview destroyed (process shared with parent)")
1074 } else {
1075 wv.logger.Debug().Uint64("id", uint64(wv.id)).Msg("webview destroyed and web process terminated")
1076 }
1077}
1078
1079// RunJavaScript executes script in the specified world (empty for main world).
1080// This is fire-and-forget: it does not block and errors are logged asynchronously.
1081// Safe to call from any context including GTK signal handlers.
1082func (wv *WebView) RunJavaScript(ctx context.Context, script, worldName string) {
1083 if wv.destroyed.Load() {
1084 return
1085 }
1086 if ctx == nil {
1087 ctx = context.Background()
1088 }
1089
1090 log := logging.FromContext(ctx)
1091
1092 cb := gio.AsyncReadyCallback(func(_ uintptr, resPtr uintptr, _ uintptr) {
1093 if resPtr == 0 {
1094 log.Warn().Uint64("webview_id", uint64(wv.id)).Msg("RunJavaScript: nil async result")
1095 return
1096 }
1097
1098 res := &gio.AsyncResultBase{Ptr: resPtr}
1099 value, err := wv.inner.EvaluateJavascriptFinish(res)
1100 if err != nil {
1101 log.Warn().Err(err).Uint64("webview_id", uint64(wv.id)).Msg("RunJavaScript: failed")
1102 return
1103 }
1104
1105 if value != nil {
1106 if jscCtx := value.GetContext(); jscCtx != nil {
1107 if exc := jscCtx.GetException(); exc != nil {
1108 log.Warn().
1109 Str("exception", exc.GetMessage()).
1110 Uint64("webview_id", uint64(wv.id)).
1111 Msg("RunJavaScript: JS exception")
1112 }
1113 }
1114 }
1115 })
1116
1117 // prevent callback from being GC'd before it's called
1118 wv.mu.Lock()
1119 wv.asyncCallbacks = append(wv.asyncCallbacks, cb)
1120 wv.mu.Unlock()
1121
1122 // worldName: nil for main world, &worldName for specific world
1123 // sourceUri: nil (not used)
1124 var worldNamePtr *string
1125 if worldName != "" {
1126 worldNamePtr = &worldName
1127 }
1128 wv.inner.EvaluateJavascript(script, -1, worldNamePtr, nil, nil, &cb, 0)
1129}
1130
1131// AttachFrontend injects scripts/styles and wires the message router once per WebView.
1132func (wv *WebView) AttachFrontend(ctx context.Context, injector *ContentInjector, router *MessageRouter) error {
1133 if ctx == nil {
1134 ctx = context.Background()
1135 }
1136
1137 log := logging.FromContext(ctx).With().
1138 Str("component", "webview").
1139 Uint64("webview_id", uint64(wv.id)).
1140 Logger()
1141
1142 log.Debug().
1143 Bool("injector_nil", injector == nil).
1144 Bool("router_nil", router == nil).
1145 Msg("AttachFrontend called")
1146
1147 if wv.destroyed.Load() {
1148 log.Debug().Msg("AttachFrontend: webview is destroyed")
1149 return fmt.Errorf("webview %d is destroyed", wv.id)
1150 }
1151 if injector == nil && router == nil {
1152 log.Debug().Msg("AttachFrontend: both injector and router are nil, skipping")
1153 return nil
1154 }
1155
1156 if !wv.frontendAttached.CompareAndSwap(false, true) {
1157 log.Debug().Msg("AttachFrontend: already attached, skipping")
1158 return nil // already attached
1159 }
1160
1161 var attachErr error
1162
1163 defer func() {
1164 if attachErr != nil {
1165 // allow retry on next call
1166 wv.frontendAttached.Store(false)
1167 }
1168 }()
1169
1170 if router != nil {
1171 log.Debug().Msg("AttachFrontend: setting up message router")
1172 if _, err := router.SetupMessageHandler(wv.ucm, ScriptWorldName); err != nil {
1173 attachErr = fmt.Errorf("setup message router: %w", err)
1174 log.Warn().Err(err).Msg("failed to attach message router")
1175 return attachErr
1176 }
1177 log.Debug().Msg("AttachFrontend: message router setup complete")
1178 }
1179
1180 if injector != nil {
1181 log.Debug().Msg("AttachFrontend: injecting scripts")
1182 injector.InjectScripts(ctx, wv.ucm, wv.id)
1183 }
1184
1185 log.Debug().Msg("frontend assets attached to webview")
1186 return nil
1187}