v0.26.0 ae3a77a

webview.go RawHistoryFolder

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

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