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