v0.14.1 0da30ba

webview.go RawHistoryFolder

1package webkit
2
3import (
4	"encoding/json"
5	"fmt"
6	"log"
7	"net/url"
8	"strconv"
9	"strings"
10	"sync"
11	"sync/atomic"
12
13	webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
14	"github.com/diamondburned/gotk4/pkg/gdk/v4"
15	"github.com/diamondburned/gotk4/pkg/gtk/v4"
16)
17
18const turnstileHost = "challenges.cloudflare.com"
19
20var (
21	viewIDCounter uint64
22	viewRegistry  = make(map[uint64]*WebView)
23	viewMu        sync.RWMutex
24)
25
26// WebView wraps a WebKitGTK WebView
27type WebView struct {
28	view      *webkit.WebView
29	container *gtk.Box // Container that holds the WebView
30	window    *Window  // Optional window (only if CreateWindow=true)
31	id        uint64
32
33	// State
34	config    *Config
35	destroyed bool
36	mu        sync.RWMutex
37
38	// Event handlers
39	onScriptMessage       func(string)
40	onTitleChanged        func(string)
41	onURIChanged          func(string)
42	onFaviconChanged      func([]byte)
43	onFaviconURIChanged   func(pageURI, faviconURI string)
44	onZoomChanged         func(float64)
45	onLoadCommitted       func(string)                            // Called when page load is committed (safe to apply zoom)
46	onPopupCreate         func(*webkit.NavigationAction) *WebView // New WebKit create signal handler
47	onReadyToShow         func()                                  // WebKit ready-to-show signal handler
48	onClose               func()
49	onNavigationPolicy    func(url string, isUserGesture bool) bool
50	onWindowTypeDetected  func(WindowType, *WindowFeatures)
51	onWorkspaceNavigation func(direction string) bool // Workspace pane navigation
52	onPaneModeShortcut    func(action string) bool    // Pane mode shortcuts (enter, actions, exit)
53	isPaneModeActive      func() bool                 // Check if pane mode is active
54	onMiddleClickLink     func(url string) bool       // Middle mouse click on link handler
55}
56
57// NewWebView creates a new WebView with the given configuration
58func NewWebView(cfg *Config) (*WebView, error) {
59	if cfg == nil {
60		cfg = GetDefaultConfig()
61	}
62
63	// Generate unique ID immediately
64	id := atomic.AddUint64(&viewIDCounter, 1)
65	log.Printf("[webkit] Generated WebView ID: %d (CreateWindow=%v)", id, cfg.CreateWindow)
66
67	InitMainThread()
68
69	// Initialize persistent session - REQUIRED, no ephemeral fallback
70	// This must be done BEFORE creating the WebView
71	if cfg.DataDir == "" || cfg.CacheDir == "" {
72		return nil, fmt.Errorf("data and cache directories are required for persistent storage")
73	}
74
75	if err := InitPersistentSession(cfg.DataDir, cfg.CacheDir); err != nil {
76		return nil, fmt.Errorf("failed to initialize persistent storage: %w", err)
77	}
78
79	// Create WebView - it will automatically use the persistent session
80	// Per WebKitGTK 6.0: newly created session becomes the default for all WebViews
81	wkView := webkit.NewWebView()
82	if wkView == nil {
83		return nil, ErrWebViewNotInitialized
84	}
85
86	// Set background color based on user's theme preference to prevent white/black flash
87	// This must be done before any content loads
88	var bg gdk.RGBA
89	if PrefersDarkTheme() {
90		bg = gdk.NewRGBA(0.11, 0.11, 0.11, 1.0) // #1c1c1c - dark background
91	} else {
92		bg = gdk.NewRGBA(0.96, 0.96, 0.96, 1.0) // #f5f5f5 - light background
93	}
94	wkView.SetBackgroundColor(&bg)
95
96	// Verify the WebView is using the persistent session
97	viewSession := wkView.NetworkSession()
98	if viewSession == nil {
99		return nil, fmt.Errorf("WebView has no network session")
100	}
101	if viewSession.IsEphemeral() {
102		return nil, fmt.Errorf("WebView is using ephemeral session despite persistent session initialization")
103	}
104
105	// CRITICAL: Configure CookieManager on the WebView's actual session
106	// This ensures cookies are persisted regardless of whether the WebView
107	// uses our global session or creates its own
108	cookieManager := viewSession.CookieManager()
109	if cookieManager == nil {
110		return nil, fmt.Errorf("failed to get cookie manager from WebView's network session")
111	}
112	cookiePath := cfg.DataDir + "/cookies.db"
113	cookieManager.SetPersistentStorage(cookiePath, webkit.CookiePersistentStorageSqlite)
114	cookieManager.SetAcceptPolicy(webkit.CookiePolicyAcceptNoThirdParty)
115
116	// Setup UserContentManager and inject GUI scripts (including webview ID)
117	// This must be done BEFORE any pages are loaded
118	if err := SetupUserContentManager(wkView, cfg.AppearanceConfigJSON, id); err != nil {
119		return nil, fmt.Errorf("failed to setup user content manager: %w", err)
120	}
121
122	// Create container (GtkBox) to hold the WebView
123	// This allows the WebView to be reparented into workspace panes
124	container := gtk.NewBox(gtk.OrientationVertical, 0)
125	container.SetHExpand(true)
126	container.SetVExpand(true)
127
128	// Configure WebView widget for expansion
129	wkView.SetHExpand(true)
130	wkView.SetVExpand(true)
131
132	// Add WebView to container
133	container.Append(wkView)
134
135	wv := &WebView{
136		view:      wkView,
137		container: container,
138		window:    nil, // Will be set below if CreateWindow is true
139		id:        id,
140		config:    cfg,
141	}
142
143	// Apply configuration
144	if err := wv.applyConfig(); err != nil {
145		return nil, err
146	}
147
148	// Setup event handlers
149	wv.setupEventHandlers()
150
151	// Attach keyboard bridge to forward keyboard events to JavaScript
152	wv.AttachKeyboardBridge()
153
154	// Attach mouse gesture controls for hardware navigation buttons
155	wv.AttachMouseGestures()
156
157	// Attach touchpad gesture controls for swipe navigation
158	wv.AttachTouchpadGestures()
159
160	// Only create window if requested (standalone WebViews)
161	// Workspace panes will set CreateWindow=false and manage the container themselves
162	if cfg.CreateWindow {
163		window, err := NewWindow("Dumber Browser")
164		if err != nil {
165			return nil, err
166		}
167		wv.window = window
168		// Add container as child of window
169		window.SetChild(container)
170		log.Printf("[webkit] Created standalone window for WebView ID %d", id)
171	} else {
172		log.Printf("[webkit] Created WebView ID %d without window (for workspace embedding)", id)
173	}
174
175	// Register in global registry
176	viewMu.Lock()
177	viewRegistry[id] = wv
178	viewMu.Unlock()
179
180	return wv, nil
181}
182
183// applyConfig applies the configuration to the WebView settings
184func (w *WebView) applyConfig() error {
185	settings := w.view.Settings()
186	if settings == nil {
187		return fmt.Errorf("webkit: failed to get settings")
188	}
189
190	// Apply settings from config
191	settings.SetEnableJavascript(w.config.EnableJavaScript)
192	settings.SetEnableWebgl(w.config.EnableWebGL)
193	settings.SetDefaultFontSize(uint32(w.config.DefaultFontSize))
194	settings.SetMinimumFontSize(uint32(w.config.MinimumFontSize))
195
196	if w.config.UserAgent != "" {
197		settings.SetUserAgent(w.config.UserAgent)
198	}
199
200	// Enable developer tools (F12, inspector)
201	settings.SetEnableDeveloperExtras(true)
202
203	// Enable hardware acceleration if configured
204	settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyAlways)
205
206	// Performance optimizations for faster page transitions
207	// Enable page cache for instant back/forward navigation (bfcache)
208	settings.SetEnablePageCache(w.config.EnablePageCache)
209
210	// Enable smooth scrolling for better UX
211	settings.SetEnableSmoothScrolling(w.config.EnableSmoothScrolling)
212
213	// Disable console messages to stdout to prevent page script flooding
214	// This stops malicious/buggy pages from spamming the terminal with console.log()
215	// Inspector/DevTools console (F12) will still show all messages
216	settings.SetEnableWriteConsoleMessagesToStdout(false)
217
218	return nil
219}
220
221// setupEventHandlers connects GTK signals to internal handlers
222func (w *WebView) setupEventHandlers() {
223	// Title changed - connect to notify::title signal
224	w.view.Connect("notify::title", func() {
225		if w.onTitleChanged != nil {
226			title := w.view.Title()
227			w.onTitleChanged(title)
228		}
229	})
230
231	// URI changed - connect to notify::uri signal
232	w.view.Connect("notify::uri", func() {
233		if w.onURIChanged != nil {
234			uri := w.view.URI()
235			w.onURIChanged(uri)
236		}
237	})
238
239	// Zoom level changed - connect to notify::zoom-level signal
240	w.view.Connect("notify::zoom-level", func() {
241		if w.onZoomChanged != nil {
242			zoomLevel := w.view.ZoomLevel()
243			w.onZoomChanged(zoomLevel)
244		}
245	})
246
247	// Load committed - connect to load-changed signal for WEBKIT_LOAD_COMMITTED
248	w.view.ConnectLoadChanged(func(loadEvent webkit.LoadEvent) {
249		if loadEvent == webkit.LoadCommitted && w.onLoadCommitted != nil {
250			uri := w.view.URI()
251			w.onLoadCommitted(uri)
252		}
253	})
254
255	// Favicon changed - connect to FaviconDatabase
256	w.setupFaviconHandlers()
257
258	// TLS error handling - connect to load-failed-with-tls-errors signal
259	w.setupTLSErrorHandler()
260
261	// Close
262	w.view.ConnectClose(func() {
263		if w.onClose != nil {
264			w.onClose()
265		}
266	})
267
268	// Create signal - for popup lifecycle management
269	w.view.ConnectCreate(func(navigationAction *webkit.NavigationAction) gtk.Widgetter {
270		log.Printf("[webkit] ConnectCreate callback fired for parent WebView ID=%d", w.id)
271		if w.onPopupCreate != nil {
272			log.Printf("[webkit] Calling onPopupCreate handler")
273			newWebView := w.onPopupCreate(navigationAction)
274			if newWebView != nil {
275				log.Printf("[webkit] onPopupCreate returned WebView wrapper ID=%d", newWebView.id)
276				log.Printf("[webkit] Extracting underlying gotk4 WebView from wrapper")
277				underlyingView := newWebView.view
278				log.Printf("[webkit] Extracted gotk4 WebView=%p, about to return to WebKit", underlyingView)
279				// Return the underlying WebView widget to WebKit
280				return underlyingView
281			}
282			log.Printf("[webkit] onPopupCreate returned nil, blocking popup")
283		}
284		// Return nil to cancel popup creation
285		log.Printf("[webkit] No onPopupCreate handler, blocking popup")
286		return nil
287	})
288
289	// Ready-to-show signal - emitted when popup is ready to be displayed
290	w.view.ConnectReadyToShow(func() {
291		if w.onReadyToShow != nil {
292			w.onReadyToShow()
293		}
294	})
295
296	// Script message received - connect to UserContentManager's script-message-received signal
297	// This receives messages from JavaScript via webkit.messageHandlers.dumber.postMessage()
298	// The signal name includes the handler name as a detail: "script-message-received::dumber"
299	ucm := w.view.UserContentManager()
300	if ucm != nil {
301		// GTK signals pass the sender as the first parameter, the actual signal data as subsequent parameters
302		ucm.Connect("script-message-received::dumber", func(sender interface{}, jscValue interface{}) {
303			if w.onScriptMessage != nil && jscValue != nil {
304				// Convert JSCValue to string using gotk4 javascriptcore bindings
305				valueStr := JSCValueToString(jscValue)
306				if valueStr != "" {
307					w.onScriptMessage(valueStr)
308				}
309			}
310		})
311	}
312
313	// Setup navigation policy handler for middle-click and Ctrl+click interception
314	w.setupNavigationPolicyHandler()
315
316	// Log CORP/CORP headers for Cloudflare Turnstile resources to help diagnose COEP issues
317	w.attachTurnstileResponseLogger()
318
319	// Apply temporary Turnstile workaround until WebKit supports COEP: credentialless
320	w.applyTurnstileCorsAllowlist()
321}
322
323// setupNavigationPolicyHandler sets up the decide-policy signal handler
324// This intercepts navigation actions BEFORE they occur, allowing us to detect
325// middle-click and Ctrl+click on links and open them in new panes
326func (w *WebView) setupNavigationPolicyHandler() {
327	w.view.ConnectDecidePolicy(func(decision webkit.PolicyDecisioner, decisionType webkit.PolicyDecisionType) bool {
328		// Only handle navigation actions (not new window or response policies)
329		if decisionType != webkit.PolicyDecisionTypeNavigationAction {
330			return false // Let WebKit handle
331		}
332
333		// Cast to NavigationPolicyDecision to access NavigationAction
334		navDecision, ok := decision.(*webkit.NavigationPolicyDecision)
335		if !ok {
336			return false
337		}
338
339		navAction := navDecision.NavigationAction()
340		if navAction == nil {
341			return false
342		}
343
344		// Check if this is a link click
345		if navAction.NavigationType() != webkit.NavigationTypeLinkClicked {
346			return false // Not a link click, let WebKit handle normally
347		}
348
349		// Get mouse button and modifiers
350		mouseButton := navAction.MouseButton()
351		modifiers := navAction.Modifiers()
352
353		// Check for middle-click (button 2) or Ctrl+left-click (button 1 + Ctrl)
354		isMiddleClick := (mouseButton == 2)
355		isCtrlClick := (mouseButton == 1 && (gdk.ModifierType(modifiers)&gdk.ControlMask) != 0)
356
357		if !isMiddleClick && !isCtrlClick {
358			return false // Normal click, let WebKit handle
359		}
360
361		// Get the link URL
362		request := navAction.Request()
363		if request == nil {
364			return false
365		}
366
367		linkURL := request.URI()
368		if linkURL == "" {
369			return false
370		}
371
372		clickType := "middle-click"
373		if isCtrlClick {
374			clickType = "Ctrl+click"
375		}
376		log.Printf("[navigation-policy] Detected %s on link: %s", clickType, linkURL)
377
378		// Call the registered handler
379		w.mu.RLock()
380		handler := w.onMiddleClickLink
381		w.mu.RUnlock()
382
383		if handler != nil {
384			handled := handler(linkURL)
385			if handled {
386				log.Printf("[navigation-policy] %s handled by workspace, blocking navigation", clickType)
387				// Block the navigation by calling Ignore()
388				navDecision.Ignore()
389				return true // Prevent default behavior
390			}
391		}
392
393		return false // Let WebKit handle if not handled
394	})
395
396	log.Printf("[webkit] Navigation policy handler attached to WebView ID %d", w.id)
397}
398
399func (w *WebView) applyTurnstileCorsAllowlist() {
400	if w.view == nil || w.config == nil || !w.config.EnableTurnstileWorkaround {
401		return
402	}
403
404	pattern := fmt.Sprintf("https://%s/*", turnstileHost)
405	w.view.SetCorsAllowlist([]string{pattern})
406	log.Printf("[turnstile] Applied CORS allowlist for %s on WebView ID %d", turnstileHost, w.id)
407}
408
409func (w *WebView) attachTurnstileResponseLogger() {
410	if w.view == nil {
411		return
412	}
413
414	w.view.ConnectResourceLoadStarted(func(resource *webkit.WebResource, request *webkit.URIRequest) {
415		if resource == nil || request == nil {
416			return
417		}
418
419		targetURL := request.URI()
420		if targetURL == "" {
421			targetURL = resource.URI()
422		}
423		if !isTurnstileURL(targetURL) {
424			return
425		}
426
427		method := request.HTTPMethod()
428		if method == "" {
429			method = "GET"
430		}
431
432		referer := "<none>"
433		if reqHeaders := request.HTTPHeaders(); reqHeaders != nil {
434			if ref := strings.TrimSpace(reqHeaders.One("Referer")); ref != "" {
435				referer = ref
436			}
437		}
438
439		resource.Connect("notify::response", func() {
440			response := resource.Response()
441			if response == nil {
442				log.Printf("[turnstile] resource=%s method=%s status=<unknown> referer=%s corp=<missing> coep=<missing> coop=<missing>", targetURL, method, referer)
443				return
444			}
445
446			headers := response.HTTPHeaders()
447			corp := headerValue(headers, "Cross-Origin-Resource-Policy")
448			coep := headerValue(headers, "Cross-Origin-Embedder-Policy")
449			coop := headerValue(headers, "Cross-Origin-Opener-Policy")
450
451			log.Printf("[turnstile] resource=%s method=%s status=%d referer=%s corp=%s coep=%s coop=%s",
452				targetURL, method, response.StatusCode(), referer, corp, coep, coop)
453		})
454	})
455}
456
457type headerLookup interface {
458	One(string) string
459}
460
461func headerValue(headers headerLookup, name string) string {
462	if headers == nil {
463		return "<missing>"
464	}
465	value := strings.TrimSpace(headers.One(name))
466	if value == "" {
467		return "<missing>"
468	}
469	return value
470}
471
472func isTurnstileURL(raw string) bool {
473	if raw == "" {
474		return false
475	}
476	parsed, err := url.Parse(raw)
477	if err != nil {
478		return false
479	}
480	host := strings.ToLower(parsed.Hostname())
481	return host == turnstileHost
482}
483
484// LoadURL loads the given URL in the WebView
485func (w *WebView) LoadURL(url string) error {
486	w.mu.RLock()
487	defer w.mu.RUnlock()
488
489	if w.destroyed {
490		return ErrWebViewDestroyed
491	}
492
493	if url == "" {
494		return ErrInvalidURL
495	}
496
497	w.view.LoadURI(url)
498	return nil
499}
500
501// GetCurrentURL returns the current URL
502func (w *WebView) GetCurrentURL() string {
503	w.mu.RLock()
504	defer w.mu.RUnlock()
505
506	if w.destroyed {
507		return ""
508	}
509
510	return w.view.URI()
511}
512
513// GetTitle returns the current page title
514func (w *WebView) GetTitle() string {
515	w.mu.RLock()
516	defer w.mu.RUnlock()
517
518	if w.destroyed {
519		return ""
520	}
521
522	return w.view.Title()
523}
524
525// GoBack navigates back in history
526func (w *WebView) GoBack() error {
527	w.mu.RLock()
528	defer w.mu.RUnlock()
529
530	if w.destroyed {
531		return ErrWebViewDestroyed
532	}
533
534	w.view.GoBack()
535	return nil
536}
537
538// GoForward navigates forward in history
539func (w *WebView) GoForward() error {
540	w.mu.RLock()
541	defer w.mu.RUnlock()
542
543	if w.destroyed {
544		return ErrWebViewDestroyed
545	}
546
547	w.view.GoForward()
548	return nil
549}
550
551// Reload reloads the current page
552func (w *WebView) Reload() error {
553	w.mu.RLock()
554	defer w.mu.RUnlock()
555
556	if w.destroyed {
557		return ErrWebViewDestroyed
558	}
559
560	w.view.Reload()
561	return nil
562}
563
564// ReloadBypassCache reloads the current page, bypassing cache
565func (w *WebView) ReloadBypassCache() error {
566	w.mu.RLock()
567	defer w.mu.RUnlock()
568
569	if w.destroyed {
570		return ErrWebViewDestroyed
571	}
572
573	w.view.ReloadBypassCache()
574	return nil
575}
576
577// Show makes the WebView visible
578func (w *WebView) Show() error {
579	w.mu.RLock()
580	defer w.mu.RUnlock()
581
582	if w.destroyed {
583		return ErrWebViewDestroyed
584	}
585
586	// Make the WebView widget visible
587	w.view.SetVisible(true)
588
589	// Show the window containing the WebView
590	if w.window != nil {
591		w.window.Show()
592	}
593
594	return nil
595}
596
597// Hide makes the WebView invisible
598func (w *WebView) Hide() error {
599	w.mu.RLock()
600	defer w.mu.RUnlock()
601
602	if w.destroyed {
603		return ErrWebViewDestroyed
604	}
605
606	w.view.SetVisible(false)
607	return nil
608}
609
610// Destroy destroys the WebView and releases resources
611func (w *WebView) Destroy() error {
612	// DO NOT acquire w.mu lock here to avoid deadlock!
613	// After GTK main loop exits, any goroutine holding the lock and calling GTK methods
614	// will block forever waiting for the main loop. This causes Destroy() to deadlock
615	// trying to acquire the same lock.
616	//
617	// During shutdown, thread safety is not critical since we're tearing everything down.
618
619	if w.destroyed {
620		return nil
621	}
622
623	w.destroyed = true
624
625	// Unregister from global registry
626	viewMu.Lock()
627	delete(viewRegistry, w.id)
628	viewMu.Unlock()
629
630	// DO NOT call StopLoading() or any other GTK methods here!
631	// After the main loop exits, GTK calls will block forever.
632	// The GTK widget will be cleaned up by Go GC.
633
634	return nil
635}
636
637// IsDestroyed returns true if the WebView has been destroyed
638func (w *WebView) IsDestroyed() bool {
639	// DO NOT acquire lock - read of bool is atomic on all platforms Go supports
640	// This prevents deadlock during shutdown when GTK signal handlers check IsDestroyed
641	return w.destroyed
642}
643
644// ID returns the unique identifier for this WebView
645func (w *WebView) ID() uint64 {
646	return w.id
647}
648
649// IDString returns the WebView ID as a string
650// This is a convenience method for compatibility with code expecting string IDs
651func (w *WebView) IDString() string {
652	return fmt.Sprintf("%d", w.id)
653}
654
655// ParseWebViewID converts a string WebView ID to uint64
656// Returns 0 if the string cannot be parsed
657func ParseWebViewID(idStr string) uint64 {
658	id, err := strconv.ParseUint(idStr, 10, 64)
659	if err != nil {
660		return 0
661	}
662	return id
663}
664
665// AsWidget returns the WebView as a gtk.Widgetter
666func (w *WebView) AsWidget() gtk.Widgetter {
667	if w == nil || w.view == nil {
668		return nil
669	}
670	return w.view
671}
672
673// Event handler registration methods
674
675// RegisterScriptMessageHandler registers a handler for script messages
676func (w *WebView) RegisterScriptMessageHandler(handler func(string)) {
677	w.mu.Lock()
678	defer w.mu.Unlock()
679	w.onScriptMessage = handler
680}
681
682// RegisterTitleChangedHandler registers a handler for title changes
683func (w *WebView) RegisterTitleChangedHandler(handler func(string)) {
684	w.mu.Lock()
685	defer w.mu.Unlock()
686	w.onTitleChanged = handler
687}
688
689// RegisterURIChangedHandler registers a handler for URI changes
690func (w *WebView) RegisterURIChangedHandler(handler func(string)) {
691	w.mu.Lock()
692	defer w.mu.Unlock()
693	w.onURIChanged = handler
694}
695
696// RegisterFaviconChangedHandler registers a handler for favicon changes
697func (w *WebView) RegisterFaviconChangedHandler(handler func([]byte)) {
698	w.mu.Lock()
699	defer w.mu.Unlock()
700	w.onFaviconChanged = handler
701}
702
703// RegisterFaviconURIChangedHandler registers a handler for favicon URI changes
704func (w *WebView) RegisterFaviconURIChangedHandler(handler func(pageURI, faviconURI string)) {
705	w.mu.Lock()
706	defer w.mu.Unlock()
707	w.onFaviconURIChanged = handler
708}
709
710// RegisterZoomChangedHandler registers a handler for zoom changes
711func (w *WebView) RegisterZoomChangedHandler(handler func(float64)) {
712	w.mu.Lock()
713	defer w.mu.Unlock()
714	w.onZoomChanged = handler
715}
716
717// RegisterLoadCommittedHandler registers a handler for load committed events
718// This fires when the page actually starts loading new content (after URI change)
719func (w *WebView) RegisterLoadCommittedHandler(handler func(string)) {
720	w.mu.Lock()
721	defer w.mu.Unlock()
722	w.onLoadCommitted = handler
723}
724
725// RegisterCloseHandler registers a handler for close requests
726func (w *WebView) RegisterCloseHandler(handler func()) {
727	w.mu.Lock()
728	defer w.mu.Unlock()
729	w.onClose = handler
730}
731
732// RegisterPopupCreateHandler registers a handler for WebKit's create signal
733// This is called when JavaScript calls window.open() or a link with target="_blank" is clicked
734// The handler should create and return a new WebView for the popup, or return nil to block it
735// The returned WebView should NOT be shown yet - WebKit will emit ready-to-show when it's ready
736func (w *WebView) RegisterPopupCreateHandler(handler func(*webkit.NavigationAction) *WebView) {
737	w.mu.Lock()
738	defer w.mu.Unlock()
739	w.onPopupCreate = handler
740}
741
742// RegisterReadyToShowHandler registers a handler for WebKit's ready-to-show signal
743// This is called when a popup WebView is ready to be displayed
744// At this point it's safe to insert the popup into the UI (workspace, window, etc.)
745func (w *WebView) RegisterReadyToShowHandler(handler func()) {
746	w.mu.Lock()
747	defer w.mu.Unlock()
748	w.onReadyToShow = handler
749}
750
751// RegisterNavigationPolicyHandler registers a handler for navigation policy decisions
752func (w *WebView) RegisterNavigationPolicyHandler(handler func(url string, isUserGesture bool) bool) {
753	w.mu.Lock()
754	defer w.mu.Unlock()
755	w.onNavigationPolicy = handler
756}
757
758// OnWindowTypeDetected registers a handler for window type detection
759func (w *WebView) OnWindowTypeDetected(handler func(WindowType, *WindowFeatures)) {
760	w.mu.Lock()
761	defer w.mu.Unlock()
762	w.onWindowTypeDetected = handler
763}
764
765// RegisterMiddleClickLinkHandler registers a handler for middle mouse button clicks on links
766// The handler receives the link URL and returns true if handled
767func (w *WebView) RegisterMiddleClickLinkHandler(handler func(url string) bool) {
768	w.mu.Lock()
769	defer w.mu.Unlock()
770	w.onMiddleClickLink = handler
771}
772
773// RunOnMainThread executes a function on the GTK main thread
774func (w *WebView) RunOnMainThread(fn func()) {
775	RunOnMainThread(fn)
776}
777
778// GetWebView returns the underlying webkit.WebView for advanced operations
779func (w *WebView) GetWebView() *webkit.WebView {
780	return w.view
781}
782
783// Widget returns the WebView widget (for compatibility with old code expecting uintptr)
784// Deprecated: Use AsWidget() instead
785func (w *WebView) Widget() gtk.Widgetter {
786	return w.AsWidget()
787}
788
789// RootWidget returns the root container widget for this WebView
790// This is the GtkBox container that holds the WebView and can be reparented
791func (w *WebView) RootWidget() gtk.Widgetter {
792	if w.container != nil {
793		return w.container
794	}
795	// Fallback to WebView if container is not available (shouldn't happen)
796	return w.AsWidget()
797}
798
799// SetZoom sets the zoom level of the WebView
800func (w *WebView) SetZoom(zoom float64) error {
801	w.mu.RLock()
802	defer w.mu.RUnlock()
803
804	if w.destroyed {
805		return ErrWebViewDestroyed
806	}
807
808	w.view.SetZoomLevel(zoom)
809	return nil
810}
811
812// GetZoom returns the current zoom level
813func (w *WebView) GetZoom() float64 {
814	w.mu.RLock()
815	defer w.mu.RUnlock()
816
817	if w.destroyed {
818		return 1.0
819	}
820
821	return w.view.ZoomLevel()
822}
823
824// UsesDomZoom indicates if this WebView uses DOM-based zoom
825// In gotk4/WebKitGTK, zoom is always viewport-based
826func (w *WebView) UsesDomZoom() bool {
827	return false
828}
829
830// SeedDomZoom is a no-op in gotk4 as we use viewport zoom
831func (w *WebView) SeedDomZoom(zoom float64) error {
832	// Not needed in gotk4 - zoom is handled differently
833	return nil
834}
835
836// InjectScript executes JavaScript in the WebView
837func (w *WebView) InjectScript(script string) error {
838	w.mu.RLock()
839	defer w.mu.RUnlock()
840
841	if w.destroyed {
842		return ErrWebViewDestroyed
843	}
844
845	// Execute JavaScript using our CGO wrapper
846	EvaluateJavascript(w.view, script)
847	return nil
848}
849
850// DispatchCustomEvent dispatches a custom event via JavaScript
851// This works across JavaScript world boundaries because it uses document.dispatchEvent.
852// GUI scripts running in the isolated world (see DumberIsolatedWorld in user_content.go)
853// can listen for these events because they share the same Document object with the main world.
854func (w *WebView) DispatchCustomEvent(eventName string, data interface{}) error {
855	// Serialize data to JSON
856	jsonData, err := json.Marshal(data)
857	if err != nil {
858		return fmt.Errorf("failed to marshal event data: %w", err)
859	}
860
861	log.Printf("[webkit] Dispatching event '%s' to WebView ID %d with data: %s", eventName, w.id, string(jsonData))
862
863	// Use document.dispatchEvent (not window) so events cross JavaScript world boundaries
864	// The GUI scripts run in an isolated world but share the same Document object
865	script := fmt.Sprintf(`
866		(function() {
867			try {
868				var detail = %s;
869				console.log('[dumber] Dispatching event: %s', detail);
870				document.dispatchEvent(new CustomEvent('%s', { detail: detail }));
871			} catch (e) {
872				console.error('[dumber] Failed to dispatch %s', e);
873			}
874		})();
875	`, string(jsonData), eventName, eventName, eventName)
876	return w.InjectScript(script)
877}
878
879// ShowDevTools opens the WebKit inspector/developer tools
880func (w *WebView) ShowDevTools() error {
881	w.mu.RLock()
882	defer w.mu.RUnlock()
883
884	if w.destroyed {
885		return ErrWebViewDestroyed
886	}
887
888	inspector := w.view.Inspector()
889	if inspector != nil {
890		inspector.Show()
891	}
892	return nil
893}
894
895// ShowPrintDialog shows the print dialog for the current page
896func (w *WebView) ShowPrintDialog() error {
897	w.mu.RLock()
898	defer w.mu.RUnlock()
899
900	if w.destroyed {
901		return ErrWebViewDestroyed
902	}
903
904	// Create and run print operation with GTK print dialog
905	printOp := webkit.NewPrintOperation(w.view)
906	printOp.RunDialog(nil) // nil = no parent window, uses active window
907	return nil
908}
909
910// RegisterKeyboardShortcut registers a keyboard shortcut handler
911// This is a compatibility method - actual shortcut handling is done at the window level
912func (w *WebView) RegisterKeyboardShortcut(key string, modifiers uint, handler func()) error {
913	// TODO: Implement keyboard shortcut registration if needed
914	// For now, shortcuts are handled at the window/application level
915	return nil
916}
917
918// SetWindowFeatures sets window features for popup windows
919func (w *WebView) SetWindowFeatures(features *WindowFeatures) {
920	// This is typically used for popup windows
921	// The features would be applied when creating the window
922}
923
924// IsActive returns whether this WebView is currently active/focused
925func (w *WebView) IsActive() bool {
926	w.mu.RLock()
927	defer w.mu.RUnlock()
928
929	if w.destroyed {
930		return false
931	}
932
933	widget := w.view
934	if widget != nil {
935		return widget.IsFocus()
936	}
937	return false
938}
939
940// Window returns the parent Window of this WebView
941func (w *WebView) Window() *Window {
942	w.mu.RLock()
943	defer w.mu.RUnlock()
944
945	if w.destroyed {
946		return nil
947	}
948
949	return w.window
950}
951
952// GetFaviconDatabase returns the WebKit FaviconDatabase for this WebView
953// Returns nil if the favicon database is not available
954func (w *WebView) GetFaviconDatabase() *webkit.FaviconDatabase {
955	w.mu.RLock()
956	defer w.mu.RUnlock()
957
958	if w.destroyed || w.view == nil {
959		return nil
960	}
961
962	session := w.view.NetworkSession()
963	if session == nil {
964		return nil
965	}
966
967	dataManager := session.WebsiteDataManager()
968	if dataManager == nil {
969		return nil
970	}
971
972	return dataManager.FaviconDatabase()
973}
974
975// UpdateContentFilters updates the content filtering rules
976func (w *WebView) UpdateContentFilters(rules string) error {
977	w.mu.RLock()
978	defer w.mu.RUnlock()
979
980	if w.destroyed {
981		return ErrWebViewDestroyed
982	}
983
984	// Note: Content filtering is handled via InitializeContentBlocking()
985	// This method is kept for backward compatibility but is deprecated
986	log.Printf("[webkit] UpdateContentFilters called but is deprecated - use InitializeContentBlocking instead")
987	return nil
988}
989
990// InitializeContentBlocking initializes content blocking with filter lists
991func (w *WebView) InitializeContentBlocking(filterJSON []byte) error {
992	w.mu.RLock()
993	defer w.mu.RUnlock()
994
995	if w.destroyed {
996		return ErrWebViewDestroyed
997	}
998
999	if len(filterJSON) == 0 {
1000		return fmt.Errorf("no filter rules provided")
1001	}
1002
1003	// Apply filters using the content blocking API
1004	return ApplyFiltersToWebView(w, filterJSON)
1005}
1006
1007// OnNavigate registers a navigation handler
1008func (w *WebView) OnNavigate(handler func(url string)) {
1009	w.mu.Lock()
1010	defer w.mu.Unlock()
1011
1012	// This wraps the URI changed handler
1013	w.onURIChanged = handler
1014}
1015
1016// RegisterWorkspaceNavigationHandler registers a handler for workspace pane navigation
1017// The handler receives the direction ("up", "down", "left", "right") and returns true if handled
1018func (w *WebView) RegisterWorkspaceNavigationHandler(handler func(direction string) bool) {
1019	w.mu.Lock()
1020	defer w.mu.Unlock()
1021	w.onWorkspaceNavigation = handler
1022}
1023
1024// RegisterPaneModeHandler registers a handler for pane mode shortcuts
1025// The handler receives the action ("enter", "close", "split-right", etc.) and returns true if handled
1026func (w *WebView) RegisterPaneModeHandler(handler func(action string) bool, isActiveChecker func() bool) {
1027	w.mu.Lock()
1028	defer w.mu.Unlock()
1029	w.onPaneModeShortcut = handler
1030	w.isPaneModeActive = isActiveChecker
1031}
1032
1033// setupFaviconHandlers enables favicons for this WebView
1034// Note: The actual favicon-changed handler is registered ONCE at the FaviconService level
1035// to avoid duplicate handlers when multiple WebViews exist
1036func (w *WebView) setupFaviconHandlers() {
1037	// Get the NetworkSession from the WebView
1038	session := w.view.NetworkSession()
1039	if session == nil {
1040		log.Printf("[webkit] Warning: No NetworkSession available for favicon handling")
1041		return
1042	}
1043
1044	// Get the WebsiteDataManager from the NetworkSession
1045	dataManager := session.WebsiteDataManager()
1046	if dataManager == nil {
1047		log.Printf("[webkit] Warning: No WebsiteDataManager available for favicon handling")
1048		return
1049	}
1050
1051	// Enable favicons if not already enabled
1052	if !dataManager.FaviconsEnabled() {
1053		dataManager.SetFaviconsEnabled(true)
1054		log.Printf("[webkit] Enabled favicons for WebView ID %d", w.id)
1055	}
1056
1057}
1058
1059// GtkWebView returns the underlying gotk4 WebView for advanced operations
1060// This is used when creating related views (popups) that need to share the same session
1061func (w *WebView) GtkWebView() *webkit.WebView {
1062	w.mu.RLock()
1063	defer w.mu.RUnlock()
1064	return w.view
1065}
1066
1067// WrapBareWebView creates a minimal WebView wrapper around a bare gotk4 WebView
1068// This is used during popup creation to return a WebView to WebKit before initialization
1069// Full initialization (container, scripts, event handlers) should be done later
1070func WrapBareWebView(bareView *webkit.WebView) *WebView {
1071	if bareView == nil {
1072		return nil
1073	}
1074
1075	// Generate unique ID
1076	id := atomic.AddUint64(&viewIDCounter, 1)
1077	log.Printf("[webkit] Created minimal wrapper for bare WebView (ID: %d)", id)
1078
1079	// Create minimal wrapper - NO initialization yet
1080	wv := &WebView{
1081		view:      bareView,
1082		container: nil, // Will be created during full initialization
1083		window:    nil,
1084		id:        id,
1085		config:    nil, // Will be set during full initialization
1086	}
1087
1088	// Register in global registry
1089	viewMu.Lock()
1090	viewRegistry[id] = wv
1091	viewMu.Unlock()
1092
1093	return wv
1094}
1095
1096// InitializeFromBare completes initialization of a bare WebView wrapper
1097// This should be called in ready-to-show after WebKit has configured the view
1098func (w *WebView) InitializeFromBare(cfg *Config) error {
1099	w.mu.Lock()
1100	defer w.mu.Unlock()
1101
1102	if w.config != nil {
1103		return fmt.Errorf("WebView already initialized")
1104	}
1105
1106	if cfg == nil {
1107		cfg = GetDefaultConfig()
1108	}
1109
1110	w.config = cfg
1111
1112	// Verify persistent session
1113	viewSession := w.view.NetworkSession()
1114	if viewSession == nil {
1115		return fmt.Errorf("WebView has no network session")
1116	}
1117
1118	// Setup UserContentManager and inject GUI scripts
1119	if err := SetupUserContentManager(w.view, cfg.AppearanceConfigJSON, w.id); err != nil {
1120		return fmt.Errorf("failed to setup user content manager: %w", err)
1121	}
1122
1123	// Create container (GtkBox) to hold the WebView
1124	container := gtk.NewBox(gtk.OrientationVertical, 0)
1125	container.SetHExpand(true)
1126	container.SetVExpand(true)
1127
1128	// Configure WebView widget for expansion
1129	w.view.SetHExpand(true)
1130	w.view.SetVExpand(true)
1131
1132	// Add WebView to container
1133	container.Append(w.view)
1134
1135	w.container = container
1136
1137	// Apply configuration
1138	if err := w.applyConfig(); err != nil {
1139		return err
1140	}
1141
1142	// Setup event handlers
1143	w.setupEventHandlers()
1144
1145	// Attach keyboard bridge
1146	w.AttachKeyboardBridge()
1147
1148	// Attach mouse gesture controls
1149	w.AttachMouseGestures()
1150
1151	// Attach touchpad gesture controls
1152	w.AttachTouchpadGestures()
1153
1154	log.Printf("[webkit] Completed initialization for WebView ID %d", w.id)
1155
1156	return nil
1157}

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