v0.21.0 73bf644

webview.go RawHistoryFolder

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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close