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