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