v0.20.1 d9a391f

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

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