v0.22.0 a85da87

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

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 9

Tags 42

↑↓ moveenter openesc close