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}