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