v0.25.0 2e6c485

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

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