detached 538edf0

Merge pull request #3 from bnema/refacto/gotk4-migration

refactor: migrate to gotk4 library
commit
538edf018ada8b2b4c3e452cfe5e8662690caf37
author
bnema · · 2025-10-15 17:00 UTC
committer
GitHub · · 2025-10-15 17:00 UTC
merge of
38db1f3 + 43577dd · diff against the first parent
signature
GPG UnverifiedB5690EEEBB952194 The key is unknown
113 files · +7427 -13210
M CHANGELOG.md +29 -0
@@ -5,16 +5,45 @@ All notable changes to this project will be documented in this file.
55 ## [Unreleased]
66
77 ### Added
8+- **Native favicon support**: WebKit FaviconDatabase integration with automatic detection and caching for all panes
89 - ENV=dev support to isolate test builds from production config/data in .dev/dumber/ directory
910 - Content filtering whitelist config (Twitch enabled by default)
1011 - `dumber config` command to open config file in $VISUAL/$EDITOR or print path with `--path`
12+- **Native WebKit popup lifecycle**: Implemented WebKit's create/ready-to-show/close signals for proper popup management, eliminating manual WebView creation that bypassed WebKit's internal architecture
13+- **Popup behavior configuration**: Added `popup_behavior` config with four modes: `split` (default), `stacked`, `tabbed`, and `windowed` for user control over popup placement
14+- **Backend-driven pane mode**: Complete rewrite of pane mode from JavaScript to Go backend with native keyboard handling. Ctrl+P enters mode, x/l/r/d/u/arrows for actions, Escape exits. Blocks all other keys during mode.
15+- **Pane mode visual border**: Zellij-style border around workspace root when pane mode is active, replacing old toast notification system. Uses GTK margins with configurable `pane_mode_border_color` (defaults to #FFA500 orange). Window background color shows through margins to create visible border effect
16+
17+### Changed
18+- **Popup architecture refactoring**: Removed ~280 lines of JavaScript window.open interception in favor of WebKit's native popup signals. Popups now follow WebKitGTK's expected lifecycle instead of being intercepted and manually created
19+- **GUI bootstrap refactoring**: Extracted 400+ lines of common initialization logic into reusable bootstrap.ts module shared between injected pages and special schemes (homepage, etc.)
20+- **Workspace pane mode**: Moved ~450 lines of JavaScript state machine to Go backend for more reliable keyboard handling and eliminated race conditions between webviews
1121
1222 ### Fixed
23+- **TLS certificate validation restored**: Re-implemented persistent certificate error handling (broken during gotk4 migration)
24+ - Interactive three-option dialog: "Go Back", "Proceed Once (Unsafe)", and "Always Accept This Site"
25+ - SQLite-based hostname decision storage (hostname-only matching - GIO certificate properties are unstable)
26+ - Smart expiration: "Proceed Once" temporary (not stored), "Always Accept" persists for 30 days
27+ - Automatic cleanup of expired validations on application startup
28+ - **Known issue**: Accepted certificates not pre-loaded into WebKit session on startup - stored decisions only apply when TLS error occurs again
29+- **Content blocking restored**: Re-implemented WebKit native ad/tracker blocking (broken during gotk4 migration) using UserContentFilterStore API
30+- **Script injection spam**: Fixed 100+ duplicate "Sending color palettes" messages by injecting scripts only in top frame instead of all iframes
31+- **Favicon race conditions**: Eliminated duplicate downloads and file handle errors via mutex protection and proper handler registration
32+- **Homepage theme switching**: Fixed color palette application for proper light/dark mode transitions
33+- **Workspace pane borders**: Hide active pane border when only one pane exists to match Zellij UX
1334 - Enabled missing WebKitGTK6 features: WebRTC, MediaSource, LocalStorage, WebAudio, MediaStream, Clipboard
1435 - Cosmetic filter duplicate injection in frames
1536 - Config file duplicate keys (snake_case/camelCase) by using Viper's SafeWriteConfigAs
1637 - Config not reading newly created default config file
1738 - Config writing to disk on every load/reload
39+- **Popup SIGSEGV crashes**: Eliminated segmentation violations during popup lifecycle by respecting WebKit's signal-based popup management. Fixes OAuth popup crashes and GTK bloom filter corruption
40+- **GTK focus event duplicates**: Added timestamp-based deduplication to prevent multiple focus enter/leave events in the same millisecond from WebKitGTK nested widgets
41+- **Popup close behavior**: Fixed app exit when closing popups by excluding them from remaining pane count check
42+- **Pane mode border cleanup**: Fixed border persisting after pane splits by saving container reference when applying margins. Ensures proper cleanup even when workspace tree structure changes during split operations
43+- **Stacked pane active border**: Fixed active border incorrectly appearing on stacked pane containers instead of individual panes
44+- **Stacked pane creation**: Fixed premature Realize() call causing widget lifecycle issues during stack creation
45+- **Browser exit on last pane close**: Fixed browser not properly quitting when closing the last webview via Ctrl+P x by calling GTK main loop quit instead of window close
46+- **Zoom level persistence restored**: Fixed per-domain zoom settings not being saved to database (broken during gotk4 migration). Added missing `notify::zoom-level` signal connection in setupEventHandlers to properly persist zoom changes
1847
1948 ## [0.11.0] - 2025-10-07
2049
M Makefile +1 -0
@@ -123,6 +123,7 @@ clean: ## Clean build artifacts
123123 rm -rf $(DIST_DIR)
124124 rm -f $(BINARY_NAME) # Remove any old binaries in root
125125 rm -rf gui/dist gui/node_modules
126+ rm -f assets/gui/gui.min.js assets/gui/homepage.min.js assets/gui/homepage.css assets/gui/main-world.min.js assets/gui/color-scheme.js
126127 go clean -cache
127128 go clean -testcache
128129
A POPUP_REFACTORING_PLAN.md +654 -0
@@ -0,0 +1,654 @@
1+# Popup Window Refactoring Plan
2+
3+## Executive Summary
4+
5+The current popup window implementation causes critical SIGSEGV crashes due to improper lifecycle management that conflicts with WebKitGTK's internal architecture. This document outlines the issues, root causes, and a comprehensive refactoring plan to align with WebKitGTK's expected signal-based popup lifecycle.
6+
7+## Issues Faced
8+
9+### 1. Critical Crash: SIGSEGV on Popup Close
10+
11+**Symptom:**
12+```
13+signal 11 received but handler not on signal stack
14+fatal error: non-Go code set up signal handler without SA_ONSTACK flag
15+```
16+
17+**Occurrences:**
18+- OAuth popup completion (Google, Notion login)
19+- Rapid popup open/close cycles (popup blocker detection)
20+- Any programmatic popup closure
21+
22+**Impact:** Browser crash, data loss, poor user experience
23+
24+### 2. GTK Bloom Filter Corruption
25+
26+**Symptom:**
27+```
28+[workspace] Skipping CSS, pane 0xc00015c000 reparented 0.6ms ago
29+```
30+
31+**Root Cause:** CSS class modifications during widget reparenting operations before GTK widget hierarchy stabilizes
32+
33+**Attempted Fixes (all failed):**
34+- Boolean flag `pendingReparent` cleared too early
35+- Timestamp-based CSS blocking (150ms safety window)
36+- `StopLoading()` before close (broke OAuth callback)
37+
38+### 3. WebKit/GTK Race Conditions
39+
40+**Manifestations:**
41+- localStorage JS injection immediately before `ClosePane()` triggered WebKit JS engine during GTK reparenting
42+- Rapid popup opens (<1s) caused WebKit rendering pipeline initialization conflicts with GTK widget destruction
43+- WebKit signal handlers firing after GTK widgets destroyed
44+
45+## Root Cause Analysis
46+
47+### Architecture Violation: Bypassing WebKit Popup Lifecycle
48+
49+**Current Implementation Flow:**
50+```
51+JavaScript window.open()
52+ ↓
53+main-world.js intercepts
54+ ↓
55+postMessage to Go
56+ ↓
57+messaging.Handler
58+ ↓
59+WorkspaceManager.handleIntentAsPopup()
60+ ↓
61+Manual WebView creation
62+ ↓
63+Manual pane insertion
64+ ↓
65+Manual close (ClosePane)
66+```
67+
68+**Problem:** This completely bypasses WebKit's native popup management system.
69+
70+### WebKitGTK's Expected Popup Lifecycle
71+
72+**Reference:** `/usr/include/webkitgtk-6.0/webkit/WebKitWebView.h:243-259`
73+
74+```c
75+struct _WebKitWebViewClass {
76+ // ... other signals ...
77+
78+ GtkWidget *(* create) (WebKitWebView *web_view,
79+ WebKitNavigationAction *navigation_action);
80+
81+ void (* ready_to_show) (WebKitWebView *web_view);
82+
83+ void (* close) (WebKitWebView *web_view);
84+
85+ // ... other signals ...
86+};
87+```
88+
89+**WebKit's Lifecycle Contract:**
90+
91+1. **`create` signal**: WebKit requests new WebView for popup
92+ - Application creates and configures new WebView
93+ - Application returns WebView to WebKit
94+ - **WebKit takes ownership of loading and content rendering**
95+
96+2. **`ready-to-show` signal**: WebKit indicates popup is ready
97+ - WebKit has finished initial setup
98+ - Safe to show/position window/pane
99+ - Window properties (size, features) are available
100+
101+3. **`close` signal**: WebKit requests popup closure
102+ - Emitted when JavaScript calls `window.close()`
103+ - Emitted when page navigates away from popup context
104+ - **Application should destroy widgets in response**
105+
106+### Why Current Implementation Fails
107+
108+1. **No signal connection**: Application never connects to WebKit's `create` signal
109+2. **Manual lifecycle**: Application creates WebViews independently, breaking WebKit's internal reference tracking
110+3. **Premature destruction**: `ClosePane()` destroys GTK widgets while WebKit still has:
111+ - Active signal handlers
112+ - Compositor threads running
113+ - Rendering pipeline active
114+ - JS engine references
115+
116+4. **Race conditions**: JavaScript message handling races with WebKit's internal operations
117+
118+## Reference Implementations
119+
120+### Epiphany (GNOME Web Browser)
121+
122+**File:** `/home/brice/dev/clone/epiphany/src/ephy-window.c`
123+
124+**Key Implementation Points:**
125+
126+1. **Signal Connection** (line 2636-2638):
127+```c
128+g_signal_connect_object (web_view, "create",
129+ G_CALLBACK (create_web_view_cb),
130+ window, 0);
131+```
132+
133+2. **Create Handler** (lines 2034-2070):
134+```c
135+static WebKitWebView *
136+create_web_view_cb (WebKitWebView *web_view,
137+ WebKitNavigationAction *navigation_action,
138+ EphyWindow *window)
139+{
140+ EphyEmbed *embed;
141+ WebKitWebView *new_web_view;
142+
143+ // Determine if popup should be new window or new tab
144+ if (new_windows_in_tabs_setting) {
145+ target_window = window; // Same window (tab)
146+ } else {
147+ target_window = ephy_window_new (); // New window
148+ }
149+
150+ // Create new tab/window with parent relationship
151+ embed = ephy_shell_new_tab_full (..., web_view, ...);
152+
153+ new_web_view = EPHY_GET_WEBKIT_WEB_VIEW_FROM_EMBED (embed);
154+
155+ // Connect ready-to-show handler
156+ g_signal_connect_object (new_web_view, "ready-to-show",
157+ G_CALLBACK (web_view_ready_cb),
158+ web_view, 0);
159+
160+ // Return new WebView to WebKit
161+ return new_web_view;
162+}
163+```
164+
165+3. **Ready-to-Show Handler** (lines 2012-2031):
166+```c
167+static gboolean
168+web_view_ready_cb (WebKitWebView *web_view,
169+ WebKitWebView *parent_web_view)
170+{
171+ EphyWindow *window;
172+
173+ window = EPHY_WINDOW (gtk_widget_get_root (GTK_WIDGET (web_view)));
174+
175+ // Apply window properties
176+ if (using_new_window) {
177+ ephy_window_configure_for_view (window, web_view);
178+ }
179+
180+ // Show the window/tab
181+ gtk_widget_set_visible (GTK_WIDGET (window), TRUE);
182+
183+ return TRUE;
184+}
185+```
186+
187+4. **Close Handler**: Uses AdwTabView's built-in `close-page` signal (lines 3314-3360), which properly coordinates with WebKit
188+
189+**Key Lessons from Epiphany:**
190+- ✅ Respects WebKit's signal-based lifecycle
191+- ✅ Creates WebView synchronously in `create` handler
192+- ✅ Returns WebView immediately to WebKit
193+- ✅ Defers visibility to `ready-to-show`
194+- ✅ Lets WebKit manage content loading
195+- ✅ Clean separation: positioning logic vs. lifecycle management
196+
197+### WebKitGTK Documentation
198+
199+**Reference:** https://webkitgtk.org/reference/webkitgtk/stable/signal.WebView.create.html
200+
201+**Signal: `WebKitWebView::create`**
202+
203+> This signal is emitted when a new WebKitWebView should be created for a popup window. The WebKitNavigationAction parameter contains information about the navigation action that triggered this signal. The signal handler should return the newly created WebKitWebView, or NULL if the popup window creation should be canceled.
204+
205+**Signal: `WebKitWebView::ready-to-show`**
206+
207+> Emitted after the WebKitWebView::create signal when the new view is ready to be shown. This signal allows you to show the new view and apply the window properties from webkit_web_view_get_window_properties() before the page is loaded.
208+
209+**Signal: `WebKitWebView::close`**
210+
211+> Emitted when the WebKitWebView is being closed by the web process. This signal is typically triggered by JavaScript window.close() calls. The signal handler should destroy the widget or otherwise handle the close request.
212+
213+## Proposed Refactoring
214+
215+### Phase 1: Implement Native WebKit Popup Signals
216+
217+#### 1.1 Add CGO Bindings for Create Signal
218+
219+**File:** `pkg/webkit/webview_popup.go` (new file)
220+
221+```go
222+package webkit
223+
224+/*
225+#cgo pkg-config: webkitgtk-6.0
226+#include <webkit/webkit.h>
227+#include <gtk/gtk.h>
228+
229+// Forward declaration
230+extern WebKitWebView* goWebViewCreateCallback(WebKitWebView*, WebKitNavigationAction*, gpointer);
231+
232+// C helper to connect create signal
233+static inline void connect_create_signal(WebKitWebView* view, gpointer user_data) {
234+ g_signal_connect(view, "create", G_CALLBACK(goWebViewCreateCallback), user_data);
235+}
236+*/
237+import "C"
238+import "unsafe"
239+
240+// ConnectCreateSignal connects the WebView's create signal for popup handling
241+func (w *WebView) ConnectCreateSignal() {
242+ if w.view == nil {
243+ return
244+ }
245+ C.connect_create_signal((*C.WebKitWebView)(unsafe.Pointer(w.view.Native())), C.gpointer(unsafe.Pointer(w)))
246+}
247+```
248+
249+**File:** `pkg/webkit/webview_popup_callback.c` (new file)
250+
251+```c
252+#include <webkit/webkit.h>
253+#include "_cgo_export.h"
254+
255+// CGO callback bridge for create signal
256+WebKitWebView* goWebViewCreateCallback(WebKitWebView* parent_view,
257+ WebKitNavigationAction* navigation_action,
258+ gpointer user_data) {
259+ // Call Go callback via CGO
260+ return handleWebViewCreate(user_data, navigation_action);
261+}
262+```
263+
264+#### 1.2 Implement Go-side Create Handler
265+
266+**File:** `pkg/webkit/webview.go` (additions)
267+
268+```go
269+type WebView struct {
270+ // ... existing fields ...
271+
272+ // Popup lifecycle handlers
273+ onPopupCreate func(*NavigationAction) *WebView
274+ onReadyToShow func()
275+}
276+
277+//export handleWebViewCreate
278+func handleWebViewCreate(webViewPtr unsafe.Pointer, navActionPtr unsafe.Pointer) *C.WebKitWebView {
279+ // Recover WebView from pointer
280+ w := (*WebView)(webViewPtr)
281+
282+ if w.onPopupCreate == nil {
283+ return nil // No handler, cancel popup
284+ }
285+
286+ // Parse navigation action
287+ navAction := parseNavigationAction(navActionPtr)
288+
289+ // Call Go handler
290+ newWebView := w.onPopupCreate(navAction)
291+
292+ if newWebView == nil {
293+ return nil
294+ }
295+
296+ // Return WebKit's native view
297+ return (*C.WebKitWebView)(unsafe.Pointer(newWebView.view.Native()))
298+}
299+
300+// RegisterPopupCreateHandler registers handler for WebKit's create signal
301+func (w *WebView) RegisterPopupCreateHandler(handler func(*NavigationAction) *WebView) {
302+ w.mu.Lock()
303+ defer w.mu.Unlock()
304+ w.onPopupCreate = handler
305+ w.ConnectCreateSignal()
306+}
307+```
308+
309+#### 1.3 Connect Ready-to-Show and Close Signals
310+
311+**File:** `pkg/webkit/webview.go` (setupEventHandlers addition)
312+
313+```go
314+func (w *WebView) setupEventHandlers() {
315+ // ... existing handlers ...
316+
317+ // Ready-to-show signal (already exists in gotk4 bindings)
318+ w.view.ConnectReadyToShow(func() {
319+ if w.onReadyToShow != nil {
320+ w.onReadyToShow()
321+ }
322+ })
323+
324+ // Close signal (already connected, line 219-223)
325+ // Just ensure onClose is used for popup lifecycle
326+}
327+```
328+
329+### Phase 2: Refactor Workspace Popup Management
330+
331+#### 2.1 Remove JavaScript Interception for window.open
332+
333+**File:** `gui/src/lib/main-world/window-intercept.ts`
334+
335+**Change:** Remove `window.open` interception, let it reach WebKit natively
336+
337+**Rationale:** WebKit's `create` signal will handle it properly
338+
339+#### 2.2 Implement Workspace-Level Create Handler
340+
341+**File:** `internal/app/browser/workspace_popup.go`
342+
343+**Refactor:** Replace `handleIntentAsPopup()` with proper signal-based approach
344+
345+```go
346+// setupPopupHandling connects WebView to workspace popup management
347+func (wm *WorkspaceManager) setupPopupHandling(webView *webkit.WebView, parentNode *paneNode) {
348+ webView.RegisterPopupCreateHandler(func(navAction *webkit.NavigationAction) *webkit.WebView {
349+ // Parse popup intent from navigation
350+ url := navAction.GetRequest().URI()
351+ features := navAction.GetWindowFeatures()
352+
353+ // Determine popup type from config/URL
354+ popupType := wm.determinePopupType(url, features)
355+
356+ // Create new WebView for popup
357+ popupView, err := wm.createWebViewFn()
358+ if err != nil {
359+ log.Printf("[workspace] Failed to create popup WebView: %v", err)
360+ return nil // Cancels popup
361+ }
362+
363+ // Create pane but DON'T insert yet (WebKit still loading)
364+ popupPane, err := wm.createPaneFn(popupView)
365+ if err != nil {
366+ log.Printf("[workspace] Failed to create popup pane: %v", err)
367+ return nil
368+ }
369+
370+ // Store popup info for ready-to-show
371+ popupInfo := &pendingPopup{
372+ view: popupView,
373+ pane: popupPane,
374+ parentNode: parentNode,
375+ popupType: popupType,
376+ url: url,
377+ features: features,
378+ }
379+ wm.pendingPopups[popupView.ID()] = popupInfo
380+
381+ // Connect ready-to-show for this specific popup
382+ popupView.RegisterReadyToShowHandler(func() {
383+ wm.handlePopupReadyToShow(popupView.ID())
384+ })
385+
386+ // Connect close handler
387+ popupView.RegisterCloseHandler(func() {
388+ wm.handlePopupClose(popupView.ID())
389+ })
390+
391+ // Return to WebKit - it will manage loading
392+ return popupView
393+ })
394+}
395+
396+// handlePopupReadyToShow inserts popup pane when WebKit says it's ready
397+func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) {
398+ info, ok := wm.pendingPopups[popupID]
399+ if !ok {
400+ return
401+ }
402+ delete(wm.pendingPopups, popupID)
403+
404+ // NOW it's safe to insert into workspace
405+ switch info.popupType {
406+ case PopupTypeSplit:
407+ wm.insertPopupPane(info.parentNode, info.pane, "right")
408+ case PopupTypeStacked:
409+ wm.stackedPaneManager.StackPane(info.parentNode)
410+ case PopupTypeTabbed:
411+ // TODO: Implement tabbed popups
412+ wm.insertPopupPane(info.parentNode, info.pane, "right")
413+ }
414+
415+ // Apply popup features (size, position, toolbar, etc.)
416+ wm.applyPopupFeatures(info.pane, info.features)
417+}
418+
419+// handlePopupClose responds to WebKit's close signal
420+func (wm *WorkspaceManager) handlePopupClose(popupID uint64) {
421+ // Find pane by WebView ID
422+ var targetNode *paneNode
423+ for webView, node := range wm.viewToNode {
424+ if webView != nil && webView.ID() == popupID {
425+ targetNode = node
426+ break
427+ }
428+ }
429+
430+ if targetNode == nil {
431+ return
432+ }
433+
434+ // Clean close via existing ClosePane (but WebKit already stopped)
435+ if err := wm.ClosePane(targetNode); err != nil {
436+ log.Printf("[workspace] Error closing popup pane: %v", err)
437+ }
438+}
439+```
440+
441+**Key Changes:**
442+- ✅ WebView created in `create` signal handler
443+- ✅ Returned immediately to WebKit
444+- ✅ Pane insertion deferred until `ready-to-show`
445+- ✅ Cleanup happens in response to `close` signal
446+- ✅ No manual lifecycle management
447+
448+#### 2.3 Configuration-Based Popup Type
449+
450+**File:** `internal/app/config/config.go`
451+
452+```go
453+type PopupBehavior string
454+
455+const (
456+ PopupBehaviorSplit PopupBehavior = "split"
457+ PopupBehaviorStacked PopupBehavior = "stacked"
458+ PopupBehaviorTabbed PopupBehavior = "tabbed"
459+ PopupBehaviorWindow PopupBehavior = "window" // Native popup window
460+)
461+
462+type Config struct {
463+ // ... existing fields ...
464+
465+ PopupBehavior PopupBehavior `json:"popup_behavior"`
466+
467+ // Per-domain popup overrides
468+ PopupOverrides map[string]PopupBehavior `json:"popup_overrides"`
469+}
470+```
471+
472+**Usage:**
473+```go
474+func (wm *WorkspaceManager) determinePopupType(url string, features *webkit.WindowFeatures) PopupType {
475+ // Check per-domain overrides
476+ domain := extractDomain(url)
477+ if override, ok := wm.app.config.PopupOverrides[domain]; ok {
478+ return popupBehaviorToType(override)
479+ }
480+
481+ // Use global config
482+ return popupBehaviorToType(wm.app.config.PopupBehavior)
483+}
484+```
485+
486+### Phase 3: Remove Temporary Workarounds
487+
488+#### 3.1 Remove CSS Bloom Filter Workarounds
489+
490+**Files to clean:**
491+- `internal/app/browser/workspace_types.go`: Remove `lastReparentTime`, `pendingHoverReattach`, `pendingFocusReattach`
492+- `internal/app/browser/workspace_utils.go`: Remove timestamp checking in `applyActivePaneBorder()`
493+- `internal/app/browser/workspace_pane_ops.go`: Remove timestamp setting in reparenting operations
494+
495+**Rationale:** Proper WebKit lifecycle management eliminates the race conditions
496+
497+#### 3.2 Remove LocalStorage Cleanup Workarounds
498+
499+**File:** `internal/app/browser/workspace_manager.go`
500+
501+**Remove:** All localStorage cleanup code in `close-popup` handler
502+
503+**Rationale:**
504+- Popup's WebView will be destroyed cleanly by WebKit
505+- Parent-child communication should use `window.postMessage` instead of localStorage
506+- If localStorage is really needed, clean it in JavaScript before calling `window.close()`
507+
508+#### 3.3 Remove JavaScript Message-Based Popup Flow
509+
510+**Files:**
511+- `gui/src/lib/main-world/window-intercept.ts`: Remove `window.open` interception
512+- `internal/app/messaging/handler.go`: Remove `WindowIntent` and `create-pane` handling
513+- `internal/app/messaging/deduplicator.go`: Potentially remove (may still be useful for other message types)
514+
515+### Phase 4: Testing & Validation
516+
517+#### 4.1 Test Scenarios
518+
519+1. **OAuth Flows:**
520+ - Google login
521+ - Notion login
522+ - GitHub OAuth
523+
524+2. **Rapid Popup Cycles:**
525+ - Popup blocker detection
526+ - Sites that rapidly open/close popups
527+
528+3. **Popup Features:**
529+ - Window.open with size specifications
530+ - Toolbar/menubar specifications
531+ - window.close() from JavaScript
532+
533+4. **Configuration:**
534+ - Split popups
535+ - Stacked popups
536+ - Per-domain overrides
537+
538+#### 4.2 Success Criteria
539+
540+- ✅ Zero SIGSEGV crashes during popup lifecycle
541+- ✅ No GTK bloom filter corruption warnings
542+- ✅ OAuth flows complete successfully
543+- ✅ Popups open in configured layout (split/stacked/tabbed)
544+- ✅ Clean popup closure
545+- ✅ Parent-child relationship respected
546+- ✅ Window features honored
547+
548+## Implementation Timeline
549+
550+### Week 1: Foundation
551+- [ ] Implement CGO bindings for create signal (`webview_popup.go`, `webview_popup_callback.c`)
552+- [ ] Add Go-side create handler infrastructure
553+- [ ] Test basic popup creation
554+
555+### Week 2: Workspace Integration
556+- [ ] Refactor workspace popup management
557+- [ ] Implement `setupPopupHandling()`, `handlePopupReadyToShow()`, `handlePopupClose()`
558+- [ ] Add popup type configuration
559+- [ ] Test split/stacked popup layouts
560+
561+### Week 3: Cleanup & Testing
562+- [ ] Remove JavaScript interception
563+- [ ] Remove temporary workarounds
564+- [ ] Comprehensive testing of OAuth flows
565+- [ ] Performance testing
566+
567+### Week 4: Polish & Documentation
568+- [ ] User documentation for popup configuration
569+- [ ] Code comments and architecture documentation
570+- [ ] Edge case testing
571+- [ ] Production readiness review
572+
573+## Migration Path
574+
575+### Backward Compatibility
576+
577+During transition period, support both flows:
578+
579+```go
580+func (wm *WorkspaceManager) setupPopupHandling(webView *webkit.WebView, parentNode *paneNode) {
581+ if wm.app.config.UseNativePopupLifecycle {
582+ // New signal-based approach
583+ webView.RegisterPopupCreateHandler(...)
584+ } else {
585+ // Legacy JavaScript interception (deprecated)
586+ // Keep for one release cycle
587+ }
588+}
589+```
590+
591+Config flag: `"use_native_popup_lifecycle": true`
592+
593+### Deprecation Notice
594+
595+Add to CHANGELOG:
596+```
597+## v0.11.0 (Upcoming)
598+
599+### BREAKING CHANGES
600+- Popup window lifecycle now uses WebKit native signals
601+- JavaScript window.open interception removed
602+- Configuration changes: `popup_behavior` replaces internal logic
603+
604+### Migration Guide
605+- Update config.json with `popup_behavior` setting
606+- Remove any custom window.open interception scripts
607+- Test OAuth flows after upgrade
608+```
609+
610+## References
611+
612+### WebKitGTK Documentation
613+- Create Signal: https://webkitgtk.org/reference/webkitgtk/stable/signal.WebView.create.html
614+- Ready-to-Show Signal: https://webkitgtk.org/reference/webkitgtk/stable/signal.WebView.ready-to-show.html
615+- Close Signal: https://webkitgtk.org/reference/webkitgtk/stable/signal.WebView.close.html
616+- WebKitWebView Class: `/usr/include/webkitgtk-6.0/webkit/WebKitWebView.h:243-259`
617+- WebKitNavigationAction: https://webkitgtk.org/reference/webkitgtk/stable/class.NavigationAction.html
618+- WebKitWindowProperties: https://webkitgtk.org/reference/webkitgtk/stable/class.WindowProperties.html
619+
620+### GTK4 Documentation
621+- GtkWidget Lifecycle: https://docs.gtk.org/gtk4/class.Widget.html
622+- Signal Handling: https://docs.gtk.org/gobject/concepts.html#signals
623+
624+### Reference Implementation
625+- Epiphany Browser: `/home/brice/dev/clone/epiphany/src/ephy-window.c`
626+ - Create handler: lines 2034-2070
627+ - Ready-to-show handler: lines 2012-2031
628+ - Close handling: lines 3314-3360
629+
630+### Related Issues
631+- Previous conversation summary: Session context showing attempted fixes
632+- GTK Bloom Filter: https://blogs.gnome.org/otte/2012/12/26/css-hacker-news/
633+
634+## Risk Assessment
635+
636+### High Risk
637+- CGO callback stability
638+- Thread safety in signal handlers
639+- Potential gotk4 binding gaps
640+
641+### Medium Risk
642+- Configuration migration complexity
643+- User workflow disruption during transition
644+- Edge cases in popup features
645+
646+### Low Risk
647+- Performance impact (should improve)
648+- Memory leaks (proper lifecycle should fix existing issues)
649+
650+## Conclusion
651+
652+This refactoring aligns dumb-browser with WebKitGTK's architecture and proven patterns from Epiphany. By respecting WebKit's signal-based popup lifecycle, we eliminate race conditions and crashes while maintaining the unique split/stacked/tabbed popup positioning features.
653+
654+The key insight: **We control WHERE popups appear, WebKit controls WHEN and HOW they lifecycle.**
A assets/gui_embed.go +14 -0
@@ -0,0 +1,14 @@
1+package assets
2+
3+import _ "embed"
4+
5+// GUI scripts embedded at compile time
6+
7+//go:embed gui/main-world.min.js
8+var MainWorldScript string
9+
10+//go:embed gui/gui.min.js
11+var GUIScript string
12+
13+//go:embed gui/color-scheme.js
14+var ColorSchemeScript string
M go.mod +7 -0
@@ -3,6 +3,8 @@ module github.com/bnema/dumber
33 go 1.25.1
44
55 require (
6+ github.com/diamondburned/gotk4-webkitgtk/pkg v0.0.0-20240108031600-dee1973cf440
7+ github.com/diamondburned/gotk4/pkg v0.3.1
68 github.com/fsnotify/fsnotify v1.9.0
79 github.com/golang/mock v1.6.0
810 github.com/ncruces/go-sqlite3 v0.29.0
@@ -12,6 +14,7 @@ require (
1214 )
1315
1416 require (
17+ github.com/KarpelesLab/weak v0.1.1 // indirect
1518 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
1619 github.com/google/go-cmp v0.7.0 // indirect
1720 github.com/inconshreveable/mousetrap v1.1.0 // indirect
@@ -27,7 +30,11 @@ require (
2730 github.com/subosito/gotenv v1.6.0 // indirect
2831 github.com/tetratelabs/wazero v1.9.0 // indirect
2932 go.yaml.in/yaml/v3 v3.0.4 // indirect
33+ go4.org/unsafe/assume-no-moving-gc v0.0.0-20231121144256-b99613f794b6 // indirect
34+ golang.org/x/sync v0.17.0 // indirect
3035 golang.org/x/sys v0.36.0 // indirect
3136 golang.org/x/text v0.29.0 // indirect
3237 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
3338 )
39+
40+replace github.com/diamondburned/gotk4-webkitgtk/pkg => github.com/bnema/gotk4-webkitgtk/pkg v0.1.1
M go.sum +10 -0 generated file: collapsed
M gui/src/components/toast/ToastContainer.svelte +7 -7
@@ -110,15 +110,15 @@
110110 $effect(() => {
111111 console.log('🔄 ToastContainer initialized, functions exposed');
112112
113- // Monitor container periodically for debugging
114- const monitorInterval = setInterval(() => {
115- const container = document.querySelector('.toast-container');
116- const browserRoot = document.querySelector('.browser-component-root');
117- console.log('[dumber] Periodic check - container:', !!container, 'browser-root:', !!browserRoot, 'toasts:', toasts.length);
118- }, 5000);
113+ // Monitor container periodically for debugging (disabled)
114+ // const monitorInterval = setInterval(() => {
115+ // const container = document.querySelector('.toast-container');
116+ // const browserRoot = document.querySelector('.browser-component-root');
117+ // console.log('[dumber] Periodic check - container:', !!container, 'browser-root:', !!browserRoot, 'toasts:', toasts.length);
118+ // }, 5000);
119119
120120 return () => {
121- clearInterval(monitorInterval);
121+ // clearInterval(monitorInterval);
122122 console.log('🗑️ ToastContainer cleanup');
123123 };
124124 });
A gui/src/injected/bootstrap.ts +404 -0
@@ -0,0 +1,404 @@
1+/**
2+ * Dumber GUI bootstrap routine shared between injected pages and special schemes.
3+ *
4+ * Sets up toast, omnibox, keyboard service, and workspace event listeners.
5+ */
6+
7+import { initializeToast, type ToastConfig } from "./modules/toast";
8+import {
9+ initializeOmnibox,
10+ type OmniboxInitConfig,
11+} from "./modules/omnibox";
12+import "./modules/workspace"; // Auto-initializes pane mode UI notifications
13+import { keyboardService, type KeyboardService } from "$lib/keyboard";
14+import type { Suggestion } from "../components/omnibox/types";
15+
16+declare global {
17+ interface Window {
18+ __dumber_gui?: DumberGUI;
19+ __dumber_gui_ready?: boo
20+ __dumber_gui_ready_for?: Document | null;
21+ __dumber_showToast?: (
22+ message: string,
23+ duration?: number,
24+ type?: "info" | "success" | "error",
25+ ) => number | void;
26+ __dumber_showZoomToast?: (level: number) => void;
27+ __dumber_keyboard?: KeyboardService;
28+ __dumber_omnibox?: {
29+ setSuggestions: (suggestions: Suggestion[]) => void;
30+ toggle: () => void;
31+ open: (mode?: string, query?: string) => void;
32+ close: () => void;
33+ findQuery: (query: string) => void;
34+ setActive: (active: boolean) => void;
35+ };
36+ __dumber_gui_bootstrap?: () => void;
37+ __dumber_toggle?: () => void;
38+ __dumber_find_open?: (query?: string) => void;
39+ __dumber_find_close?: () => void;
40+ __dumber_find_query?: (query: string) => void;
41+ __dumber_dismissToast?: (id: number) => void;
42+ __dumber_clearToasts?: () => void;
43+ }
44+}
45+
46+interface DumberGUI {
47+ initializeToast: (config?: ToastConfig) => Promise<void>;
48+ initializeOmnibox: (config?: OmniboxInitConfig) => Promise<void>;
49+ keyboard: KeyboardService;
50+ isReady: boolean;
51+}
52+
53+function whenDOMReady(callback: () => void) {
54+ if (document.readyState === "loading") {
55+ document.addEventListener("DOMContentLoaded", callback, { once: true });
56+ } else if (document.readyState === "interactive" && !document.body) {
57+ setTimeout(() => whenDOMReady(callback), 10);
58+ } else {
59+ callback();
60+ }
61+}
62+
63+function normalizeWebviewId(value: unknown): number | null {
64+ if (typeof value === "number" && Number.isFinite(value)) {
65+ return value;
66+ }
67+ if (typeof value === "string") {
68+ const trimmed = value.trim();
69+ if (!trimmed || trimmed === "__WEBVIEW_ID__") {
70+ return null;
71+ }
72+ const parsed = Number.parseInt(trimmed, 10);
73+ return Number.isNaN(parsed) ? null : parsed;
74+ }
75+
76+ return null;
77+}
78+
79+export function bootstrapGUI(): void {
80+ if (window.__dumber_gui_ready_for === document) {
81+ return;
82+ }
83+
84+ if (window.self !== window.top) {
85+ console.log("🚫 Dumber GUI skipped in iframe");
86+ return;
87+ }
88+
89+ window.__dumber_gui_ready_for = document;
90+ window.__dumber_gui_ready = true;
91+
92+ try {
93+ window.addEventListener("pagehide", () => {
94+ window.__dumber_gui_ready = false;
95+ window.__dumber_gui_ready_for = null;
96+ }, { once: true });
97+ } catch {
98+ // Ignore environments without pagehide support
99+ }
100+
101+ let workspaceHasFocus = false;
102+ const initialRawWebViewId = window.__dumber_webview_id;
103+ let currentWebViewId = normalizeWebviewId(initialRawWebViewId);
104+ if (currentWebViewId !== null) {
105+ window.__dumber_webview_id = currentWebViewId;
106+ }
107+ let hasReceivedFocusEvent = false;
108+
109+ if (currentWebViewId === null) {
110+ console.log("[workspace] WebView ID unknown, requesting from Go backend");
111+ if (window.webkit?.messageHandlers?.dumber) {
112+ window.webkit.messageHandlers.dumber.postMessage(
113+ JSON.stringify({
114+ type: "request-webview-id",
115+ payload: { timestamp: Date.now() },
116+ }),
117+ );
118+ }
119+ }
120+
121+ document.addEventListener("dumber:webview-id", (event: Event) => {
122+ const detail = (event as CustomEvent).detail || {};
123+ const incomingId = normalizeWebviewId(detail.webviewId);
124+ if (incomingId !== null && incomingId !== currentWebViewId) {
125+ currentWebViewId = incomingId;
126+ window.__dumber_webview_id = incomingId;
127+ console.log("[workspace] Received webview ID from Go:", incomingId);
128+ }
129+ });
130+
131+ document.addEventListener("dumber:workspace-focus", (event: Event) => {
132+ const detail = (event as CustomEvent).detail || {};
133+ const eventWebviewId = normalizeWebviewId(detail?.webviewId);
134+
135+ if (eventWebviewId !== null) {
136+ if (currentWebViewId === null) {
137+ currentWebViewId = eventWebviewId;
138+ window.__dumber_webview_id = eventWebviewId;
139+ console.log(
140+ "[workspace] Learned webview ID from focus event:",
141+ eventWebviewId,
142+ );
143+ } else if (eventWebviewId !== currentWebViewId) {
144+ console.log("[workspace] Ignoring focus event for other webview", {
145+ eventWebviewId,
146+ self: currentWebViewId,
147+ detail,
148+ });
149+ return;
150+ }
151+ }
152+
153+ console.log("[workspace focus event]", detail, "prev focus=", workspaceHasFocus);
154+
155+ hasReceivedFocusEvent = true;
156+
157+ if (typeof detail?.active === "boolean") {
158+ workspaceHasFocus = detail.active;
159+ } else {
160+ workspaceHasFocus = detail !== false;
161+ }
162+
163+ console.log(
164+ "[workspace focus updated] focus=",
165+ workspaceHasFocus,
166+ "webviewId=",
167+ currentWebViewId,
168+ );
169+
170+ if (window.__dumber_omnibox) {
171+ if (workspaceHasFocus) {
172+ window.__dumber_omnibox.setActive(true);
173+ } else {
174+ window.__dumber_omnibox.setActive(false);
175+ window.__dumber_omnibox.close();
176+ }
177+ }
178+ });
179+
180+ whenDOMReady(async () => {
181+ console.log("[GUI] DOM ready, starting initialization");
182+ let toastInitialized = false;
183+
184+ if (typeof window.__dumber_showToast === "function" && typeof window.__dumber_showZoomToast === "function") {
185+ console.log("[GUI] Toast already initialized, skipping");
186+ toastInitialized = true;
187+ } else {
188+ try {
189+ console.log("[GUI] Attempting toast initialization");
190+ await initializeToast();
191+ if (typeof window.__dumber_showToast !== "function") {
192+ throw new Error("Toast functions not exposed after initialization");
193+ }
194+ toastInitialized = true;
195+ console.log("[GUI] Toast initialized successfully");
196+ } catch (e) {
197+ console.error("❌ Failed to initialize Svelte toast system:", e);
198+ throw e;
199+ }
200+ }
201+
202+ try {
203+ await initializeOmnibox();
204+ console.log("✅ Omnibox system initialized successfully");
205+
206+ // Auto-open omnibox on about:blank pages
207+ if (window.location.href === "about:blank" && window.__dumber_omnibox) {
208+ console.log("[about:blank] Auto-opening omnibox");
209+ setTimeout(() => {
210+ window.__dumber_omnibox?.open?.("omnibox", "");
211+ }, 100);
212+ }
213+ } catch (omniboxError) {
214+ console.error("❌ Failed to initialize omnibox system:", omniboxError);
215+ }
216+
217+ console.log("✅ GUI systems initialization complete", {
218+ toast: toastInitialized ? "Svelte" : "Fallback",
219+ omnibox: !!window.__dumber_omnibox,
220+ });
221+ });
222+
223+ window.__dumber_keyboard = keyboardService;
224+
225+ whenDOMReady(() => {
226+ document.addEventListener(
227+ "keydown",
228+ (event) => {
229+ keyboardService.handleKeyboardEvent(event);
230+ },
231+ true,
232+ );
233+
234+ document.addEventListener(
235+ "mousedown",
236+ (event) => {
237+ keyboardService.handleMouseEvent(event);
238+ },
239+ true,
240+ );
241+
242+ console.log("✅ KeyboardService initialized with global listeners");
243+
244+ document.addEventListener("dumber:key", (e: Event) => {
245+ const detail = (e as CustomEvent).detail || {};
246+ if (detail && typeof detail.shortcut === "string") {
247+ keyboardService.handleNativeShortcut(detail.shortcut);
248+ }
249+ });
250+
251+ document.addEventListener("dumber:ui:shortcut", (e: Event) => {
252+ const detail = (e as CustomEvent).detail || {};
253+ const action = detail?.action;
254+ const eventWebViewId = normalizeWebviewId(detail?.webviewId);
255+ const source = detail?.source || "unknown";
256+
257+ if (eventWebViewId !== null && currentWebViewId === null) {
258+ currentWebViewId = eventWebViewId;
259+ window.__dumber_webview_id = eventWebViewId;
260+ console.log(
261+ "[dumber shortcuts] Learned webview ID from shortcut event:",
262+ eventWebViewId,
263+ );
264+ }
265+
266+ console.log("[dumber shortcuts] Event received", {
267+ action,
268+ eventWebViewId,
269+ currentWebViewId,
270+ detail,
271+ });
272+
273+ if (typeof action !== "string") {
274+ console.log("[dumber shortcuts] No action in event, ignoring");
275+ return;
276+ }
277+
278+ const isForThisWebView =
279+ eventWebViewId === null || eventWebViewId === currentWebViewId;
280+ const isOmniboxAction = action.startsWith("omnibox-");
281+ const shouldHandle =
282+ isForThisWebView &&
283+ (isOmniboxAction
284+ ? hasReceivedFocusEvent
285+ ? workspaceHasFocus
286+ : true
287+ : true);
288+
289+ if (!shouldHandle) {
290+ console.log("[dumber shortcuts] ignored action", {
291+ action,
292+ workspaceHasFocus,
293+ isForThisWebView,
294+ eventWebViewId,
295+ currentWebViewId,
296+ source,
297+ detail,
298+ hasReceivedFocusEvent,
299+ });
300+ return;
301+ }
302+
303+ console.log("[dumber shortcuts] handling action", {
304+ action,
305+ source,
306+ webviewId: currentWebViewId,
307+ });
308+
309+ const omnibox = window.__dumber_omnibox;
310+
311+ switch (action) {
312+ case "omnibox-nav-toggle":
313+ omnibox?.open?.("omnibox", detail?.query);
314+ break;
315+ case "omnibox-open":
316+ omnibox?.open?.("omnibox", detail?.query);
317+ break;
318+ case "omnibox-find-toggle":
319+ omnibox?.open?.("find", detail?.query);
320+ break;
321+ case "omnibox-close":
322+ omnibox?.close?.();
323+ break;
324+ default:
325+ console.warn("[dumber] Unknown UI shortcut action:", action);
326+ }
327+ });
328+ });
329+
330+ window.__dumber_toggle = () => {
331+ try {
332+ console.log("🎯 __dumber_toggle called");
333+ console.log("🔧 Omnibox API available:", !!window.__dumber_omnibox);
334+ if (window.__dumber_omnibox?.toggle) {
335+ console.log("✅ Using Svelte omnibox toggle");
336+ window.__dumber_omnibox.toggle();
337+ return;
338+ }
339+
340+ throw new Error("Omnibox toggle requested but API is unavailable");
341+ } catch (error) {
342+ console.error("❌ Error in __dumber_toggle:", error);
343+ }
344+ };
345+
346+ window.__dumber_find_open = (query?: string) => {
347+ try {
348+ if (window.__dumber_omnibox?.open) {
349+ window.__dumber_omnibox.open("find", query);
350+ return;
351+ }
352+
353+ throw new Error("Omnibox find requested but API is unavailable");
354+ } catch (error) {
355+ console.error("❌ Error in __dumber_find_open:", error);
356+ }
357+ };
358+
359+ window.__dumber_find_close = () => {
360+ if (window.__dumber_omnibox?.close) {
361+ window.__dumber_omnibox.close();
362+ return;
363+ }
364+
365+ const error = new Error("Omnibox close requested but API is unavailable");
366+ console.error("❌ Error in __dumber_find_close:", error);
367+ };
368+
369+ window.__dumber_find_query = (query: string) => {
370+ if (window.__dumber_omnibox?.findQuery) {
371+ window.__dumber_omnibox.findQuery(query);
372+ }
373+ };
374+
375+ window.__dumber_gui = {
376+ initializeToast: async (config?: ToastConfig) => {
377+ return await initializeToast(config);
378+ },
379+ initializeOmnibox: async (config?: OmniboxInitConfig) => {
380+ return await initializeOmnibox(config);
381+ },
382+ keyboard: keyboardService,
383+ isReady: true,
384+ };
385+
386+ window.__dumber_gui_bootstrap = function () {
387+ console.log("[gui-bootstrap] Initializing GUI for workspace");
388+
389+ if (!window.__dumber_omnibox) {
390+ console.log(
391+ `[gui-bootstrap] Omnibox not yet available for webview ${currentWebViewId}, will be loaded when needed`,
392+ );
393+ } else {
394+ console.log(
395+ `[gui-bootstrap] Omnibox already available for webview ${currentWebViewId}`,
396+ );
397+ window.__dumber_omnibox.setActive(workspaceHasFocus);
398+ }
399+
400+ console.log(
401+ `[gui-bootstrap] GUI bootstrap complete for webview ${currentWebViewId}`,
402+ );
403+ };
404+}
M gui/src/injected/gui.ts +9 -427
@@ -1,432 +1,14 @@
11 /**
2- * Unified GUI Bundle Entry Point
2+ * Unified GUI bundle entry point.
33 *
4- * Single entry point for all WebKit GUI functionality.
5- * Loads immediately and provides modular initialization system.
4+ * Delegates to the shared bootstrap so special pages can reuse the same setup
5+ * without depending on this injected build.
6+ *
7+ * Always calls bootstrapGUI() - deduplication is handled inside bootstrap.ts
8+ * via window.__dumber_gui_ready_for check. This ensures GUI loads in all
9+ * contexts including new split panes and about:blank pages.
610 */
711
8-// Import all GUI modules
9-import { initializeToast, type ToastConfig } from "./modules/toast";
10-import { initializeOmnibox, type OmniboxInitConfig } from "./modules/omnibox";
11-import {
12- initializeWorkspace,
13- type WorkspaceConfigPayload,
14- type WorkspaceRuntime,
15-} from "./modules/workspace";
16-import { keyboardService, type KeyboardService } from "$lib/keyboard";
17-import type { Suggestion } from "../components/omnibox/types";
18-// Note: color-scheme module is loaded separately at document-start by WebKit
19-
20-// Global interface for the unified GUI system
21-interface DumberGUI {
22- initializeToast: (config?: ToastConfig) => Promise<void>;
23- initializeOmnibox: (config?: OmniboxInitConfig) => Promise<void>;
24- keyboard: KeyboardService;
25- isReady: boolean;
26-}
27-
28-// (legacy suggestions normalization removed; omnibox now fetches suggestions directly via API)
29-
30-// DOM readiness utility - handles all document ready states
31-function whenDOMReady(callback: () => void) {
32- if (document.readyState === "loading") {
33- document.addEventListener("DOMContentLoaded", callback, { once: true });
34- } else if (document.readyState === "interactive" && !document.body) {
35- // Interactive but no body yet, wait a bit more
36- setTimeout(() => whenDOMReady(callback), 10);
37- } else {
38- // DOM ready and body exists
39- callback();
40- }
41-}
42-
43-// Install a page-world bridge so Go can call window.__dumber_omnibox_suggestions()
44-// Page-world bridge is injected by WebKit at document-start; no-op here to avoid duplicates.
45-
46-// Create global namespace
47-declare global {
48- interface Window {
49- __dumber_gui?: DumberGUI;
50- __dumber_gui_ready?: boolean;
51- __dumber_showToast?: (
52- message: string,
53- duration?: number,
54- type?: "info" | "success" | "error",
55- ) => number | void;
56- __dumber_showZoomToast?: (level: number) => void;
57- __dumber_keyboard?: KeyboardService;
58- __dumber_workspace_config?: WorkspaceConfigPayload;
59- __dumber_workspace?: WorkspaceRuntime;
60- __dumber_omnibox?: {
61- setSuggestions: (suggestions: Suggestion[]) => void;
62- toggle: () => void;
63- open: (mode?: string, query?: string) => void;
64- close: () => void;
65- findQuery: (query: string) => void;
66- setActive: (active: boolean) => void;
67- };
68- __dumber_gui_bootstrap?: () => void;
69- // Legacy compatibility functions for Go bridge
70- __dumber_toggle?: () => void;
71- __dumber_find_open?: (query?: string) => void;
72- __dumber_find_close?: () => void;
73- __dumber_find_query?: (query: string) => void;
74- }
75-}
76-
77-// Prevent multiple initialization with enhanced logging
78-if (!window.__dumber_gui_ready) {
79- // Skip initialization in iframes - GUI only needed in main frame
80- if (window.self !== window.top) {
81- console.log("🚫 Dumber GUI skipped in iframe");
82- } else {
83- window.__dumber_gui_ready = true;
84-
85- let workspaceHasFocus = true; // Assume focus initially, will be corrected by focus events
86- let currentWebViewId = window.__dumber_webview_id || "unknown";
87- let hasReceivedFocusEvent = false; // Track if we've received any workspace focus events
88-
89- // Request webview ID from Go if it's unknown (needed for stub builds)
90- if (
91- currentWebViewId === "unknown" ||
92- currentWebViewId === "__WEBVIEW_ID__"
93- ) {
94- console.log("[workspace] WebView ID unknown, requesting from Go backend");
95- if (window.webkit?.messageHandlers?.dumber) {
96- window.webkit.messageHandlers.dumber.postMessage(
97- JSON.stringify({
98- type: "request-webview-id",
99- payload: { timestamp: Date.now() },
100- }),
101- );
102- }
103- }
104-
105- // Listen for webview ID from Go backend
106- document.addEventListener("dumber:webview-id", (event: Event) => {
107- const detail = (event as CustomEvent).detail || {};
108- if (detail.webviewId) {
109- currentWebViewId = detail.webviewId;
110- window.__dumber_webview_id = detail.webviewId;
111- console.log(
112- "[workspace] Received webview ID from Go:",
113- detail.webviewId,
114- );
115- }
116- });
117-
118- document.addEventListener("dumber:workspace-focus", (event: Event) => {
119- const detail = (event as CustomEvent).detail || {};
120- console.log(
121- "[workspace focus event]",
122- detail,
123- "prev focus=",
124- workspaceHasFocus,
125- );
126-
127- hasReceivedFocusEvent = true;
128-
129- // Update focus state
130- if (typeof detail?.active === "boolean") {
131- workspaceHasFocus = detail.active;
132- } else {
133- workspaceHasFocus = detail !== false;
134- }
135-
136- console.log(
137- "[workspace focus updated] focus=",
138- workspaceHasFocus,
139- "webviewId=",
140- currentWebViewId,
141- );
142-
143- // Handle GUI visibility based on focus
144- if (window.__dumber_omnibox) {
145- if (workspaceHasFocus) {
146- window.__dumber_omnibox.setActive(true);
147- } else {
148- window.__dumber_omnibox.setActive(false);
149- window.__dumber_omnibox.close();
150- }
151- }
152- });
153-
154- // Initialize Svelte GUI systems immediately
155- whenDOMReady(async () => {
156- try {
157- // Page-world bridge is injected by WebKit; avoid duplicate injection here
158- // window.open interceptor is now in main-world.ts (page-world context)
159-
160- // Initialize toast system first
161- await initializeToast();
162-
163- // Verify toast functions are available
164- if (typeof window.__dumber_showToast !== "function") {
165- throw new Error("Toast functions not exposed after initialization");
166- }
167-
168- // Initialize omnibox system
169- await initializeOmnibox();
170-
171- // Initialize workspace controls (pane/tab scaffolding)
172- initializeWorkspace(window.__dumber_workspace_config);
173-
174- console.log("✅ All GUI systems initialized successfully");
175- } catch (e) {
176- console.error("❌ Failed to initialize Svelte toast system:", e);
177-
178- // Enhanced fallback with better error recovery
179- window.__dumber_showToast = (
180- message: string,
181- duration: number = 2500,
182- ) => {
183- // Remove existing toasts to prevent overlap
184- document
185- .querySelectorAll(".dumber-fallback-toast")
186- .forEach((el) => el.remove());
187-
188- const toast = document.createElement("div");
189- toast.className = "dumber-fallback-toast";
190- toast.textContent = message;
191- toast.style.cssText = `
192- position: fixed;
193- bottom: 20px;
194- right: 20px;
195- background: rgba(0,0,0,0.9);
196- color: white;
197- padding: 12px 16px;
198- border-radius: 6px;
199- z-index: 2147483647;
200- font-family: system-ui, -apple-system, sans-serif;
201- font-size: 14px;
202- opacity: 0;
203- transform: translateX(100%);
204- transition: all 0.3s ease;
205- max-width: 300px;
206- word-wrap: break-word;
207- `;
208-
209- if (document.body) {
210- document.body.appendChild(toast);
211-
212- // Animate in
213- requestAnimationFrame(() => {
214- toast.style.opacity = "1";
215- toast.style.transform = "translateX(0)";
216- });
217-
218- // Animate out and remove
219- setTimeout(() => {
220- toast.style.opacity = "0";
221- toast.style.transform = "translateX(100%)";
222- setTimeout(() => toast.remove(), 300);
223- }, duration);
224- }
225- };
226-
227- window.__dumber_showZoomToast = (level: number) => {
228- const percentage = Math.round(level * 100);
229- window.__dumber_showToast!(`Zoom: ${percentage}%`, 1500);
230- };
231-
232- // Expose other required functions
233- window.__dumber_dismissToast = () => {
234- document
235- .querySelectorAll(".dumber-fallback-toast")
236- .forEach((el) => el.remove());
237- };
238-
239- window.__dumber_clearToasts = () => {
240- document
241- .querySelectorAll(".dumber-fallback-toast")
242- .forEach((el) => el.remove());
243- };
244- }
245- });
246-
247- // Expose keyboard service globally for Go bridge
248- window.__dumber_keyboard = keyboardService;
249-
250- // Initialize global keyboard and mouse event listeners for the keyboard service
251- whenDOMReady(() => {
252- // Global keyboard event listener (capture phase for high priority)
253- document.addEventListener(
254- "keydown",
255- (event) => {
256- keyboardService.handleKeyboardEvent(event);
257- },
258- true,
259- );
260-
261- // Global mouse event listener for navigation buttons
262- document.addEventListener(
263- "mousedown",
264- (event) => {
265- keyboardService.handleMouseEvent(event);
266- },
267- true,
268- );
269-
270- console.log("✅ KeyboardService initialized with global listeners");
271-
272- // Listen to bridge keyboard events from page-world and forward to KeyboardService
273- document.addEventListener("dumber:key", (e: Event) => {
274- const detail = (e as CustomEvent).detail || {};
275- if (detail && typeof detail.shortcut === "string") {
276- keyboardService.handleNativeShortcut(detail.shortcut);
277- }
278- });
279-
280- document.addEventListener("dumber:ui:shortcut", (e: Event) => {
281- const detail = (e as CustomEvent).detail || {};
282- const action = detail?.action;
283- const eventWebViewId = detail?.webviewId;
284- const source = detail?.source || "unknown";
285-
286- if (typeof action !== "string") {
287- return;
288- }
289-
290- // Enhanced focus checking with webview ID validation
291- const isForThisWebView =
292- !eventWebViewId || eventWebViewId === currentWebViewId;
293- // Only apply strict focus checking to omnibox actions, be permissive for others
294- const isOmniboxAction = action.startsWith("omnibox-");
295- const shouldHandle =
296- isForThisWebView &&
297- (isOmniboxAction
298- ? hasReceivedFocusEvent
299- ? workspaceHasFocus
300- : true
301- : true);
302-
303- if (!shouldHandle) {
304- console.log("[dumber shortcuts] ignored action", {
305- action,
306- workspaceHasFocus,
307- isForThisWebView,
308- eventWebViewId,
309- currentWebViewId,
310- source,
311- detail,
312- hasReceivedFocusEvent,
313- });
314- return;
315- }
316-
317- console.log("[dumber shortcuts] handling action", {
318- action,
319- source,
320- webviewId: currentWebViewId,
321- });
322-
323- const omnibox = window.__dumber_omnibox;
324-
325- switch (action) {
326- case "omnibox-nav-toggle":
327- omnibox?.open?.("omnibox", detail?.query);
328- break;
329- case "omnibox-open":
330- omnibox?.open?.("omnibox", detail?.query);
331- break;
332- case "omnibox-find-toggle":
333- omnibox?.open?.("find", detail?.query);
334- break;
335- case "omnibox-close":
336- omnibox?.close?.();
337- break;
338- default:
339- console.warn("[dumber] Unknown UI shortcut action:", action);
340- }
341- });
342-
343- // Suggestions are now fetched via API directly by the omnibox component; no page-bridge handlers needed
344- });
345-
346- // Legacy compatibility functions for existing Go code
347- window.__dumber_toggle = () => {
348- try {
349- console.log("🎯 __dumber_toggle called");
350- console.log("🔧 Omnibox API available:", !!window.__dumber_omnibox);
351- if (window.__dumber_omnibox?.toggle) {
352- console.log("✅ Using Svelte omnibox toggle");
353- window.__dumber_omnibox.toggle();
354- return;
355- }
356-
357- throw new Error("Omnibox toggle requested but API is unavailable");
358- } catch (error) {
359- console.error("❌ Error in __dumber_toggle:", error);
360- }
361- };
362-
363- window.__dumber_find_open = (query?: string) => {
364- try {
365- if (window.__dumber_omnibox?.open) {
366- window.__dumber_omnibox.open("find", query);
367- return;
368- }
369-
370- throw new Error("Omnibox find requested but API is unavailable");
371- } catch (error) {
372- console.error("❌ Error in __dumber_find_open:", error);
373- }
374- };
375-
376- window.__dumber_find_close = () => {
377- if (window.__dumber_omnibox?.close) {
378- window.__dumber_omnibox.close();
379- return;
380- }
381-
382- const error = new Error("Omnibox close requested but API is unavailable");
383- console.error("❌ Error in __dumber_find_close:", error);
384- };
385-
386- // Find query function that Go uses to set find query
387- window.__dumber_find_query = (query: string) => {
388- if (window.__dumber_omnibox?.findQuery) {
389- window.__dumber_omnibox.findQuery(query);
390- }
391- };
392-
393- // Create the global GUI object
394- window.__dumber_gui = {
395- initializeToast: async (config?: ToastConfig) => {
396- return await initializeToast(config);
397- },
398-
399- initializeOmnibox: async (config?: OmniboxInitConfig) => {
400- return await initializeOmnibox(config);
401- },
402-
403- keyboard: keyboardService,
404-
405- isReady: true,
406- };
407-
408- // Bootstrap function for lazy GUI initialization by workspace manager
409- window.__dumber_gui_bootstrap = function () {
410- console.log("[gui-bootstrap] Initializing GUI for workspace");
411-
412- // Pane ID will be updated via focus events
413-
414- // Ensure omnibox is available for this pane
415- if (!window.__dumber_omnibox) {
416- console.log(
417- `[gui-bootstrap] Omnibox not yet available for webview ${currentWebViewId}, will be loaded when needed`,
418- );
419- } else {
420- console.log(
421- `[gui-bootstrap] Omnibox already available for webview ${currentWebViewId}`,
422- );
423- // Ensure omnibox is aware of the current pane
424- window.__dumber_omnibox.setActive(workspaceHasFocus);
425- }
12+import { bootstrapGUI } from "./bootstrap";
42613
427- console.log(
428- `[gui-bootstrap] GUI bootstrap complete for webview ${currentWebViewId}`,
429- );
430- };
431- }
432-}
14+bootstrapGUI();
M gui/src/injected/main-world.ts +10 -384
@@ -14,7 +14,6 @@ declare global {
1414 interface Window {
1515 __dumber: DumberAPI;
1616 __dumber_page_bridge_installed?: boolean;
17- __dumber_window_open_intercepted?: boolean;
1817 __dumber_dom_zoom_seed?: number;
1918 __dumber_dom_zoom_level?: number;
2019 __dumber_initial_theme?: string;
@@ -33,7 +32,7 @@ declare global {
3332 ) => number | void;
3433 __dumber_showZoomToast?: (level: number) => void;
3534 __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void;
36- __dumber_webview_id?: string;
35+ __dumber_webview_id?: string | number;
3736 __dumber_is_active?: boolean;
3837 __dumber_teardown?: () => void;
3938 webkit?: {
@@ -46,65 +45,17 @@ declare global {
4645 }
4746 }
4847
49-interface WindowIntent {
50- url: string;
51- target: string;
52- features: string;
53- timestamp: number;
54- userTriggered: boolean;
55- requestId: string;
56- windowType: string; // "tab", "popup", "unknown"
57-}
58-
5948 interface Suggestion {
6049 // Define proper suggestion types based on your needs
6150 [key: string]: unknown;
6251 }
6352
6453 interface DumberAPI {
65- toast: {
66- show: (
67- message: string,
68- duration?: number,
69- type?: "info" | "success" | "error",
70- ) => void;
71- zoom: (level: number) => void;
72- };
7354 omnibox: {
7455 suggestions: (suggestions: Suggestion[]) => void;
7556 };
7657 }
7758
78-// Function to detect if window.open call is for a popup based on features
79-function detectWindowType(features?: string | null): string {
80- if (!features) {
81- return "tab"; // Default to tab if no features specified
82- }
83-
84- const featuresStr = features.toLowerCase();
85-
86- // Check for typical popup characteristics
87- const hasSize = /width=\d+|height=\d+/.test(featuresStr);
88- const hasNoToolbar = /toolbar=0|toolbar=no/.test(featuresStr);
89- const hasNoMenubar = /menubar=0|menubar=no/.test(featuresStr);
90- const hasNoLocation = /location=0|location=no/.test(featuresStr);
91-
92- // OAuth/login popups typically have size constraints and disabled UI elements
93- const popupIndicators = [
94- hasSize,
95- hasNoToolbar,
96- hasNoMenubar,
97- hasNoLocation,
98- ].filter(Boolean).length;
99-
100- // If 2 or more popup indicators are present, treat as popup
101- if (popupIndicators >= 2) {
102- return "popup";
103- }
104-
105- return "tab";
106-}
107-
10859 (() => {
10960 try {
11061 // Prevent multiple installations
@@ -120,9 +71,14 @@ function detectWindowType(features?: string | null): string {
12071 const initialZoom = 1.0; // __DOM_ZOOM_DEFAULT__ will be replaced by Go
12172 window.__dumber_dom_zoom_seed = initialZoom;
12273
123- // Initialize WebView ID and active state (will be replaced by Go)
124- window.__dumber_webview_id = "__WEBVIEW_ID__";
125- window.__dumber_is_active = "__WEBVIEW_ACTIVE__" as unknown as boolean;
74+ // Initialize WebView ID and active state (injected by Go at document-start)
75+ // Only set fallback values if not already injected
76+ if (typeof window.__dumber_webview_id === 'undefined') {
77+ window.__dumber_webview_id = "__WEBVIEW_ID__";
78+ }
79+ if (typeof window.__dumber_is_active === 'undefined') {
80+ window.__dumber_is_active = "__WEBVIEW_ACTIVE__" as unknown as boolean;
81+ }
12682
12783 // Request color palettes from Go backend
12884 try {
@@ -273,59 +229,9 @@ function detectWindowType(features?: string | null): string {
273229 }
274230 };
275231
276- // Initialize unified API object
232+ // Initialize unified API object (toast functions are exposed by ToastContainer.svelte)
277233 window.__dumber = window.__dumber || ({} as DumberAPI);
278234
279- // Toast bridge
280- window.__dumber.toast = window.__dumber.toast || {
281- show: (message: string, duration?: number, type?: string) => {
282- try {
283- document.dispatchEvent(
284- new CustomEvent("dumber:toast:show", {
285- detail: { message, duration, type },
286- }),
287- );
288- // Legacy compatibility
289- document.dispatchEvent(
290- new CustomEvent("dumber:showToast", {
291- detail: { message, duration, type },
292- }),
293- );
294- } catch {
295- // ignore
296- }
297- },
298- zoom: (level: number) => {
299- try {
300- document.dispatchEvent(
301- new CustomEvent("dumber:toast:zoom", {
302- detail: { level },
303- }),
304- );
305- // Legacy compatibility
306- document.dispatchEvent(
307- new CustomEvent("dumber:showZoomToast", {
308- detail: { level },
309- }),
310- );
311- } catch {
312- // ignore
313- }
314- },
315- };
316-
317- // Legacy toast helpers
318- window.__dumber_showToast = (
319- message: string,
320- duration?: number,
321- type?: "info" | "success" | "error",
322- ) => {
323- window.__dumber.toast.show(message, duration, type);
324- };
325- window.__dumber_showZoomToast = (level: number) => {
326- window.__dumber.toast.zoom(level);
327- };
328-
329235 // DOM zoom functionality
330236 if (typeof window.__dumber_dom_zoom_level !== "number") {
331237 window.__dumber_dom_zoom_level = initialZoom;
@@ -894,284 +800,6 @@ function detectWindowType(features?: string | null): string {
894800 // Setup OAuth callback detection
895801 setupOAuthCallbackDetection();
896802
897- // Enhanced window.open deduplication system
898- interface PendingRequest {
899- id: string;
900- url: string;
901- timestamp: number;
902- webviewId: string;
903- resolved: boolean;
904- }
905-
906- class WindowOpenDebouncer {
907- private pendingRequests = new Map<string, PendingRequest>();
908- private readonly DEBOUNCE_WINDOW = 150; // Increased from 100ms
909- private readonly CLEANUP_INTERVAL = 1000; // Clean old entries every 1s
910-
911- generateRequestId(): string {
912- return `${window.__dumber_webview_id}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
913- }
914-
915- isDuplicate(url: string, webviewId: string): boolean {
916- const key = this.createKey(url, webviewId);
917- const existing = this.pendingRequests.get(key);
918-
919- if (!existing) return false;
920-
921- const timeDiff = Date.now() - existing.timestamp;
922- return timeDiff < this.DEBOUNCE_WINDOW && !existing.resolved;
923- }
924-
925- registerRequest(url: string, webviewId: string): string {
926- const requestId = this.generateRequestId();
927- const key = this.createKey(url, webviewId);
928-
929- this.pendingRequests.set(key, {
930- id: requestId,
931- url,
932- timestamp: Date.now(),
933- webviewId,
934- resolved: false,
935- });
936-
937- // Schedule cleanup
938- setTimeout(() => this.markResolved(key), this.DEBOUNCE_WINDOW);
939- return requestId;
940- }
941-
942- private createKey(url: string, webviewId: string): string {
943- return `${webviewId}:${url}`;
944- }
945-
946- private markResolved(key: string): void {
947- const request = this.pendingRequests.get(key);
948- if (request) {
949- request.resolved = true;
950- // Clean up after additional delay
951- setTimeout(
952- () => this.pendingRequests.delete(key),
953- this.CLEANUP_INTERVAL,
954- );
955- }
956- }
957- }
958-
959- // Global debouncer instance
960- const windowOpenDebouncer = new WindowOpenDebouncer();
961-
962- // User interaction tracking
963- let lastUserInteraction = 0;
964- const INTERACTION_TIMEOUT = 5000; // 5 seconds
965-
966- const trackUserInteraction = () => {
967- lastUserInteraction = Date.now();
968- };
969-
970- const isUserInteraction = (): boolean => {
971- const now = Date.now();
972- const timeSinceInteraction = now - lastUserInteraction;
973- return timeSinceInteraction <= INTERACTION_TIMEOUT;
974- };
975-
976- const createFakeWindow = (url: string, popupId?: string): WindowProxy => {
977- // Generate unique popup ID if not provided
978- const actualPopupId =
979- popupId ||
980- `${window.__dumber_webview_id}-${Date.now()}-${Math.random().toString(36).substring(2, 11)}`;
981-
982- // Set up communication channel via shared localStorage
983- try {
984- localStorage.setItem(
985- `popup_${actualPopupId}_parent_info`,
986- JSON.stringify({
987- parentUrl: window.location.href,
988- parentWebViewId: window.__dumber_webview_id,
989- timestamp: Date.now(),
990- popupUrl: url,
991- }),
992- );
993-
994- console.log(
995- `[window-open] [WebView ${window.__dumber_webview_id}] Set up shared storage communication for popup ID: ${actualPopupId}`,
996- );
997- } catch (err) {
998- console.warn(
999- `[window-open] [WebView ${window.__dumber_webview_id}] Failed to set up shared storage:`,
1000- err,
1001- );
1002- }
1003-
1004- return {
1005- closed: false,
1006- location: { href: url },
1007- close() {
1008- (this as { closed: boolean }).closed = true;
1009- },
1010- focus: () => {
1011- try {
1012- localStorage.setItem(
1013- `popup_${actualPopupId}_parent_action`,
1014- JSON.stringify({
1015- action: "focus",
1016- timestamp: Date.now(),
1017- }),
1018- );
1019- } catch (err) {
1020- console.warn(`[window-open] Failed to store focus action:`, err);
1021- }
1022- },
1023- blur: () => {
1024- try {
1025- localStorage.setItem(
1026- `popup_${actualPopupId}_parent_action`,
1027- JSON.stringify({
1028- action: "blur",
1029- timestamp: Date.now(),
1030- }),
1031- );
1032- } catch (err) {
1033- console.warn(`[window-open] Failed to store blur action:`, err);
1034- }
1035- },
1036- postMessage: (data: unknown, targetOrigin?: string) => {
1037- try {
1038- localStorage.setItem(
1039- `popup_${actualPopupId}_message_to_popup`,
1040- JSON.stringify({
1041- data,
1042- origin: targetOrigin || "*",
1043- timestamp: Date.now(),
1044- source: "parent",
1045- }),
1046- );
1047- console.log(
1048- `[window-open] [WebView ${window.__dumber_webview_id}] Stored message for popup ${actualPopupId}:`,
1049- data,
1050- );
1051- } catch (err) {
1052- console.warn(`[window-open] Failed to store postMessage:`, err);
1053- }
1054- },
1055- } as unknown as WindowProxy;
1056- };
1057-
1058- // Track user interactions for popup validation
1059- const interactionEvents = [
1060- "click",
1061- "mousedown",
1062- "keydown",
1063- "touchstart",
1064- ] as const;
1065- interactionEvents.forEach((eventType) => {
1066- document.addEventListener(eventType, trackUserInteraction, true);
1067- });
1068- cleanupHandlers.push(() => {
1069- interactionEvents.forEach((eventType) => {
1070- document.removeEventListener(eventType, trackUserInteraction, true);
1071- });
1072- });
1073-
1074- console.log(
1075- `[window-open] [WebView ${window.__dumber_webview_id}] User interaction tracking enabled`,
1076- );
1077-
1078- // Window.open interceptor - only install once
1079- if (!window.__dumber_window_open_intercepted) {
1080- try {
1081- window.open = function (
1082- url?: string | URL | null,
1083- target?: string | null,
1084- features?: string | null,
1085- ): WindowProxy | null {
1086- const urlString = url
1087- ? typeof url === "string"
1088- ? url
1089- : url.toString()
1090- : "";
1091- const webviewId = window.__dumber_webview_id || "unknown";
1092-
1093- console.log(
1094- `[window-open] [WebView ${webviewId}] Bridge called with:`,
1095- urlString,
1096- target,
1097- features,
1098- );
1099-
1100- // Enhanced duplicate check
1101- if (windowOpenDebouncer.isDuplicate(urlString, webviewId)) {
1102- console.log(
1103- `[window-open] [WebView ${webviewId}] BLOCKED: Duplicate request within debounce window`,
1104- );
1105- return createFakeWindow(urlString);
1106- }
1107-
1108- // Active WebView check
1109- if (!window.__dumber_is_active) {
1110- console.log(
1111- `[window-open] [WebView ${webviewId}] BLOCKED: Inactive WebView`,
1112- );
1113- return createFakeWindow(urlString);
1114- }
1115-
1116- // User interaction check
1117- if (!isUserInteraction()) {
1118- console.log(
1119- `[window-open] [WebView ${webviewId}] BLOCKED: No recent user interaction`,
1120- );
1121- return createFakeWindow("");
1122- }
1123-
1124- // Register request and get unique ID
1125- const requestId = windowOpenDebouncer.registerRequest(
1126- urlString,
1127- webviewId,
1128- );
1129-
1130- const intent: WindowIntent = {
1131- url: urlString,
1132- target: target || "_blank",
1133- features: features || "",
1134- timestamp: Date.now(),
1135- userTriggered: true,
1136- requestId, // NEW: Add unique request ID
1137- windowType: detectWindowType(features), // Detect popup vs tab
1138- };
1139-
1140- // Send to Go backend
1141- try {
1142- if (window.webkit?.messageHandlers?.dumber) {
1143- window.webkit.messageHandlers.dumber.postMessage(
1144- JSON.stringify({
1145- type: "handle-window-open",
1146- payload: intent,
1147- }),
1148- );
1149- console.log(
1150- `[window-open] [WebView ${webviewId}] Sent request ${requestId} to Go backend`,
1151- );
1152- }
1153- } catch (err) {
1154- console.error(
1155- `[window-open] [WebView ${webviewId}] Failed to send request ${requestId}:`,
1156- err,
1157- );
1158- }
1159-
1160- return createFakeWindow(urlString, requestId);
1161- };
1162-
1163- window.__dumber_window_open_intercepted = true;
1164- console.log(
1165- `[window-open] [WebView ${window.__dumber_webview_id}] ✅ Page-world bridge installed`,
1166- );
1167- } catch (error) {
1168- console.error(
1169- `[window-open] [WebView ${window.__dumber_webview_id}] ❌ Failed to install page-world bridge:`,
1170- error,
1171- );
1172- }
1173- }
1174-
1175803 const teardown = () => {
1176804 if (teardownExecuted) {
1177805 return;
@@ -1193,7 +821,6 @@ function detectWindowType(features?: string | null): string {
1193821 omniboxQueue.length = 0;
1194822 omniboxReady = false;
1195823
1196- window.__dumber_window_open_intercepted = false;
1197824 window.__dumber_page_bridge_installed = false;
1198825 window.__dumber_teardown = undefined;
1199826 window.__dumber_omnibox_suggestions = undefined;
@@ -1205,7 +832,6 @@ function detectWindowType(features?: string | null): string {
1205832 if (globalState) {
1206833 const mutableState = globalState as unknown as Record<string, unknown>;
1207834 delete mutableState.omnibox;
1208- delete mutableState.toast;
1209835 }
1210836 };
1211837
A gui/src/injected/modules/appearance-config.ts +133 -0
@@ -0,0 +1,133 @@
1+/**
2+ * Appearance Configuration Module
3+ *
4+ * Handles application appearance configuration including color palettes and fonts.
5+ * This config is injected by the native layer at startup and made available to all
6+ * injected modules and components.
7+ */
8+
9+export interface ColorPalette {
10+ background: string;
11+ surface: string;
12+ surfaceVariant: string;
13+ text: string;
14+ muted: string;
15+ accent: string;
16+ border: string;
17+}
18+
19+export interface AppearanceConfig {
20+ theme: "light" | "dark";
21+ palettes: {
22+ light: ColorPalette;
23+ dark: ColorPalette;
24+ };
25+ fonts: {
26+ sans: string;
27+ serif: string;
28+ monospace: string;
29+ defaultSize: number;
30+ };
31+}
32+
33+declare global {
34+ interface Window {
35+ __dumber_appearance_config?: AppearanceConfig;
36+ __dumber_applyPalette?: (theme: "light" | "dark") => void;
37+ }
38+}
39+
40+/**
41+ * Applies the appearance configuration to the document
42+ * Sets CSS custom properties for colors and fonts
43+ */
44+export function applyAppearanceConfig(): void {
45+ const config = window.__dumber_appearance_config;
46+ if (!config) {
47+ console.warn("[dumber] Appearance config not available");
48+ return;
49+ }
50+
51+ const theme = config.theme || "light";
52+ const palette = config.palettes[theme];
53+ const root = document.documentElement;
54+
55+ // Apply color palette as CSS custom properties
56+ root.style.setProperty("--color-background", palette.background);
57+ root.style.setProperty("--color-surface", palette.surface);
58+ root.style.setProperty("--color-surface-variant", palette.surfaceVariant);
59+ root.style.setProperty("--color-text", palette.text);
60+ root.style.setProperty("--color-muted", palette.muted);
61+ root.style.setProperty("--color-accent", palette.accent);
62+ root.style.setProperty("--color-border", palette.border);
63+
64+ // Apply font families
65+ root.style.setProperty("--font-sans", config.fonts.sans);
66+ root.style.setProperty("--font-serif", config.fonts.serif);
67+ root.style.setProperty("--font-mono", config.fonts.monospace);
68+
69+ console.log(`[dumber] Applied ${theme} appearance config`);
70+
71+ // Dispatch event for components that need to react to appearance changes
72+ document.dispatchEvent(
73+ new CustomEvent("dumber:appearance-applied", {
74+ detail: { theme, palette, fonts: config.fonts },
75+ }),
76+ );
77+}
78+
79+/**
80+ * Gets the current theme from appearance config
81+ */
82+export function getCurrentTheme(): "light" | "dark" {
83+ return window.__dumber_appearance_config?.theme || "light";
84+}
85+
86+/**
87+ * Gets the current color palette for the active theme
88+ */
89+export function getCurrentPalette(): ColorPalette | null {
90+ const config = window.__dumber_appearance_config;
91+ if (!config) return null;
92+
93+ return config.palettes[config.theme];
94+}
95+
96+/**
97+ * Expose palette application function globally for integration with color-scheme module
98+ */
99+window.__dumber_applyPalette = (theme: "light" | "dark") => {
100+ const config = window.__dumber_appearance_config;
101+ if (!config) return;
102+
103+ const palette = config.palettes[theme];
104+ const root = document.documentElement;
105+
106+ root.style.setProperty("--color-background", palette.background);
107+ root.style.setProperty("--color-surface", palette.surface);
108+ root.style.setProperty("--color-surface-variant", palette.surfaceVariant);
109+ root.style.setProperty("--color-text", palette.text);
110+ root.style.setProperty("--color-muted", palette.muted);
111+ root.style.setProperty("--color-accent", palette.accent);
112+ root.style.setProperty("--color-border", palette.border);
113+
114+ console.log(`[dumber] Switched to ${theme} palette`);
115+};
116+
117+// Auto-initialize if config is already available
118+if (window.__dumber_appearance_config) {
119+ applyAppearanceConfig();
120+}
121+
122+// Listen for config injection and apply
123+document.addEventListener("DOMContentLoaded", () => {
124+ if (window.__dumber_appearance_config) {
125+ applyAppearanceConfig();
126+ }
127+});
128+
129+export default {
130+ applyAppearanceConfig,
131+ getCurrentTheme,
132+ getCurrentPalette,
133+};
M gui/src/injected/modules/toast.ts +4 -64
@@ -96,78 +96,18 @@ export async function initializeToast(config?: ToastConfig): Promise<void> {
9696 updateTheme(initialTheme === "dark");
9797 }
9898
99- // Mount the Svelte toast container (Svelte 5 syntax)
99+ // Mount the Svelte toast container - this exposes all toast functions to window
100100 mount(ToastContainer, {
101101 target: rootElement as unknown as Element,
102102 });
103103
104- // Signal isolated GUI readiness for page-world bridge
105- try {
106- document.documentElement.setAttribute("data-dumber-gui", "ready");
107- document.dispatchEvent(new CustomEvent("dumber:gui-ready"));
108- } catch {
109- // Ignore errors in GUI readiness signaling
110- }
111-
112- // Listen for page-world bridge events and forward to internal toast functions
113- const handleShowToast = (e: Event) => {
114- const detail = (e as CustomEvent).detail || {};
115- if (typeof window.__dumber_showToast === "function") {
116- window.__dumber_showToast(detail.message, detail.duration, detail.type);
117- }
118- };
119- document.addEventListener("dumber:showToast", handleShowToast);
120- document.addEventListener("dumber:toast:show", handleShowToast);
121-
122- const handleZoomToast = (e: Event) => {
123- const detail = (e as CustomEvent).detail || {};
124- if (typeof window.__dumber_showZoomToast === "function") {
125- window.__dumber_showZoomToast(detail.level);
126- }
127- };
128- document.addEventListener("dumber:showZoomToast", handleZoomToast);
129- document.addEventListener("dumber:toast:zoom", handleZoomToast);
130-
131- // Force immediate effect execution to ensure onMount callbacks run
104+ // Force immediate effect execution to ensure component functions are exposed
132105 flushSync();
133106
134- // Toast functions are now exposed by the component itself
135- console.log("✅ Toast functions exposed via component instantiation");
136-
137107 toastInitialized = true;
138- console.log("✅ Toast system initialized with Svelte 5");
108+ console.log("✅ Toast system initialized - functions exposed by ToastContainer");
139109 } catch (error) {
140110 console.error("❌ Failed to initialize toast system:", error);
141-
142- // Fallback to basic toast system if Svelte fails
143- initializeFallbackToast();
111+ throw error;
144112 }
145113 }
146-
147-function exposeToastFunctions(): void {
148- // The Svelte ToastContainer component already exposes these functions
149- // We just need to make sure they exist, they're handled by the component
150- console.log("✅ Toast functions will be exposed by ToastContainer component");
151-}
152-
153-function initializeFallbackToast(): void {
154- console.warn("🔄 Falling back to basic toast system");
155-
156- // Create basic toast container for fallback
157- const fallbackContainer = document.createElement("div");
158- fallbackContainer.id = "dumber-fallback-toast";
159- fallbackContainer.style.cssText = `
160- position: fixed;
161- top: 20px;
162- right: 20px;
163- z-index: 10000;
164- pointer-events: none;
165- `;
166- document.documentElement.appendChild(fallbackContainer);
167-
168- // Expose toast functions for fallback too
169- exposeToastFunctions();
170-
171- toastInitialized = true;
172- console.log("✅ Fallback toast system ready");
173-}
M gui/src/injected/modules/workspace.ts +4 -425
@@ -1,429 +1,8 @@
11 /**
2- * Workspace Controller Module
2+ * Workspace UI Notifications Module
33 *
4- * Provides the initial scaffolding for Zellij-inspired pane management.
5- * Currently tracks pane mode state, exposes configuration to the page,
6- * and wires keyboard service shortcuts based on configuration defaults.
4+ * Pane mode is now handled entirely in the Go backend with visual border feedback.
5+ * This module is kept for potential future UI enhancements.
76 */
87
9-import { keyboardService } from "$lib/keyboard";
10-
11-const ACTIVATION_COMPONENT_ID = "workspace-pane-activation";
12-const ACTION_COMPONENT_ID = "workspace-pane-actions";
13-
14-export interface WorkspaceConfigPayload {
15- enable_zellij_controls?: boolean;
16- pane_mode?: {
17- activation_shortcut?: string;
18- timeout_ms?: number;
19- action_bindings?: Record<string, string>;
20- };
21- tabs?: {
22- new_tab?: string;
23- close_tab?: string;
24- next_tab?: string;
25- previous_tab?: string;
26- };
27- popups?: {
28- placement?: string;
29- open_in_new_pane?: boolean;
30- follow_pane_context?: boolean;
31- };
32-}
33-
34-export interface WorkspacePaneModeConfig {
35- activationShortcut: string;
36- timeoutMs: number;
37- actionBindings: Record<string, string>;
38-}
39-
40-export interface WorkspaceTabsConfig {
41- newTab: string;
42- closeTab: string;
43- nextTab: string;
44- previousTab: string;
45-}
46-
47-export interface WorkspacePopupConfig {
48- placement: string;
49- openInNewPane: boolean;
50- followPaneContext: boolean;
51-}
52-
53-export interface WorkspaceConfigNormalized {
54- enableZellijControls: boolean;
55- paneMode: WorkspacePaneModeConfig;
56- tabs: WorkspaceTabsConfig;
57- popups: WorkspacePopupConfig;
58-}
59-
60-export interface WorkspaceRuntime {
61- enterPaneMode: () => void;
62- exitPaneMode: (reason?: string) => void;
63- isPaneModeActive: () => boolean;
64- getConfig: () => WorkspaceConfigNormalized;
65- getInstanceId: () => string;
66- isActiveInstance: () => boolean;
67-}
68-
69-const DEFAULT_CONFIG: WorkspaceConfigNormalized = {
70- enableZellijControls: true,
71- paneMode: {
72- activationShortcut: "cmdorctrl+p",
73- timeoutMs: 3000,
74- actionBindings: {
75- arrowright: "split-right",
76- arrowleft: "split-left",
77- arrowup: "split-up",
78- arrowdown: "split-down",
79- r: "split-right",
80- l: "split-left",
81- u: "split-up",
82- d: "split-down",
83- s: "stack-pane",
84- x: "close-pane",
85- enter: "confirm",
86- escape: "cancel",
87- },
88- },
89- tabs: {
90- newTab: "cmdorctrl+t",
91- closeTab: "cmdorctrl+w",
92- nextTab: "cmdorctrl+tab",
93- previousTab: "cmdorctrl+shift+tab",
94- },
95- popups: {
96- placement: "right",
97- openInNewPane: true,
98- followPaneContext: true,
99- },
100-};
101-
102-function normalizeWorkspaceConfig(
103- payload?: WorkspaceConfigPayload | null,
104-): WorkspaceConfigNormalized {
105- const paneModePayload = payload?.pane_mode ?? {};
106- const tabsPayload = payload?.tabs ?? {};
107- const popupsPayload = payload?.popups ?? {};
108-
109- return {
110- enableZellijControls:
111- payload?.enable_zellij_controls ?? DEFAULT_CONFIG.enableZellijControls,
112- paneMode: {
113- activationShortcut:
114- paneModePayload.activation_shortcut ??
115- DEFAULT_CONFIG.paneMode.activationShortcut,
116- timeoutMs:
117- paneModePayload.timeout_ms ?? DEFAULT_CONFIG.paneMode.timeoutMs,
118- actionBindings: {
119- ...DEFAULT_CONFIG.paneMode.actionBindings,
120- ...(paneModePayload.action_bindings ?? {}),
121- },
122- },
123- tabs: {
124- newTab: tabsPayload.new_tab ?? DEFAULT_CONFIG.tabs.newTab,
125- closeTab: tabsPayload.close_tab ?? DEFAULT_CONFIG.tabs.closeTab,
126- nextTab: tabsPayload.next_tab ?? DEFAULT_CONFIG.tabs.nextTab,
127- previousTab: tabsPayload.previous_tab ?? DEFAULT_CONFIG.tabs.previousTab,
128- },
129- popups: {
130- placement: popupsPayload.placement ?? DEFAULT_CONFIG.popups.placement,
131- openInNewPane:
132- popupsPayload.open_in_new_pane ?? DEFAULT_CONFIG.popups.openInNewPane,
133- followPaneContext:
134- popupsPayload.follow_pane_context ??
135- DEFAULT_CONFIG.popups.followPaneContext,
136- },
137- };
138-}
139-
140-class WorkspaceController implements WorkspaceRuntime {
141- private config: WorkspaceConfigNormalized = DEFAULT_CONFIG;
142- private paneModeActive = false;
143- private paneModeTimer: ReturnType<typeof setTimeout> | null = null;
144- private instanceId: string;
145- private lastActiveCheckTime = 0;
146-
147- constructor() {
148- // Generate unique instance ID for this webview
149- this.instanceId = `workspace-${Date.now()}-${Math.random().toString(36).substring(2)}`;
150- console.log(
151- `[workspace] Created WorkspaceController instance: ${this.instanceId}`,
152- );
153- }
154-
155- getInstanceId(): string {
156- return this.instanceId;
157- }
158-
159- isActiveInstance(): boolean {
160- // Cache active check for 50ms to avoid excessive checks
161- const now = Date.now();
162- if (now - this.lastActiveCheckTime < 50) {
163- return true; // Assume active within debounce window
164- }
165- this.lastActiveCheckTime = now;
166-
167- // Check if this webview has focus by attempting to query document.hasFocus()
168- // In a multi-pane environment, only the focused webview should handle pane mode
169- try {
170- const hasFocus = document.hasFocus();
171- const isVisible = document.visibilityState === "visible";
172- const isActive = hasFocus && isVisible;
173-
174- if (!isActive) {
175- console.log(
176- `[workspace] Instance ${this.instanceId} not active: focus=${hasFocus} visible=${isVisible}`,
177- );
178- }
179-
180- return isActive;
181- } catch (error) {
182- console.warn(
183- "[workspace] Failed to check active state, assuming active:",
184- error,
185- );
186- return true; // Fallback to active if check fails
187- }
188- }
189-
190- configure(payload?: WorkspaceConfigPayload | null): void {
191- const normalized = normalizeWorkspaceConfig(payload);
192- this.unregisterShortcuts();
193- this.config = normalized;
194-
195- if (!normalized.enableZellijControls) {
196- console.log("[workspace] Zellij controls disabled via config");
197- window.__dumber_workspace = this.exportRuntime();
198- return;
199- }
200-
201- this.registerShortcuts();
202- window.__dumber_workspace = this.exportRuntime();
203- console.log(
204- "[workspace] Zellij controls enabled with config:",
205- this.config,
206- );
207- }
208-
209- private bridge(message: Record<string, unknown>): void {
210- const bridge = window.webkit?.messageHandlers?.dumber;
211- if (!bridge || typeof bridge.postMessage !== "function") {
212- return;
213- }
214-
215- try {
216- bridge.postMessage(
217- JSON.stringify({
218- type: "workspace",
219- ...message,
220- }),
221- );
222- } catch (error) {
223- console.error("[workspace] Failed to post workspace message", error);
224- }
225- }
226-
227- enterPaneMode(): void {
228- if (!this.config.enableZellijControls) return;
229-
230- // Only allow the active instance to enter pane mode
231- if (!this.isActiveInstance()) {
232- console.log(
233- `[workspace] Instance ${this.instanceId} ignoring pane mode - not active instance`,
234- );
235- return;
236- }
237-
238- console.log(`[workspace] Instance ${this.instanceId} entering pane mode`);
239- this.paneModeActive = true;
240- this.restartPaneModeTimer();
241- this.emitWorkspaceEvent("pane-mode-entered", {
242- action: "enter",
243- config: this.config,
244- instanceId: this.instanceId,
245- });
246- this.bridge({ event: "pane-mode-entered", instanceId: this.instanceId });
247- this.showToast("Pane mode: awaiting direction");
248- }
249-
250- exitPaneMode(reason: string = "exit"): void {
251- if (!this.paneModeActive) return;
252-
253- this.paneModeActive = false;
254- if (this.paneModeTimer) {
255- clearTimeout(this.paneModeTimer);
256- this.paneModeTimer = null;
257- }
258- this.bridge({ event: "pane-mode-exited", reason });
259- }
260-
261- isPaneModeActive(): boolean {
262- return this.paneModeActive;
263- }
264-
265- getConfig(): WorkspaceConfigNormalized {
266- return this.config;
267- }
268-
269- private registerShortcuts(): void {
270- keyboardService.registerShortcuts(ACTIVATION_COMPONENT_ID, [
271- {
272- key: this.config.paneMode.activationShortcut,
273- handler: () => this.enterPaneMode(),
274- description: "Enter pane mode",
275- preventDefault: true,
276- stopPropagation: true,
277- whenFocused: false,
278- },
279- ]);
280-
281- const paneShortcuts = Object.entries(
282- this.config.paneMode.actionBindings,
283- ).map(([key, action]) => ({
284- key,
285- handler: () => this.handlePaneAction(action, key),
286- description: `Pane action: ${action}`,
287- preventDefault: true,
288- stopPropagation: true,
289- whenFocused: false,
290- }));
291-
292- keyboardService.registerShortcuts(ACTION_COMPONENT_ID, paneShortcuts);
293- }
294-
295- private unregisterShortcuts(): void {
296- keyboardService.unregisterShortcuts(ACTIVATION_COMPONENT_ID);
297- keyboardService.unregisterShortcuts(ACTION_COMPONENT_ID);
298- }
299-
300- private handlePaneAction(action: string, key?: string): void {
301- if (!this.paneModeActive) {
302- return;
303- }
304-
305- switch (action) {
306- case "split-right":
307- this.emitWorkspaceEvent("pane-split", {
308- direction: "right",
309- config: this.config,
310- });
311- this.bridge({ event: "pane-split", direction: "right", action: "split-right" });
312- this.showToast("Split pane to the right");
313- this.exitPaneMode("split-right");
314- break;
315- case "split-left":
316- this.emitWorkspaceEvent("pane-split", {
317- direction: "left",
318- config: this.config,
319- });
320- this.bridge({ event: "pane-split", direction: "left", action: "split-left" });
321- this.showToast("Split pane to the left");
322- this.exitPaneMode("split-left");
323- break;
324- case "split-up":
325- this.emitWorkspaceEvent("pane-split", {
326- direction: "up",
327- config: this.config,
328- });
329- this.bridge({ event: "pane-split", direction: "up", action: "split-up" });
330- this.showToast("Split pane upwards");
331- this.exitPaneMode("split-up");
332- break;
333- case "split-down":
334- this.emitWorkspaceEvent("pane-split", {
335- direction: "down",
336- config: this.config,
337- });
338- this.bridge({ event: "pane-split", direction: "down", action: "split-down" });
339- this.showToast("Split pane downwards");
340- this.exitPaneMode("split-down");
341- break;
342- case "stack-pane":
343- this.bridge({ event: "pane-stack", action: "stack-pane" });
344- this.showToast("Stacked pane");
345- this.exitPaneMode("stack-pane");
346- break;
347- case "close-pane":
348- this.emitWorkspaceEvent("pane-closed", { key, config: this.config });
349- this.bridge({ event: "pane-close", action });
350- this.showToast("Closed pane");
351- this.exitPaneMode("close-pane");
352- break;
353- case "confirm":
354- this.bridge({ event: "pane-confirmed" });
355- this.showToast("Pane mode confirmed");
356- this.exitPaneMode("confirm");
357- break;
358- case "cancel":
359- this.bridge({ event: "pane-cancelled" });
360- this.showToast("Pane mode cancelled");
361- this.exitPaneMode("cancel");
362- break;
363- default:
364- console.warn("[workspace] Unhandled pane action:", action);
365- }
366- }
367-
368- private restartPaneModeTimer(): void {
369- if (this.paneModeTimer) {
370- clearTimeout(this.paneModeTimer);
371- }
372-
373- if (this.config.paneMode.timeoutMs > 0) {
374- this.paneModeTimer = setTimeout(() => {
375- this.showToast("Pane mode timed out");
376- this.exitPaneMode("timeout");
377- }, this.config.paneMode.timeoutMs);
378- }
379- }
380-
381- private showToast(message: string): void {
382- if (typeof window.__dumber_showToast === "function") {
383- window.__dumber_showToast(message, 2000, "info");
384- } else {
385- console.log("[workspace toast]", message);
386- }
387- }
388-
389- private emitWorkspaceEvent(
390- event: string,
391- detail: Record<string, unknown>,
392- ): void {
393- document.dispatchEvent(
394- new CustomEvent(`dumber:workspace-${event}`, { detail }),
395- );
396- }
397-
398- private exportRuntime(): WorkspaceRuntime {
399- return {
400- enterPaneMode: () => this.enterPaneMode(),
401- exitPaneMode: (reason?: string) =>
402- this.exitPaneMode(reason ?? "external"),
403- isPaneModeActive: () => this.isPaneModeActive(),
404- getConfig: () => this.getConfig(),
405- getInstanceId: () => this.getInstanceId(),
406- isActiveInstance: () => this.isActiveInstance(),
407- };
408- }
409-}
410-
411-const workspaceController = new WorkspaceController();
412-let listenersAttached = false;
413-
414-export function initializeWorkspace(
415- payload?: WorkspaceConfigPayload | null,
416-): void {
417- if (!listenersAttached) {
418- document.addEventListener("dumber:workspace-config", (event: Event) => {
419- const detail = (event as CustomEvent<WorkspaceConfigPayload>).detail;
420- workspaceController.configure(detail);
421- });
422- listenersAttached = true;
423- }
424-
425- const bootstrapPayload = payload ?? window.__dumber_workspace_config ?? null;
426- workspaceController.configure(bootstrapPayload);
427-}
428-
429-export { workspaceController };
8+console.log("[workspace] UI notification handlers initialized");
M gui/src/pages/Homepage.svelte +13 -16
@@ -549,10 +549,15 @@
549549 return;
550550 }
551551
552- if (mode === "dark") {
553- document.documentElement.classList.add("dark");
554- } else {
555- document.documentElement.classList.remove("dark");
552+ switch (mode) {
553+ case "light":
554+ document.documentElement.classList.add("light");
555+ document.documentElement.classList.remove("dark");
556+ break;
557+ case "dark":
558+ document.documentElement.classList.remove("light");
559+ document.documentElement.classList.add("dark");
560+ break;
556561 }
557562 persistTheme(mode);
558563 };
@@ -766,10 +771,8 @@
766771 <meta name="description" content="Fast Wayland Browser - Your browsing patterns at a glance" />
767772
768773 {@html `<style>
769- html { background: #0a0a0a; }
770- body { background: #0a0a0a; color: #e5e5e5; }
771- html.light { background: #ffffff; }
772- html.light body { background: #ffffff; color: #171717; }
774+ html { background: var(--dynamic-bg, #0a0a0a); }
775+ body { background: var(--dynamic-bg, #0a0a0a); color: var(--dynamic-text, #e5e5e5); }
773776 </style>`}
774777
775778 {@html `<script>
@@ -806,13 +809,8 @@
806809 {themeMode === "dark" ? "Switch to light mode" : "Switch to dark mode"}
807810 </span>
808811 {#if themeMode === "dark"}
809- <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
810- <path
811- fill="currentColor"
812- fill-rule="evenodd"
813- clip-rule="evenodd"
814- d="M17.293 13.293A8 8 0 0 1 10.707 2.997a8.001 8.001 0 1 0 6.586 10.296z"
815- />
812+ <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
813+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
816814 </svg>
817815 {:else}
818816 <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
@@ -1193,7 +1191,6 @@
11931191 <style>
11941192 .homepage-shell {
11951193 min-height: 100vh;
1196- background-color: var(--dynamic-bg);
11971194 color: var(--dynamic-text);
11981195 display: flex;
11991196 flex-direction: column;
M gui/src/pages/homepage.ts +3 -0
@@ -1,6 +1,7 @@
11 import { mount } from "svelte";
22 import "../styles/tailwind.css";
33 import Homepage from "./Homepage.svelte";
4+import { bootstrapGUI } from "../injected/bootstrap";
45
56 // Proactively unregister any Service Workers and clear caches that may have been
67 // installed by previous builds (e.g., SvelteKit), which can otherwise attempt to
@@ -25,6 +26,8 @@ async function cleanupServiceWorkersAndCaches() {
2526 }
2627 }
2728
29+bootstrapGUI();
30+
2831 // Mount the Homepage component to the DOM
2932 cleanupServiceWorkersAndCaches().finally(() => {
3033 console.log("[dumber] Mounting Homepage component to document.body");
M gui/vite.config.homepage.ts +4 -3
@@ -5,13 +5,14 @@ import { pageGenerator } from "./vite-plugin-pages";
55
66 export default defineConfig({
77 plugins: [
8- svelte(),
8+ svelte({
9+ emitCss: false, // Inline component CSS for shadow DOM compatibility
10+ }),
911 pageGenerator([
1012 {
1113 name: "homepage",
1214 title: "Dumber Browser",
1315 script: "homepage.min.js",
14- css: "homepage.css",
1516 filename: "index.html",
1617 },
1718 ]),
@@ -33,7 +34,7 @@ export default defineConfig({
3334 target: ["es2020", "chrome91", "firefox90"],
3435 minify: true,
3536 sourcemap: false,
36- cssCodeSplit: true,
37+ cssCodeSplit: false,
3738 assetsInlineLimit: 4096,
3839 },
3940 resolve: {
M internal/app/api/scheme.go +88 -10
@@ -2,15 +2,20 @@ package api
22
33 import (
44 "embed"
5+ "fmt"
56 "log"
67 "mime"
78 neturl "net/url"
9+ "os"
810 "path/filepath"
911 "strings"
1012
1113 "github.com/bnema/dumber/internal/app/constants"
1214 "github.com/bnema/dumber/internal/config"
1315 "github.com/bnema/dumber/internal/services"
16+ webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
17+ "github.com/diamondburned/gotk4/pkg/gio/v2"
18+ "github.com/diamondburned/gotk4/pkg/glib/v2"
1419 )
1520
1621 // SchemeHandler handles custom dumb:// scheme resolution
@@ -18,6 +23,7 @@ type SchemeHandler struct {
1823 assets embed.FS
1924 parserService *services.ParserService
2025 browserService *services.BrowserService
26+ cfg *config.Config
2127 }
2228
2329 // NewSchemeHandler creates a new scheme handler
@@ -33,25 +39,39 @@ func NewSchemeHandler(
3339 }
3440 }
3541
36-// Handle processes dumb:// scheme requests
37-func (s *SchemeHandler) Handle(uri string, cfg *config.Config) (string, []byte, bool) {
42+// SetConfig sets the configuration for the scheme handler
43+func (s *SchemeHandler) SetConfig(cfg *config.Config) {
44+ s.cfg = cfg
45+}
46+
47+// Handle processes dumb:// scheme requests using the new URISchemeRequest API
48+func (s *SchemeHandler) Handle(req *webkit.URISchemeRequest) {
49+ uri := req.URI()
3850 log.Printf("[scheme] request: %s", uri)
3951
4052 // Known forms:
4153 // - dumb://homepage or dumb:homepage → index.html
4254 // - dumb://app/index.html, dumb://app/<path> → serve from gui/<path>
55+ // - dumb://favicon/<hash>.png → serve cached favicon
4356 // - dumb://<anything> without path → index.html
4457 u, err := neturl.Parse(uri)
4558 if err != nil || u.Scheme != "dumb" {
46- return "", nil, false
59+ req.FinishError(fmt.Errorf("invalid URI: %s", uri))
60+ return
61+ }
62+
63+ // Check if this is a favicon request
64+ if u.Host == "favicon" || (u.Host == "" && strings.HasPrefix(u.Path, "/favicon/")) {
65+ s.handleFavicon(req, u)
66+ return
4767 }
4868
4969 // Static assets
50- return s.handleAsset(u)
70+ s.handleAsset(req, u)
5171 }
5272
5373 // handleAsset serves static assets from embedded filesystem
54-func (s *SchemeHandler) handleAsset(u *neturl.URL) (string, []byte, bool) {
74+func (s *SchemeHandler) handleAsset(req *webkit.URISchemeRequest, u *neturl.URL) {
5575 // Resolve target path inside embed FS
5676 var rel string
5777 if u.Opaque == constants.HomepagePath || (u.Host == constants.HomepagePath && (u.Path == "" || u.Path == "/")) || (u.Host == "" && (u.Path == "" || u.Path == "/")) {
@@ -79,7 +99,8 @@ func (s *SchemeHandler) handleAsset(u *neturl.URL) (string, []byte, bool) {
7999 log.Printf("[scheme] asset: rel=%s (host=%s path=%s) → mapping to favicon.png", rel, u.Host, u.Path)
80100 data, rerr := s.assets.ReadFile(filepath.ToSlash(filepath.Join("assets", "gui", "favicon.png")))
81101 if rerr == nil {
82- return "image/png", data, true
102+ s.finishRequest(req, "image/png", data, "favicon.png")
103+ return
83104 }
84105 }
85106
@@ -89,16 +110,34 @@ func (s *SchemeHandler) handleAsset(u *neturl.URL) (string, []byte, bool) {
89110 data, rerr := s.assets.ReadFile(filepath.ToSlash(filepath.Join("assets", "gui", rel)))
90111 if rerr != nil {
91112 log.Printf("[scheme] not found: %s", rel)
92- return "", nil, false
113+ req.FinishError(fmt.Errorf("asset not found: %s", rel))
114+ return
93115 }
94116
95117 // Determine mime type
96118 mt := s.getMimeType(rel)
97119
98- // Add cache control for development (prevents future caching issues)
99- // Note: This doesn't affect the current cache, but prevents new caching
120+ // Finish the request with the data
100121 log.Printf("[scheme] serving %s with mime-type: %s", rel, mt)
101- return mt, data, true
122+ s.finishRequest(req, mt, data, rel)
123+}
124+
125+// finishRequest completes a URI scheme request with the provided data
126+func (s *SchemeHandler) finishRequest(req *webkit.URISchemeRequest, mimeType string, data []byte, filename string) {
127+ // Convert byte slice to GLib Bytes
128+ gbytes := glib.NewBytes(data)
129+
130+ // Create an input stream from the bytes
131+ stream := gio.NewMemoryInputStreamFromBytes(gbytes)
132+ if stream == nil {
133+ req.FinishError(fmt.Errorf("failed to create input stream for: %s", filename))
134+ log.Printf("[scheme] failed to create stream for: %s", filename)
135+ return
136+ }
137+
138+ // Finish the request with the stream
139+ contentLength := int64(len(data))
140+ req.Finish(stream, contentLength, mimeType)
102141 }
103142
104143 // getMimeType determines the MIME type for a given file path
@@ -152,3 +191,42 @@ func (s *SchemeHandler) getMimeType(filename string) string {
152191 return "text/plain"
153192 }
154193 }
194+
195+// handleFavicon serves cached favicon files
196+func (s *SchemeHandler) handleFavicon(req *webkit.URISchemeRequest, u *neturl.URL) {
197+ // Extract the filename from the URL
198+ // URL format: dumb://favicon/<hash>.png
199+ var filename string
200+ if u.Host == "favicon" {
201+ filename = strings.TrimPrefix(u.Path, "/")
202+ } else {
203+ filename = strings.TrimPrefix(u.Path, "/favicon/")
204+ }
205+
206+ if filename == "" {
207+ log.Printf("[scheme] favicon: empty filename")
208+ req.FinishError(fmt.Errorf("invalid favicon path"))
209+ return
210+ }
211+
212+ // Get the favicon cache directory path
213+ dataDir, err := config.GetDataDir()
214+ if err != nil {
215+ log.Printf("[scheme] favicon: failed to get data directory: %v", err)
216+ req.FinishError(fmt.Errorf("failed to get data directory"))
217+ return
218+ }
219+
220+ faviconPath := filepath.Join(dataDir, "favicons", filename)
221+
222+ // Read the favicon file
223+ data, err := os.ReadFile(faviconPath)
224+ if err != nil {
225+ log.Printf("[scheme] favicon: file not found: %s", faviconPath)
226+ req.FinishError(fmt.Errorf("favicon not found: %s", filename))
227+ return
228+ }
229+
230+ log.Printf("[scheme] favicon: serving %s (%d bytes)", filename, len(data))
231+ s.finishRequest(req, "image/png", data, filename)
232+}
M internal/app/browser/browser.go +16 -13
@@ -3,7 +3,6 @@ package browser
33 import (
44 "database/sql"
55 "embed"
6- "fmt"
76 "log"
87 "os"
98 "os/signal"
@@ -18,7 +17,6 @@ import (
1817 "github.com/bnema/dumber/internal/db"
1918 "github.com/bnema/dumber/internal/filtering"
2019 "github.com/bnema/dumber/internal/logging"
21- "github.com/bnema/dumber/internal/migrations"
2220 "github.com/bnema/dumber/internal/services"
2321 "github.com/bnema/dumber/pkg/webkit"
2422 )
@@ -119,21 +117,20 @@ func (app *BrowserApp) Initialize() error {
119117 // Detect keyboard layout
120118 environment.DetectAndSetKeyboardLocale()
121119
122- // Initialize database
120+ // Initialize database (migrations run automatically in InitDB)
123121 database, err := db.InitDB(app.config.Database.Path)
124122 if err != nil {
125123 return err
126124 }
127125 app.database = database
128-
129- // Run database migrations
130- if err := migrations.RunEmbeddedMigrations(database); err != nil {
131- return fmt.Errorf("failed to run database migrations: %w", err)
132- }
133-
134126 app.queries = db.New(database)
135127 log.Printf("Database opened at %s", app.config.Database.Path)
136128
129+ // Cleanup expired certificate validations
130+ if err := webkit.CleanupExpiredCertificateValidations(); err != nil {
131+ log.Printf("Warning: failed to cleanup expired certificate validations: %v", err)
132+ }
133+
137134 // Initialize services
138135 app.parserService = services.NewParserService(app.config, app.queries)
139136 app.browserService = services.NewBrowserService(app.config, app.queries)
@@ -150,10 +147,11 @@ func (app *BrowserApp) Run() {
150147 defer app.cleanup()
151148 defer logging.SetupPanicRecovery()
152149
153- // Register custom scheme resolver
154- webkit.SetURISchemeResolver(func(uri string) (string, []byte, bool) {
155- return app.schemeHandler.Handle(uri, app.config)
156- })
150+ // Set config on scheme handler
151+ app.schemeHandler.SetConfig(app.config)
152+
153+ // Register custom scheme resolver for "dumb://" URIs (will be applied after WebView creation)
154+ webkit.RegisterURIScheme("dumb", app.schemeHandler.Handle)
157155
158156 // Create and setup WebView
159157 if err := app.createWebView(); err != nil {
@@ -161,6 +159,11 @@ func (app *BrowserApp) Run() {
161159 return
162160 }
163161
162+ // Apply URI scheme handlers after WebView creation
163+ if err := webkit.ApplyURISchemeHandlers(app.webView.GetWebView()); err != nil {
164+ log.Printf("Warning: failed to register URI scheme handlers: %v", err)
165+ }
166+
164167 // Initialize content blocking
165168 if err := app.setupContentBlocking(); err != nil {
166169 log.Printf("Warning: failed to setup content blocking: %v", err)
M internal/app/browser/focus_state_machine.go +83 -37
@@ -10,6 +10,7 @@ import (
1010 "time"
1111
1212 "github.com/bnema/dumber/pkg/webkit"
13+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
1314 )
1415
1516 // FocusState represents the current state of the focus management system
@@ -158,7 +159,7 @@ func NewRequestDeduplicator(ttl time.Duration) *RequestDeduplicator {
158159 }
159160
160161 // IsDuplicate checks if a request is a duplicate within the TTL window
161-func (rd *RequestDeduplicator) IsDuplicate(req FocusRequest) bool {
162+func (rd *RequestDeduplicator) IsDuplicate(req FocusRequest, isActiveTarget bool) bool {
162163 rd.mu.Lock()
163164 defer rd.mu.Unlock()
164165
@@ -173,12 +174,16 @@ func (rd *RequestDeduplicator) IsDuplicate(req FocusRequest) bool {
173174 // Generate signature for this request
174175 sig := fmt.Sprintf("%p:%s", req.TargetNode, req.Source)
175176
176- // Check if this signature exists
177- if _, exists := rd.recentSigs[sig]; exists {
178- return true
177+ // Always record latest timestamp so future checks use fresh window
178+ // Prevent duplicates only when the target is already active; otherwise we
179+ // want to process the request to move focus back to that pane.
180+ if timestamp, exists := rd.recentSigs[sig]; exists {
181+ if isActiveTarget && now.Sub(timestamp) <= rd.ttl {
182+ rd.recentSigs[sig] = now
183+ return true
184+ }
179185 }
180186
181- // Record this signature
182187 rd.recentSigs[sig] = now
183188 return false
184189 }
@@ -366,8 +371,11 @@ func (fsm *FocusStateMachine) RequestFocus(node *paneNode, source FocusSource) e
366371 fsm.mu.Unlock()
367372 }
368373
369- // Check for duplicates
370- if fsm.deduplicator.IsDuplicate(request) {
374+ activePane := fsm.GetActivePane()
375+
376+ // Check for duplicates (only dedupe when the request targets the currently
377+ // active pane to avoid suppressing necessary focus transitions).
378+ if fsm.deduplicator.IsDuplicate(request, activePane == node) {
371379 if fsm.metricsEnabled {
372380 fsm.mu.Lock()
373381 fsm.metrics.DuplicateRequests++
@@ -556,17 +564,14 @@ func (fsm *FocusStateMachine) findTopLeftPane(leaves []*paneNode) *paneNode {
556564 var bestScore float64 = 1e9
557565
558566 for _, leaf := range leaves {
559- if leaf.container == 0 || !webkit.WidgetIsValid(leaf.container) {
567+ if leaf.container == nil {
560568 continue
561569 }
562570
563- bounds, ok := webkit.WidgetGetBounds(leaf.container)
564- if !ok {
565- continue
566- }
571+ x, y, _, _ := webkit.WidgetGetAllocation(leaf.container)
567572
568573 // Score based on distance from top-left corner (0,0)
569- score := bounds.X + bounds.Y
574+ score := float64(x) + float64(y)
570575 if score < bestScore {
571576 bestScore = score
572577 bestPane = leaf
@@ -591,8 +596,7 @@ func (fsm *FocusStateMachine) applyInitialFocus(node *paneNode) error {
591596
592597 // Apply initial visual border
593598 if fsm.wm != nil {
594- ctx := fsm.wm.determineBorderContext(node)
595- fsm.wm.applyActivePaneBorder(ctx)
599+ fsm.wm.applyActivePaneBorder(node)
596600 }
597601
598602 // Update state
@@ -615,6 +619,11 @@ func (fsm *FocusStateMachine) applyInitialFocus(node *paneNode) error {
615619 fsm.wm.app.activePane = node.pane
616620 }
617621
622+ // Notify JS runtime about focus state
623+ if fsm.wm != nil {
624+ fsm.wm.DispatchPaneFocusEvent(node, true)
625+ }
626+
618627 return nil
619628 }
620629
@@ -625,7 +634,7 @@ func (fsm *FocusStateMachine) applyGTKFocus(node *paneNode) error {
625634 }
626635
627636 viewWidget := node.pane.webView.Widget()
628- if viewWidget == 0 {
637+ if viewWidget == nil {
629638 return fmt.Errorf("webview has no valid widget")
630639 }
631640
@@ -705,8 +714,17 @@ func (fsm *FocusStateMachine) handleFocusRequest(request FocusRequest) {
705714 log.Printf("[FSM] Processing focus request %s: %s -> %p",
706715 request.ID, request.Source, request.TargetNode)
707716
708- // Execute focus change
709- if err := fsm.executeFocusChange(request); err != nil {
717+ // GTK operations must run on main thread - always use IdleAdd for simplicity
718+ var err error
719+ done := make(chan struct{})
720+ _ = webkit.IdleAdd(func() bool {
721+ err = fsm.executeFocusChange(request)
722+ close(done)
723+ return false
724+ })
725+ <-done
726+
727+ if err != nil {
710728 fsm.mu.Lock()
711729 fsm.metrics.FailedRequests++
712730 fsm.mu.Unlock()
@@ -758,8 +776,7 @@ func (fsm *FocusStateMachine) executeFocusChange(request FocusRequest) error {
758776 fsm.wm.removeActivePaneBorder(oldPane)
759777 }
760778 // Add border to new pane
761- ctx := fsm.wm.determineBorderContext(newPane)
762- fsm.wm.applyActivePaneBorder(ctx)
779+ fsm.wm.applyActivePaneBorder(newPane)
763780 }
764781
765782 // Update state
@@ -775,6 +792,16 @@ func (fsm *FocusStateMachine) executeFocusChange(request FocusRequest) error {
775792 fsm.wm.app.activePane = newPane.pane
776793 }
777794
795+ // Notify JS runtimes about focus change
796+ if fsm.wm != nil {
797+ if oldPane != nil {
798+ fsm.wm.DispatchPaneFocusEvent(oldPane, false)
799+ }
800+ if newPane != nil {
801+ fsm.wm.DispatchPaneFocusEvent(newPane, true)
802+ }
803+ }
804+
778805 // Start settling timer
779806 fsm.startSettlingTimer()
780807
@@ -918,27 +945,50 @@ func (fsm *FocusStateMachine) attachGTKController(node *paneNode) {
918945 }
919946
920947 widget := node.pane.webView.Widget()
921- if widget == 0 || !webkit.WidgetIsValid(widget) {
948+ if widget == nil {
922949 return
923950 }
924951
925- // Create focus enter/leave callbacks for this specific node
926- onEnter := func() {
952+ // Create focus controller for GTK4
953+ controller := gtk.NewEventControllerFocus()
954+
955+ // Set propagation phase to TARGET only to avoid duplicate events from parent widgets
956+ // GTK_PHASE_TARGET means we only get events for this specific widget, not bubbled events
957+ controller.SetPropagationPhase(gtk.PhaseTarget)
958+
959+ // Connect focus enter/leave callbacks with timestamp-based deduplication
960+ controller.ConnectEnter(func() {
961+ // Deduplicate: WebKitGTK nested widgets can fire multiple events in same millisecond
962+ now := time.Now().UnixMilli()
963+ if node.lastFocusEnterTime == now {
964+ return // Duplicate event in same millisecond
965+ }
966+ node.lastFocusEnterTime = now
967+
927968 log.Printf("[FSM] GTK focus enter: %p", node)
928969 // Don't automatically change focus on GTK enter - let user interactions drive this
929970 // This prevents infinite loops with our own focus changes
930- }
971+ })
972+
973+ controller.ConnectLeave(func() {
974+ // Deduplicate: WebKitGTK nested widgets can fire multiple events in same millisecond
975+ now := time.Now().UnixMilli()
976+ if node.lastFocusLeaveTime == now {
977+ return // Duplicate event in same millisecond
978+ }
979+ node.lastFocusLeaveTime = now
931980
932- onLeave := func() {
933981 log.Printf("[FSM] GTK focus leave: %p", node)
934982 // Similarly, don't react to GTK leave events automatically
935- }
983+ })
936984
937- // Add the focus controller and store the token for cleanup
938- token := webkit.WidgetAddFocusController(widget, onEnter, onLeave)
939- if token != 0 {
940- node.focusControllerToken = token
941- log.Printf("[FSM] Attached GTK focus controller to pane %p with token %d", node, token)
985+ // Add controller to widget
986+ webkit.WidgetAddController(widget, controller)
987+
988+ // Store controller pointer as token for later removal
989+ node.focusControllerToken = uintptr(controller.Native())
990+ if node.focusControllerToken != 0 {
991+ log.Printf("[FSM] Attached GTK focus controller to pane %p with token %d", node, node.focusControllerToken)
942992 }
943993 }
944994
@@ -955,12 +1005,8 @@ func (fsm *FocusStateMachine) detachGTKController(node *paneNode, token uintptr)
9551005
9561006 node.focusControllerToken = 0
9571007
958- widget := node.pane.webView.Widget()
959- if widget == 0 || !webkit.WidgetIsValid(widget) {
960- return
961- }
962-
963- webkit.WidgetRemoveFocusController(widget, token)
1008+ // Note: In GTK4, controllers are automatically removed when widget is destroyed
1009+ // We just need to clear our token reference
9641010 log.Printf("[FSM] Detached GTK focus controller from pane %p", node)
9651011 }
9661012
M internal/app/browser/focus_state_machine_test.go +8 -8
@@ -221,7 +221,7 @@ func TestRequestDeduplicator_DetectsDuplicates(t *testing.T) {
221221 }
222222
223223 // First request should not be duplicate
224- if dedup.IsDuplicate(req1) {
224+ if dedup.IsDuplicate(req1, false) {
225225 t.Error("first request should not be duplicate")
226226 }
227227
@@ -232,7 +232,7 @@ func TestRequestDeduplicator_DetectsDuplicates(t *testing.T) {
232232 Timestamp: time.Now(),
233233 }
234234
235- if !dedup.IsDuplicate(req2) {
235+ if !dedup.IsDuplicate(req2, true) {
236236 t.Error("second identical request should be duplicate")
237237 }
238238 }
@@ -253,10 +253,10 @@ func TestRequestDeduplicator_DifferentSourcesNotDuplicate(t *testing.T) {
253253 Source: SourceMouse,
254254 }
255255
256- dedup.IsDuplicate(req1)
256+ dedup.IsDuplicate(req1, false)
257257
258258 // Different source should not be duplicate
259- if dedup.IsDuplicate(req2) {
259+ if dedup.IsDuplicate(req2, false) {
260260 t.Error("requests with different sources should not be duplicates")
261261 }
262262 }
@@ -274,12 +274,12 @@ func TestRequestDeduplicator_ExpiresOldSignatures(t *testing.T) {
274274 }
275275
276276 // First request
277- if dedup.IsDuplicate(req) {
277+ if dedup.IsDuplicate(req, false) {
278278 t.Error("first request should not be duplicate")
279279 }
280280
281281 // Immediate duplicate
282- if !dedup.IsDuplicate(req) {
282+ if !dedup.IsDuplicate(req, true) {
283283 t.Error("immediate duplicate should be detected")
284284 }
285285
@@ -287,7 +287,7 @@ func TestRequestDeduplicator_ExpiresOldSignatures(t *testing.T) {
287287 time.Sleep(ttl + 10*time.Millisecond)
288288
289289 // Should not be duplicate after expiration
290- if dedup.IsDuplicate(req) {
290+ if dedup.IsDuplicate(req, false) {
291291 t.Error("request after TTL should not be duplicate")
292292 }
293293 }
@@ -312,7 +312,7 @@ func TestRequestDeduplicator_ConcurrentAccess(t *testing.T) {
312312 TargetNode: nodes[idx%len(nodes)],
313313 Source: SourceKeyboard,
314314 }
315- _ = dedup.IsDuplicate(req)
315+ _ = dedup.IsDuplicate(req, false)
316316 time.Sleep(time.Microsecond)
317317 }
318318 }(i)
M internal/app/browser/shortcuts.go +3 -3
@@ -67,7 +67,7 @@ func (s *ShortcutHandler) RegisterShortcuts() {
6767 // by WindowShortcutHandler at the window level to prevent duplicates
6868
6969 // Page refresh shortcuts
70- _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+r", func() {
70+ _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+r", 0, func() {
7171 if !s.isActivePane() {
7272 return
7373 }
@@ -75,7 +75,7 @@ func (s *ShortcutHandler) RegisterShortcuts() {
7575 _ = s.webView.Reload()
7676 })
7777
78- _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+shift+r", func() {
78+ _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+shift+r", 0, func() {
7979 if !s.isActivePane() {
8080 return
8181 }
@@ -83,7 +83,7 @@ func (s *ShortcutHandler) RegisterShortcuts() {
8383 _ = s.webView.ReloadBypassCache()
8484 })
8585
86- _ = s.webView.RegisterKeyboardShortcut("F5", func() {
86+ _ = s.webView.RegisterKeyboardShortcut("F5", 0, func() {
8787 if !s.isActivePane() {
8888 return
8989 }
M internal/app/browser/stacked_panes.go +160 -150
@@ -10,6 +10,7 @@ import (
1010
1111 "github.com/bnema/dumber/internal/cache"
1212 "github.com/bnema/dumber/pkg/webkit"
13+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
1314 )
1415
1516 const (
@@ -19,16 +20,16 @@ const (
1920
2021 // StackedPaneManager handles all stacked pane operations
2122 type StackedPaneManager struct {
22- wm *WorkspaceManager
23- titleBarToPane map[uintptr]*paneNode
24- nextTitleBarID uint64
23+ wm *WorkspaceManager
24+ titleBarToPane map[uint64]*paneNode
25+ nextTitleBarID uint64
2526 }
2627
2728 // NewStackedPaneManager creates a new stacked pane manager
2829 func NewStackedPaneManager(wm *WorkspaceManager) *StackedPaneManager {
2930 return &StackedPaneManager{
3031 wm: wm,
31- titleBarToPane: make(map[uintptr]*paneNode),
32+ titleBarToPane: make(map[uint64]*paneNode),
3233 }
3334 }
3435
@@ -84,12 +85,11 @@ func (spm *StackedPaneManager) prepareNewStackedPane() (*paneNode, error) {
8485 }
8586
8687 newContainer := newPane.webView.RootWidget()
87- if newContainer == 0 {
88+ if newContainer == nil {
8889 return nil, errors.New("new pane missing container")
8990 }
9091 webkit.WidgetSetHExpand(newContainer, true)
9192 webkit.WidgetSetVExpand(newContainer, true)
92- webkit.WidgetRealizeInContainer(newContainer)
9393
9494 // Create the new leaf node
9595 newLeaf := &paneNode{
@@ -105,7 +105,9 @@ func (spm *StackedPaneManager) prepareNewStackedPane() (*paneNode, error) {
105105
106106 // Keep new container hidden initially - will be shown after transition
107107 webkit.WidgetSetVisible(newContainer, false)
108- webkit.WidgetSetVisible(newTitleBar, false)
108+ if newTitleBar != nil {
109+ webkit.WidgetSetVisible(newTitleBar, false)
110+ }
109111
110112 return newLeaf, nil
111113 }
@@ -141,8 +143,8 @@ func (spm *StackedPaneManager) addPaneToExistingStack(target, newLeaf *paneNode)
141143 stackNode.stackedPanes[insertIndex] = newLeaf // Insert new pane
142144
143145 // Unparent the new container before adding it to the stack (only if it has a parent)
144- if newLeaf.container != 0 && webkit.WidgetIsValid(newLeaf.container) {
145- if webkit.WidgetGetParent(newLeaf.container) != 0 {
146+ if newLeaf.container != nil {
147+ if webkit.WidgetGetParent(newLeaf.container) != nil {
146148 webkit.WidgetUnparent(newLeaf.container)
147149 }
148150 }
@@ -151,32 +153,32 @@ func (spm *StackedPaneManager) addPaneToExistingStack(target, newLeaf *paneNode)
151153 // Each pane has 2 widgets (titleBar, container), so position = insertIndex * 2
152154
153155 // Find the widget to insert after (based on insertIndex)
154- var insertAfterWidget uintptr = 0
156+ var insertAfterWidget gtk.Widgetter
155157 if insertIndex > 0 {
156158 // Insert after the previous pane's container
157159 prevPane := stackNode.stackedPanes[insertIndex-1]
158- if prevPane.container != 0 {
160+ if prevPane.container != nil {
159161 insertAfterWidget = prevPane.container
160162 }
161163 }
162164
163- if insertAfterWidget != 0 {
165+ if insertAfterWidget != nil {
164166 // Insert titleBar after the previous pane's container
165- if stackNode.stackWrapper != 0 && newLeaf.titleBar != 0 {
166- webkit.BoxInsertChildAfter(stackNode.stackWrapper, newLeaf.titleBar, insertAfterWidget)
167+ if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.titleBar != nil {
168+ box.InsertChildAfter(newLeaf.titleBar, insertAfterWidget)
167169 }
168170 // Insert container after the newly inserted titleBar
169- if stackNode.stackWrapper != 0 && newLeaf.container != 0 && newLeaf.titleBar != 0 {
170- webkit.BoxInsertChildAfter(stackNode.stackWrapper, newLeaf.container, newLeaf.titleBar)
171+ if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.container != nil && newLeaf.titleBar != nil {
172+ box.InsertChildAfter(newLeaf.container, newLeaf.titleBar)
171173 }
172- log.Printf("[workspace] inserted widgets at position %d (after widget %#x)", insertIndex, insertAfterWidget)
174+ log.Printf("[workspace] inserted widgets at position %d (after widget %p)", insertIndex, insertAfterWidget)
173175 } else {
174176 // Insert at the beginning (insertIndex = 0)
175- if stackNode.stackWrapper != 0 && newLeaf.container != 0 {
176- webkit.BoxPrepend(stackNode.stackWrapper, newLeaf.container)
177+ if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.container != nil {
178+ box.Prepend(newLeaf.container)
177179 }
178- if stackNode.stackWrapper != 0 && newLeaf.titleBar != 0 {
179- webkit.BoxPrepend(stackNode.stackWrapper, newLeaf.titleBar)
180+ if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.titleBar != nil {
181+ box.Prepend(newLeaf.titleBar)
180182 }
181183 log.Printf("[workspace] prepended widgets at position 0")
182184 }
@@ -189,27 +191,27 @@ func (spm *StackedPaneManager) convertToStackedContainer(target, newLeaf *paneNo
189191 log.Printf("[workspace] converting pane to stacked: %p", target)
190192
191193 // Create the wrapper container - this is what will be used by splitNode
192- stackWrapperContainer := webkit.NewBox(webkit.OrientationVertical, 0)
193- if stackWrapperContainer == 0 {
194+ stackWrapperContainer := gtk.NewBox(gtk.OrientationVertical, 0)
195+ if stackWrapperContainer == nil {
194196 return nil, 0, errors.New("failed to create stack wrapper container")
195197 }
196- webkit.WidgetSetHExpand(stackWrapperContainer, true)
197- webkit.WidgetSetVExpand(stackWrapperContainer, true)
198- webkit.WidgetRealizeInContainer(stackWrapperContainer) // Ensures size request is set like regular panes
198+ stackWrapperContainer.SetHExpand(true)
199+ stackWrapperContainer.SetVExpand(true)
200+ // Note: Don't call Realize() here - GTK4 will realize automatically when added to toplevel
199201
200202 // Add CSS class to the stack container for proper styling
201- webkit.WidgetAddCSSClass(stackWrapperContainer, stackContainerClass)
203+ stackWrapperContainer.AddCSSClass(stackContainerClass)
202204
203205 // Create the internal box for the actual stacked widgets (titles + webviews)
204- stackInternalBox := webkit.NewBox(webkit.OrientationVertical, 0)
205- if stackInternalBox == 0 {
206+ stackInternalBox := gtk.NewBox(gtk.OrientationVertical, 0)
207+ if stackInternalBox == nil {
206208 return nil, 0, errors.New("failed to create stack internal box")
207209 }
208- webkit.WidgetSetHExpand(stackInternalBox, true)
209- webkit.WidgetSetVExpand(stackInternalBox, true)
210+ stackInternalBox.SetHExpand(true)
211+ stackInternalBox.SetVExpand(true)
210212
211213 // The internal box goes inside the wrapper
212- webkit.BoxAppend(stackWrapperContainer, stackInternalBox)
214+ stackWrapperContainer.Append(stackInternalBox)
213215
214216 // Get the existing container and parent info
215217 existingContainer := target.container
@@ -219,15 +221,19 @@ func (spm *StackedPaneManager) convertToStackedContainer(target, newLeaf *paneNo
219221 titleBar := spm.createTitleBar(target)
220222
221223 // Keep existing container visible during transition to prevent rendering glitch
222- webkit.WidgetSetVisible(titleBar, false)
224+ if titleBar != nil {
225+ webkit.WidgetSetVisible(titleBar, false)
226+ }
223227
224228 // Detach existing container from its current parent first
225229 spm.detachFromParent(target, parent)
226230
227231 // Build the complete stack structure with hidden widgets
228- webkit.BoxAppend(stackInternalBox, titleBar)
229- if existingContainer != 0 {
230- webkit.BoxAppend(stackInternalBox, existingContainer)
232+ if titleBar != nil {
233+ stackInternalBox.Append(titleBar)
234+ }
235+ if existingContainer != nil {
236+ stackInternalBox.Append(existingContainer)
231237 }
232238
233239 // Immediately reattach the stack wrapper to minimize visibility gap
@@ -275,24 +281,24 @@ func (spm *StackedPaneManager) convertToStackedContainer(target, newLeaf *paneNo
275281
276282 // Unparent the new container before adding it to the stack
277283 // This is critical: even freshly created WebViews might have internal parent refs
278- if newLeaf.container != 0 && webkit.WidgetIsValid(newLeaf.container) {
279- parent := webkit.WidgetGetParent(newLeaf.container)
280- if parent != 0 {
281- log.Printf("[workspace] unparenting new pane container %#x from parent %#x before stack append", newLeaf.container, parent)
284+ if newLeaf.container != nil {
285+ parentWidget := webkit.WidgetGetParent(newLeaf.container)
286+ if parentWidget != nil {
287+ log.Printf("[workspace] unparenting new pane container %p from parent %p before stack append", newLeaf.container, parentWidget)
282288 webkit.WidgetUnparent(newLeaf.container)
283289 // Verify unparent succeeded
284- if finalParent := webkit.WidgetGetParent(newLeaf.container); finalParent != 0 {
285- log.Printf("[workspace] WARNING: container %#x still has parent %#x after unparent", newLeaf.container, finalParent)
290+ if finalParent := webkit.WidgetGetParent(newLeaf.container); finalParent != nil {
291+ log.Printf("[workspace] WARNING: container %p still has parent %p after unparent", newLeaf.container, finalParent)
286292 }
287293 }
288294 }
289295
290296 // Add the new widgets to the internal stack box
291- if stackNode.stackWrapper != 0 && newLeaf.titleBar != 0 {
292- webkit.BoxAppend(stackNode.stackWrapper, newLeaf.titleBar)
297+ if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.titleBar != nil {
298+ box.Append(newLeaf.titleBar)
293299 }
294- if stackNode.stackWrapper != 0 && newLeaf.container != 0 {
295- webkit.BoxAppend(stackNode.stackWrapper, newLeaf.container)
300+ if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.container != nil {
301+ box.Append(newLeaf.container)
296302 }
297303
298304 return stackNode, insertIndex, nil
@@ -303,35 +309,35 @@ func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNo
303309 if parent == nil {
304310 // Target is the root - remove it from window
305311 if spm.wm.window != nil {
306- spm.wm.window.SetChild(0)
312+ spm.wm.window.SetChild(nil)
307313 }
308314 // Unparent if it has a GTK parent
309- if target.container != 0 && webkit.WidgetIsValid(target.container) {
310- if webkit.WidgetGetParent(target.container) != 0 {
315+ if target.container != nil {
316+ if webkit.WidgetGetParent(target.container) != nil {
311317 webkit.WidgetUnparent(target.container)
312318 }
313319 }
314- } else if parent.container != 0 && webkit.WidgetIsValid(parent.container) {
320+ } else if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
315321 // Target has a parent paned - remove it (automatically unparents in GTK4)
316322 if parent.left == target {
317- webkit.PanedSetStartChild(parent.container, 0)
323+ paned.SetStartChild(nil)
318324 } else if parent.right == target {
319- webkit.PanedSetEndChild(parent.container, 0)
325+ paned.SetEndChild(nil)
320326 }
321327 }
322328 }
323329
324330 // reattachToParent attaches a container to the parent
325-func (spm *StackedPaneManager) reattachToParent(container uintptr, target *paneNode, parent *paneNode) {
331+func (spm *StackedPaneManager) reattachToParent(container gtk.Widgetter, target *paneNode, parent *paneNode) {
326332 if parent == nil {
327333 if spm.wm.window != nil {
328334 spm.wm.window.SetChild(container)
329335 }
330- } else if parent.container != 0 && webkit.WidgetIsValid(parent.container) {
336+ } else if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
331337 if parent.left == target {
332- webkit.PanedSetStartChild(parent.container, container)
338+ paned.SetStartChild(container)
333339 } else if parent.right == target {
334- webkit.PanedSetEndChild(parent.container, container)
340+ paned.SetEndChild(container)
335341 }
336342 }
337343 }
@@ -353,7 +359,7 @@ func (spm *StackedPaneManager) finalizeStackCreation(stackNode, newLeaf *paneNod
353359 currentActiveIndex := stackNode.activeStackIndex
354360 if currentActiveIndex >= 0 && currentActiveIndex < len(stackNode.stackedPanes) && currentActiveIndex != insertIndex {
355361 currentlyActivePane := stackNode.stackedPanes[currentActiveIndex]
356- if currentlyActivePane.pane != nil && currentlyActivePane.pane.webView != nil && currentlyActivePane.titleBar != 0 {
362+ if currentlyActivePane.pane != nil && currentlyActivePane.pane.webView != nil && currentlyActivePane.titleBar != nil {
357363 currentTitle := currentlyActivePane.pane.webView.GetTitle()
358364 if currentTitle != "" {
359365 spm.updateTitleBarLabel(currentlyActivePane, currentTitle)
@@ -406,19 +412,19 @@ func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) {
406412 for i, pane := range stackNode.stackedPanes {
407413 if i == activeIndex {
408414 // Active pane: show container, NEVER show title bar
409- if pane.container != 0 {
415+ if pane.container != nil {
410416 webkit.WidgetSetVisible(pane.container, true)
411417 }
412- if pane.titleBar != 0 {
418+ if pane.titleBar != nil {
413419 webkit.WidgetSetVisible(pane.titleBar, false) // ABSOLUTE RULE: never visible for active pane
414420 }
415421 log.Printf("[workspace] active pane %d: container=visible, titleBar=HIDDEN", i)
416422 } else {
417423 // Inactive panes: hide container, show title bar
418- if pane.container != 0 {
424+ if pane.container != nil {
419425 webkit.WidgetSetVisible(pane.container, false)
420426 }
421- if pane.titleBar != 0 {
427+ if pane.titleBar != nil {
422428 webkit.WidgetSetVisible(pane.titleBar, true)
423429
424430 // Refresh title bar to pick up any newly downloaded favicons
@@ -436,7 +442,7 @@ func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) {
436442
437443 // NavigateStack handles navigation within a stacked pane container
438444 func (spm *StackedPaneManager) NavigateStack(direction string) bool {
439- if spm.wm.GetActiveNode() == nil {
445+ if spm == nil || spm.wm == nil || spm.wm.GetActiveNode() == nil {
440446 return false
441447 }
442448
@@ -491,7 +497,7 @@ func (spm *StackedPaneManager) NavigateStack(direction string) bool {
491497 // Update title bar for the pane transitioning from ACTIVE to INACTIVE
492498 // This ensures the collapsed pane shows its current page title (Zellij-style layout)
493499 currentActivePane := stackNode.stackedPanes[currentIndex]
494- if currentActivePane.pane != nil && currentActivePane.pane.webView != nil && currentActivePane.titleBar != 0 {
500+ if currentActivePane.pane != nil && currentActivePane.pane.webView != nil && currentActivePane.titleBar != nil {
495501 currentTitle := currentActivePane.pane.webView.GetTitle()
496502 if currentTitle != "" {
497503 spm.updateTitleBarLabel(currentActivePane, currentTitle)
@@ -513,7 +519,7 @@ func (spm *StackedPaneManager) NavigateStack(direction string) bool {
513519 }
514520
515521 // createTitleBar creates a title bar widget for a pane in a stack
516-func (spm *StackedPaneManager) createTitleBar(pane *paneNode) uintptr {
522+func (spm *StackedPaneManager) createTitleBar(pane *paneNode) gtk.Widgetter {
517523 // Get the actual title and URL from the WebView
518524 var titleText, pageURL string
519525 if pane.pane != nil && pane.pane.webView != nil {
@@ -527,15 +533,16 @@ func (spm *StackedPaneManager) createTitleBar(pane *paneNode) uintptr {
527533 }
528534
529535 titleBar := spm.createTitleBarWithTitle(titleText, pageURL)
530- if titleBar != 0 {
536+ if titleBar != nil {
531537 // Store the mapping from titleBar ID to pane
532538 titleBarID := atomic.AddUint64(&spm.nextTitleBarID, 1)
533- spm.titleBarToPane[uintptr(titleBarID)] = pane
539+ spm.titleBarToPane[titleBarID] = pane
534540
535541 // Attach click handler
536- webkit.WidgetAttachClickHandler(titleBar, func() {
537- spm.handleTitleBarClick(uintptr(titleBarID))
538- })
542+ // TODO: Implement click handler with gtk.GestureClick
543+ // webkit.WidgetAttachClickHandler(titleBar, func() {
544+ // spm.handleTitleBarClick(titleBarID)
545+ // })
539546 }
540547
541548 return titleBar
@@ -554,7 +561,7 @@ func (spm *StackedPaneManager) UpdateTitleBar(webView *webkit.WebView, title str
554561 }
555562
556563 // Check if this pane is in a stack and has a title bar
557- if node.parent != nil && node.parent.isStacked && node.titleBar != 0 {
564+ if node.parent != nil && node.parent.isStacked && node.titleBar != nil {
558565 // CRITICAL: Only update title bar for INACTIVE panes
559566 // Active panes should never show their title bar
560567 stackNode := node.parent
@@ -572,17 +579,17 @@ func (spm *StackedPaneManager) UpdateTitleBar(webView *webkit.WebView, title str
572579 if paneIndex != -1 && paneIndex != activeIndex {
573580 // This is an INACTIVE pane - safe to update title bar
574581 spm.updateTitleBarLabel(node, title)
575- log.Printf("[workspace] updated title bar for INACTIVE WebView %s: %s", webView.ID(), title)
582+ log.Printf("[workspace] updated title bar for INACTIVE WebView %d: %s", webView.ID(), title)
576583 } else {
577584 // This is the ACTIVE pane - title bar should remain hidden
578- log.Printf("[workspace] skipped title bar update for ACTIVE WebView %s: %s", webView.ID(), title)
585+ log.Printf("[workspace] skipped title bar update for ACTIVE WebView %d: %s", webView.ID(), title)
579586 }
580587 }
581588 }
582589
583590 // updateTitleBarLabel updates the label widget within a title bar by recreating it
584591 func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string) {
585- if node == nil || node.titleBar == 0 || node.parent == nil || !node.parent.isStacked {
592+ if node == nil || node.titleBar == nil || node.parent == nil || !node.parent.isStacked {
586593 return
587594 }
588595
@@ -594,22 +601,23 @@ func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string)
594601
595602 // Create new title bar with updated title and favicon
596603 newTitleBar := spm.createTitleBarWithTitle(title, pageURL)
597- if newTitleBar == 0 {
604+ if newTitleBar == nil {
598605 log.Printf("[workspace] failed to create new title bar")
599606 return
600607 }
601608
602609 // Store the mapping from titleBar ID to pane and attach click handler
603610 titleBarID := atomic.AddUint64(&spm.nextTitleBarID, 1)
604- spm.titleBarToPane[uintptr(titleBarID)] = node
611+ spm.titleBarToPane[titleBarID] = node
605612
606613 // Attach click handler
607- webkit.WidgetAttachClickHandler(newTitleBar, func() {
608- spm.handleTitleBarClick(uintptr(titleBarID))
609- })
614+ // TODO: Implement with gtk.GestureClick
615+ // webkit.WidgetAttachClickHandler(newTitleBar, func() {
616+ // spm.handleTitleBarClick(titleBarID)
617+ // })
610618
611619 // Replace the old title bar in the stack
612- if node.parent != nil && node.parent.isStacked && node.parent.stackWrapper != 0 {
620+ if node.parent != nil && node.parent.isStacked && node.parent.stackWrapper != nil {
613621 // Find the correct insertion position based on the pane's index in the stack
614622 paneIndex := -1
615623 for i, stackedPane := range node.parent.stackedPanes {
@@ -620,7 +628,7 @@ func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string)
620628 }
621629
622630 // Remove old title bar
623- if node.titleBar != 0 && webkit.WidgetIsValid(node.titleBar) {
631+ if node.titleBar != nil {
624632 webkit.WidgetUnparent(node.titleBar)
625633 }
626634
@@ -628,16 +636,16 @@ func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string)
628636 spm.wm.setTitleBar(node, newTitleBar)
629637
630638 // Insert the new title bar at the correct position
631- if paneIndex == 0 {
632- // First pane - insert at the beginning
633- if node.parent.stackWrapper != 0 {
634- webkit.BoxPrepend(node.parent.stackWrapper, newTitleBar)
635- }
636- } else {
637- // Insert after the previous pane's container
638- prevPane := node.parent.stackedPanes[paneIndex-1]
639- if node.parent.stackWrapper != 0 && prevPane.container != 0 {
640- webkit.BoxInsertChildAfter(node.parent.stackWrapper, newTitleBar, prevPane.container)
639+ if box, ok := node.parent.stackWrapper.(*gtk.Box); ok {
640+ if paneIndex == 0 {
641+ // First pane - insert at the beginning
642+ box.Prepend(newTitleBar)
643+ } else {
644+ // Insert after the previous pane's container
645+ prevPane := node.parent.stackedPanes[paneIndex-1]
646+ if prevPane.container != nil {
647+ box.InsertChildAfter(newTitleBar, prevPane.container)
648+ }
641649 }
642650 }
643651
@@ -646,18 +654,18 @@ func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string)
646654 }
647655
648656 // createTitleBarWithTitle creates a title bar with a specific title and optional favicon
649-func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL string) uintptr {
650- titleBox := webkit.NewBox(webkit.OrientationHorizontal, 8)
651- if titleBox == 0 {
652- return 0
657+func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL string) gtk.Widgetter {
658+ titleBox := gtk.NewBox(gtk.OrientationHorizontal, 8)
659+ if titleBox == nil {
660+ return nil
653661 }
654662
655- webkit.WidgetAddCSSClass(titleBox, "stacked-pane-title")
656- webkit.WidgetSetHExpand(titleBox, true)
657- webkit.WidgetSetVExpand(titleBox, false)
663+ titleBox.AddCSSClass("stacked-pane-title")
664+ titleBox.SetHExpand(true)
665+ titleBox.SetVExpand(false)
658666
659667 // Add favicon placeholder for debugging (always visible)
660- var faviconImg uintptr
668+ var faviconImg gtk.Widgetter
661669 var debugReason string
662670
663671 if pageURL == "" {
@@ -691,9 +699,11 @@ func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL str
691699 faviconPath := faviconCache.GetCachedPath(faviconURL)
692700 if faviconPath != "" {
693701 log.Printf("[favicon] Loading cached favicon from: %s", faviconPath)
694- faviconImg = webkit.ImageNewFromFile(faviconPath)
695- if faviconImg != 0 {
696- webkit.ImageSetPixelSize(faviconImg, 16)
702+ faviconImg = gtk.NewImageFromFile(faviconPath)
703+ if faviconImg != nil {
704+ if img, ok := faviconImg.(*gtk.Image); ok {
705+ img.SetPixelSize(16)
706+ }
697707 webkit.WidgetAddCSSClass(faviconImg, "stacked-pane-favicon")
698708 log.Printf("[favicon] Successfully created favicon image widget")
699709 } else {
@@ -711,32 +721,32 @@ func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL str
711721 }
712722
713723 // Add favicon or placeholder
714- if faviconImg != 0 {
715- webkit.BoxAppend(titleBox, faviconImg)
724+ if faviconImg != nil {
725+ titleBox.Append(faviconImg)
716726 webkit.WidgetShow(faviconImg)
717727 } else {
718728 // DEBUG: Add visible placeholder label to show code is running
719- placeholderLabel := webkit.NewLabel(faviconPlaceholder)
720- if placeholderLabel != 0 {
721- webkit.WidgetAddCSSClass(placeholderLabel, "stacked-pane-favicon-placeholder")
722- webkit.BoxAppend(titleBox, placeholderLabel)
723- webkit.WidgetShow(placeholderLabel)
729+ placeholderLabel := gtk.NewLabel(faviconPlaceholder)
730+ if placeholderLabel != nil {
731+ placeholderLabel.AddCSSClass("stacked-pane-favicon-placeholder")
732+ titleBox.Append(placeholderLabel)
733+ placeholderLabel.Show()
724734 log.Printf("[favicon] Using placeholder for %s (reason: %s)", pageURL, debugReason)
725735 }
726736 }
727737
728738 // Create title label with the specified title
729- titleLabel := webkit.NewLabel(title)
730- if titleLabel == 0 {
739+ titleLabel := gtk.NewLabel(title)
740+ if titleLabel == nil {
731741 return titleBox
732742 }
733743
734- webkit.WidgetAddCSSClass(titleLabel, "stacked-pane-title-text")
735- webkit.LabelSetEllipsize(titleLabel, webkit.EllipsizeEnd)
744+ titleLabel.AddCSSClass("stacked-pane-title-text")
745+ titleLabel.SetEllipsize(2) // pango.EllipsizeEnd = 2
736746
737- webkit.BoxAppend(titleBox, titleLabel)
738- webkit.WidgetShow(titleBox)
739- webkit.WidgetShow(titleLabel)
747+ titleBox.Append(titleLabel)
748+ titleBox.Show()
749+ titleLabel.Show()
740750
741751 return titleBox
742752 }
@@ -775,15 +785,15 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
775785
776786 // Remove the pane widgets from the stack container before mutating the tree.
777787 stackBox := stackNode.stackWrapper
778- if stackBox == 0 {
788+ if stackBox == nil {
779789 stackBox = stackNode.container
780790 }
781- if stackBox != 0 {
782- if node.titleBar != 0 {
783- webkit.BoxRemove(stackBox, node.titleBar)
791+ if box, ok := stackBox.(*gtk.Box); ok && box != nil {
792+ if node.titleBar != nil {
793+ box.Remove(node.titleBar)
784794 }
785- if node.container != 0 {
786- webkit.BoxRemove(stackBox, node.container)
795+ if node.container != nil {
796+ box.Remove(node.container)
787797 }
788798 }
789799
@@ -796,29 +806,29 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
796806 parent := stackNode.parent
797807 if parent == nil {
798808 // Stack was the root of the workspace — closing this pane exits the app.
799- if stackNode.container != 0 && webkit.WidgetIsValid(stackNode.container) {
800- if webkit.WidgetGetParent(stackNode.container) != 0 {
809+ if stackNode.container != nil {
810+ if webkit.WidgetGetParent(stackNode.container) != nil {
801811 webkit.WidgetUnparent(stackNode.container)
802812 }
803813 }
804814 if spm.wm.window != nil {
805- spm.wm.window.SetChild(0)
815+ spm.wm.window.SetChild(nil)
806816 }
807817 _, err := spm.wm.cleanupAndExit(node)
808818 return err
809819 }
810820
811821 // Detach the empty stack container from its GtkPaned parent.
812- if parent.container != 0 && webkit.WidgetIsValid(parent.container) {
822+ if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
813823 if parent.left == stackNode {
814- webkit.PanedSetStartChild(parent.container, 0)
824+ paned.SetStartChild(nil)
815825 } else if parent.right == stackNode {
816- webkit.PanedSetEndChild(parent.container, 0)
826+ paned.SetEndChild(nil)
817827 }
818- webkit.WidgetQueueAllocate(parent.container)
828+ webkit.WidgetQueueAllocate(paned)
819829 }
820- if stackNode.container != 0 && webkit.WidgetIsValid(stackNode.container) {
821- if webkit.WidgetGetParent(stackNode.container) != 0 {
830+ if stackNode.container != nil {
831+ if webkit.WidgetGetParent(stackNode.container) != nil {
822832 webkit.WidgetUnparent(stackNode.container)
823833 }
824834 }
@@ -840,8 +850,8 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
840850 }
841851
842852 stackNode.parent = nil
843- stackNode.container = 0
844- stackNode.stackWrapper = 0
853+ stackNode.container = nil
854+ stackNode.stackWrapper = nil
845855 stackNode.stackedPanes = nil
846856
847857 return nil
@@ -852,14 +862,14 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
852862 parent := stackNode.parent
853863 lastPaneContainer := lastPane.container
854864
855- if lastPane.titleBar != 0 && stackBox != 0 {
856- webkit.BoxRemove(stackBox, lastPane.titleBar)
865+ if box, ok := stackBox.(*gtk.Box); ok && box != nil && lastPane.titleBar != nil {
866+ box.Remove(lastPane.titleBar)
857867 }
858- if stackBox != 0 && lastPaneContainer != 0 {
859- webkit.BoxRemove(stackBox, lastPaneContainer)
868+ if box, ok := stackBox.(*gtk.Box); ok && box != nil && lastPaneContainer != nil {
869+ box.Remove(lastPaneContainer)
860870 }
861- if stackNode.container != 0 && webkit.WidgetIsValid(stackNode.container) {
862- if webkit.WidgetGetParent(stackNode.container) != 0 {
871+ if stackNode.container != nil {
872+ if webkit.WidgetGetParent(stackNode.container) != nil {
863873 webkit.WidgetUnparent(stackNode.container)
864874 }
865875 }
@@ -867,7 +877,7 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
867877 if parent == nil {
868878 spm.wm.root = lastPane
869879 lastPane.parent = nil
870- if spm.wm.window != nil && lastPaneContainer != 0 {
880+ if spm.wm.window != nil && lastPaneContainer != nil {
871881 spm.wm.window.SetChild(lastPaneContainer)
872882 webkit.WidgetQueueAllocate(lastPaneContainer)
873883 webkit.WidgetShow(lastPaneContainer)
@@ -879,36 +889,36 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
879889 parent.right = lastPane
880890 }
881891 lastPane.parent = parent
882- if parent.container != 0 && webkit.WidgetIsValid(parent.container) && lastPaneContainer != 0 {
892+ if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil && lastPaneContainer != nil {
883893 if parent.left == lastPane {
884- webkit.PanedSetStartChild(parent.container, lastPaneContainer)
894+ paned.SetStartChild(lastPaneContainer)
885895 } else {
886- webkit.PanedSetEndChild(parent.container, lastPaneContainer)
896+ paned.SetEndChild(lastPaneContainer)
887897 }
888- webkit.WidgetQueueAllocate(parent.container)
898+ webkit.WidgetQueueAllocate(paned)
889899 }
890- if lastPaneContainer != 0 {
900+ if lastPaneContainer != nil {
891901 webkit.WidgetSetVisible(lastPaneContainer, true)
892902 }
893903 }
894904
895905 lastPane.isStacked = false
896906 lastPane.stackedPanes = nil
897- lastPane.titleBar = 0
907+ lastPane.titleBar = nil
898908 spm.wm.viewToNode[lastPane.pane.webView] = lastPane
899909
900910 generation := spm.wm.nextCleanupGeneration()
901911 spm.wm.cleanupPane(node, generation)
902912
903913 spm.wm.SetActivePane(lastPane, SourceClose)
904- if lastPaneWidget := lastPane.pane.webView.Widget(); lastPaneWidget != 0 {
914+ if lastPaneWidget := lastPane.pane.webView.Widget(); lastPaneWidget != nil {
905915 webkit.WidgetShow(lastPaneWidget)
906916 webkit.WidgetGrabFocus(lastPaneWidget)
907917 }
908918
909919 stackNode.parent = nil
910- stackNode.container = 0
911- stackNode.stackWrapper = 0
920+ stackNode.container = nil
921+ stackNode.stackWrapper = nil
912922 stackNode.stackedPanes = nil
913923
914924 log.Printf("[workspace] converted stack back to regular pane")
@@ -939,7 +949,7 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
939949 }
940950
941951 // handleTitleBarClick handles clicks on title bars to switch the active pane
942-func (spm *StackedPaneManager) handleTitleBarClick(titleBarID uintptr) {
952+func (spm *StackedPaneManager) handleTitleBarClick(titleBarID uint64) {
943953 pane, exists := spm.titleBarToPane[titleBarID]
944954 if !exists || pane == nil || pane.parent == nil || !pane.parent.isStacked {
945955 return
@@ -957,7 +967,7 @@ func (spm *StackedPaneManager) handleTitleBarClick(titleBarID uintptr) {
957967
958968 // Update title bar for the pane transitioning from ACTIVE to INACTIVE
959969 currentActivePane := stackNode.stackedPanes[stackNode.activeStackIndex]
960- if currentActivePane.pane != nil && currentActivePane.pane.webView != nil && currentActivePane.titleBar != 0 {
970+ if currentActivePane.pane != nil && currentActivePane.pane.webView != nil && currentActivePane.titleBar != nil {
961971 currentTitle := currentActivePane.pane.webView.GetTitle()
962972 if currentTitle != "" {
963973 spm.updateTitleBarLabel(currentActivePane, currentTitle)
M internal/app/browser/webview.go +134 -138
@@ -2,69 +2,26 @@ package browser
22
33 import (
44 "context"
5+ "encoding/json"
56 "fmt"
67 "log"
78 "os"
89 "path/filepath"
9- "strings"
1010 "time"
1111
1212 "github.com/bnema/dumber/internal/app/constants"
1313 "github.com/bnema/dumber/internal/app/control"
14- "github.com/bnema/dumber/internal/app/environment"
1514 "github.com/bnema/dumber/internal/app/messaging"
1615 "github.com/bnema/dumber/internal/config"
1716 "github.com/bnema/dumber/internal/filtering"
1817 "github.com/bnema/dumber/pkg/webkit"
1918 )
2019
21-// buildMemoryConfig converts config.WebkitMemoryConfig to webkit.MemoryConfig
22-func buildMemoryConfig(cfg config.WebkitMemoryConfig) webkit.MemoryConfig {
23- mc := webkit.MemoryConfig{
24- MemoryLimitMB: cfg.MemoryLimitMB,
25- ConservativeThreshold: cfg.ConservativeThreshold,
26- StrictThreshold: cfg.StrictThreshold,
27- KillThreshold: cfg.KillThreshold,
28- PollIntervalSeconds: cfg.PollIntervalSeconds,
29- EnableGCInterval: cfg.EnableGCInterval,
30- ProcessRecycleThreshold: cfg.ProcessRecycleThreshold,
31- EnablePageCache: cfg.EnablePageCache,
32- EnableMemoryMonitoring: cfg.EnableMemoryMonitoring,
33- }
34-
35- // Convert string cache model to webkit constant
36- switch strings.ToLower(cfg.CacheModel) {
37- case "document_viewer", "documentviewer", "doc":
38- mc.CacheModel = webkit.CacheModelDocumentViewer
39- case "primary_web_browser", "primary", "primarywebbrowser":
40- mc.CacheModel = webkit.CacheModelPrimaryWebBrowser
41- default:
42- mc.CacheModel = webkit.CacheModelWebBrowser
43- }
44-
45- return mc
46-}
47-
48-func buildColorPalettes(cfg config.AppearanceConfig) webkit.ColorPalettes {
49- toPalette := func(src config.ColorPalette) webkit.ColorPalette {
50- return webkit.ColorPalette{
51- Background: src.Background,
52- Surface: src.Surface,
53- SurfaceVariant: src.SurfaceVariant,
54- Text: src.Text,
55- Muted: src.Muted,
56- Accent: src.Accent,
57- Border: src.Border,
58- }
59- }
60-
61- return webkit.ColorPalettes{
62- Light: toPalette(cfg.LightPalette),
63- Dark: toPalette(cfg.DarkPalette),
64- }
65-}
20+// Note: Memory management and color palettes are now handled at the application level
21+// The gotk4 webkit.Config only contains basic WebKit settings
6622
6723 func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) {
24+ // Ensure data directories exist for WebKit
6825 dataDir, err := config.GetDataDir()
6926 if err != nil {
7027 return nil, err
@@ -83,39 +40,70 @@ func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) {
8340 return nil, err
8441 }
8542
43+ // Build WebKit configuration with data directories for persistence
8644 cfg := &webkit.Config{
87- Assets: app.assets,
88- InitialURL: "dumb://homepage",
89- ZoomDefault: app.config.DefaultZoom,
90- EnableDeveloperExtras: true,
91- DataDir: webkitData,
92- CacheDir: webkitCache,
93- APIToken: app.config.APISecurity.Token,
94- DefaultSansFont: app.config.Appearance.SansFont,
95- DefaultSerifFont: app.config.Appearance.SerifFont,
96- DefaultMonospaceFont: app.config.Appearance.MonospaceFont,
97- DefaultFontSize: app.config.Appearance.DefaultFontSize,
98- Rendering: webkit.RenderingConfig{Mode: string(app.config.RenderingMode)},
99- UseDomZoom: app.config.UseDomZoom,
100- VideoAcceleration: webkit.VideoAccelerationConfig{
101- EnableVAAPI: app.config.VideoAcceleration.EnableVAAPI,
102- AutoDetectGPU: app.config.VideoAcceleration.AutoDetectGPU,
103- VAAPIDriverName: app.config.VideoAcceleration.VAAPIDriverName,
104- EnableAllDrivers: app.config.VideoAcceleration.EnableAllDrivers,
105- LegacyVAAPI: app.config.VideoAcceleration.LegacyVAAPI,
45+ UserAgent: buildUserAgent(app.config),
46+ EnableJavaScript: true,
47+ EnableWebGL: true,
48+ EnableMediaStream: true,
49+ HardwareAcceleration: true,
50+ DefaultFontSize: app.config.Appearance.DefaultFontSize,
51+ MinimumFontSize: 8,
52+ DataDir: webkitData,
53+ CacheDir: webkitCache,
54+ AppearanceConfigJSON: app.buildAppearanceConfigJSON(),
55+ CreateWindow: true, // Default to creating a window for standalone WebViews
56+ }
57+
58+ return cfg, nil
59+}
60+
61+// buildUserAgent constructs the user agent string from config
62+func buildUserAgent(cfg *config.Config) string {
63+ if cfg.CodecPreferences.CustomUserAgent != "" {
64+ return cfg.CodecPreferences.CustomUserAgent
65+ }
66+ // Use default WebKit user agent
67+ return "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36"
68+}
69+
70+// buildAppearanceConfigJSON builds the palette configuration as JSON
71+// The GUI expects window.__dumber_palette with just the palette data
72+func (app *BrowserApp) buildAppearanceConfigJSON() string {
73+ if app.config == nil {
74+ return ""
75+ }
76+
77+ // Build palette config in the format main-world.ts expects
78+ // It looks for window.__dumber_palette = { "light": {...}, "dark": {...} }
79+ paletteConfig := map[string]interface{}{
80+ "light": map[string]string{
81+ "background": app.config.Appearance.LightPalette.Background,
82+ "surface": app.config.Appearance.LightPalette.Surface,
83+ "surface_variant": app.config.Appearance.LightPalette.SurfaceVariant,
84+ "text": app.config.Appearance.LightPalette.Text,
85+ "muted": app.config.Appearance.LightPalette.Muted,
86+ "accent": app.config.Appearance.LightPalette.Accent,
87+ "border": app.config.Appearance.LightPalette.Border,
10688 },
107- Memory: buildMemoryConfig(app.config.WebkitMemory),
108- CodecPreferences: webkit.CodecPreferencesConfig{
109- PreferredCodecs: strings.Split(app.config.CodecPreferences.PreferredCodecs, ","),
110- BlockedCodecs: environment.BuildBlockedCodecsList(app.config.CodecPreferences),
111- ForceAV1: app.config.CodecPreferences.ForceAV1,
112- CustomUserAgent: app.config.CodecPreferences.CustomUserAgent,
113- DisableTwitchCodecControl: app.config.CodecPreferences.DisableTwitchCodecControl,
89+ "dark": map[string]string{
90+ "background": app.config.Appearance.DarkPalette.Background,
91+ "surface": app.config.Appearance.DarkPalette.Surface,
92+ "surface_variant": app.config.Appearance.DarkPalette.SurfaceVariant,
93+ "text": app.config.Appearance.DarkPalette.Text,
94+ "muted": app.config.Appearance.DarkPalette.Muted,
95+ "accent": app.config.Appearance.DarkPalette.Accent,
96+ "border": app.config.Appearance.DarkPalette.Border,
11497 },
115- Colors: buildColorPalettes(app.config.Appearance),
11698 }
11799
118- return cfg, nil
100+ payload, err := json.Marshal(paletteConfig)
101+ if err != nil {
102+ log.Printf("[webview] Failed to marshal palette config: %v", err)
103+ return ""
104+ }
105+
106+ return string(payload)
119107 }
120108
121109 func (app *BrowserApp) buildPane(view *webkit.WebView) (*BrowserPane, error) {
@@ -167,7 +155,7 @@ func (app *BrowserApp) createPaneForView(view *webkit.WebView) (*BrowserPane, er
167155 pane.initializeGUITracking()
168156
169157 // GUI manager will be loaded on-demand when needed
170- log.Printf("[webview] Created pane for webview: %s", view.ID())
158+ log.Printf("[webview] Created pane for webview: %d", view.ID())
171159
172160 app.attachPaneHandlers(pane)
173161 return pane, nil
@@ -202,7 +190,7 @@ func (app *BrowserApp) attachPaneHandlers(pane *BrowserPane) {
202190 })
203191
204192 pane.webView.RegisterScriptMessageHandler(func(payload string) {
205- if app.workspace != nil {
193+ if app.workspace != nil && shouldFocusForScriptMessage(payload) {
206194 app.workspace.focusByView(pane.webView)
207195 }
208196 pane.messageHandler.Handle(payload)
@@ -212,21 +200,32 @@ func (app *BrowserApp) attachPaneHandlers(pane *BrowserPane) {
212200 pane.messageHandler.SetNavigationController(pane.navigationController)
213201 }
214202
215- pane.webView.RegisterPopupHandler(func(uri string) *webkit.WebView {
216- if app.workspace != nil {
217- return app.workspace.HandlePopup(pane.webView, uri)
203+ // Setup WebKit-native popup lifecycle using create/ready-to-show/close signals
204+ if app.workspace != nil {
205+ node := app.workspace.GetNodeForWebView(pane.webView)
206+ if node != nil {
207+ app.workspace.setupPopupHandling(pane.webView, node)
208+ log.Printf("[webview] Setup native popup handling for WebView ID: %d", pane.webView.ID())
218209 }
210+ }
211+}
219212
220- // No workspace manager - just navigate in current pane
221- if uri != "" && pane.navigationController != nil {
222- go func() {
223- if err := pane.navigationController.NavigateToURL(uri); err != nil {
224- log.Printf("[webview] failed to navigate to URL %s: %v", uri, err)
225- }
226- }()
227- }
228- return nil
229- })
213+// shouldFocusForScriptMessage filters script messages and only allows focus handoff
214+// for explicit user-driven actions (navigation, history commands, popup lifecycle).
215+// Background bootstrap messages (palette sync, workspace bridge chatter, etc.) are
216+// ignored so they don't steal focus from the user's active pane.
217+func shouldFocusForScriptMessage(payload string) bool {
218+ var msg messaging.Message
219+ if err := json.Unmarshal([]byte(payload), &msg); err != nil {
220+ return true // keep legacy behaviour if parsing fails
221+ }
222+
223+ switch msg.Type {
224+ case "navigate", "query", "wails", "history_recent", "history_stats", "history_search", "history_delete", "close-popup":
225+ return true
226+ default:
227+ return false
228+ }
230229 }
231230
232231 // createWebView creates and configures the WebView
@@ -238,9 +237,7 @@ func (app *BrowserApp) createWebView() error {
238237 return err
239238 }
240239
241- // Main window needs a top-level window
242- cfg.CreateWindow = true
243-
240+ // Note: Window creation is now handled automatically by webkit.NewWebView
244241 view, err := webkit.NewWebView(cfg)
245242
246243 if err != nil {
@@ -263,6 +260,19 @@ func (app *BrowserApp) createWebView() error {
263260
264261 if app.workspace == nil {
265262 app.workspace = NewWorkspaceManager(app, pane)
263+ // CRITICAL: Register navigation handler for the root webview
264+ // This is needed because buildPane() couldn't register it when workspace was nil
265+ app.workspace.RegisterNavigationHandler(view)
266+
267+ // CRITICAL: Setup popup handling for the root webview
268+ // This must be done after workspace is created so WebKit's create signal is connected
269+ node := app.workspace.GetNodeForWebView(view)
270+ if node != nil {
271+ app.workspace.setupPopupHandling(view, node)
272+ log.Printf("[webview] Setup native popup handling for root WebView ID: %d", view.ID())
273+ } else {
274+ log.Printf("[webview] WARNING: Could not find node for root WebView ID: %d", view.ID())
275+ }
266276 }
267277
268278 // Initialize window-level global shortcuts AFTER workspace is set up
@@ -294,8 +304,8 @@ func (app *BrowserApp) createWebView() error {
294304 func (app *BrowserApp) setupWebViewIntegration() {
295305 app.browserService.AttachWebView(app.webView)
296306
297- // GUI bundle is loaded via WebKit user scripts in enableUserContentManager
298- // No need to load separately in browser service
307+ // GUI bundle is loaded via WebKit user scripts in SetupUserContentManager
308+ // Appearance config is also injected at document-start via UserContentManager
299309
300310 // Use native window as title updater
301311 if win := app.webView.Window(); win != nil {
@@ -308,7 +318,7 @@ func (app *BrowserApp) setupContentBlocking() error {
308318 log.Printf("Initializing content blocking system...")
309319
310320 // Enable WebKit debug logging if requested
311- webkit.SetupWebKitDebugLogging(app.config)
321+ webkit.SetupWebKitDebugLogging()
312322
313323 defer func() {
314324 if r := recover(); r != nil {
@@ -326,58 +336,44 @@ func (app *BrowserApp) setupContentBlocking() error {
326336 // Store reference to filter manager
327337 app.filterManager = filterManager
328338
329- // Set up callback to re-apply network filters when they become ready
339+ // Set up callback to apply network filters when they become ready
330340 // This callback will be called from the async filter loading process
331341 filterManager.SetFiltersReadyCallback(func() {
332- // Add small delay to avoid race conditions with WebView initialization
333- go func() {
334- time.Sleep(300 * time.Millisecond)
335- if app.webView != nil {
336- if err := app.webView.UpdateContentFilters(filterManager); err != nil {
337- log.Printf("Failed to apply filters after loading: %v", err)
338- } else {
339- log.Printf("Successfully applied filters after async loading")
340- }
341- }
342- }()
343- })
342+ log.Printf("[filtering] Filters ready, applying to WebView...")
344343
345- // Start async filter loading early (before WebView content blocking initialization)
346- // This allows filters to be ready when the WebView needs them
347- go func() {
348- if err := filtering.InitializeFiltersAsync(filterManager); err != nil {
349- log.Printf("Warning: failed to initialize filters asynchronously: %v", err)
344+ // Get the WebKit JSON rules from filter manager
345+ filterJSON, err := filterManager.GetNetworkFilters()
346+ if err != nil {
347+ log.Printf("[filtering] Failed to get network filters: %v", err)
348+ return
350349 }
351- }()
352-
353- // Initialize content blocking in WebView with delay
354- // Wait for WebView to be fully loaded before setting up content blocking
355- go func() {
356- // Wait extra time for the first navigation to avoid preconnect interference
357- log.Printf("Waiting for WebView to complete initial load before enabling content blocking...")
358- time.Sleep(3000 * time.Millisecond) // 3 seconds for first load stability
359350
360- if err := app.webView.InitializeContentBlocking(filterManager); err != nil {
361- log.Printf("Warning: Failed to initialize content blocking: %v", err)
362- // Continue without content blocking rather than failing
351+ if len(filterJSON) == 0 {
352+ log.Printf("[filtering] No network filters to apply")
353+ return
363354 }
364355
365- // Register navigation handler for domain-specific filtering and GUI injection
366- // Only register after content blocking is initialized
367- app.webView.RegisterURIChangedHandler(func(uri string) {
368- // Add small delay to avoid conflicts with page load
369- go func() {
370- time.Sleep(200 * time.Millisecond) // Slightly longer delay for stability
356+ log.Printf("[filtering] Got %d bytes of WebKit JSON rules", len(filterJSON))
371357
372- // Apply content filtering if available
373- if app.filterManager != nil {
374- app.webView.OnNavigate(uri, app.filterManager, app.config.ContentFilteringWhitelist)
358+ // Apply filters to the WebView
359+ // This must be done on the main thread since it touches GTK/WebKit
360+ if app.webView != nil {
361+ app.webView.RunOnMainThread(func() {
362+ if err := app.webView.InitializeContentBlocking(filterJSON); err != nil {
363+ log.Printf("[filtering] Failed to apply content filters: %v", err)
364+ } else {
365+ log.Printf("[filtering] ✅ Content blocking enabled successfully")
375366 }
367+ })
368+ }
369+ })
376370
377- // Note: GUI bundle (controls and toast) is now injected as User Script
378- // in WebKit's enableUserContentManager, so it persists across all navigations
379- }()
380- })
371+ // Start async filter loading
372+ // This allows filters to compile in the background while the browser starts
373+ go func() {
374+ if err := filtering.InitializeFiltersAsync(filterManager); err != nil {
375+ log.Printf("Warning: failed to initialize filters asynchronously: %v", err)
376+ }
381377 }()
382378
383379 log.Printf("Content blocking system initialization started")
M internal/app/browser/window_shortcuts.go +39 -20
@@ -41,8 +41,8 @@ func NewWindowShortcutHandler(window *webkit.Window, app *BrowserApp) *WindowSho
4141 }
4242
4343 func (h *WindowShortcutHandler) initialize() error {
44- // Initialize GTK4 global shortcuts
45- h.shortcuts = h.window.InitializeGlobalShortcuts()
44+ // Create WindowShortcuts manager
45+ h.shortcuts = webkit.NewWindowShortcuts(h.window)
4646 if h.shortcuts == nil {
4747 return ErrFailedToInitialize
4848 }
@@ -60,6 +60,7 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
6060 {"ctrl+f", h.handleFindToggle, "Find in page"},
6161 {"ctrl+shift+c", h.handleCopyURL, "Copy URL"},
6262 {"ctrl+shift+p", h.handlePrint, "Print page"},
63+ {"ctrl+t", h.handleNewTab, "New tab (no-op)"},
6364 {"ctrl+w", h.handleClosePane, "Close current pane"},
6465 {"F12", h.handleDevTools, "Developer tools"},
6566 // Zoom shortcuts - global level for proper active pane targeting
@@ -75,11 +76,7 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
7576 }
7677
7778 for _, shortcut := range shortcuts {
78- if err := h.shortcuts.RegisterGlobalShortcut(shortcut.key, shortcut.handler); err != nil {
79- log.Printf("[window-shortcuts] Failed to register %s (%s): %v",
80- shortcut.key, shortcut.desc, err)
81- return err
82- }
79+ h.shortcuts.RegisterShortcut(shortcut.key, shortcut.handler)
8380 log.Printf("[window-shortcuts] Registered global shortcut: %s (%s)",
8481 shortcut.key, shortcut.desc)
8582 }
@@ -208,11 +205,7 @@ func (h *WindowShortcutHandler) handleZoom(action string, multiplier float64) {
208205 log.Printf("[window-shortcuts] Zoom %s -> pane %p", action, activeWebView)
209206
210207 // Get current zoom level
211- currentZoom, err := activeWebView.GetZoom()
212- if err != nil {
213- log.Printf("[window-shortcuts] Failed to get current zoom: %v", err)
214- return
215- }
208+ currentZoom := activeWebView.GetZoom()
216209
217210 // Calculate new zoom level
218211 var newZoom float64
@@ -234,6 +227,27 @@ func (h *WindowShortcutHandler) handleZoom(action string, multiplier float64) {
234227 // Apply zoom to active pane
235228 if err := activeWebView.SetZoom(newZoom); err != nil {
236229 log.Printf("[window-shortcuts] Failed to set zoom: %v", err)
230+ return
231+ }
232+
233+ // Ensure GUI/toast component is loaded before showing toast
234+ h.ensureGUIInActivePane("toast")
235+
236+ // Show zoom toast notification
237+ toastScript := fmt.Sprintf(`
238+ (function() {
239+ try {
240+ if (typeof window.__dumber_showZoomToast === 'function') {
241+ window.__dumber_showZoomToast(%f);
242+ }
243+ } catch (e) {
244+ console.error('[window-shortcuts] Failed to show zoom toast:', e);
245+ }
246+ })();
247+ `, newZoom)
248+
249+ if err := activeWebView.InjectScript(toastScript); err != nil {
250+ log.Printf("[window-shortcuts] Failed to show zoom toast: %v", err)
237251 }
238252 }
239253
@@ -261,7 +275,7 @@ func (h *WindowShortcutHandler) getWebViewId(pane *BrowserPane) string {
261275 if pane == nil || pane.webView == nil {
262276 return "unknown"
263277 }
264- return pane.webView.ID()
278+ return pane.webView.IDString()
265279 }
266280
267281 func (h *WindowShortcutHandler) handleUIToggle(lastToggle *time.Time, featureName, action string) {
@@ -320,10 +334,10 @@ func (h *WindowShortcutHandler) handleClosePane() {
320334 for webView := range h.app.workspace.viewToNode {
321335 if webView != nil {
322336 isActive := webView.IsActive()
323- log.Printf("[window-shortcuts] WebView %s: IsActive=%t", webView.ID(), isActive)
337+ log.Printf("[window-shortcuts] WebView %d: IsActive=%t", webView.ID(), isActive)
324338 if isActive {
325339 activeWebView = webView
326- log.Printf("[window-shortcuts] Found active WebView: %s", webView.ID())
340+ log.Printf("[window-shortcuts] Found active WebView: %d", webView.ID())
327341 break
328342 }
329343 }
@@ -342,7 +356,7 @@ func (h *WindowShortcutHandler) handleClosePane() {
342356 // Use the proper close-popup message for popups
343357 msg := messaging.Message{
344358 Event: "close-popup",
345- WebViewID: activeWebView.ID(),
359+ WebViewID: activeWebView.IDString(),
346360 Reason: "user-ctrl-w",
347361 }
348362 h.app.workspace.OnWorkspaceMessage(activeWebView, msg)
@@ -353,12 +367,17 @@ func (h *WindowShortcutHandler) handleClosePane() {
353367 }
354368 }
355369
370+// handleNewTab is a no-op handler for Ctrl+T
371+// This prevents the default browser behavior and allows for future implementation
372+func (h *WindowShortcutHandler) handleNewTab() {
373+ log.Printf("[window-shortcuts] Ctrl+T pressed (no-op - tabs not yet implemented)")
374+ // TODO: Implement new tab functionality
375+}
376+
356377 // Cleanup releases resources
357378 func (h *WindowShortcutHandler) Cleanup() {
358- if h.shortcuts != nil {
359- h.shortcuts.Cleanup()
360- h.shortcuts = nil
361- }
379+ // WindowShortcuts doesn't require explicit cleanup
380+ h.shortcuts = nil
362381 }
363382
364383 // Error definitions
M internal/app/browser/workspace_css.go +38 -24
@@ -3,6 +3,7 @@ package browser
33
44 import (
55 "fmt"
6+ "log"
67 "strings"
78
89 "github.com/bnema/dumber/internal/config"
@@ -72,31 +73,50 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string {
7273
7374 activeBorderColor := getActivePaneBorderColor(styling, isDark)
7475
76+ // Pane mode border color - use configured value or fallback to orange
77+ paneModeColor := styling.PaneModeBorderColor
78+ if paneModeColor == "" {
79+ paneModeColor = "#FFA500" // Fallback to orange if not configured
80+ }
81+
7582 css := fmt.Sprintf(`window {
7683 background-color: %s;
84+ padding: 0;
85+ margin: 0;
86+ }
87+
88+ /* Pane mode active - change window background to border color */
89+ window.pane-mode-active {
90+ background-color: %s;
91+ }
92+
93+ /* Workspace root container */
94+ paned, box {
95+ background-color: %s;
96+ transition: margin 150ms ease-in-out;
7797 }
7898
79- /* Base pane styling - transparent outline for smooth transitions */
99+ /* Base pane styling - subtle border for inactive panes */
80100 .workspace-pane, .stacked-pane-container {
81- outline: 2px solid transparent;
82- outline-offset: -2px;
83- transition: outline-color %dms ease-in-out;
101+ border: 2px solid %s;
102+ border-radius: %dpx;
103+ transition: border-color %dms ease-in-out;
104+ margin: 0;
84105 }
85106
86- /* Active pane border styling using outline (no layout impact) */
107+ /* Active pane border styling */
87108 .workspace-pane-active {
88- outline-color: %s;
109+ border-color: %s;
89110 }
90111
91112 /* Stacked panes styling */
92113 .stacked-pane-container {
93114 background-color: %s;
94- border-radius: %dpx;
95115 }
96116
97- /* Active stacked pane container gets the outline color */
117+ /* Stacked pane containers never get active border - focus is shown via WebKit */
98118 .stacked-pane-container.workspace-pane-active {
99- outline-color: %s;
119+ border-color: %s;
100120 }
101121
102122 .stacked-pane-title {
@@ -129,11 +149,14 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string {
129149 /* Collapsed panes are hidden - handled in code via widget visibility */
130150 }`,
131151 windowBackgroundColor, // window background
132- styling.TransitionDuration, // base pane outline transition
133- activeBorderColor, // workspace-pane-active outline-color
152+ paneModeColor, // window.pane-mode-active background (border color)
153+ windowBackgroundColor, // paned, box background
154+ inactiveBorderColor, // base pane border color (inactive)
155+ styling.BorderRadius, // base pane border radius
156+ styling.TransitionDuration, // base pane border transition
157+ activeBorderColor, // workspace-pane-active border color
134158 windowBackgroundColor, // stacked-pane-container background
135- styling.BorderRadius, // stacked-pane-container border-radius
136- activeBorderColor, // stacked-pane-container.workspace-pane-active outline-color
159+ inactiveBorderColor, // stacked-pane-container.workspace-pane-active border color (keep subtle)
137160 getStackTitleBg(isDark), // stacked-pane-title background
138161 inactiveBorderColor, // stacked-pane-title border-bottom
139162 styling.TransitionDuration, // stacked-pane-title transition
@@ -144,12 +167,6 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string {
144167 return css
145168 }
146169
147-// generateStackedPaneCSS generates the CSS for stacked pane title bars (deprecated - use generateWorkspaceCSS)
148-func (wm *WorkspaceManager) generateStackedPaneCSS() string {
149- // Delegate to the new comprehensive CSS generator
150- return wm.generateWorkspaceCSS()
151-}
152-
153170 // Global CSS provider tracking to prevent duplication
154171 var (
155172 globalCSSInitialized bool
@@ -171,12 +188,9 @@ func (wm *WorkspaceManager) ensureWorkspaceStyles() {
171188 webkit.AddCSSProvider(workspaceCSS)
172189 globalCSSInitialized = true
173190 globalCSSContent = workspaceCSS
191+ log.Printf("[workspace] Applied workspace CSS styles (%d bytes)", len(workspaceCSS))
192+ log.Printf("[workspace] CSS preview: %s...", workspaceCSS[:min(200, len(workspaceCSS))])
174193 }
175194
176195 wm.cssInitialized = true
177196 }
178-
179-// ensureStackedPaneStyles ensures that CSS styles are applied for stacked panes (deprecated - use ensureWorkspaceStyles)
180-func (wm *WorkspaceManager) ensureStackedPaneStyles() {
181- wm.ensureWorkspaceStyles()
182-}
M internal/app/browser/workspace_diagnostics.go +2 -2
@@ -134,8 +134,8 @@ func describePaneNode(node *paneNode) string {
134134 parts = append(parts, fmt.Sprintf("split orientation=%d", node.orientation))
135135 }
136136
137- if node.container != 0 {
138- parts = append(parts, fmt.Sprintf("widget=%#x", node.container))
137+ if node.container != nil {
138+ parts = append(parts, fmt.Sprintf("widget=%p", node.container))
139139 }
140140
141141 if node.parent != nil {
M internal/app/browser/workspace_geometry_validator.go +6 -6
@@ -50,7 +50,7 @@ func (gv *GeometryValidator) SetDebugMode(debug bool) {
5050 func (gv *GeometryValidator) GetPaneGeometry(node *paneNode) PaneGeometry {
5151 geometry := PaneGeometry{IsValid: false}
5252
53- if node == nil || node.container == 0 {
53+ if node == nil || node.container == nil {
5454 if gv.debugMode {
5555 log.Printf("[geometry] Invalid node or missing container")
5656 }
@@ -58,12 +58,12 @@ func (gv *GeometryValidator) GetPaneGeometry(node *paneNode) PaneGeometry {
5858 }
5959
6060 // Get widget allocation directly - GTK operations are always on main thread
61- allocation := webkit.WidgetGetAllocation(node.container)
61+ x, y, width, height := webkit.WidgetGetAllocation(node.container)
6262
63- geometry.X = allocation.X
64- geometry.Y = allocation.Y
65- geometry.Width = allocation.Width
66- geometry.Height = allocation.Height
63+ geometry.X = x
64+ geometry.Y = y
65+ geometry.Width = width
66+ geometry.Height = height
6767 geometry.IsValid = true
6868
6969 if gv.debugMode {
A internal/app/browser/workspace_hover.go +80 -0
@@ -0,0 +1,80 @@
1+// workspace_hover.go - Hover handler management for workspace panes
2+package browser
3+
4+import (
5+ "log"
6+
7+ "github.com/bnema/dumber/pkg/webkit"
8+ "github.com/diamondburned/gotk4/pkg/glib/v2"
9+)
10+
11+// ensureHover attaches hover handlers to a pane node for mouse-based focus changes
12+func (wm *WorkspaceManager) ensureHover(node *paneNode) {
13+ if wm == nil || node == nil || !node.isLeaf {
14+ return
15+ }
16+
17+ // Skip if already attached or no container
18+ if node.container == nil || node.hoverToken != nil {
19+ return
20+ }
21+
22+ // Attach hover handler to the pane container
23+ controller := webkit.WidgetAddHoverHandler(node.container, func() {
24+ if wm == nil || node == nil {
25+ return
26+ }
27+
28+ // Skip if this pane is already active to avoid redundant focus churn.
29+ if wm.GetActiveNode() == node {
30+ return
31+ }
32+
33+ // Debounce hover events to prevent rapid focus changes (minimum 150ms between hover-triggered focus changes)
34+ now := glib.GetMonotonicTime()
35+ if node.lastHoverTime > 0 && (now-node.lastHoverTime) < 150000 { // 150ms in microseconds
36+ return
37+ }
38+ node.lastHoverTime = now
39+
40+ // Request focus change when mouse enters the pane
41+ wm.SetActivePane(node, SourceMouse)
42+ })
43+
44+ if controller != nil {
45+ node.hoverToken = controller
46+ log.Printf("[workspace] Attached hover handler to pane %p (controller=%p)", node, controller)
47+ } else {
48+ log.Printf("[workspace] Failed to attach hover handler to pane %p", node)
49+ }
50+}
51+
52+// detachHover removes hover handlers from a pane node
53+func (wm *WorkspaceManager) detachHover(node *paneNode) {
54+ if wm == nil || node == nil || node.hoverToken == nil {
55+ return
56+ }
57+
58+ if node.container == nil {
59+ node.hoverToken = nil
60+ return
61+ }
62+
63+ webkit.WidgetRemoveHoverHandler(node.container, node.hoverToken)
64+ log.Printf("[workspace] Detached hover handler from pane %p (controller=%p)", node, node.hoverToken)
65+ node.hoverToken = nil
66+}
67+
68+// attachHoverHandlersToAllPanes attaches hover handlers to all leaf panes
69+func (wm *WorkspaceManager) attachHoverHandlersToAllPanes() {
70+ if wm == nil {
71+ return
72+ }
73+
74+ leaves := wm.collectLeaves()
75+ for _, leaf := range leaves {
76+ wm.ensureHover(leaf)
77+ }
78+
79+ log.Printf("[workspace] Attached hover handlers to %d panes", len(leaves))
80+}
M internal/app/browser/workspace_manager.go +190 -265
@@ -12,6 +12,8 @@ import (
1212
1313 "github.com/bnema/dumber/internal/app/messaging"
1414 "github.com/bnema/dumber/pkg/webkit"
15+ "github.com/diamondburned/gotk4/pkg/glib/v2"
16+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
1517 )
1618
1719 // WorkspaceManager coordinates Zellij-style pane operations.
@@ -30,9 +32,10 @@ type WorkspaceManager struct {
3032 createPaneFn func(*webkit.WebView) (*BrowserPane, error)
3133
3234 // Coordination fields for preventing duplicate events
33- paneModeSource *webkit.WebView // Which webview initiated pane mode
34- lastPaneModeEntry time.Time // When pane mode was last entered
35- paneMutex sync.Mutex // Protects pane mode state
35+ paneModeSource *webkit.WebView // Which webview initiated pane mode
36+ paneModeActivePane *paneNode // Which pane node has pane mode active
37+ paneModeContainer gtk.Widgetter // The container widget that has margin applied
38+ paneMutex sync.Mutex // Protects pane mode state
3639
3740 // Focus throttling fields to prevent infinite loops
3841 lastFocusChange time.Time // When focus was last changed
@@ -42,7 +45,8 @@ type WorkspaceManager struct {
4245 lastStackOperation time.Time // When a stack operation was last performed
4346
4447 // NEW: Pane creation deduplicator
45- paneDeduplicator *messaging.PaneRequestDeduplicator
48+ // Popups waiting for ready-to-show lifecycle callback
49+ pendingPopups map[uint64]*pendingPopup
4650
4751 // Specialized managers for different pane operations
4852 stackedPaneManager *StackedPaneManager
@@ -76,14 +80,14 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
7680 debugLevel := getDebugLevel()
7781
7882 manager := &WorkspaceManager{
79- app: app,
80- window: rootPane.webView.Window(),
81- viewToNode: make(map[*webkit.WebView]*paneNode),
82- lastSplitMsg: make(map[*webkit.WebView]time.Time),
83- lastExitMsg: make(map[*webkit.WebView]time.Time),
84- paneDeduplicator: messaging.NewPaneRequestDeduplicator(),
85- focusDebounce: 150 * time.Millisecond,
86- debugLevel: debugLevel,
83+ app: app,
84+ window: rootPane.webView.Window(),
85+ viewToNode: make(map[*webkit.WebView]*paneNode),
86+ lastSplitMsg: make(map[*webkit.WebView]time.Time),
87+ lastExitMsg: make(map[*webkit.WebView]time.Time),
88+ pendingPopups: make(map[uint64]*pendingPopup),
89+ focusDebounce: 150 * time.Millisecond,
90+ debugLevel: debugLevel,
8791 }
8892
8993 // Initialize validation components (opt-in based on debug level)
@@ -114,10 +118,9 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
114118 if err != nil {
115119 return nil, err
116120 }
117- // Pane WebViews are embedded inside the main application window, so they do not
118- // need their own toplevel GtkWindow. Avoid creating per-pane windows to keep GTK
119- // ownership simple (Epiphany follows the same pattern) and to prevent premature
120- // swapchain destruction when panes are closed.
121+ // CRITICAL: Pane WebViews are embedded inside the workspace paned containers,
122+ // so they do not need their own toplevel GtkWindow. Setting CreateWindow=false
123+ // prevents GTK "widget already has parent" errors when adding to paned containers.
121124 cfg.CreateWindow = false
122125 return webkit.NewWebView(cfg)
123126 }
@@ -166,6 +169,9 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
166169 log.Printf("[workspace] Failed to initialize focus state machine: %v", err)
167170 }
168171
172+ // Ensure hover controllers are attached to all panes for mouse-driven focus changes
173+ manager.attachHoverHandlersToAllPanes()
174+
169175 if manager.debugPaneClose {
170176 manager.dumpTreeState("workspace_init")
171177 }
@@ -173,213 +179,142 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
173179 return manager
174180 }
175181
176-// OnWorkspaceMessage implements messaging.WorkspaceObserver.
177-func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messaging.Message) {
178- node := wm.viewToNode[source]
179- if node == nil {
180- log.Printf("[workspace] message from unknown webview: event=%s", msg.Event)
181- return
182+// shouldDebounce checks if an operation should be debounced based on timing
183+func (wm *WorkspaceManager) shouldDebounce(source *webkit.WebView, threshold time.Duration) bool {
184+ if last, ok := wm.lastSplitMsg[source]; ok {
185+ if elapsed := time.Since(last); elapsed < threshold {
186+ log.Printf("[workspace] operation debounced: %.0fms", elapsed.Seconds()*1000)
187+ return true
188+ }
182189 }
190+ wm.lastSplitMsg[source] = time.Now()
191+ return false
192+}
183193
184- switch event := strings.ToLower(msg.Event); event {
185- case "pane-mode-entered":
186- // Use mutex to prevent race conditions when multiple webviews try to enter pane mode
187- wm.paneMutex.Lock()
188- defer wm.paneMutex.Unlock()
194+// withSplitLock executes an operation with atomic splitting lock protection
195+func (wm *WorkspaceManager) withSplitLock(operation string, fn func() error) error {
196+ if !atomic.CompareAndSwapInt32(&wm.splitting, 0, 1) {
197+ log.Printf("[workspace] %s ignored: operation in progress", operation)
198+ return fmt.Errorf("operation in progress")
199+ }
189200
190- // Check if pane mode was already entered recently (within 200ms debounce window)
191- if time.Since(wm.lastPaneModeEntry) < 200*time.Millisecond {
192- log.Printf("[workspace] pane-mode-entered rejected: debounce protection (%.0fms ago) source=%p",
193- time.Since(wm.lastPaneModeEntry).Seconds()*1000, source)
194- return
201+ defer func() {
202+ atomic.StoreInt32(&wm.splitting, 0)
203+ if r := recover(); r != nil {
204+ log.Printf("[workspace] %s panicked: %v", operation, r)
205+ panic(r)
195206 }
207+ }()
196208
197- // Check if pane mode is already active from a different source
198- if wm.paneModeActive && wm.paneModeSource != nil && wm.paneModeSource != source {
199- log.Printf("[workspace] pane-mode-entered rejected: already active from different source (current=%p, new=%p)",
200- wm.paneModeSource, source)
201- return
202- }
209+ return fn()
210+}
203211
204- log.Printf("[workspace] pane-mode-entered accepted: source=%p", source)
205- wm.paneModeActive = true
206- wm.paneModeSource = source
207- wm.lastPaneModeEntry = time.Now()
208- wm.SetActivePane(node, SourceProgrammatic)
209- case "pane-confirmed", "pane-cancelled", "pane-mode-exited":
210- // Debounce pane-mode-exited events to prevent duplicate focus calls
211- if event == "pane-mode-exited" {
212- if last, ok := wm.lastExitMsg[source]; ok {
213- if time.Since(last) < 100*time.Millisecond {
214- log.Printf("[workspace] pane-mode-exited ignored: debounce (%.0fms)", time.Since(last).Seconds()*1000)
215- return
216- }
217- }
218- wm.lastExitMsg[source] = time.Now()
219- }
212+// cleanupPopupStorage removes localStorage entries for a closed popup
213+func (wm *WorkspaceManager) cleanupPopupStorage(parentNode, targetNode *paneNode, webviewID string) {
214+ if parentNode.pane == nil || parentNode.pane.WebView() == nil || targetNode.requestID == "" {
215+ return
216+ }
220217
221- wm.paneMutex.Lock()
222- wm.paneModeActive = false
223- wm.paneModeSource = nil
224- wm.paneMutex.Unlock()
218+ requestID := targetNode.requestID
219+ parentWebViewID := parentNode.pane.WebView().ID()
220+
221+ script := fmt.Sprintf(`
222+ try {
223+ localStorage.removeItem('popup_mapping_%d');
224+ const keys = ['popup_%s_parent_info', 'popup_%s_parent_action',
225+ 'popup_%s_message_to_popup', 'popup_%s_message_to_parent',
226+ 'oauth_callback_%s'];
227+ keys.forEach(k => { try { localStorage.removeItem(k); } catch(e) {} });
228+ console.log('[workspace] Cleaned popup localStorage');
229+ } catch(e) { console.warn('[workspace] localStorage cleanup failed:', e); }
230+ `, parentWebViewID, requestID, requestID, requestID, requestID, webviewID)
231+
232+ if err := parentNode.pane.WebView().InjectScript(script); err != nil {
233+ log.Printf("[workspace] localStorage cleanup failed: %v", err)
234+ } else {
235+ log.Printf("[workspace] Cleaned localStorage for popup requestId=%s webviewId=%s", requestID, webviewID)
236+ }
237+}
238+
239+// OnWorkspaceMessage implements messaging.WorkspaceObserver.
240+func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messaging.Message) {
241+ node := wm.viewToNode[source]
225242
226- wm.focusAfterPaneMode(node)
227- case "pane-close":
228- if !wm.paneModeActive {
229- log.Printf("[workspace] close requested outside pane mode; ignoring")
230- break
243+ if msg.WebViewID != "" {
244+ if explicitNode := wm.findNodeByWebViewID(msg.WebViewID); explicitNode != nil {
245+ if explicitNode.pane != nil && explicitNode.pane.webView != nil {
246+ node = explicitNode
247+ source = explicitNode.pane.webView
248+ }
231249 }
250+ }
232251
233- wm.paneMutex.Lock()
234- wm.paneModeActive = false
235- wm.paneModeSource = nil
236- wm.paneMutex.Unlock()
252+ if node == nil {
253+ log.Printf("[workspace] message from unknown webview: event=%s webviewId=%s", msg.Event, msg.WebViewID)
254+ return
255+ }
237256
238- // Don't focus the node that's about to be closed - closeCurrentPane() will handle focus
239- wm.closeCurrentPane()
257+ switch event := strings.ToLower(msg.Event); event {
240258 case "pane-split":
241259 wm.SetActivePane(node, SourceProgrammatic)
242260 direction := strings.ToLower(msg.Direction)
243261 if direction == "" {
244262 direction = "right"
245263 }
246- if last, ok := wm.lastSplitMsg[source]; ok {
247- if time.Since(last) < 200*time.Millisecond {
248- log.Printf("[workspace] split ignored: debounce (%.0fms)", time.Since(last).Seconds()*1000)
249- return
250- }
251- }
252264
253- // CRITICAL FIX: Use atomic operations for splitting flag to prevent race conditions
254- if !atomic.CompareAndSwapInt32(&wm.splitting, 0, 1) {
255- log.Printf("[workspace] split ignored: operation already in progress")
265+ if wm.shouldDebounce(source, 200*time.Millisecond) {
256266 return
257267 }
258268
259- // CRITICAL FIX: Always clear splitting flag on exit (panic recovery)
260- defer func() {
261- atomic.StoreInt32(&wm.splitting, 0)
262- if r := recover(); r != nil {
263- log.Printf("[workspace] split operation panicked, flag cleared: %v", r)
264- panic(r) // Re-panic to maintain error handling
269+ err := wm.withSplitLock("split", func() error {
270+ newNode, err := wm.SplitPane(node, direction)
271+ if err != nil {
272+ if glib.MainContextDefault().IsOwner() {
273+ glib.MainContextDefault().Iteration(false)
274+ }
275+ return err
265276 }
266- }()
267-
268- wm.lastSplitMsg[source] = time.Now()
277+ wm.clonePaneState(node, newNode)
278+ return nil
279+ })
269280
270- newNode, err := wm.SplitPane(node, direction)
271281 if err != nil {
272282 log.Printf("[workspace] split failed: %v", err)
273- // CRITICAL FIX: Pump GTK events after validation failure to clear pending operations
274- if webkit.IsMainThread() {
275- webkit.IterateMainLoop()
276- }
277- return
278283 }
279- wm.clonePaneState(node, newNode)
284+
280285 case "pane-stack":
281286 wm.SetActivePane(node, SourceProgrammatic)
282- if last, ok := wm.lastSplitMsg[source]; ok {
283- if time.Since(last) < 200*time.Millisecond {
284- log.Printf("[workspace] stack ignored: debounce (%.0fms)", time.Since(last).Seconds()*1000)
285- return
286- }
287- }
288287
289- // CRITICAL FIX: Use atomic operations for splitting flag
290- if !atomic.CompareAndSwapInt32(&wm.splitting, 0, 1) {
291- log.Printf("[workspace] stack ignored: operation already in progress")
288+ if wm.shouldDebounce(source, 200*time.Millisecond) {
292289 return
293290 }
294291
295- // CRITICAL FIX: Always clear splitting flag on exit (panic recovery)
296- defer func() {
297- atomic.StoreInt32(&wm.splitting, 0)
298- if r := recover(); r != nil {
299- log.Printf("[workspace] stack operation panicked, flag cleared: %v", r)
300- panic(r) // Re-panic to maintain error handling
292+ err := wm.withSplitLock("stack", func() error {
293+ newNode, err := wm.stackedPaneManager.StackPane(node)
294+ if err != nil {
295+ if glib.MainContextDefault().IsOwner() {
296+ glib.MainContextDefault().Iteration(false)
297+ }
298+ return err
301299 }
302- }()
300+ wm.clonePaneState(node, newNode)
301+ return nil
302+ })
303303
304- wm.lastSplitMsg[source] = time.Now()
305-
306- newNode, err := wm.stackedPaneManager.StackPane(node)
307304 if err != nil {
308305 log.Printf("[workspace] stack failed: %v", err)
309- // CRITICAL FIX: Pump GTK events after stack failure
310- if webkit.IsMainThread() {
311- webkit.IterateMainLoop()
312- }
313- return
314- }
315- wm.clonePaneState(node, newNode)
316- case "create-pane":
317- log.Printf("[workspace] create-pane requested: url=%s action=%s requestId=%s", msg.URL, msg.Action, msg.RequestID)
318-
319- if msg.URL == "" {
320- log.Printf("[workspace] create-pane: empty URL, ignoring")
321- break
322- }
323-
324- // NEW: Get WebView ID for deduplication
325- webViewID := "unknown"
326- if source != nil {
327- // Try to get a unique identifier for the WebView
328- webViewID = fmt.Sprintf("%p", source)
329306 }
330307
331- // NEW: Create intent for deduplication check
332- intent := &messaging.WindowIntent{
333- URL: msg.URL,
334- WindowType: msg.Action,
335- Timestamp: time.Now().UnixMilli(),
336- RequestID: msg.RequestID,
337- UserTriggered: true,
338- }
339-
340- // NEW: Check for duplicates
341- if isDup, reason := wm.paneDeduplicator.IsDuplicate(intent, webViewID); isDup {
342- log.Printf("[workspace] create-pane BLOCKED: %s", reason)
343- break
344- }
345-
346- // Use the existing methods to handle tab vs popup creation
347- switch strings.ToLower(msg.Action) {
348- case "tab":
349- newView := wm.handleIntentAsTab(node, msg.URL, intent)
350- if newView != nil {
351- log.Printf("[workspace] create-pane: tab created successfully")
352- } else {
353- log.Printf("[workspace] create-pane: failed to create tab")
354- }
355- case "popup":
356- newView := wm.handleIntentAsPopup(node, msg.URL, intent)
357- if newView != nil {
358- log.Printf("[workspace] create-pane: popup created successfully")
359- } else {
360- log.Printf("[workspace] create-pane: failed to create popup")
361- }
362- default:
363- log.Printf("[workspace] create-pane: unknown action '%s', defaulting to tab", msg.Action)
364- newView := wm.handleIntentAsTab(node, msg.URL, intent)
365- if newView != nil {
366- log.Printf("[workspace] create-pane: default tab created successfully")
367- } else {
368- log.Printf("[workspace] create-pane: failed to create default tab")
369- }
370- }
371308 case "close-popup":
372- log.Printf("[workspace] close-popup requested: webviewId=%s reason=%s", msg.WebViewID, msg.Reason)
373-
374309 if msg.WebViewID == "" {
375310 log.Printf("[workspace] close-popup: empty webviewId, ignoring")
376- break
311+ return
377312 }
378313
379- // Find the popup pane by webview ID
314+ // Find popup pane by webview ID
380315 var targetNode *paneNode
381316 for webView, node := range wm.viewToNode {
382- if webView != nil && webView.ID() == msg.WebViewID {
317+ if webView != nil && fmt.Sprintf("%d", webView.ID()) == msg.WebViewID {
383318 targetNode = node
384319 break
385320 }
@@ -387,118 +322,57 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa
387322
388323 if targetNode == nil {
389324 log.Printf("[workspace] close-popup: webview not found: %s", msg.WebViewID)
390- break
325+ return
391326 }
392327
393328 if !targetNode.isPopup {
394329 log.Printf("[workspace] close-popup: target is not a popup: %s", msg.WebViewID)
395- break
330+ return
396331 }
397332
398- log.Printf("[workspace] Closing popup pane due to %s", msg.Reason)
333+ log.Printf("[workspace] Closing popup due to %s", msg.Reason)
399334
400- // Remove this popup from parent's activePopupChildren before closing
335+ // Remove from parent's active popup list
401336 if targetNode.parentPane != nil {
402337 parentNode := targetNode.parentPane
403338 for i, childID := range parentNode.activePopupChildren {
404339 if childID == msg.WebViewID {
405340 parentNode.activePopupChildren = append(parentNode.activePopupChildren[:i], parentNode.activePopupChildren[i+1:]...)
406- log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", msg.WebViewID, len(parentNode.activePopupChildren))
341+ log.Printf("[workspace] Removed popup from parent (remaining: %d)", len(parentNode.activePopupChildren))
407342 break
408343 }
409344 }
410345
411- // Clean up ALL localStorage entries for this popup in parent WebView
412- // This includes popup_<requestId>_parent_info, popup_<requestId>_parent_action, etc.
413- if parentNode.pane != nil && parentNode.pane.WebView() != nil && targetNode.requestID != "" {
414- parentWebViewID := parentNode.pane.WebView().ID()
415- requestID := targetNode.requestID
416-
417- cleanupScript := fmt.Sprintf(`
418- try {
419- // Clean up popup_mapping_<parentId> (from Go code)
420- localStorage.removeItem('popup_mapping_%s');
421-
422- // Clean up popup_<requestId>_* entries (from JavaScript)
423- const requestId = '%s';
424- const keysToRemove = [
425- 'popup_' + requestId + '_parent_info',
426- 'popup_' + requestId + '_parent_action',
427- 'popup_' + requestId + '_message_to_popup',
428- 'popup_' + requestId + '_message_to_parent',
429- 'oauth_callback_' + '%s'
430- ];
431-
432- keysToRemove.forEach(key => {
433- try {
434- localStorage.removeItem(key);
435- } catch(e) {}
436- });
437-
438- console.log('[workspace] Cleaned up popup localStorage for requestId:', requestId);
439- } catch(e) {
440- console.warn('[workspace] Failed to clean popup localStorage:', e);
441- }
442- `, parentWebViewID, requestID, msg.WebViewID)
443-
444- if err := parentNode.pane.WebView().InjectScript(cleanupScript); err != nil {
445- log.Printf("[workspace] Failed to inject localStorage cleanup script: %v", err)
446- } else {
447- log.Printf("[workspace] Cleaned up localStorage for popup requestId=%s webviewId=%s", requestID, msg.WebViewID)
448- }
449- }
346+ wm.cleanupPopupStorage(parentNode, targetNode, msg.WebViewID)
450347 }
451348
452- // Close the popup pane
453349 if err := wm.ClosePane(targetNode); err != nil {
454- log.Printf("[workspace] Failed to close popup pane: %v", err)
455- } else {
456- log.Printf("[workspace] Successfully closed popup pane: %s", msg.WebViewID)
457-
458- // Fix GTK rendering for parent pane after popup closes
459- // The parent pane's rendering can become corrupted after popup closes
460- if targetNode.parentPane != nil {
461- parentNode := targetNode.parentPane
462- if parentNode.container != 0 && webkit.WidgetIsValid(parentNode.container) {
463- log.Printf("[workspace] Fixing parent pane rendering after popup close")
464-
465- // CRITICAL: Hide parent container to disconnect WebKitGTK rendering pipeline
466- webkit.WidgetHide(parentNode.container)
467-
468- // Schedule showing and forcing GTK to reconnect rendering
469- wm.scheduleIdleGuarded(func() bool {
470- if parentNode == nil || !parentNode.widgetValid || parentNode.container == 0 {
471- return false
472- }
473- // Show widget to reconnect WebKit rendering pipeline
474- webkit.WidgetShow(parentNode.container)
475- // Force GTK to recalculate size and recreate rendering surface
476- webkit.WidgetQueueResize(parentNode.container)
477- webkit.WidgetQueueDraw(parentNode.container)
478-
479- // Also queue resize+draw on the WebView widget itself
480- if parentNode.pane != nil && parentNode.pane.WebView() != nil {
481- webViewWidget := parentNode.pane.WebView().Widget()
482- if webViewWidget != 0 && webkit.WidgetIsValid(webViewWidget) {
483- webkit.WidgetQueueResize(webViewWidget)
484- webkit.WidgetQueueDraw(webViewWidget)
485- log.Printf("[workspace] Queued resize+draw for parent WebView widget")
486- }
487- }
488-
489- log.Printf("[workspace] Shown and queued resize+draw for parent pane after popup close")
490- return false
491- }, parentNode)
492- }
493- }
350+ log.Printf("[workspace] Failed to close popup: %v", err)
494351 }
352+
495353 default:
496354 log.Printf("[workspace] unhandled workspace event: %s", msg.Event)
497355 }
498356 }
499357
358+func (wm *WorkspaceManager) findNodeByWebViewID(id string) *paneNode {
359+ if wm == nil || id == "" {
360+ return nil
361+ }
362+
363+ for webView, node := range wm.viewToNode {
364+ if webView != nil && webView.IDString() == id {
365+ return node
366+ }
367+ }
368+ return nil
369+}
370+
500371 // GetActiveNode returns the currently active pane node
501372 func (wm *WorkspaceManager) GetActiveNode() *paneNode {
373+ if wm == nil || wm.focusStateMachine == nil {
374+ return nil
375+ }
502376 return wm.focusStateMachine.GetActivePane()
503377 }
504378
@@ -526,11 +400,62 @@ func (wm *WorkspaceManager) GetNodeForWebView(webView *webkit.WebView) *paneNode
526400 return wm.viewToNode[webView]
527401 }
528402
529-// RegisterNavigationHandler sets up navigation handling for a webview (simplified)
403+// RegisterNavigationHandler sets up navigation handling for a webview
530404 func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
531405 if webView == nil {
532406 return
533407 }
534408
535- log.Printf("[workspace] Registered navigation handler for webview: %s", webView.ID())
409+ // Register workspace navigation callback for alt+arrow pane navigation
410+ webView.RegisterWorkspaceNavigationHandler(func(direction string) bool {
411+ if wm == nil {
412+ return false
413+ }
414+ return wm.FocusNeighbor(direction)
415+ })
416+
417+ // Register pane mode callbacks for Ctrl+P and pane mode actions
418+ webView.RegisterPaneModeHandler(func(action string) bool {
419+ if wm == nil {
420+ return false
421+ }
422+
423+ if action == "enter" {
424+ wm.EnterPaneMode()
425+ return true
426+ }
427+
428+ if action == "exit" {
429+ // Check if pane mode is active
430+ wm.paneMutex.Lock()
431+ wasActive := wm.paneModeActive
432+ wm.paneMutex.Unlock()
433+
434+ if wasActive {
435+ wm.ExitPaneMode("escape")
436+ return true // Mode was active, we handled it
437+ }
438+ return false // No mode active, pass through to DOM
439+ }
440+
441+ // For other actions, only handle if pane mode is active
442+ wm.paneMutex.Lock()
443+ isActive := wm.paneModeActive
444+ wm.paneMutex.Unlock()
445+
446+ if !isActive {
447+ return false // Not in pane mode, allow normal behavior
448+ }
449+
450+ // Pane mode is active, handle the action
451+ wm.HandlePaneAction(action)
452+ return true
453+ }, func() bool {
454+ // Check if pane mode is currently active
455+ wm.paneMutex.Lock()
456+ defer wm.paneMutex.Unlock()
457+ return wm.paneModeActive
458+ })
459+
460+ log.Printf("[workspace] Registered navigation handler for webview: %d", webView.ID())
536461 }
M internal/app/browser/workspace_navigation.go +25 -76
@@ -9,6 +9,7 @@ import (
99 "time"
1010
1111 "github.com/bnema/dumber/pkg/webkit"
12+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
1213 )
1314
1415 const (
@@ -33,7 +34,7 @@ func (wm *WorkspaceManager) DispatchPaneFocusEvent(node *paneNode, active bool)
3334 if err := node.pane.webView.DispatchCustomEvent("dumber:workspace-focus", detail); err != nil {
3435 log.Printf("[workspace] failed to dispatch focus event: %v", err)
3536 } else if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug {
36- log.Printf("[workspace] dispatched focus event for webview %s (active=%v)", node.pane.webView.ID(), active)
37+ log.Printf("[workspace] dispatched focus event for webview %d (active=%v)", node.pane.webView.ID(), active)
3738 }
3839 }
3940
@@ -88,45 +89,7 @@ func (wm *WorkspaceManager) focusByView(view *webkit.WebView) {
8889 }
8990 }
9091
91-// ensureHover sets up hover handlers for a pane node
92-func (wm *WorkspaceManager) ensureHover(node *paneNode) {
93- if wm == nil || node == nil || !node.isLeaf {
94- return
95- }
96- if node.container == 0 || node.hoverToken != 0 {
97- return
98- }
99-
100- token := webkit.WidgetAddHoverHandler(node.container, func() {
101- if wm == nil {
102- return
103- }
104- wm.SetActivePane(node, SourceMouse)
105- })
106- node.hoverToken = token
107- if token == 0 {
108- log.Printf("[workspace] failed to attach hover handler")
109- }
110-}
111-
112-// detachHover removes hover handlers from a pane node
113-func (wm *WorkspaceManager) detachHover(node *paneNode) {
114- if wm == nil || node == nil || node.hoverToken == 0 {
115- return
116- }
117-
118- if node.container == 0 || !webkit.WidgetIsValid(node.container) {
119- node.hoverToken = 0
120- return
121- }
122-
123- token := node.hoverToken
124- node.hoverToken = 0
125-
126- if webkit.WidgetIsValid(node.container) {
127- webkit.WidgetRemoveHoverHandler(node.container, token)
128- }
129-}
92+// Note: ensureHover and detachHover are now implemented in workspace_hover.go
13093
13194 // detachFocus removes the GTK focus controller attached to a pane, if any.
13295 func (wm *WorkspaceManager) detachFocus(node *paneNode) {
@@ -240,7 +203,7 @@ func (wm *WorkspaceManager) navigateStack(direction string) bool {
240203 // focusAdjacent uses geometry-based calculations to find and focus adjacent panes
241204 func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
242205 currentFocused := wm.GetActiveNode()
243- if currentFocused == nil || !currentFocused.isLeaf || currentFocused.container == 0 {
206+ if currentFocused == nil || !currentFocused.isLeaf || currentFocused.container == nil {
244207 return false
245208 }
246209
@@ -249,14 +212,9 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
249212 return true
250213 }
251214
252- currentBounds, ok := webkit.WidgetGetBounds(currentFocused.container)
253- if !ok {
254- log.Printf("[workspace] unable to compute bounds for active pane")
255- return false
256- }
257-
258- cx := currentBounds.X + currentBounds.Width/2.0
259- cy := currentBounds.Y + currentBounds.Height/2.0
215+ curX, curY, curWidth, curHeight := webkit.WidgetGetAllocation(currentFocused.container)
216+ cx := float64(curX) + float64(curWidth)/2.0
217+ cy := float64(curY) + float64(curHeight)/2.0
260218
261219 leaves := wm.collectLeaves()
262220 bestScore := math.MaxFloat64
@@ -264,16 +222,13 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
264222 var debugCandidates []string
265223
266224 for _, candidate := range leaves {
267- if candidate == nil || candidate == currentFocused || candidate.container == 0 {
225+ if candidate == nil || candidate == currentFocused || candidate.container == nil {
268226 continue
269227 }
270228
271- bounds, ok := webkit.WidgetGetBounds(candidate.container)
272- if !ok {
273- continue
274- }
275- tx := bounds.X + bounds.Width/2.0
276- ty := bounds.Y + bounds.Height/2.0
229+ x, y, width, height := webkit.WidgetGetAllocation(candidate.container)
230+ tx := float64(x) + float64(width)/2.0
231+ ty := float64(y) + float64(height)/2.0
277232
278233 dx := tx - cx
279234 dy := ty - cy
@@ -303,7 +258,7 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
303258 }
304259
305260 if direction == "up" || direction == "down" {
306- debugCandidates = append(debugCandidates, fmt.Sprintf("cand=%#x dx=%.2f dy=%.2f score=%.2f", candidate.container, dx, dy, score))
261+ debugCandidates = append(debugCandidates, fmt.Sprintf("cand=%p dx=%.2f dy=%.2f score=%.2f", candidate.container, dx, dy, score))
307262 }
308263
309264 if score < bestScore {
@@ -318,47 +273,44 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
318273 }
319274
320275 if len(debugCandidates) > 0 {
321- log.Printf("[workspace] focusAdjacent no candidate direction=%s current=%#x candidates=%s", direction, currentFocused.container, strings.Join(debugCandidates, "; "))
276+ log.Printf("[workspace] focusAdjacent no candidate direction=%s current=%p candidates=%s", direction, currentFocused.container, strings.Join(debugCandidates, "; "))
322277 }
323278 return false
324279 }
325280
326281 // structuralNeighbor finds neighbors based on the tree structure rather than geometry
327282 func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string) *paneNode {
328- if node == nil || node.container == 0 {
283+ if node == nil || node.container == nil {
329284 return nil
330285 }
331286
332- refBounds, ok := webkit.WidgetGetBounds(node.container)
333- if !ok {
334- return nil
335- }
336- cx := refBounds.X + refBounds.Width/2.0
337- cy := refBounds.Y + refBounds.Height/2.0
287+ refX, refY, refWidth, refHeight := webkit.WidgetGetAllocation(node.container)
288+ cx := float64(refX) + float64(refWidth)/2.0
289+ cy := float64(refY) + float64(refHeight)/2.0
338290 axisVertical := direction == "up" || direction == "down"
339291
340292 for parent := node.parent; parent != nil; parent = parent.parent {
341293 switch direction {
342294 case "up":
343- if axisVertical && parent.orientation == webkit.OrientationVertical && parent.right == node {
295+ if axisVertical && parent.orientation == gtk.OrientationVertical && parent.right == node {
344296 if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {
345297 return leaf
346298 }
347299 }
348300 case "down":
349- if axisVertical && parent.orientation == webkit.OrientationVertical && parent.left == node {
301+ if axisVertical && parent.orientation == gtk.OrientationVertical && parent.left == node {
350302 if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {
351303 return leaf
352304 }
353305 }
354306 case "left":
355- if !axisVertical && parent.orientation == webkit.OrientationHorizontal && parent.right == node {
307+ if !axisVertical && parent.orientation == gtk.OrientationHorizontal && parent.right == node {
356308 if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {
357309 return leaf
358310 }
359311 }
360312 case "right":
361- if !axisVertical && parent.orientation == webkit.OrientationHorizontal && parent.left == node {
313+ if !axisVertical && parent.orientation == gtk.OrientationHorizontal && parent.left == node {
362314 if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {
363315 return leaf
364316 }
@@ -375,16 +327,13 @@ func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float6
375327 bestScore := math.MaxFloat64
376328 var best *paneNode
377329 for _, leaf := range leaves {
378- if leaf == nil || leaf.container == 0 {
330+ if leaf == nil || leaf.container == nil {
379331 continue
380332 }
381333
382- bounds, ok := webkit.WidgetGetBounds(leaf.container)
383- if !ok {
384- continue
385- }
386- tx := bounds.X + bounds.Width/2.0
387- ty := bounds.Y + bounds.Height/2.0
334+ x, y, width, height := webkit.WidgetGetAllocation(leaf.container)
335+ tx := float64(x) + float64(width)/2.0
336+ ty := float64(y) + float64(height)/2.0
388337 dx := tx - cx
389338 dy := ty - cy
390339 var score float64
A internal/app/browser/workspace_navigation_test.go +146 -0
@@ -0,0 +1,146 @@
1+package browser
2+
3+import (
4+ "testing"
5+
6+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
7+)
8+
9+// TestFocusNeighborVerticalSplit tests basic tree structure validation
10+// Note: Full geometry-based navigation testing requires integration tests with real GTK widgets
11+func TestFocusNeighborVerticalSplit(t *testing.T) {
12+ t.Skip("Structural neighbor navigation requires real GTK widgets for geometry calculations")
13+}
14+
15+// TestFocusNeighborHorizontalSplit tests basic tree structure validation
16+// Note: Full geometry-based navigation testing requires integration tests with real GTK widgets
17+func TestFocusNeighborHorizontalSplit(t *testing.T) {
18+ t.Skip("Structural neighbor navigation requires real GTK widgets for geometry calculations")
19+}
20+
21+// TestPaneNodeStructure verifies basic pane node relationships
22+func TestPaneNodeStructure(t *testing.T) {
23+ root := &paneNode{
24+ orientation: gtk.OrientationVertical,
25+ isLeaf: false,
26+ }
27+
28+ child1 := &paneNode{
29+ parent: root,
30+ isLeaf: true,
31+ }
32+
33+ child2 := &paneNode{
34+ parent: root,
35+ isLeaf: true,
36+ }
37+
38+ root.left = child1
39+ root.right = child2
40+
41+ // Verify parent-child relationships
42+ if child1.parent != root {
43+ t.Errorf("child1.parent should be root")
44+ }
45+
46+ if child2.parent != root {
47+ t.Errorf("child2.parent should be root")
48+ }
49+
50+ // Verify root children
51+ if root.left != child1 {
52+ t.Errorf("root.left should be child1")
53+ }
54+
55+ if root.right != child2 {
56+ t.Errorf("root.right should be child2")
57+ }
58+
59+ // Verify leaf status
60+ if !child1.isLeaf {
61+ t.Errorf("child1 should be a leaf")
62+ }
63+
64+ if !child2.isLeaf {
65+ t.Errorf("child2 should be a leaf")
66+ }
67+
68+ if root.isLeaf {
69+ t.Errorf("root should not be a leaf")
70+ }
71+}
72+
73+// TestMapDirection verifies direction mapping to GTK orientation
74+func TestMapDirection(t *testing.T) {
75+ tests := []struct {
76+ direction string
77+ orientation gtk.Orientation
78+ existingFirst bool
79+ }{
80+ {"down", gtk.OrientationVertical, true},
81+ {"up", gtk.OrientationVertical, false},
82+ {"right", gtk.OrientationHorizontal, true},
83+ {"left", gtk.OrientationHorizontal, false},
84+ }
85+
86+ for _, tt := range tests {
87+ t.Run(tt.direction, func(t *testing.T) {
88+ orientation, existingFirst := mapDirection(tt.direction)
89+
90+ if orientation != tt.orientation {
91+ t.Errorf("mapDirection(%q) orientation = %v, want %v",
92+ tt.direction, orientation, tt.orientation)
93+ }
94+
95+ if existingFirst != tt.existingFirst {
96+ t.Errorf("mapDirection(%q) existingFirst = %v, want %v",
97+ tt.direction, existingFirst, tt.existingFirst)
98+ }
99+ })
100+ }
101+}
102+
103+// TestInvalidDirections verifies handling of invalid directions
104+func TestInvalidDirections(t *testing.T) {
105+ wm := &WorkspaceManager{}
106+
107+ invalidDirections := []string{
108+ "",
109+ "unknown",
110+ "diagonal",
111+ }
112+
113+ for _, direction := range invalidDirections {
114+ t.Run(direction, func(t *testing.T) {
115+ success := wm.FocusNeighbor(direction)
116+ if success {
117+ t.Errorf("Expected FocusNeighbor(%q) to return false, got true", direction)
118+ }
119+ })
120+ }
121+}
122+
123+// TestCaseSensitiveDirections verifies that direction strings are case-insensitive
124+func TestCaseSensitiveDirections(t *testing.T) {
125+ wm := &WorkspaceManager{}
126+
127+ // These should be accepted (case insensitive), but will return false
128+ // because there's no active pane to navigate from
129+ validDirections := []string{
130+ "UP",
131+ "Down",
132+ "LEFT",
133+ "Right",
134+ "up",
135+ "down",
136+ "left",
137+ "right",
138+ }
139+
140+ for _, direction := range validDirections {
141+ t.Run(direction, func(t *testing.T) {
142+ // Should not panic or error, just return false since no panes exist
143+ _ = wm.FocusNeighbor(direction)
144+ })
145+ }
146+}
M internal/app/browser/workspace_operations.go +2 -2
@@ -54,7 +54,7 @@ func (wm *WorkspaceManager) SplitPane(target *paneNode, direction string) (*pane
5454 // Step 2: Execute directly if we're already on the GTK main thread
5555 if webkit.IsMainThread() {
5656 log.Printf("[workspace] Already on main thread, executing split directly")
57- newNode, err := wm.splitNode(target, direction)
57+ newNode, err := wm.splitNode(target, direction, nil)
5858 if err != nil {
5959 return nil, err
6060 }
@@ -78,7 +78,7 @@ func (wm *WorkspaceManager) SplitPane(target *paneNode, direction string) (*pane
7878 done := make(chan struct{})
7979
8080 _ = webkit.IdleAdd(func() bool {
81- newNode, splitErr = wm.splitNode(target, direction)
81+ newNode, splitErr = wm.splitNode(target, direction, nil)
8282 if splitErr == nil {
8383 if wm.debugLevel >= DebugBasic {
8484 if verr := wm.treeValidator.ValidateTree(wm.root, "after_split"); verr != nil {
A internal/app/browser/workspace_pane_mode.go +264 -0
@@ -0,0 +1,264 @@
1+// workspace_pane_mode.go - Backend-driven pane mode implementation
2+package browser
3+
4+import (
5+ "log"
6+
7+ "github.com/bnema/dumber/pkg/webkit"
8+)
9+
10+// EnterPaneMode activates pane mode on the currently focused pane
11+// This is called directly from the keyboard bridge, not from JavaScript
12+func (wm *WorkspaceManager) EnterPaneMode() {
13+ wm.paneMutex.Lock()
14+ defer wm.paneMutex.Unlock()
15+
16+ // Get the currently focused pane
17+ activeNode := wm.GetActiveNode()
18+ if activeNode == nil {
19+ log.Printf("[pane-mode] Cannot enter pane mode: no active pane")
20+ return
21+ }
22+
23+ // Already in pane mode on this pane?
24+ if wm.paneModeActive && wm.paneModeActivePane == activeNode {
25+ log.Printf("[pane-mode] Pane mode already active on this pane")
26+ return
27+ }
28+
29+ log.Printf("[pane-mode] Entering pane mode on pane %p", activeNode)
30+
31+ wm.paneModeActive = true
32+ wm.paneModeActivePane = activeNode
33+
34+ if activeNode.pane != nil && activeNode.pane.webView != nil {
35+ wm.paneModeSource = activeNode.pane.webView
36+ }
37+
38+ // Apply visual border to workspace root
39+ wm.applyPaneModeBorder()
40+
41+ // Notify JavaScript for UI feedback (visual indicators)
42+ wm.dispatchPaneModeEvent("entered", "")
43+}
44+
45+// ExitPaneMode deactivates pane mode
46+func (wm *WorkspaceManager) ExitPaneMode(reason string) {
47+ wm.paneMutex.Lock()
48+ defer wm.paneMutex.Unlock()
49+
50+ if !wm.paneModeActive {
51+ return
52+ }
53+
54+ log.Printf("[pane-mode] Exiting pane mode: %s", reason)
55+
56+ wm.paneModeActive = false
57+ wm.paneModeActivePane = nil
58+ wm.paneModeSource = nil
59+
60+ // Remove visual border from workspace root
61+ wm.removePaneModeBorder()
62+
63+ // Notify JavaScript
64+ wm.dispatchPaneModeEvent("exited", reason)
65+}
66+
67+// HandlePaneAction processes pane mode actions (close, split, etc.)
68+// Called from keyboard bridge when user presses action keys in pane mode
69+func (wm *WorkspaceManager) HandlePaneAction(action string) {
70+ wm.paneMutex.Lock()
71+
72+ if !wm.paneModeActive {
73+ wm.paneMutex.Unlock()
74+ log.Printf("[pane-mode] Ignoring action '%s': pane mode not active", action)
75+ return
76+ }
77+
78+ activePane := wm.paneModeActivePane
79+ wm.paneMutex.Unlock()
80+
81+ if activePane == nil {
82+ log.Printf("[pane-mode] Ignoring action '%s': no active pane", action)
83+ wm.ExitPaneMode("no-active-pane")
84+ return
85+ }
86+
87+ log.Printf("[pane-mode] Handling action '%s' on pane %p", action, activePane)
88+
89+ switch action {
90+ case "close":
91+ wm.handlePaneClose(activePane)
92+ case "split-right", "split-left", "split-up", "split-down":
93+ wm.handlePaneSplit(activePane, action)
94+ case "stack":
95+ wm.handlePaneStack(activePane)
96+ default:
97+ log.Printf("[pane-mode] Unknown action: %s", action)
98+ }
99+
100+ // Exit pane mode after action
101+ wm.ExitPaneMode(action)
102+}
103+
104+// handlePaneClose closes the pane that has pane mode active
105+func (wm *WorkspaceManager) handlePaneClose(node *paneNode) {
106+ if node == nil {
107+ return
108+ }
109+
110+ log.Printf("[pane-mode] Closing pane %p", node)
111+
112+ // Notify JavaScript first so it can show toast
113+ wm.dispatchPaneModeEvent("action", "close")
114+
115+ // Close the pane
116+ if err := wm.ClosePane(node); err != nil {
117+ log.Printf("[pane-mode] Failed to close pane: %v", err)
118+ }
119+}
120+
121+// handlePaneSplit splits the pane in the specified direction
122+func (wm *WorkspaceManager) handlePaneSplit(node *paneNode, action string) {
123+ if node == nil {
124+ return
125+ }
126+
127+ // Extract direction from action (e.g., "split-right" -> "right")
128+ var direction string
129+ switch action {
130+ case "split-right":
131+ direction = "right"
132+ case "split-left":
133+ direction = "left"
134+ case "split-up":
135+ direction = "up"
136+ case "split-down":
137+ direction = "down"
138+ default:
139+ log.Printf("[pane-mode] Invalid split action: %s", action)
140+ return
141+ }
142+
143+ log.Printf("[pane-mode] Splitting pane %p in direction %s", node, direction)
144+
145+ // Notify JavaScript first so it can show toast
146+ wm.dispatchPaneModeEvent("action", action)
147+
148+ // Perform the split (pass nil so it creates a fresh pane, not popup mode)
149+ newNode, err := wm.splitNode(node, direction, nil)
150+ if err != nil {
151+ log.Printf("[pane-mode] Failed to split pane: %v", err)
152+ return
153+ }
154+
155+ // Load initial URL in the new pane so scripts execute
156+ wm.clonePaneState(node, newNode)
157+
158+ log.Printf("[pane-mode] Split successful: direction=%s", direction)
159+}
160+
161+// handlePaneStack creates a stacked pane
162+func (wm *WorkspaceManager) handlePaneStack(node *paneNode) {
163+ if node == nil {
164+ return
165+ }
166+
167+ log.Printf("[pane-mode] Stacking pane %p", node)
168+
169+ // Notify JavaScript first so it can show toast
170+ wm.dispatchPaneModeEvent("action", "stack")
171+
172+ // Perform the stack operation
173+ newNode, err := wm.stackedPaneManager.StackPane(node)
174+ if err != nil {
175+ log.Printf("[pane-mode] Failed to stack pane: %v", err)
176+ return
177+ }
178+
179+ // Load initial URL in the new pane so scripts execute
180+ wm.clonePaneState(node, newNode)
181+
182+ log.Printf("[pane-mode] Stack successful")
183+}
184+
185+// dispatchPaneModeEvent sends a pane mode event to JavaScript for UI updates
186+func (wm *WorkspaceManager) dispatchPaneModeEvent(event string, detail string) {
187+ if wm.paneModeActivePane == nil || wm.paneModeActivePane.pane == nil || wm.paneModeActivePane.pane.webView == nil {
188+ return
189+ }
190+
191+ view := wm.paneModeActivePane.pane.webView
192+ data := map[string]interface{}{
193+ "event": event,
194+ "detail": detail,
195+ }
196+
197+ if err := view.DispatchCustomEvent("dumber:pane-mode", data); err != nil {
198+ log.Printf("[pane-mode] Failed to dispatch event: %v", err)
199+ }
200+}
201+
202+// applyPaneModeBorder applies pane mode visual indicator using GTK margins
203+func (wm *WorkspaceManager) applyPaneModeBorder() {
204+ if wm.root == nil || wm.root.container == nil || wm.window == nil {
205+ return
206+ }
207+
208+ // Save the container reference so we can remove margins from it later
209+ // (even if wm.root.container changes due to splits)
210+ wm.paneModeContainer = wm.root.container
211+
212+ // Apply 4px margins to root container to create space for the border
213+ webkit.WidgetSetMargin(wm.root.container, 4)
214+
215+ // Add CSS class to window for background color (the "border" color shows in the margin space)
216+ webkit.WidgetAddCSSClass(wm.window.AsWindow(), "pane-mode-active")
217+
218+ // Force resize/allocation to apply margin changes immediately
219+ webkit.WidgetQueueResize(wm.root.container)
220+ webkit.WidgetQueueAllocate(wm.root.container)
221+
222+ // Queue redraw to show changes
223+ webkit.WidgetQueueDraw(wm.window.AsWindow())
224+ webkit.WidgetQueueDraw(wm.root.container)
225+
226+ log.Printf("[pane-mode] Applied border to workspace root using margins (container=%p)", wm.root.container)
227+}
228+
229+// removePaneModeBorder removes the pane mode visual indicator
230+func (wm *WorkspaceManager) removePaneModeBorder() {
231+ if wm.window == nil {
232+ return
233+ }
234+
235+ // Use the saved container reference (the one that had margins applied)
236+ // This is important because wm.root.container may have changed due to splits
237+ container := wm.paneModeContainer
238+ if container == nil {
239+ log.Printf("[pane-mode] Warning: no saved container, trying current root")
240+ if wm.root == nil || wm.root.container == nil {
241+ return
242+ }
243+ container = wm.root.container
244+ }
245+
246+ // Remove margins from the container that had them applied
247+ webkit.WidgetSetMargin(container, 0)
248+
249+ // Remove CSS class from window
250+ webkit.WidgetRemoveCSSClass(wm.window.AsWindow(), "pane-mode-active")
251+
252+ // Force resize/allocation to apply margin changes immediately
253+ webkit.WidgetQueueResize(container)
254+ webkit.WidgetQueueAllocate(container)
255+
256+ // Queue redraw to show changes
257+ webkit.WidgetQueueDraw(wm.window.AsWindow())
258+ webkit.WidgetQueueDraw(container)
259+
260+ // Clear the saved container reference
261+ wm.paneModeContainer = nil
262+
263+ log.Printf("[pane-mode] Removed border from workspace root (container=%p)", container)
264+}
M internal/app/browser/workspace_pane_ops.go +300 -490
@@ -6,6 +6,8 @@ import (
66 "log"
77
88 "github.com/bnema/dumber/pkg/webkit"
9+ "github.com/diamondburned/gotk4/pkg/gdk/v4"
10+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
911 )
1012
1113 // closeContext manages the context for a pane close operation
@@ -112,23 +114,29 @@ func (wm *WorkspaceManager) createPane(view *webkit.WebView) (*BrowserPane, erro
112114 // clonePaneState sets up a new pane with cloned state from another pane
113115 func (wm *WorkspaceManager) clonePaneState(_ *paneNode, target *paneNode) {
114116 if wm == nil || target == nil {
117+ log.Printf("[workspace] clonePaneState: wm or target is nil")
115118 return
116119 }
117120 if target.pane == nil || target.pane.webView == nil {
121+ log.Printf("[workspace] clonePaneState: target pane or webView is nil")
118122 return
119123 }
120124
125+ // Load about:blank and auto-open omnibox
121126 const blankURL = "about:blank"
122127
128+ log.Printf("[workspace] clonePaneState: loading %s in pane %p", blankURL, target.pane)
123129 if err := target.pane.webView.LoadURL(blankURL); err != nil {
124- log.Printf("[workspace] failed to prepare blank pane: %v", err)
130+ log.Printf("[workspace] failed to load blank page in new pane: %v", err)
131+ } else {
132+ log.Printf("[workspace] successfully initiated load of %s", blankURL)
125133 }
126-
127- // Omnibox will auto-open on about:blank via client-side detection
128134 }
129135
130-// safelyDetachControllersBeforeReparent removes GTK controllers that GTK will auto-clean up during reparenting.
131-// It marks nodes for reattachment once the widget hierarchy settles after the split operation.
136+// safelyDetachControllersBeforeReparent marks nodes for controller reattachment after reparenting.
137+// GTK4 automatically removes controllers when widgets are unparented, so we just clear our
138+// references and mark for reattachment. Manually removing controllers causes GTK bloom filter
139+// corruption (double-removal assertion failure).
132140 func (wm *WorkspaceManager) safelyDetachControllersBeforeReparent(node *paneNode) {
133141 if wm == nil || node == nil {
134142 return
@@ -139,15 +147,18 @@ func (wm *WorkspaceManager) safelyDetachControllersBeforeReparent(node *paneNode
139147 return
140148 }
141149
142- if target.hoverToken != 0 {
150+ // Just mark for reattachment and clear our references
151+ // GTK4 will auto-remove controllers during unparent
152+ if target.hoverToken != nil {
143153 target.pendingHoverReattach = true
144- wm.detachHover(target)
154+ target.hoverToken = nil // Clear reference, don't manually remove
155+ log.Printf("[workspace] Marked hover controller for reattach on pane %p (GTK will auto-remove)", target)
145156 }
146157
147- if wm.focusStateMachine != nil && target.focusControllerToken != 0 {
148- token := target.focusControllerToken
158+ if target.focusControllerToken != 0 {
149159 target.pendingFocusReattach = true
150- wm.focusStateMachine.detachGTKController(target, token)
160+ target.focusControllerToken = 0 // Clear reference, don't manually remove
161+ log.Printf("[workspace] Marked focus controller for reattach on pane %p (GTK will auto-remove)", target)
151162 }
152163 }
153164
@@ -284,311 +295,15 @@ func (wm *WorkspaceManager) removeFromAppPanes(pane *BrowserPane) {
284295 }
285296 }
286297
287-// insertPopupPane inserts a pre-created popup pane into the workspace
288-func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPane, direction string) error {
289- if target == nil {
290- return errors.New("insert target cannot be nil")
291- }
292-
293- // Accept both leaf panes and stacked panes as valid targets
294- if !target.isLeaf && !target.isStacked {
295- return errors.New("insert target must be a leaf pane or stacked pane")
296- }
297-
298- // For leaf panes, require a pane
299- if target.isLeaf && target.pane == nil {
300- return errors.New("leaf target missing pane")
301- }
302-
303- // For stacked panes, we trust the caller - production stacks have varied structures
304- // Do not validate internal stack structure here
305-
306- if newPane == nil || newPane.webView == nil {
307- return errors.New("new pane missing webview")
308- }
309-
310- if handler := newPane.MessageHandler(); handler != nil {
311- handler.SetWorkspaceObserver(wm)
312- }
313-
314- newContainer := newPane.webView.RootWidget()
315- if newContainer == 0 {
316- return errors.New("new pane missing container")
317- }
318-
319- webkit.WidgetSetHExpand(newContainer, true)
320- webkit.WidgetSetVExpand(newContainer, true)
321- webkit.WidgetRealizeInContainer(newContainer)
322-
323- // Also realize the WebView widget itself for proper popup rendering
324- webViewWidget := newPane.webView.Widget()
325- if webViewWidget != 0 {
326- webkit.WidgetRealizeInContainer(webViewWidget)
327- }
328-
329- // CRITICAL: Handle stacked panes correctly - same logic as splitNode
330- // When the target is inside a stacked pane, we need to split around the entire stack,
331- // not the individual pane within the stack
332- originalTarget := target
333- var stackContainer *paneNode
334- if target.parent != nil && target.parent.isStacked {
335- // Target is in a stack - we'll create the split around the entire stack
336- stackContainer = target.parent
337- log.Printf("[workspace] target is in stack, will split around the stack: originalTarget=%p stackContainer=%p", originalTarget, stackContainer)
338- }
339-
340- // Determine what we're actually splitting
341- var splitTarget *paneNode
342- var splitTargetContainer uintptr
343-
344- if stackContainer != nil {
345- // Case: splitting from inside a stack - we split around the entire stack
346- splitTarget = stackContainer
347- splitTargetContainer = stackContainer.container
348- log.Printf("[workspace] splitting around stack: stackContainer=%p", splitTarget)
349- } else {
350- // Case: normal split from a simple pane or direct stack target
351- splitTarget = target
352- splitTargetContainer = target.container
353- log.Printf("[workspace] normal split from pane: target=%p", splitTarget)
354- }
355-
356- if splitTargetContainer == 0 {
357- return errors.New("split target missing container")
358- }
359-
360- orientation, existingFirst := mapDirection(direction)
361- log.Printf("[workspace] inserting popup direction=%s orientation=%v existingFirst=%v splitTarget.parent=%p", direction, orientation, existingFirst, splitTarget.parent)
362-
363- paned := webkit.NewPaned(orientation)
364- if paned == 0 {
365- return errors.New("failed to create GtkPaned")
366- }
367- webkit.WidgetSetHExpand(paned, true)
368- webkit.WidgetSetVExpand(paned, true)
369- // For popup panes: preserve existing pane size, let popup take remaining space
370- webkit.PanedSetResizeStart(paned, false) // existing pane keeps its size
371- webkit.PanedSetResizeEnd(paned, true) // popup can resize
372- log.Printf("[workspace] configured paned for popup: start=fixed, end=flexible")
373-
374- newLeaf := &paneNode{
375- pane: newPane,
376- isLeaf: true,
377- }
378- wm.initializePaneWidgets(newLeaf, newContainer)
379-
380- split := &paneNode{
381- parent: splitTarget.parent,
382- orientation: orientation,
383- isLeaf: false,
384- }
385- wm.initializePaneWidgets(split, paned)
386-
387- parent := split.parent
388-
389- // CRITICAL: Capture parent paned's divider position BEFORE reparenting to preserve layout
390- // Only attempt this if the parent container is actually a paned widget
391- var parentDividerPos int
392- if parent != nil && parent.container != 0 && webkit.WidgetIsValid(parent.container) && webkit.IsPaned(parent.container) {
393- parentDividerPos = webkit.PanedGetPosition(parent.container)
394- log.Printf("[workspace] captured parent divider position: %d", parentDividerPos)
395- } else if parent != nil && parent.container != 0 {
396- log.Printf("[workspace] parent container is not a paned widget (likely a stacked container)")
397- }
398-
399- // Clear CSS classes before reparenting to avoid GTK bloom filter corruption
400- if splitTargetContainer != 0 && webkit.WidgetIsValid(splitTargetContainer) {
401- // Remove active border class if present (prevents GTK bloom filter corruption during unparent)
402- if webkit.WidgetHasCSSClass(splitTargetContainer, activePaneClass) {
403- webkit.WidgetRemoveCSSClass(splitTargetContainer, activePaneClass)
404- }
405-
406- // CRITICAL: Hide widget before reparenting to disconnect WebKitGTK rendering pipeline
407- // This forces WebKit to detach its compositor, preventing rendering corruption
408- webkit.WidgetHide(splitTargetContainer)
409- log.Printf("[workspace] Hidden split target container before reparenting: %#x", splitTargetContainer)
410- }
411-
412- // Prevent GTK from auto-removing controllers while the widget is temporarily unparented.
413- wm.safelyDetachControllersBeforeReparent(splitTarget)
414-
415- // Detach existing container from its current GTK parent before inserting into new paned.
416- if parent == nil {
417- // Split target is the root - remove it from the window
418- log.Printf("[workspace] removing split target container=%#x from window", splitTargetContainer)
419- if wm.window != nil {
420- wm.window.SetChild(0)
421- }
422- // GTK4 will auto-unparent when we add to new paned - no manual unparent needed
423- } else if parent.container != 0 {
424- // Split target has a parent paned - unparent it from there
425- log.Printf("[workspace] unparenting split target container from parent paned")
426- if webkit.WidgetIsValid(parent.container) {
427- log.Printf("[workspace] executing unparent: parentPtr=%#x", parent.container)
428- if parent.left == splitTarget {
429- webkit.PanedSetStartChild(parent.container, 0)
430- } else if parent.right == splitTarget {
431- webkit.PanedSetEndChild(parent.container, 0)
432- }
433- webkit.WidgetQueueAllocate(parent.container)
434- }
435- }
436-
437- // Add both containers to the new paned
438- if existingFirst {
439- split.left = splitTarget
440- split.right = newLeaf
441- webkit.PanedSetStartChild(paned, splitTargetContainer)
442- webkit.PanedSetEndChild(paned, newContainer)
443- log.Printf("[workspace] added splitTarget=%#x as start child, new=%#x as end child", splitTargetContainer, newContainer)
444- } else {
445- split.left = newLeaf
446- split.right = splitTarget
447- webkit.PanedSetStartChild(paned, newContainer)
448- webkit.PanedSetEndChild(paned, splitTargetContainer)
449- log.Printf("[workspace] added new=%#x as start child, splitTarget=%#x as end child", newContainer, splitTargetContainer)
450- }
451-
452- splitTarget.parent = split
453- newLeaf.parent = split
454-
455- if parent == nil {
456- wm.root = split
457- if wm.window != nil {
458- wm.window.SetChild(paned)
459- webkit.WidgetQueueAllocate(paned)
460- log.Printf("[workspace] paned set as window child: paned=%#x", paned)
461- }
462- } else {
463- if webkit.WidgetIsValid(parent.container) {
464- log.Printf("[workspace] executing parent reparent: parentPtr=%#x, paned=%#x", parent.container, paned)
465- if parent.left == splitTarget {
466- parent.left = split
467- webkit.PanedSetStartChild(parent.container, paned)
468- } else if parent.right == splitTarget {
469- parent.right = split
470- webkit.PanedSetEndChild(parent.container, paned)
471- }
472- webkit.WidgetQueueAllocate(parent.container)
473- webkit.WidgetQueueAllocate(paned)
474- log.Printf("[workspace] paned inserted into parent successfully")
475-
476- // CRITICAL: Restore parent paned's divider position to preserve existing layout
477- // Only attempt this if we captured a position and the parent is actually a paned widget
478- if parentDividerPos > 0 && webkit.IsPaned(parent.container) {
479- webkit.PanedSetPosition(parent.container, parentDividerPos)
480- log.Printf("[workspace] restored parent divider position: %d", parentDividerPos)
481- }
482- }
483- }
484-
485- webkit.WidgetShow(paned)
486-
487- // Force GTK to re-layout the new GtkPaned container once both children are attached.
488- wm.scheduleIdleGuarded(func() bool {
489- if split == nil || !split.widgetValid {
490- return false
491- }
492- if split.container != 0 && webkit.WidgetIsValid(split.container) {
493- webkit.WidgetQueueResize(split.container)
494- webkit.WidgetQueueDraw(split.container)
495- }
496- return false
497- }, split)
498-
499- // CRITICAL: Set initial 50/50 split position after showing paned
500- // GTK needs the widget to be realized before we can set position based on allocation
501- // Schedule this to run after GTK has allocated space to the paned
502- wm.scheduleIdleGuarded(func() bool {
503- if !webkit.WidgetIsValid(paned) {
504- return false
505- }
506- // Get paned allocation to calculate 50% position
507- alloc := webkit.WidgetGetAllocation(paned)
508- var splitPos int
509- if orientation == webkit.OrientationHorizontal {
510- splitPos = alloc.Width / 2
511- } else {
512- splitPos = alloc.Height / 2
513- }
514- if splitPos > 0 {
515- webkit.PanedSetPosition(paned, splitPos)
516- log.Printf("[workspace] Set initial paned position to 50%%: %d (orientation=%d, size=%dx%d)", splitPos, orientation, alloc.Width, alloc.Height)
517- }
518- return false
519- }, split)
520-
521- // CRITICAL: Show the split target container and force GTK to recreate rendering surface after reparenting
522- // This reconnects WebKitGTK's rendering pipeline and fixes compositor sync issues
523- if splitTarget != nil && splitTarget.container != 0 {
524- wm.scheduleIdleGuarded(func() bool {
525- if splitTarget == nil || !splitTarget.widgetValid || splitTarget.container == 0 {
526- return false
527- }
528- // Show widget to reconnect WebKit rendering pipeline
529- webkit.WidgetShow(splitTarget.container)
530- // Force GTK to recalculate size and recreate rendering surface
531- webkit.WidgetQueueResize(splitTarget.container)
532- webkit.WidgetQueueDraw(splitTarget.container)
533- log.Printf("[workspace] Shown and queued resize+draw for split target container after reparenting")
534- return false
535- }, splitTarget)
536- }
537-
538- wm.viewToNode[newPane.webView] = newLeaf
539- wm.ensureHover(newLeaf)
540- wm.ensureHover(splitTarget)
541- wm.app.panes = append(wm.app.panes, newPane)
542- if newPane.zoomController != nil {
543- newPane.zoomController.ApplyInitialZoom()
544- }
545-
546- // Update CSS classes for all panes now that we have multiple panes
547-
548- reattachTargets := []*paneNode{splitTarget}
549- if splitTarget != nil && splitTarget.isStacked {
550- reattachTargets = append(reattachTargets, splitTarget.stackedPanes...)
551- }
552- guardNodes := append([]*paneNode{newLeaf}, reattachTargets...)
553- wm.scheduleIdleGuarded(func() bool {
554- if newLeaf == nil || !newLeaf.widgetValid {
555- return false
556- }
557- if newContainer != 0 && webkit.WidgetIsValid(newContainer) {
558- webkit.WidgetShow(newContainer)
559- }
560- // Attach GTK focus controller to new pane
561- if wm.focusStateMachine != nil {
562- wm.focusStateMachine.attachGTKController(newLeaf)
563- }
564-
565- for _, candidate := range reattachTargets {
566- if candidate == nil || !candidate.widgetValid {
567- continue
568- }
569- if candidate.pendingHoverReattach {
570- wm.ensureHover(candidate)
571- if candidate.hoverToken != 0 {
572- candidate.pendingHoverReattach = false
573- }
574- }
575- if candidate.pendingFocusReattach && wm.focusStateMachine != nil {
576- wm.focusStateMachine.attachGTKController(candidate)
577- if candidate.focusControllerToken != 0 {
578- candidate.pendingFocusReattach = false
579- }
580- }
581- }
582-
583- wm.SetActivePane(newLeaf, SourceSplit)
584- return false
585- }, guardNodes...)
586-
587- return nil
588-}
298+// insertPopupPane has been REMOVED - all popup insertion now uses splitNode() with existing pane parameter.
299+// This eliminates widget reparenting of existing WebViews, which was causing rendering corruption.
300+// See splitNode() for the unified implementation.
589301
590302 // splitNode splits a target pane into two panes in the specified direction
591-func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*paneNode, error) {
303+// splitNode splits a target pane into two panes in the specified direction.
304+// If existingPane is provided, it will be used instead of creating a new WebView.
305+// This allows reusing the function for both normal splits (nil) and popup insertion (popup pane).
306+func (wm *WorkspaceManager) splitNode(target *paneNode, direction string, existingPane *BrowserPane) (*paneNode, error) {
592307 if target == nil || !target.isLeaf || target.pane == nil {
593308 return nil, errors.New("split target must be a leaf pane")
594309 }
@@ -605,7 +320,7 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
605320
606321 // Determine what we're actually splitting
607322 var splitTarget *paneNode
608- var splitTargetContainer uintptr
323+ var splitTargetContainer gtk.Widgetter
609324
610325 if stackContainer != nil {
611326 // Case: splitting from inside a stack - we split around the entire stack
@@ -619,14 +334,25 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
619334 log.Printf("[workspace] normal split from pane: target=%p", splitTarget)
620335 }
621336
622- newView, err := wm.createWebView()
623- if err != nil {
624- return nil, err
625- }
337+ // Use provided pane or create new one
338+ var newPane *BrowserPane
626339
627- newPane, err := wm.createPane(newView)
628- if err != nil {
629- return nil, err
340+ if existingPane != nil {
341+ // Reuse existing pane (for popup insertion)
342+ newPane = existingPane
343+ log.Printf("[workspace] Using existing pane for split: %p", newPane)
344+ } else {
345+ // Create new WebView and pane (for normal splits)
346+ newView, err := wm.createWebView()
347+ if err != nil {
348+ return nil, err
349+ }
350+
351+ newPane, err = wm.createPane(newView)
352+ if err != nil {
353+ return nil, err
354+ }
355+ log.Printf("[workspace] Created new pane for split: %p", newPane)
630356 }
631357
632358 if handler := newPane.MessageHandler(); handler != nil {
@@ -634,48 +360,47 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
634360 }
635361
636362 newContainer := newPane.webView.RootWidget()
637- if newContainer == 0 {
363+ if newContainer == nil {
638364 return nil, errors.New("new pane missing container")
639365 }
640366 webkit.WidgetSetHExpand(newContainer, true)
641367 webkit.WidgetSetVExpand(newContainer, true)
642- webkit.WidgetRealizeInContainer(newContainer)
643368
644369 // Use the determined split target container
645- if splitTargetContainer == 0 {
370+ if splitTargetContainer == nil {
646371 return nil, errors.New("split target missing container")
647372 }
648373
649374 // Only apply GTK operations if this is a simple widget (leaf pane)
650375 // For stacked containers, we need to be more careful
651376 if splitTarget.isLeaf {
652- if webkit.WidgetIsValid(splitTargetContainer) {
377+ if splitTargetContainer != nil {
653378 webkit.WidgetSetHExpand(splitTargetContainer, true)
654379 webkit.WidgetSetVExpand(splitTargetContainer, true)
655- webkit.WidgetRealizeInContainer(splitTargetContainer)
656380 }
657381 } else if splitTarget.isStacked {
658382 // Stack containers need the same setup as regular panes for proper splitting
659- if webkit.WidgetIsValid(splitTargetContainer) {
383+ if splitTargetContainer != nil {
660384 webkit.WidgetSetHExpand(splitTargetContainer, true)
661385 webkit.WidgetSetVExpand(splitTargetContainer, true)
662- webkit.WidgetRealizeInContainer(splitTargetContainer)
663- log.Printf("[workspace] configured stack wrapper for split: %#x", splitTargetContainer)
386+ log.Printf("[workspace] configured stack wrapper for split: %p", splitTargetContainer)
664387 }
665388 }
666389
667390 orientation, existingFirst := mapDirection(direction)
668- log.Printf("[workspace] splitting direction=%s orientation=%v existingFirst=%v splitTarget.parent=%p splitTarget.isStacked=%v",
669- direction, orientation, existingFirst, splitTarget.parent, splitTarget.isStacked)
391+ log.Printf("[workspace] splitting direction=%s orientation=%v existingFirst=%v splitTarget.parent=%p splitTarget.isStacked=%v isPopup=%v",
392+ direction, orientation, existingFirst, splitTarget.parent, splitTarget.isStacked, existingPane != nil)
670393
671- paned := webkit.NewPaned(orientation)
672- if paned == 0 {
394+ paned := gtk.NewPaned(orientation)
395+ if paned == nil {
673396 return nil, errors.New("failed to create GtkPaned")
674397 }
675- webkit.WidgetSetHExpand(paned, true)
676- webkit.WidgetSetVExpand(paned, true)
677- webkit.PanedSetResizeStart(paned, true)
678- webkit.PanedSetResizeEnd(paned, true)
398+ paned.SetHExpand(true)
399+ paned.SetVExpand(true)
400+
401+ // Allow both children to resize; avoids shrinking existing pane to zero during root splits
402+ paned.SetResizeStartChild(true)
403+ paned.SetResizeEndChild(true)
679404
680405 newLeaf := &paneNode{
681406 pane: newPane,
@@ -693,37 +418,35 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
693418 parent := split.parent
694419
695420 // Clear CSS classes before reparenting to avoid GTK bloom filter corruption
696- if splitTargetContainer != 0 && webkit.WidgetIsValid(splitTargetContainer) {
421+ // NOTE: The active class is correctly removed here - focus will transfer to the NEW pane
422+ // The focus state machine will apply the active class to the new pane via SetActivePane()
423+ if splitTargetContainer != nil {
697424 // Remove active border class if present (prevents GTK bloom filter corruption during unparent)
698425 if webkit.WidgetHasCSSClass(splitTargetContainer, activePaneClass) {
699426 webkit.WidgetRemoveCSSClass(splitTargetContainer, activePaneClass)
427+ log.Printf("[workspace] removed active class from split target (will transfer to new pane)")
700428 }
701429 }
702430
703431 // Prevent GTK from auto-dropping controllers while the widget is temporarily unparented.
704432 wm.safelyDetachControllersBeforeReparent(splitTarget)
705433
706- // Detach split target container from its current GTK parent before inserting into new paned.
707- if parent == nil {
708- // Split target is the root - remove it from the window
709- log.Printf("[workspace] removing split target container=%#x from window", splitTargetContainer)
710- if wm.window != nil {
711- wm.window.SetChild(0)
712- }
713- // GTK4 will auto-unparent when we add to new paned - no manual unparent needed
714- } else if parent.container != 0 {
434+ // For non-root splits, unparent from parent paned
435+ if parent != nil && parent.container != nil {
715436 // Split target has a parent paned - remove it (automatically unparents in GTK4)
716- log.Printf("[workspace] unparenting split target container=%#x from parent paned=%#x", splitTargetContainer, parent.container)
717- if webkit.WidgetIsValid(parent.container) {
437+ log.Printf("[workspace] unparenting split target container=%p from parent paned=%p", splitTargetContainer, parent.container)
438+ if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil {
718439 if parent.left == splitTarget {
719- webkit.PanedSetStartChild(parent.container, 0)
440+ parentPaned.SetStartChild(nil)
720441 } else if parent.right == splitTarget {
721- webkit.PanedSetEndChild(parent.container, 0)
442+ parentPaned.SetEndChild(nil)
722443 }
723- // GTK4 automatically unparents when we set paned child to 0 - no manual unparent needed
444+ // GTK4 automatically unparents when we set paned child to nil - no manual unparent needed
724445 webkit.WidgetQueueAllocate(parent.container)
725446 }
726447 }
448+ // NOTE: For root splits (parent == nil), we DON'T unparent here.
449+ // We'll unparent immediately before adding to paned below to minimize orphan time.
727450
728451 // Set up the tree structure first
729452 if existingFirst {
@@ -750,81 +473,66 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
750473
751474 // GTK4 handles widget operations automatically - no need to force redraw
752475
753- // Add both containers to the new paned
754- if existingFirst {
755- if webkit.WidgetIsValid(splitTargetContainer) {
756- webkit.PanedSetStartChild(paned, splitTargetContainer)
757- webkit.PanedSetEndChild(paned, newContainer)
758- log.Printf("[workspace] added splitTarget=%#x as start child, new=%#x as end child", splitTargetContainer, newContainer)
759- }
760- } else {
761- if webkit.WidgetIsValid(splitTargetContainer) {
762- webkit.PanedSetStartChild(paned, newContainer)
763- webkit.PanedSetEndChild(paned, splitTargetContainer)
764- log.Printf("[workspace] added new=%#x as start child, splitTarget=%#x as end child", newContainer, splitTargetContainer)
765- }
766- }
767-
768- // Attach the new paned to its parent
476+ // Handle root split: attach paned to window FIRST, then let GTK handle reparenting
769477 if parent == nil {
478+ // ROOT SPLIT: Attach empty paned to window, then add children
479+ // GTK will automatically reparent the existing widget when we call SetStartChild/SetEndChild
770480 if wm.window != nil {
481+ // Clear any existing window child (the splitTargetContainer)
482+ wm.window.SetChild(nil)
483+
484+ // Attach the new paned to window FIRST
771485 wm.window.SetChild(paned)
772- webkit.WidgetQueueAllocate(paned)
773- log.Printf("[workspace] paned set as window child: paned=%#x", paned)
486+ log.Printf("[workspace] ROOT SPLIT: attached paned %p to window", paned)
487+ }
488+
489+ // Now add children to the paned - GTK will handle reparenting automatically
490+ if existingFirst {
491+ paned.SetStartChild(splitTargetContainer)
492+ paned.SetEndChild(newContainer)
493+ log.Printf("[workspace] ROOT SPLIT: added splitTarget=%p (start), new=%p (end)", splitTargetContainer, newContainer)
494+ } else {
495+ paned.SetStartChild(newContainer)
496+ paned.SetEndChild(splitTargetContainer)
497+ log.Printf("[workspace] ROOT SPLIT: added new=%p (start), splitTarget=%p (end)", newContainer, splitTargetContainer)
774498 }
775499 } else {
776- if webkit.WidgetIsValid(parent.container) {
500+ // NON-ROOT SPLIT: Standard approach
501+
502+ // Add both containers to the new paned
503+ if existingFirst {
504+ if splitTargetContainer != nil {
505+ paned.SetStartChild(splitTargetContainer)
506+ paned.SetEndChild(newContainer)
507+ log.Printf("[workspace] added splitTarget=%p as start child, new=%p as end child", splitTargetContainer, newContainer)
508+ }
509+ } else {
510+ if splitTargetContainer != nil {
511+ paned.SetStartChild(newContainer)
512+ paned.SetEndChild(splitTargetContainer)
513+ log.Printf("[workspace] added new=%p as start child, splitTarget=%p as end child", newContainer, splitTargetContainer)
514+ }
515+ }
516+
517+ // Attach the new paned to its parent
518+ if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil {
777519 if parent.left == split {
778- webkit.PanedSetStartChild(parent.container, paned)
520+ parentPaned.SetStartChild(paned)
779521 } else if parent.right == split {
780- webkit.PanedSetEndChild(parent.container, paned)
522+ parentPaned.SetEndChild(paned)
781523 }
782524 webkit.WidgetQueueAllocate(parent.container)
783- webkit.WidgetQueueAllocate(paned)
784- log.Printf("[workspace] paned inserted into parent=%#x", parent.container)
525+ paned.QueueAllocate()
526+ log.Printf("[workspace] paned inserted into parent=%p", parent.container)
785527 }
786528 }
787529
788- webkit.WidgetShow(paned)
530+ paned.Show()
789531
790- // CRITICAL: Set initial 50/50 split position after showing paned
791- // GTK needs the widget to be realized before we can set position based on allocation
792- // Schedule this to run after GTK has allocated space to the paned
793- wm.scheduleIdleGuarded(func() bool {
794- if !webkit.WidgetIsValid(paned) {
795- return false
796- }
797- // Get paned allocation to calculate 50% position
798- alloc := webkit.WidgetGetAllocation(paned)
799- var splitPos int
800- if orientation == webkit.OrientationHorizontal {
801- splitPos = alloc.Width / 2
802- } else {
803- splitPos = alloc.Height / 2
804- }
805- if splitPos > 0 {
806- webkit.PanedSetPosition(paned, splitPos)
807- log.Printf("[workspace] Set initial paned position to 50%%: %d (orientation=%d, size=%dx%d)", splitPos, orientation, alloc.Width, alloc.Height)
808- }
809- return false
810- }, split)
532+ // Synchronize the divider once the paned has a meaningful allocation.
533+ wm.syncPanedDivider(paned, orientation, split)
811534
812- // CRITICAL: Show the existing container and force GTK to recreate rendering surface after reparenting
813- // This reconnects WebKitGTK's rendering pipeline and fixes compositor sync issues
814- if target != nil && target.container != 0 {
815- wm.scheduleIdleGuarded(func() bool {
816- if target == nil || !target.widgetValid || target.container == 0 {
817- return false
818- }
819- // Show widget to reconnect WebKit rendering pipeline
820- webkit.WidgetShow(target.container)
821- // Force GTK to recalculate size and recreate rendering surface
822- webkit.WidgetQueueResize(target.container)
823- webkit.WidgetQueueDraw(target.container)
824- log.Printf("[workspace] Shown and queued resize+draw for target container after reparenting")
825- return false
826- }, target)
827- }
535+ // GTK4 automatically handles widget rendering after reparenting - no manual updates needed
828536
829537 wm.viewToNode[newPane.webView] = newLeaf
830538 wm.ensureHover(newLeaf)
@@ -838,7 +546,14 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
838546 }
839547 }
840548
841- wm.app.panes = append(wm.app.panes, newPane)
549+ // Only add to app.panes if this is a new pane (not an existing popup pane)
550+ if existingPane == nil {
551+ wm.app.panes = append(wm.app.panes, newPane)
552+ } else {
553+ // For existing popup panes, they're already in app.panes from createPane() in handlePopupReadyToShow
554+ log.Printf("[workspace] Skipping app.panes append for existing popup pane (already added)")
555+ }
556+
842557 if newPane.zoomController != nil {
843558 newPane.zoomController.ApplyInitialZoom()
844559 }
@@ -854,9 +569,27 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
854569 if newLeaf == nil || !newLeaf.widgetValid {
855570 return false
856571 }
857- if newContainer != 0 && webkit.WidgetIsValid(newContainer) {
572+ if newContainer != nil {
858573 webkit.WidgetShow(newContainer)
859574 }
575+
576+ // CRITICAL: For root splits, also show the split target container after reparenting
577+ // When the split target is reparented from window → paned, GTK doesn't preserve visibility
578+ if parent == nil && splitTargetContainer != nil {
579+ webkit.WidgetShow(splitTargetContainer)
580+ webkit.WidgetQueueAllocate(splitTargetContainer)
581+ webkit.WidgetQueueDraw(splitTargetContainer)
582+ }
583+
584+ // Ensure the underlying WebView widget is visible after reparenting
585+ if parent == nil && splitTarget != nil && splitTarget.pane != nil && splitTarget.pane.webView != nil {
586+ if webViewWidget := splitTarget.pane.webView.AsWidget(); webViewWidget != nil {
587+ webkit.WidgetShow(webViewWidget)
588+ webkit.WidgetQueueAllocate(webViewWidget)
589+ webkit.WidgetQueueDraw(webViewWidget)
590+ }
591+ }
592+
860593 // Attach GTK focus controller to new pane
861594 if wm.focusStateMachine != nil {
862595 wm.focusStateMachine.attachGTKController(newLeaf)
@@ -868,7 +601,7 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
868601 }
869602 if candidate.pendingHoverReattach {
870603 wm.ensureHover(candidate)
871- if candidate.hoverToken != 0 {
604+ if candidate.hoverToken != nil {
872605 candidate.pendingHoverReattach = false
873606 }
874607 }
@@ -931,25 +664,25 @@ func (wm *WorkspaceManager) swapContainers(grand *paneNode, sibling *paneNode) {
931664 return
932665 }
933666 // Clear CSS classes before reparenting to avoid GTK bloom filter corruption
934- if sibling.container != 0 && webkit.WidgetIsValid(sibling.container) {
667+ if sibling.container != nil {
935668 if webkit.WidgetHasCSSClass(sibling.container, activePaneClass) {
936669 webkit.WidgetRemoveCSSClass(sibling.container, activePaneClass)
937670 }
938671 }
939672
940- if webkit.WidgetIsValid(grand.container) && webkit.WidgetIsValid(sibling.container) {
673+ if grandPaned, ok := grand.container.(*gtk.Paned); ok && grandPaned != nil && sibling.container != nil {
941674 // GTK4 PanedSetStartChild/EndChild auto-unparent from current parent
942675 // Focus was already cleared before unparenting closed pane
943- if parent := webkit.WidgetGetParent(sibling.container); parent != 0 && parent != grand.container {
944- log.Printf("[workspace] widget %#x reparenting from %#x to %#x (GTK4 will auto-unparent)", sibling.container, parent, grand.container)
676+ if parent := webkit.WidgetGetParent(sibling.container); parent != nil && parent != grand.container {
677+ log.Printf("[workspace] widget %p reparenting from %p to %p (GTK4 will auto-unparent)", sibling.container, parent, grand.container)
945678 }
946679
947680 if grand.left == sibling {
948- webkit.PanedSetStartChild(grand.container, sibling.container)
949- log.Printf("[workspace] swapContainers: set widget %#x as start child of paned %#x", sibling.container, grand.container)
681+ grandPaned.SetStartChild(sibling.container)
682+ log.Printf("[workspace] swapContainers: set widget %p as start child of paned %p", sibling.container, grand.container)
950683 } else {
951- webkit.PanedSetEndChild(grand.container, sibling.container)
952- log.Printf("[workspace] swapContainers: set widget %#x as end child of paned %#x", sibling.container, grand.container)
684+ grandPaned.SetEndChild(sibling.container)
685+ log.Printf("[workspace] swapContainers: set widget %p as end child of paned %p", sibling.container, grand.container)
953686 }
954687 webkit.WidgetQueueAllocate(grand.container)
955688 }
@@ -985,7 +718,7 @@ func (wm *WorkspaceManager) cascadePromotion(singleChildPaned *paneNode) {
985718 log.Printf("[workspace] Only child %p promoted to root", onlyChild)
986719
987720 // Attach to window
988- if onlyChild.container != 0 && webkit.WidgetIsValid(onlyChild.container) {
721+ if onlyChild.container != nil {
989722 wm.attachRoot(onlyChild)
990723 }
991724
@@ -1002,12 +735,12 @@ func (wm *WorkspaceManager) cascadePromotion(singleChildPaned *paneNode) {
1002735 log.Printf("[workspace] Only child %p attached to great-grandparent %p", onlyChild, greatGrandparent)
1003736
1004737 // Reparent widget in GTK
1005- if onlyChild.container != 0 && webkit.WidgetIsValid(onlyChild.container) && greatGrandparent.container != 0 && webkit.WidgetIsValid(greatGrandparent.container) {
738+ if greatGrandPaned, ok := greatGrandparent.container.(*gtk.Paned); ok && greatGrandPaned != nil && onlyChild.container != nil {
1006739 // GTK4 PanedSet*Child automatically handles unparenting
1007740 if greatGrandparent.left == onlyChild {
1008- webkit.PanedSetStartChild(greatGrandparent.container, onlyChild.container)
741+ greatGrandPaned.SetStartChild(onlyChild.container)
1009742 } else {
1010- webkit.PanedSetEndChild(greatGrandparent.container, onlyChild.container)
743+ greatGrandPaned.SetEndChild(onlyChild.container)
1011744 }
1012745
1013746 webkit.WidgetSetHExpand(onlyChild.container, true)
@@ -1018,7 +751,7 @@ func (wm *WorkspaceManager) cascadePromotion(singleChildPaned *paneNode) {
1018751 if onlyChild == nil || !onlyChild.widgetValid {
1019752 return false
1020753 }
1021- if onlyChild.container != 0 && webkit.WidgetIsValid(onlyChild.container) {
754+ if onlyChild.container != nil {
1022755 webkit.WidgetShow(onlyChild.container)
1023756 webkit.WidgetQueueResize(onlyChild.container)
1024757 webkit.WidgetQueueDraw(onlyChild.container)
@@ -1040,50 +773,33 @@ func (wm *WorkspaceManager) cascadePromotion(singleChildPaned *paneNode) {
1040773
1041774 // attachRoot attaches a node as the new window root
1042775 func (wm *WorkspaceManager) attachRoot(root *paneNode) {
1043- if root == nil || root.container == 0 || wm.window == nil {
1044- return
1045- }
1046- if !webkit.WidgetIsValid(root.container) {
776+ if root == nil || root.container == nil || wm.window == nil {
1047777 return
1048778 }
1049779
1050- // Hold a transient reference so GTK does not destroy the widget while detached
1051- releaseRef := webkit.WidgetRef(root.container)
1052- if !releaseRef {
1053- log.Printf("[workspace] WARNING: failed to acquire temporary ref for widget %#x; aborting attachRoot", root.container)
1054- return
1055- }
1056- defer func() {
1057- if webkit.WidgetIsValid(root.container) {
1058- webkit.WidgetUnref(root.container)
1059- }
1060- }()
780+ // Note: In gotk4, reference counting is handled automatically by Go's GC
781+ // No need for manual Ref()/Unref() calls
1061782
1062783 // Detach from previous container (paned, stack, etc.) before replacing the window child.
1063- if parent := webkit.WidgetGetParent(root.container); parent != 0 {
1064- log.Printf("[workspace] unparenting widget %#x from parent %#x before window attach", root.container, parent)
784+ // While GTK4 auto-unparents for paned operations, window.SetChild requires manual unparent
785+ if parent := webkit.WidgetGetParent(root.container); parent != nil {
786+ log.Printf("[workspace] unparenting widget %p from parent %p before window attach", root.container, parent)
1065787 webkit.WidgetUnparent(root.container)
1066- if !webkit.WidgetIsValid(root.container) {
1067- log.Printf("[workspace] WARNING: widget %#x became invalid during unparent; aborting attachRoot", root.container)
1068- return
1069- }
788+ // Note: In gotk4, the Go object reference remains valid after unparent
789+ // Only the GTK parent relationship is cleared
1070790 }
1071791
1072792 // Clear any existing window child to avoid GTK warnings when swapping roots.
1073- wm.window.SetChild(0)
793+ wm.window.SetChild(nil)
1074794
1075- if webkit.WidgetIsValid(root.container) {
795+ if root.container != nil {
1076796 webkit.WidgetSetHExpand(root.container, true)
1077797 webkit.WidgetSetVExpand(root.container, true)
1078798 }
1079799
1080- if !webkit.WidgetIsValid(root.container) {
1081- log.Printf("[workspace] WARNING: widget %#x invalid prior to window attach", root.container)
1082- return
1083- }
1084-
800+ // GTK4 will automatically unparent root.container from its previous parent when we attach it here
1085801 wm.window.SetChild(root.container)
1086- if webkit.WidgetIsValid(root.container) {
802+ if root.container != nil {
1087803 webkit.WidgetQueueAllocate(root.container)
1088804 webkit.WidgetShow(root.container)
1089805 webkit.WidgetQueueResize(root.container)
@@ -1094,7 +810,7 @@ func (wm *WorkspaceManager) attachRoot(root *paneNode) {
1094810 if root == nil || !root.widgetValid {
1095811 return false
1096812 }
1097- if root.container != 0 && webkit.WidgetIsValid(root.container) {
813+ if root.container != nil {
1098814 webkit.WidgetQueueResize(root.container)
1099815 webkit.WidgetQueueDraw(root.container)
1100816 }
@@ -1102,10 +818,10 @@ func (wm *WorkspaceManager) attachRoot(root *paneNode) {
1102818 }, root)
1103819
1104820 // Verify attachment succeeded
1105- if finalParent := webkit.WidgetGetParent(root.container); finalParent == 0 {
1106- log.Printf("[workspace] WARNING: widget %#x has no parent after SetChild", root.container)
821+ if finalParent := webkit.WidgetGetParent(root.container); finalParent == nil {
822+ log.Printf("[workspace] WARNING: widget %p has no parent after SetChild", root.container)
1107823 } else {
1108- log.Printf("[workspace] attachRoot successful: widget %#x now child of %#x", root.container, finalParent)
824+ log.Printf("[workspace] attachRoot successful: widget %p now child of %p", root.container, finalParent)
1109825 }
1110826 }
1111827
@@ -1138,9 +854,9 @@ func (wm *WorkspaceManager) cleanupPane(node *paneNode, generation uint) {
1138854 node.pane = nil
1139855 }
1140856
1141- if node.container != 0 {
857+ if node.container != nil {
1142858 // Container widget will be destroyed by GTK when unparented
1143- node.container = 0
859+ node.container = nil
1144860 }
1145861
1146862 node.pendingIdleHandles = nil
@@ -1157,9 +873,10 @@ func (wm *WorkspaceManager) decommissionParent(parent *paneNode, generation uint
1157873
1158874 // For branch nodes (paneds), destroy the widget
1159875 // Children have already been unparented during promotion/close, no need to clear them
1160- if !parent.isLeaf && parent.container != 0 && webkit.WidgetIsValid(parent.container) {
1161- webkit.WidgetHookDestroy(parent.container)
1162- log.Printf("[workspace] destroyed paned widget: %#x", parent.container)
876+ if !parent.isLeaf && parent.container != nil {
877+ // In gotk4, we don't need to manually destroy - GTK handles this when unparented
878+ // Just mark it for cleanup
879+ log.Printf("[workspace] marked paned widget for cleanup: %p", parent.container)
1163880 }
1164881
1165882 wm.cleanupPane(parent, generation)
@@ -1191,6 +908,8 @@ func (wm *WorkspaceManager) cleanupAndExit(node *paneNode) (*paneNode, error) {
1191908 log.Printf("[workspace] closing final pane; exiting browser")
1192909 wm.cleanupPane(node, wm.nextCleanupGeneration())
1193910 wm.detachHover(node)
911+
912+ // Gracefully quit the GTK main loop
1194913 webkit.QuitMainLoop()
1195914 return nil, nil
1196915 }
@@ -1223,7 +942,8 @@ func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) {
1223942 wm.detachFocus(node)
1224943
1225944 // STEP 3: Handle trivial exit cases
1226- if ctx.remaining == 1 {
945+ // Don't exit if closing a popup - popups aren't counted in app.panes
946+ if ctx.remaining == 1 && !node.isPopup {
1227947 return wm.cleanupAndExit(node)
1228948 }
1229949 if node == wm.root {
@@ -1237,36 +957,52 @@ func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) {
1237957
1238958 // Clear grandparent's focus child BEFORE unparenting closed pane
1239959 // GTK propagates focus events upward, so we must clear grandparent focus first
1240- if grandparent != nil && grandparent.container != 0 && webkit.WidgetIsValid(grandparent.container) {
1241- webkit.WidgetSetFocusChild(grandparent.container, 0)
960+ if grandparent != nil && grandparent.container != nil {
961+ webkit.WidgetSetFocusChild(grandparent.container, nil)
1242962 }
1243963
1244964 // Unparent the closed pane's container from the paned BEFORE promoting sibling
1245- if node.container != 0 && webkit.WidgetIsValid(node.container) {
965+ if node.container != nil {
1246966 if containerParent := webkit.WidgetGetParent(node.container); containerParent == parent.container {
1247- if parent.left == node {
1248- webkit.PanedSetStartChild(parent.container, 0)
1249- } else if parent.right == node {
1250- webkit.PanedSetEndChild(parent.container, 0)
967+ if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil {
968+ if parent.left == node {
969+ parentPaned.SetStartChild(nil)
970+ } else if parent.right == node {
971+ parentPaned.SetEndChild(nil)
972+ }
973+ log.Printf("[workspace] unparented closed pane container: %p", node.container)
974+ }
975+ }
976+ }
977+
978+ // STEP 5: Unparent sibling from parent paned BEFORE promotion
979+ // The sibling needs to be detached from the parent paned before we can attach it elsewhere
980+ if sibling.container != nil && parent.container != nil {
981+ if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil {
982+ if parent.left == sibling {
983+ parentPaned.SetStartChild(nil)
984+ } else if parent.right == sibling {
985+ parentPaned.SetEndChild(nil)
1251986 }
1252- log.Printf("[workspace] unparented closed pane container: %#x", node.container)
987+ log.Printf("[workspace] unparented sibling container %p from parent paned %p", sibling.container, parent.container)
1253988 }
1254989 }
1255990
1256- // STEP 5: Promote sibling in-place (tree structure)
991+ // STEP 6: Promote sibling in-place (tree structure)
1257992 wm.promoteSibling(grandparent, parent, sibling)
1258993
1259- // STEP 6: GTK handles all reparenting automatically
1260- // PanedSetStartChild/EndChild and window.SetChild auto-unparent widgets
994+ // STEP 7: GTK widget reparenting
995+ // For paned containers: GTK4 PanedSetStartChild/EndChild auto-unparent from current parent
996+ // For window: we already manually unparented above, so now we can safely attach
1261997 wm.swapContainers(grandparent, sibling)
1262998
1263999 // Reconnect the promoted sibling's rendering pipeline after GTK reparenting.
1264- if sibling != nil && sibling.container != 0 {
1000+ if sibling != nil && sibling.container != nil {
12651001 wm.scheduleIdleGuarded(func() bool {
12661002 if sibling == nil || !sibling.widgetValid {
12671003 return false
12681004 }
1269- if sibling.container != 0 && webkit.WidgetIsValid(sibling.container) {
1005+ if sibling.container != nil {
12701006 webkit.WidgetShow(sibling.container)
12711007 webkit.WidgetQueueResize(sibling.container)
12721008 webkit.WidgetQueueDraw(sibling.container)
@@ -1275,14 +1011,14 @@ func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) {
12751011 }, sibling)
12761012 }
12771013
1278- // STEP 7: Check if grandparent now has only one child (cascade promotion needed)
1014+ // STEP 8: Check if grandparent now has only one child (cascade promotion needed)
12791015 if grandparent != nil && ((grandparent.left == nil) != (grandparent.right == nil)) {
12801016 // Grandparent has exactly one child - it should be promoted too
12811017 log.Printf("[workspace] Grandparent %p now has only one child, cascading promotion", grandparent)
12821018 wm.cascadePromotion(grandparent)
12831019 }
12841020
1285- // STEP 8: Cleanup & focus
1021+ // STEP 9: Cleanup & focus
12861022 wm.cleanupPane(node, ctx.Generation())
12871023 wm.decommissionParent(parent, ctx.Generation())
12881024 if sibling != nil {
@@ -1335,17 +1071,17 @@ func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, e
13351071 }
13361072
13371073 // Update GTK widgets
1338- if webkit.WidgetIsValid(stack.parent.container) && webkit.WidgetIsValid(remaining.container) {
1074+ if parentPaned, ok := stack.parent.container.(*gtk.Paned); ok && parentPaned != nil && remaining.container != nil {
13391075 if stack.parent.left == remaining {
1340- webkit.PanedSetStartChild(stack.parent.container, remaining.container)
1076+ parentPaned.SetStartChild(remaining.container)
13411077 } else {
1342- webkit.PanedSetEndChild(stack.parent.container, remaining.container)
1078+ parentPaned.SetEndChild(remaining.container)
13431079 }
13441080 }
13451081 } else {
13461082 // Stack was root
13471083 wm.root = remaining
1348- if webkit.WidgetIsValid(remaining.container) {
1084+ if remaining.container != nil {
13491085 wm.window.SetChild(remaining.container)
13501086 }
13511087 }
@@ -1362,14 +1098,14 @@ func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, e
13621098 }
13631099
13641100 // Remove widget from GTK box
1365- if webkit.WidgetIsValid(stack.container) && webkit.WidgetIsValid(node.container) {
1366- webkit.BoxRemove(stack.container, node.container)
1101+ if stackBox, ok := stack.container.(*gtk.Box); ok && stackBox != nil && node.container != nil {
1102+ stackBox.Remove(node.container)
13671103 }
13681104
13691105 // Show new active pane
13701106 if stack.activeStackIndex >= 0 {
13711107 active := stack.stackedPanes[stack.activeStackIndex]
1372- if webkit.WidgetIsValid(active.container) {
1108+ if active.container != nil {
13731109 webkit.WidgetShow(active.container)
13741110 }
13751111 }
@@ -1379,3 +1115,77 @@ func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, e
13791115
13801116 return stack, nil
13811117 }
1118+
1119+// syncPanedDivider waits until a GtkPaned has a meaningful allocation before snapping
1120+// the divider to an even split. GTK often reports near-zero allocations for a few frames
1121+// after insertion, so we retry on map/tick/idle until the size stabilizes.
1122+func (wm *WorkspaceManager) syncPanedDivider(paned *gtk.Paned, orientation gtk.Orientation, guard *paneNode) {
1123+ if wm == nil || paned == nil {
1124+ return
1125+ }
1126+
1127+ const (
1128+ minDimension = 32
1129+ maxFrames = 120
1130+ )
1131+
1132+ setPosition := func() bool {
1133+ if guard != nil && !guard.widgetValid {
1134+ return true
1135+ }
1136+
1137+ alloc := paned.Allocation()
1138+ dimension := alloc.Width()
1139+ if orientation == gtk.OrientationVertical {
1140+ dimension = alloc.Height()
1141+ }
1142+
1143+ if dimension < minDimension {
1144+ return false
1145+ }
1146+
1147+ pos := dimension / 2
1148+ if pos <= 0 {
1149+ return false
1150+ }
1151+
1152+ paned.SetPosition(pos)
1153+ log.Printf("[workspace] Set paned position to 50%%: %d (orientation=%d, size=%dx%d)", pos, orientation, alloc.Width(), alloc.Height())
1154+ return true
1155+ }
1156+
1157+ // Attempt immediately with the current allocation.
1158+ if setPosition() {
1159+ return
1160+ }
1161+
1162+ var frames uint
1163+
1164+ paned.ConnectMap(func() {
1165+ setPosition()
1166+ })
1167+
1168+ paned.AddTickCallback(func(widget gtk.Widgetter, _ gdk.FrameClocker) bool {
1169+ if guard != nil && !guard.widgetValid {
1170+ return false
1171+ }
1172+
1173+ frames++
1174+ if setPosition() {
1175+ return false
1176+ }
1177+
1178+ if frames >= maxFrames {
1179+ alloc := paned.Allocation()
1180+ log.Printf("[workspace] Unable to stabilize paned position after %d frames (size=%dx%d)", frames, alloc.Width(), alloc.Height())
1181+ return false
1182+ }
1183+
1184+ return true
1185+ })
1186+
1187+ wm.scheduleIdleGuarded(func() bool {
1188+ setPosition()
1189+ return false
1190+ }, guard)
1191+}
M internal/app/browser/workspace_popup.go +330 -554
@@ -2,13 +2,14 @@
22 package browser
33
44 import (
5- "fmt"
65 "log"
76 "strings"
87 "time"
98
10- "github.com/bnema/dumber/internal/app/messaging"
9+ "github.com/bnema/dumber/internal/config"
1110 "github.com/bnema/dumber/pkg/webkit"
11+ webkitgtk "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
12+ "github.com/diamondburned/gotk4/pkg/glib/v2"
1213 )
1314
1415 const (
@@ -17,262 +18,9 @@ const (
1718 defaultPopupMinHeight = 600 // Minimum height for OAuth popups
1819 )
1920
20-// HandlePopup handles popup window creation requests from WebViews
21-func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *webkit.WebView {
22- log.Printf("[workspace] HandlePopup called for URL: %s", url)
23-
24- // Check for frame type markers added by WebKit layer
25- isBlankTarget := strings.HasSuffix(url, "#__dumber_frame_blank")
26- isPopupTarget := strings.HasSuffix(url, "#__dumber_frame_popup")
27-
28- // Clean the URL by removing our markers
29- if isBlankTarget || isPopupTarget {
30- if isBlankTarget {
31- url = strings.TrimSuffix(url, "#__dumber_frame_blank")
32- log.Printf("[workspace] Detected _blank target - will create regular pane for: %s", url)
33- } else {
34- url = strings.TrimSuffix(url, "#__dumber_frame_popup")
35- log.Printf("[workspace] Detected popup target - will create popup pane for: %s", url)
36- }
37- }
38-
39- if wm == nil || source == nil {
40- log.Printf("[workspace] HandlePopup: nil workspace manager or source - allowing native popup")
41- return nil
42- }
43-
44- node := wm.viewToNode[source]
45- if node == nil {
46- log.Printf("[workspace] popup from unknown webview - allowing native popup")
47- return nil
48- }
49-
50- // Note: HandlePopup is now obsolete - window.open is handled directly via JavaScript bypass
51-
52- cfg := wm.app.config
53- if cfg == nil {
54- log.Printf("[workspace] HandlePopup: nil config - allowing native popup")
55- return nil
56- }
57-
58- popCfg := cfg.Workspace.Popups
59- log.Printf("[workspace] Popup config - OpenInNewPane: %v, Placement: %s", popCfg.OpenInNewPane, popCfg.Placement)
60-
61- if !popCfg.OpenInNewPane {
62- log.Printf("[workspace] Popup creation disabled in config - allowing native popup")
63- return nil
64- }
65-
66- // Smart detection path: create temporary view and decide placement once type is known
67- if popCfg.EnableSmartDetection {
68- webkitCfg, err := wm.app.buildWebkitConfig()
69- if err != nil {
70- log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
71- return nil
72- }
73- webkitCfg.CreateWindow = false
74- // Create as related to avoid WindowFeatures crash; we'll decide final placement later
75- newView, err := webkit.NewWebViewWithRelated(webkitCfg, source)
76- if err != nil {
77- log.Printf("[workspace] failed to create temp WebView: %v - allowing native popup", err)
78- return nil
79- }
80-
81- // Register detection callback
82- newView.OnWindowTypeDetected(func(t webkit.WindowType, feat *webkit.WindowFeatures) {
83- wm.RunOnUI(func() {
84- wm.handleDetectedWindowType(node, newView, url, t, feat)
85- })
86- })
87-
88- // Fallback: if detection never fires, treat as popup as before
89- go func() {
90- time.Sleep(1500 * time.Millisecond)
91- if newView != nil {
92- wm.RunOnUI(func() {
93- if wm.viewToNode[newView] == nil { // not yet placed
94- wm.handleDetectedWindowType(node, newView, url, webkit.WindowTypePopup, nil)
95- }
96- })
97- }
98- }()
99-
100- return newView
101- }
102-
103- // Legacy path preserved
104- webkitCfg, err := wm.app.buildWebkitConfig()
105- if err != nil {
106- log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
107- return nil
108- }
109- webkitCfg.CreateWindow = false
110- newView, err := webkit.NewWebViewWithRelated(webkitCfg, source)
111- if err != nil {
112- log.Printf("[workspace] failed to create placeholder WebView: %v - allowing native popup", err)
113- return nil
114- }
115-
116- // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler
117-
118- // Create a pane for the new WebView
119- newPane, err := wm.createPane(newView)
120- if err != nil {
121- log.Printf("[workspace] failed to create popup pane: %v - allowing native popup", err)
122- return nil
123- }
124-
125- // Determine placement direction
126- direction := strings.ToLower(popCfg.Placement)
127- if direction == "" {
128- direction = "right"
129- }
130-
131- // Determine target node for splitting
132- target := node
133- if !popCfg.FollowPaneContext && wm.GetActiveNode() != nil {
134- target = wm.GetActiveNode()
135- }
136-
137- // Add the popup pane to the workspace using manual pane insertion
138- if err := wm.insertPopupPane(target, newPane, direction); err != nil {
139- log.Printf("[workspace] popup pane insertion failed: %v - allowing native popup", err)
140- return nil
141- }
142-
143- // Get the new node and popup WebView ID for tracking
144- newNode := wm.viewToNode[newView]
145- popupWebViewID := newView.ID()
146-
147- // Apply different behavior based on target type
148- if isBlankTarget {
149- log.Printf("[workspace] Treating _blank target as regular pane - no auto-close behavior")
150- // For _blank targets, set up parent relationship but no auto-close
151- if newNode != nil {
152- newNode.parentPane = node
153- }
154-
155- // Register close handler to clean up activePopupChildren tracking
156- newView.RegisterCloseHandler(func() {
157- log.Printf("[workspace] _blank popup requesting close via window.close()")
158- // Remove this popup from parent's activePopupChildren
159- if node != nil && popupWebViewID != "" {
160- for i, childID := range node.activePopupChildren {
161- if childID == popupWebViewID {
162- node.activePopupChildren = append(node.activePopupChildren[:i], node.activePopupChildren[i+1:]...)
163- log.Printf("[workspace] Removed _blank popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(node.activePopupChildren))
164- break
165- }
166- }
167- }
168- })
169- } else {
170- log.Printf("[workspace] Treating as popup pane - applying popup-specific behavior")
171- // Mark as popup for auto-close handling (OAuth flows, etc.)
172- if newNode != nil {
173- newNode.isPopup = true
174- newNode.parentPane = node
175- log.Printf("[workspace] Marked pane as popup for auto-close handling")
176-
177- // Register close handler for popup auto-close on window.close()
178- newView.RegisterCloseHandler(func() {
179- log.Printf("[workspace] Popup requesting close via window.close()")
180-
181- // Remove this popup from parent's activePopupChildren
182- if node != nil && popupWebViewID != "" {
183- for i, childID := range node.activePopupChildren {
184- if childID == popupWebViewID {
185- node.activePopupChildren = append(node.activePopupChildren[:i], node.activePopupChildren[i+1:]...)
186- log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(node.activePopupChildren))
187- break
188- }
189- }
190- }
191-
192- // Look up the node at close time
193- if node := wm.viewToNode[newView]; node != nil && node.isPopup {
194- log.Printf("[workspace] Closing popup pane")
195- // Brief delay to allow any final redirects to complete
196- time.AfterFunc(200*time.Millisecond, func() {
197- wm.scheduleIdleGuarded(func() bool {
198- if node == nil || !node.widgetValid {
199- return false
200- }
201- if err := wm.ClosePane(node); err != nil {
202- log.Printf("[workspace] Failed to close popup pane: %v", err)
203- }
204- return false
205- }, node)
206- })
207- } else {
208- log.Printf("[workspace] Could not find popup node for close handler")
209- }
210- })
211- } else {
212- log.Printf("[workspace] Warning: could not find node for popup WebView in viewToNode map")
213- }
214- }
215-
216- // Track this popup in the parent's activePopupChildren (for both _blank and popup types)
217- // This is critical to prevent OAuth flows from hijacking the parent pane via window.opener
218- if node != nil && popupWebViewID != "" {
219- if node.activePopupChildren == nil {
220- node.activePopupChildren = make([]string, 0)
221- }
222- node.activePopupChildren = append(node.activePopupChildren, popupWebViewID)
223- log.Printf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)", popupWebViewID, len(node.activePopupChildren))
224-
225- // Register navigation policy handler for parent to block script-initiated navigations
226- // when it has active popup children (prevents OAuth from hijacking parent pane)
227- if node.pane != nil && node.pane.WebView() != nil {
228- node.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
229- // Always allow user-initiated navigations
230- if isUserGesture {
231- return true
232- }
233-
234- // Block script-initiated navigations if this pane has active popup children
235- if len(node.activePopupChildren) > 0 {
236- log.Printf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s", len(node.activePopupChildren), url)
237- return false
238- }
239-
240- // Allow if no active popups
241- return true
242- })
243- log.Printf("[workspace] Registered navigation policy handler for parent pane")
244- }
245- }
246-
247- // Ensure GUI components are available in the new pane
248- wm.ensureGUIInPane(newPane)
249-
250- // Inject GUI components into the popup pane
251- wm.ensureGUIInPane(newPane)
252-
253- // Load the URL if provided
254- if url != "" {
255- paneType := "popup"
256- if isBlankTarget {
257- paneType = "_blank target"
258- }
259- log.Printf("[webkit] LoadURL (%s): %s", paneType, url)
260- if err := newView.LoadURL(url); err != nil {
261- log.Printf("[workspace] failed to load %s URL: %v", paneType, err)
262- }
263- // Ensure the WebView is visible after loading URL
264- if err := newView.Show(); err != nil {
265- log.Printf("[workspace] failed to show popup WebView: %v", err)
266- }
267- }
268-
269- if isBlankTarget {
270- log.Printf("[workspace] Created regular pane for _blank target URL: %s", url)
271- } else {
272- log.Printf("[workspace] Created popup pane for URL: %s", url)
273- }
274- return newView
275-}
21+// HandlePopup is DEPRECATED and removed.
22+// Popup lifecycle now uses WebKit's native create/ready-to-show/close signals.
23+// See setupPopupHandling(), handlePopupReadyToShow(), and handlePopupClose() instead.
27624
27725 // registerOAuthAutoClose sets up OAuth auto-close functionality for popups
27826 // Note: OAuth detection is now handled by the main-world.js injection script
@@ -281,294 +29,6 @@ func (wm *WorkspaceManager) registerOAuthAutoClose(view *webkit.WebView, url str
28129 log.Printf("[workspace] OAuth detection will be handled by main-world.js injection script")
28230 }
28331
284-// applyWindowFeatures applies window features to a WebView based on intent
285-func (wm *WorkspaceManager) applyWindowFeatures(view *webkit.WebView, intent *messaging.WindowIntent, isPopup bool) {
286- if intent == nil {
287- return
288- }
289-
290- features := &webkit.WindowFeatures{}
291-
292- // Apply dimensions if specified
293- if intent.Width != nil {
294- features.Width = *intent.Width
295- }
296- if intent.Height != nil {
297- features.Height = *intent.Height
298- }
299-
300- // Apply visibility features based on window type
301- defaultToolbar := !isPopup
302- defaultLocation := !isPopup
303- defaultMenubar := !isPopup
304-
305- if intent.Toolbar != nil {
306- features.ToolbarVisible = *intent.Toolbar
307- } else {
308- features.ToolbarVisible = defaultToolbar
309- }
310-
311- if intent.Location != nil {
312- features.LocationbarVisible = *intent.Location
313- } else {
314- features.LocationbarVisible = defaultLocation
315- }
316-
317- if intent.Menubar != nil {
318- features.MenubarVisible = *intent.Menubar
319- } else {
320- features.MenubarVisible = defaultMenubar
321- }
322-
323- if intent.Resizable != nil {
324- features.Resizable = *intent.Resizable
325- } else {
326- features.Resizable = true // Usually resizable unless explicitly disabled
327- }
328-
329- view.SetWindowFeatures(features)
330- windowTypeStr := "tab"
331- if isPopup {
332- windowTypeStr = "popup"
333- }
334- log.Printf("[workspace] Applied %s window features from intent: size=%dx%d, toolbar=%t, location=%t, menubar=%t, resizable=%t",
335- windowTypeStr, features.Width, features.Height, features.ToolbarVisible, features.LocationbarVisible, features.MenubarVisible, features.Resizable)
336-}
337-
338-// handleIntentAsTab creates a tab pane based on window.open intent
339-func (wm *WorkspaceManager) handleIntentAsTab(sourceNode *paneNode, url string, intent *messaging.WindowIntent) *webkit.WebView {
340- log.Printf("[workspace] Handling intent as tab: %s", url)
341-
342- webkitCfg, err := wm.app.buildWebkitConfig()
343- if err != nil {
344- log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
345- return nil
346- }
347- webkitCfg.CreateWindow = false
348-
349- newView, err := webkit.NewWebView(webkitCfg)
350- if err != nil {
351- log.Printf("[workspace] failed to create tab WebView: %v - allowing native popup", err)
352- return nil
353- }
354-
355- newPane, err := wm.createPane(newView)
356- if err != nil {
357- log.Printf("[workspace] failed to create tab pane: %v - allowing native popup", err)
358- return nil
359- }
360-
361- direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
362- if direction == "" {
363- direction = "right"
364- }
365-
366- if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
367- log.Printf("[workspace] tab pane insertion failed: %v - allowing native popup", err)
368- return nil
369- }
370-
371- node := wm.viewToNode[newView]
372- if node != nil {
373- node.windowType = webkit.WindowTypeTab
374- node.isRelated = false
375-
376- // Apply window features from JavaScript intent
377- wm.applyWindowFeatures(newView, intent, false)
378- }
379-
380- wm.ensureGUIInPane(newPane)
381-
382- if url != "" {
383- if err := newView.LoadURL(url); err != nil {
384- log.Printf("[workspace] failed to load tab URL: %v", err)
385- }
386- if err := newView.Show(); err != nil {
387- log.Printf("[workspace] failed to show tab WebView: %v", err)
388- }
389- }
390-
391- log.Printf("[workspace] Created tab pane for URL: %s", url)
392- return newView
393-}
394-
395-// handleIntentAsPopup creates a related popup pane based on window.open intent
396-func (wm *WorkspaceManager) handleIntentAsPopup(sourceNode *paneNode, url string, intent *messaging.WindowIntent) *webkit.WebView {
397- log.Printf("[workspace] Handling intent as popup: %s", url)
398-
399- webkitCfg, err := wm.app.buildWebkitConfig()
400- if err != nil {
401- log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err)
402- return nil
403- }
404- webkitCfg.CreateWindow = false
405-
406- newView, err := webkit.NewWebViewWithRelated(webkitCfg, sourceNode.pane.webView)
407- if err != nil {
408- log.Printf("[workspace] failed to create popup WebView: %v - allowing native popup", err)
409- return nil
410- }
411-
412- // Log the parent-popup WebView ID relationship for OAuth auto-close
413- parentWebViewID := sourceNode.pane.webView.ID()
414- popupWebViewID := newView.ID()
415- log.Printf("[workspace] Created popup WebView: parentID=%s popupID=%s url=%s", parentWebViewID, popupWebViewID, url)
416-
417- // Store popup WebView ID in parent's localStorage for OAuth callback lookup
418- storeScript := fmt.Sprintf(`
419- try {
420- const parentWebViewId = '%s';
421- const popupWebViewId = '%s';
422- const popupMapping = {
423- parentId: parentWebViewId,
424- popupId: popupWebViewId,
425- timestamp: Date.now(),
426- url: '%s'
427- };
428- localStorage.setItem('popup_mapping_' + parentWebViewId, JSON.stringify(popupMapping));
429- console.log('[workspace] Stored popup mapping:', popupMapping);
430- } catch(e) {
431- console.warn('[workspace] Failed to store popup mapping:', e);
432- }
433- `, parentWebViewID, popupWebViewID, url)
434-
435- // Inject into parent WebView so it can find its popup later
436- if err := sourceNode.pane.webView.InjectScript(storeScript); err != nil {
437- log.Printf("[workspace] Failed to inject popup mapping script into parent: %v", err)
438- }
439-
440- newPane, err := wm.createPane(newView)
441- if err != nil {
442- log.Printf("[workspace] failed to create popup pane: %v - allowing native popup", err)
443- return nil
444- }
445-
446- direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
447- if direction == "" {
448- direction = "right"
449- }
450-
451- if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
452- log.Printf("[workspace] popup pane insertion failed: %v - allowing native popup", err)
453- return nil
454- }
455-
456- node := wm.viewToNode[newView]
457- var requestID string
458- if node != nil {
459- node.windowType = webkit.WindowTypePopup
460- node.isRelated = true
461- node.parentPane = sourceNode
462- node.isPopup = true
463- node.autoClose = wm.shouldAutoClose(url)
464-
465- // Track this popup in the parent's activePopupChildren
466- if sourceNode != nil {
467- if sourceNode.activePopupChildren == nil {
468- sourceNode.activePopupChildren = make([]string, 0)
469- }
470- sourceNode.activePopupChildren = append(sourceNode.activePopupChildren, popupWebViewID)
471- log.Printf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)", popupWebViewID, len(sourceNode.activePopupChildren))
472-
473- // Register navigation policy handler for parent to block script-initiated navigations
474- // when it has active popup children (prevents OAuth from hijacking parent pane)
475- if sourceNode.pane != nil && sourceNode.pane.WebView() != nil {
476- sourceNode.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
477- // Always allow user-initiated navigations
478- if isUserGesture {
479- return true
480- }
481-
482- // Block script-initiated navigations if this pane has active popup children
483- if len(sourceNode.activePopupChildren) > 0 {
484- log.Printf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s", len(sourceNode.activePopupChildren), url)
485- return false
486- }
487-
488- // Allow if no active popups
489- return true
490- })
491- log.Printf("[workspace] Registered navigation policy handler for parent pane")
492- }
493- }
494-
495- // Store requestID for deduplication cleanup
496- if intent != nil {
497- requestID = intent.RequestID
498- node.requestID = requestID
499- }
500-
501- // Apply window features from JavaScript intent
502- wm.applyWindowFeatures(newView, intent, true)
503-
504- // Apply minimum size constraints to prevent compression
505- var width, height int
506- if intent != nil {
507- if intent.Width != nil {
508- width = *intent.Width
509- }
510- if intent.Height != nil {
511- height = *intent.Height
512- }
513- }
514- wm.applyPopupSizeConstraints(newView, width, height)
515- }
516-
517- // Register close handler for popup auto-close
518- newView.RegisterCloseHandler(func() {
519- log.Printf("[workspace] Popup requesting close via window.close()")
520-
521- // Clear the RequestID from deduplicator to allow new popups with same ID
522- if requestID != "" && wm.paneDeduplicator != nil {
523- wm.paneDeduplicator.ClearRequestID(requestID)
524- }
525-
526- // Remove this popup from parent's activePopupChildren
527- if sourceNode != nil && popupWebViewID != "" {
528- for i, childID := range sourceNode.activePopupChildren {
529- if childID == popupWebViewID {
530- sourceNode.activePopupChildren = append(sourceNode.activePopupChildren[:i], sourceNode.activePopupChildren[i+1:]...)
531- log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(sourceNode.activePopupChildren))
532- break
533- }
534- }
535- }
536-
537- if n := wm.viewToNode[newView]; n != nil && n.isPopup {
538- time.AfterFunc(200*time.Millisecond, func() {
539- wm.scheduleIdleGuarded(func() bool {
540- if n == nil || !n.widgetValid {
541- return false
542- }
543- if err := wm.ClosePane(n); err != nil {
544- log.Printf("[workspace] Failed to close popup pane: %v", err)
545- }
546- return false
547- }, n)
548- })
549- }
550- })
551-
552- // URL-based auto-close for OAuth popups
553- if node != nil && node.isPopup && node.autoClose {
554- wm.registerOAuthAutoClose(newView, url)
555- }
556-
557- wm.ensureGUIInPane(newPane)
558-
559- if url != "" {
560- if err := newView.LoadURL(url); err != nil {
561- log.Printf("[workspace] failed to load popup URL: %v", err)
562- }
563- if err := newView.Show(); err != nil {
564- log.Printf("[workspace] failed to show popup WebView: %v", err)
565- }
566- }
567-
568- log.Printf("[workspace] Created popup pane for URL: %s", url)
569- return newView
570-}
571-
57232 // insertIndependentPane inserts a new independent pane next to the source
57333 func (wm *WorkspaceManager) insertIndependentPane(sourceNode *paneNode, webView *webkit.WebView, url string) error {
57434 newPane, err := wm.createPane(webView)
@@ -579,7 +39,8 @@ func (wm *WorkspaceManager) insertIndependentPane(sourceNode *paneNode, webView
57939 if direction == "" {
58040 direction = "right"
58141 }
582- if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil { // reuse insertion primitive
42+ // Use splitNode with existing pane instead of insertPopupPane
43+ if _, err := wm.splitNode(sourceNode, direction, newPane); err != nil {
58344 return err
58445 }
58546 node := wm.viewToNode[webView]
@@ -606,12 +67,13 @@ func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView
60667 if direction == "" {
60768 direction = "right"
60869 }
609- if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil {
70+ // Use splitNode with existing pane instead of insertPopupPane
71+ if _, err := wm.splitNode(sourceNode, direction, newPane); err != nil {
61072 log.Printf("[workspace] failed to insert related popup pane: %v", err)
61173 return
61274 }
61375 node := wm.viewToNode[related]
614- popupWebViewID := related.ID()
76+ popupWebViewID := related.IDString()
61577
61678 if node != nil {
61779 node.windowType = webkit.WindowTypePopup
@@ -715,17 +177,17 @@ func (wm *WorkspaceManager) applyPopupSizeConstraints(view *webkit.WebView, widt
715177 }
716178
717179 // Apply to container
718- if container := view.RootWidget(); container != 0 && webkit.WidgetIsValid(container) {
180+ if container := view.RootWidget(); container != nil {
719181 webkit.WidgetSetSizeRequest(container, minWidth, minHeight)
720182 webkit.WidgetQueueResize(container)
721- log.Printf("[workspace] Applied minimum size %dx%d to popup container=%#x", minWidth, minHeight, container)
183+ log.Printf("[workspace] Applied minimum size %dx%d to popup container=%p", minWidth, minHeight, container)
722184 }
723185
724186 // Apply to WebView widget
725- if webViewWidget := view.Widget(); webViewWidget != 0 && webkit.WidgetIsValid(webViewWidget) {
187+ if webViewWidget := view.Widget(); webViewWidget != nil {
726188 webkit.WidgetSetSizeRequest(webViewWidget, minWidth, minHeight)
727189 webkit.WidgetQueueResize(webViewWidget)
728- log.Printf("[workspace] Applied minimum size %dx%d to popup webview=%#x", minWidth, minHeight, webViewWidget)
190+ log.Printf("[workspace] Applied minimum size %dx%d to popup webview=%p", minWidth, minHeight, webViewWidget)
729191 }
730192 }
731193
@@ -796,9 +258,10 @@ func (wm *WorkspaceManager) handleDetectedWindowType(sourceNode *paneNode, webVi
796258 log.Printf("[workspace] failed to build webkit config for tab: %v", err)
797259 return
798260 }
261+ // Tabs are embedded in workspace paned containers - no separate window needed
799262 webkitCfg.CreateWindow = false
800263
801- // Create independent WebView like handleIntentAsTab does
264+ // Create independent WebView matching our standard tab insertion path
802265 independentView, err := webkit.NewWebView(webkitCfg)
803266 if err != nil {
804267 log.Printf("[workspace] failed to create independent tab WebView: %v", err)
@@ -819,3 +282,316 @@ func (wm *WorkspaceManager) handleDetectedWindowType(sourceNode *paneNode, webVi
819282 wm.configureRelatedPopup(sourceNode, webView, url, features)
820283 }
821284 }
285+
286+// setupPopupHandling connects WebView's create signal for popup lifecycle management
287+// This implements the WebKit-native popup lifecycle as described in POPUP_REFACTORING_PLAN.md
288+func (wm *WorkspaceManager) setupPopupHandling(webView *webkit.WebView, parentNode *paneNode) {
289+ if webView == nil || parentNode == nil {
290+ return
291+ }
292+
293+ webView.RegisterPopupCreateHandler(func(navAction *webkitgtk.NavigationAction) *webkit.WebView {
294+ // Get URL from navigation action
295+ req := navAction.Request()
296+ if req == nil {
297+ log.Printf("[workspace] create signal: no request in navigation action")
298+ return nil
299+ }
300+ url := req.URI()
301+
302+ log.Printf("[workspace] create signal received for URL: %s", url)
303+
304+ // Check if popups are enabled in config
305+ if wm.app.config != nil && !wm.app.config.Workspace.Popups.OpenInNewPane {
306+ log.Printf("[workspace] Popup creation disabled in config - blocking popup")
307+ return nil
308+ }
309+
310+ // CRITICAL: Create a BARE gotk4 WebView as RELATED to parent (no wrapping, no initialization)
311+ // WebKit requires the related-view property to be set during construction for proper lifecycle
312+ // This ensures session/cookie sharing and proper WindowFeatures initialization
313+ // All initialization (wrapping, container, scripts) happens in ready-to-show
314+ log.Printf("[workspace] Step 1: Creating bare gotk4 WebView with related-view property")
315+ barePopupView := webkit.NewBareRelatedWebView(webView.GtkWebView())
316+ if barePopupView == nil {
317+ log.Printf("[workspace] Failed to create bare related popup WebView - blocking popup")
318+ return nil
319+ }
320+ log.Printf("[workspace] Step 1 complete: bare related gotk4 WebView created=%p (parent=%p)", barePopupView, webView.GtkWebView())
321+
322+ // Wrap the bare view immediately to get a proper ID
323+ // BUT don't initialize it yet - that happens in ready-to-show
324+ log.Printf("[workspace] Step 2: Wrapping bare view (no initialization)")
325+ wrappedView := webkit.WrapBareWebView(barePopupView)
326+ if wrappedView == nil {
327+ log.Printf("[workspace] Failed to wrap bare popup WebView - blocking popup")
328+ return nil
329+ }
330+
331+ // Use the wrapper's ID for tracking
332+ popupID := wrappedView.ID()
333+ log.Printf("[workspace] Step 2 complete: wrapped view created with ID=%d", popupID)
334+
335+ // Store wrapped view (not initialized) for initialization in ready-to-show
336+ log.Printf("[workspace] Step 3: Storing in pendingPopups map")
337+ wm.pendingPopups[popupID] = &pendingPopup{
338+ wrappedView: wrappedView,
339+ parentView: webView,
340+ parentNode: parentNode,
341+ url: url,
342+ }
343+ log.Printf("[workspace] Step 3 complete: stored in pendingPopups")
344+
345+ // CRITICAL: Do NOT connect signals before returning to WebKit
346+ // Connecting signals might trigger WebKit to access WindowFeatures before it's set
347+ // Instead, we'll connect them after WebKit has processed the return
348+ log.Printf("[workspace] Step 4: Scheduling signal connections for next main loop iteration (AFTER WebKit configures the view)")
349+
350+ // Use GLib idle_add to connect signals AFTER this callback returns
351+ // This ensures WebKit has finished configuring WindowFeatures before we touch the view
352+ glib.IdleAdd(func() bool {
353+ log.Printf("[workspace] Idle callback: Now connecting signals for popup ID=%d", popupID)
354+
355+ // Connect ready-to-show signal NOW (after WebKit has configured the view)
356+ barePopupView.ConnectReadyToShow(func() {
357+ log.Printf("[workspace] ready-to-show signal received for popup ID=%d", popupID)
358+ wm.handlePopupReadyToShow(popupID)
359+ })
360+ log.Printf("[workspace] Idle callback: ready-to-show connected")
361+
362+ // Connect close handler
363+ barePopupView.ConnectClose(func() {
364+ log.Printf("[workspace] close signal received for popup ID=%d", popupID)
365+ wm.handlePopupClose(popupID)
366+ })
367+ log.Printf("[workspace] Idle callback: close signal connected")
368+
369+ return false // Don't repeat
370+ })
371+
372+ log.Printf("[workspace] Step 4 complete: signal connections scheduled")
373+
374+ log.Printf("[workspace] Step 5: Returning wrapped view to WebKit (will extract bare view in ConnectCreate)")
375+
376+ // Return the wrapped view - WebKit will extract the underlying gotk4 view and configure it
377+ return wrappedView
378+ })
379+}
380+
381+// handlePopupReadyToShow inserts popup pane when WebKit says it's ready
382+func (wm *WorkspaceManager) handlePopupReadyToShow(popupID uint64) {
383+ info, ok := wm.pendingPopups[popupID]
384+ if !ok {
385+ log.Printf("[workspace] ready-to-show for unknown popup ID: %d", popupID)
386+ return
387+ }
388+ delete(wm.pendingPopups, popupID)
389+
390+ log.Printf("[workspace] ready-to-show received for popup ID=%d, starting full initialization", popupID)
391+
392+ // NOW it's safe to initialize (WebKit has configured WindowProperties)
393+ // We already have the wrapped view from setupPopupHandling
394+ wrappedView := info.wrappedView
395+
396+ // Build webkit config for initialization
397+ webkitCfg, err := wm.app.buildWebkitConfig()
398+ if err != nil {
399+ log.Printf("[workspace] Failed to build webkit config for popup initialization: %v", err)
400+ return
401+ }
402+ webkitCfg.CreateWindow = false // Embedded in workspace
403+
404+ // Initialize the wrapped view with full configuration
405+ if err := wrappedView.InitializeFromBare(webkitCfg); err != nil {
406+ log.Printf("[workspace] Failed to initialize popup WebView: %v", err)
407+ return
408+ }
409+
410+ log.Printf("[workspace] Successfully initialized popup WebView ID %d", wrappedView.ID())
411+
412+ // Create pane for the fully initialized WebView
413+ popupPane, err := wm.createPane(wrappedView)
414+ if err != nil {
415+ log.Printf("[workspace] Failed to create popup pane: %v", err)
416+ return
417+ }
418+
419+ log.Printf("[workspace] Created popup pane, now inserting into workspace")
420+
421+ // NOW it's safe to insert into workspace based on configured behavior
422+ behavior := wm.app.config.Workspace.Popups.Behavior
423+ log.Printf("[workspace] Popup behavior: %s", behavior)
424+
425+ switch behavior {
426+ case config.PopupBehaviorSplit:
427+ // Split pane behavior (default) - use splitNode with existing popup pane
428+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
429+ if direction == "" {
430+ direction = "right"
431+ }
432+ if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
433+ log.Printf("[workspace] Failed to insert split popup pane: %v", err)
434+ return
435+ }
436+
437+ case config.PopupBehaviorStacked:
438+ // Stacked pane behavior
439+ _, err := wm.stackedPaneManager.StackPane(info.parentNode)
440+ if err != nil {
441+ log.Printf("[workspace] Failed to stack existing pane: %v", err)
442+ return
443+ }
444+ // Insert the popup pane in the same container - use splitNode with existing popup pane
445+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
446+ if direction == "" {
447+ direction = "right"
448+ }
449+ if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
450+ log.Printf("[workspace] Failed to insert stacked popup pane: %v", err)
451+ return
452+ }
453+
454+ case config.PopupBehaviorTabbed:
455+ // Tabbed behavior - TODO: implement proper tabbed interface
456+ log.Printf("[workspace] WARNING: Tabbed popup behavior not yet implemented, falling back to split")
457+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
458+ if direction == "" {
459+ direction = "right"
460+ }
461+ if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
462+ log.Printf("[workspace] Failed to insert tabbed popup pane: %v", err)
463+ return
464+ }
465+
466+ case config.PopupBehaviorWindowed:
467+ // Windowed behavior - opens in new workspace/window
468+ // TODO: implement new window creation with GTK
469+ log.Printf("[workspace] WARNING: Windowed popup behavior not yet implemented, falling back to split")
470+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
471+ if direction == "" {
472+ direction = "right"
473+ }
474+ if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
475+ log.Printf("[workspace] Failed to insert windowed popup pane: %v", err)
476+ return
477+ }
478+
479+ default:
480+ // Fallback to split if unknown behavior
481+ log.Printf("[workspace] Unknown popup behavior '%s', falling back to split", behavior)
482+ direction := strings.ToLower(wm.app.config.Workspace.Popups.Placement)
483+ if direction == "" {
484+ direction = "right"
485+ }
486+ if _, err := wm.splitNode(info.parentNode, direction, popupPane); err != nil {
487+ log.Printf("[workspace] Failed to insert popup pane: %v", err)
488+ return
489+ }
490+ }
491+
492+ // Configure the popup node
493+ node := wm.viewToNode[wrappedView]
494+ if node != nil {
495+ node.windowType = webkit.WindowTypePopup
496+ node.isRelated = true
497+ node.parentPane = info.parentNode
498+ node.isPopup = true
499+ node.autoClose = wm.shouldAutoClose(info.url)
500+ node.popupID = popupID // Store popupID for close signal lookup
501+
502+ // Track this popup in the parent's activePopupChildren
503+ if info.parentNode != nil {
504+ popupWebViewID := wrappedView.IDString()
505+ if info.parentNode.activePopupChildren == nil {
506+ info.parentNode.activePopupChildren = make([]string, 0)
507+ }
508+ info.parentNode.activePopupChildren = append(info.parentNode.activePopupChildren, popupWebViewID)
509+ log.Printf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)",
510+ popupWebViewID, len(info.parentNode.activePopupChildren))
511+
512+ // Register navigation policy handler for parent
513+ if info.parentNode.pane != nil && info.parentNode.pane.WebView() != nil {
514+ info.parentNode.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool {
515+ if isUserGesture {
516+ return true
517+ }
518+ if len(info.parentNode.activePopupChildren) > 0 {
519+ log.Printf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s",
520+ len(info.parentNode.activePopupChildren), url)
521+ return false
522+ }
523+ return true
524+ })
525+ }
526+ }
527+
528+ // URL-based auto-close for OAuth popups
529+ if node.autoClose {
530+ wm.registerOAuthAutoClose(wrappedView, info.url)
531+ }
532+ }
533+
534+ wm.ensureGUIInPane(popupPane)
535+
536+ // Widgets are already shown by splitNode - no need to show again
537+ // GTK4 handles visibility automatically during reparenting
538+
539+ log.Printf("[workspace] Popup pane inserted and visible for URL: %s", info.url)
540+}
541+
542+// handlePopupClose responds to WebKit's close signal
543+func (wm *WorkspaceManager) handlePopupClose(popupID uint64) {
544+ log.Printf("[workspace] close signal received for popup native=%d", popupID)
545+
546+ // Check if it's in pending (not yet shown)
547+ if info, ok := wm.pendingPopups[popupID]; ok {
548+ delete(wm.pendingPopups, popupID)
549+ log.Printf("[workspace] Popup closed before ready-to-show, cleaning up")
550+ // Just remove from pending, WebKit will handle cleanup
551+ _ = info // avoid unused variable
552+ return
553+ }
554+
555+ // Find pane by popupID (stored in node during ready-to-show)
556+ var targetNode *paneNode
557+ for _, node := range wm.viewToNode {
558+ if node != nil && node.popupID == popupID {
559+ targetNode = node
560+ break
561+ }
562+ }
563+
564+ if targetNode == nil {
565+ log.Printf("[workspace] close signal for unknown popup ID=%d, already closed?", popupID)
566+ return
567+ }
568+
569+ // Remove from parent's activePopupChildren
570+ // Find the wrapped WebView for this node to get its IDString
571+ var wrappedViewID string
572+ for webView, node := range wm.viewToNode {
573+ if node == targetNode {
574+ wrappedViewID = webView.IDString()
575+ break
576+ }
577+ }
578+
579+ if targetNode.parentPane != nil && wrappedViewID != "" {
580+ for i, childID := range targetNode.parentPane.activePopupChildren {
581+ if childID == wrappedViewID {
582+ targetNode.parentPane.activePopupChildren = append(
583+ targetNode.parentPane.activePopupChildren[:i],
584+ targetNode.parentPane.activePopupChildren[i+1:]...)
585+ log.Printf("[workspace] Removed popup from parent's activePopupChildren (remaining: %d)",
586+ len(targetNode.parentPane.activePopupChildren))
587+ break
588+ }
589+ }
590+ }
591+
592+ // Clean close via existing ClosePane (WebKit already stopped)
593+ log.Printf("[workspace] Closing popup pane via ClosePane")
594+ if err := wm.ClosePane(targetNode); err != nil {
595+ log.Printf("[workspace] Error closing popup pane: %v", err)
596+ }
597+}
A internal/app/browser/workspace_popup_test.go +419 -0
@@ -0,0 +1,419 @@
1+package browser
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/config"
7+ "github.com/bnema/dumber/pkg/webkit"
8+)
9+
10+// TestPendingPopupStorage tests the pendingPopups map operations
11+func TestPendingPopupStorage(t *testing.T) {
12+ wm := &WorkspaceManager{
13+ pendingPopups: make(map[uint64]*pendingPopup),
14+ }
15+
16+ // Test adding a pending popup
17+ popupID := uint64(1)
18+ pending := &pendingPopup{
19+ url: "https://example.com",
20+ }
21+
22+ wm.pendingPopups[popupID] = pending
23+
24+ // Verify storage
25+ retrieved, ok := wm.pendingPopups[popupID]
26+ if !ok {
27+ t.Errorf("Expected popup ID %d to be stored", popupID)
28+ }
29+
30+ if retrieved.url != "https://example.com" {
31+ t.Errorf("Expected URL 'https://example.com', got %s", retrieved.url)
32+ }
33+
34+ // Test deletion
35+ delete(wm.pendingPopups, popupID)
36+ _, ok = wm.pendingPopups[popupID]
37+ if ok {
38+ t.Errorf("Expected popup ID %d to be deleted", popupID)
39+ }
40+}
41+
42+// TestPendingPopupCleanup tests cleanup of pending popups that never showed
43+func TestPendingPopupCleanup(t *testing.T) {
44+ wm := &WorkspaceManager{
45+ pendingPopups: make(map[uint64]*pendingPopup),
46+ }
47+
48+ // Add multiple pending popups
49+ for i := uint64(1); i <= 5; i++ {
50+ wm.pendingPopups[i] = &pendingPopup{
51+ url: "https://example.com",
52+ }
53+ }
54+
55+ // Verify all added
56+ if len(wm.pendingPopups) != 5 {
57+ t.Errorf("Expected 5 pending popups, got %d", len(wm.pendingPopups))
58+ }
59+
60+ // Simulate close before ready-to-show for popup 3
61+ popupID := uint64(3)
62+ if _, ok := wm.pendingPopups[popupID]; ok {
63+ delete(wm.pendingPopups, popupID)
64+ }
65+
66+ // Verify cleanup
67+ if len(wm.pendingPopups) != 4 {
68+ t.Errorf("Expected 4 pending popups after cleanup, got %d", len(wm.pendingPopups))
69+ }
70+
71+ _, ok := wm.pendingPopups[popupID]
72+ if ok {
73+ t.Errorf("Expected popup ID %d to be removed", popupID)
74+ }
75+}
76+
77+// TestPopupIDTracking tests that popup IDs are tracked correctly in paneNode
78+func TestPopupIDTracking(t *testing.T) {
79+ node := &paneNode{
80+ isLeaf: true,
81+ windowType: webkit.WindowTypePopup,
82+ isPopup: true,
83+ popupID: 42,
84+ }
85+
86+ // Verify popup ID is stored
87+ if node.popupID != 42 {
88+ t.Errorf("Expected popupID 42, got %d", node.popupID)
89+ }
90+
91+ // Verify window type
92+ if node.windowType != webkit.WindowTypePopup {
93+ t.Errorf("Expected WindowTypePopup, got %d", node.windowType)
94+ }
95+
96+ // Verify popup flag
97+ if !node.isPopup {
98+ t.Errorf("Expected isPopup to be true")
99+ }
100+}
101+
102+// TestActivePopupChildren tests tracking of active popup children in parent pane
103+func TestActivePopupChildren(t *testing.T) {
104+ parentNode := &paneNode{
105+ isLeaf: true,
106+ activePopupChildren: make([]string, 0),
107+ }
108+
109+ // Add popup children
110+ popupID1 := "1"
111+ popupID2 := "2"
112+ popupID3 := "3"
113+
114+ parentNode.activePopupChildren = append(parentNode.activePopupChildren, popupID1)
115+ parentNode.activePopupChildren = append(parentNode.activePopupChildren, popupID2)
116+ parentNode.activePopupChildren = append(parentNode.activePopupChildren, popupID3)
117+
118+ // Verify count
119+ if len(parentNode.activePopupChildren) != 3 {
120+ t.Errorf("Expected 3 active popup children, got %d", len(parentNode.activePopupChildren))
121+ }
122+
123+ // Test removal of middle child
124+ for i, childID := range parentNode.activePopupChildren {
125+ if childID == popupID2 {
126+ parentNode.activePopupChildren = append(
127+ parentNode.activePopupChildren[:i],
128+ parentNode.activePopupChildren[i+1:]...,
129+ )
130+ break
131+ }
132+ }
133+
134+ // Verify removal
135+ if len(parentNode.activePopupChildren) != 2 {
136+ t.Errorf("Expected 2 active popup children after removal, got %d", len(parentNode.activePopupChildren))
137+ }
138+
139+ // Verify correct children remain
140+ if parentNode.activePopupChildren[0] != popupID1 || parentNode.activePopupChildren[1] != popupID3 {
141+ t.Errorf("Expected children [%s, %s], got %v", popupID1, popupID3, parentNode.activePopupChildren)
142+ }
143+}
144+
145+// TestShouldAutoClose tests OAuth URL detection logic
146+func TestShouldAutoClose(t *testing.T) {
147+ tests := []struct {
148+ name string
149+ url string
150+ enabled bool
151+ expected bool
152+ }{
153+ {
154+ name: "OAuth authorize URL",
155+ url: "https://accounts.google.com/o/oauth2/v2/auth?client_id=...",
156+ enabled: true,
157+ expected: true,
158+ },
159+ {
160+ name: "OAuth callback URL",
161+ url: "https://example.com/auth/callback?code=xyz",
162+ enabled: true,
163+ expected: true,
164+ },
165+ {
166+ name: "OAuth token response",
167+ url: "https://example.com/redirect?access_token=xyz",
168+ enabled: true,
169+ expected: true,
170+ },
171+ {
172+ name: "Regular URL - auto-close enabled",
173+ url: "https://example.com/page",
174+ enabled: true,
175+ expected: false,
176+ },
177+ {
178+ name: "OAuth URL - auto-close disabled",
179+ url: "https://accounts.google.com/o/oauth2/v2/auth?client_id=...",
180+ enabled: false,
181+ expected: false,
182+ },
183+ {
184+ name: "OpenID Connect URL",
185+ url: "https://example.com/oidc/authorize",
186+ enabled: true,
187+ expected: true,
188+ },
189+ {
190+ name: "Error response",
191+ url: "https://example.com/callback?error=access_denied",
192+ enabled: true,
193+ expected: true,
194+ },
195+ }
196+
197+ for _, tt := range tests {
198+ t.Run(tt.name, func(t *testing.T) {
199+ wm := &WorkspaceManager{
200+ app: &BrowserApp{
201+ config: &config.Config{
202+ Workspace: config.WorkspaceConfig{
203+ Popups: config.PopupBehaviorConfig{
204+ OAuthAutoClose: tt.enabled,
205+ },
206+ },
207+ },
208+ },
209+ }
210+
211+ result := wm.shouldAutoClose(tt.url)
212+ if result != tt.expected {
213+ t.Errorf("shouldAutoClose(%q) with enabled=%v = %v, want %v",
214+ tt.url, tt.enabled, result, tt.expected)
215+ }
216+ })
217+ }
218+}
219+
220+// TestPopupBehaviorConfig tests popup behavior configuration
221+func TestPopupBehaviorConfig(t *testing.T) {
222+ tests := []struct {
223+ name string
224+ behavior config.PopupBehavior
225+ }{
226+ {"Split behavior", config.PopupBehaviorSplit},
227+ {"Stacked behavior", config.PopupBehaviorStacked},
228+ {"Tabbed behavior", config.PopupBehaviorTabbed},
229+ {"Windowed behavior", config.PopupBehaviorWindowed},
230+ }
231+
232+ for _, tt := range tests {
233+ t.Run(tt.name, func(t *testing.T) {
234+ cfg := &config.Config{
235+ Workspace: config.WorkspaceConfig{
236+ Popups: config.PopupBehaviorConfig{
237+ Behavior: tt.behavior,
238+ },
239+ },
240+ }
241+
242+ // Verify behavior is stored correctly
243+ if cfg.Workspace.Popups.Behavior != tt.behavior {
244+ t.Errorf("Expected behavior %s, got %s",
245+ tt.behavior, cfg.Workspace.Popups.Behavior)
246+ }
247+ })
248+ }
249+}
250+
251+// TestPopupWindowTypeTracking tests window type tracking in paneNode
252+func TestPopupWindowTypeTracking(t *testing.T) {
253+ tests := []struct {
254+ name string
255+ windowType webkit.WindowType
256+ isPopup bool
257+ isRelated bool
258+ }{
259+ {
260+ name: "Regular tab",
261+ windowType: webkit.WindowTypeTab,
262+ isPopup: false,
263+ isRelated: false,
264+ },
265+ {
266+ name: "Popup window",
267+ windowType: webkit.WindowTypePopup,
268+ isPopup: true,
269+ isRelated: true,
270+ },
271+ }
272+
273+ for _, tt := range tests {
274+ t.Run(tt.name, func(t *testing.T) {
275+ node := &paneNode{
276+ windowType: tt.windowType,
277+ isPopup: tt.isPopup,
278+ isRelated: tt.isRelated,
279+ }
280+
281+ if node.windowType != tt.windowType {
282+ t.Errorf("Expected windowType %d, got %d", tt.windowType, node.windowType)
283+ }
284+
285+ if node.isPopup != tt.isPopup {
286+ t.Errorf("Expected isPopup %v, got %v", tt.isPopup, node.isPopup)
287+ }
288+
289+ if node.isRelated != tt.isRelated {
290+ t.Errorf("Expected isRelated %v, got %v", tt.isRelated, node.isRelated)
291+ }
292+ })
293+ }
294+}
295+
296+// TestPopupParentRelationship tests parent-child relationship tracking
297+func TestPopupParentRelationship(t *testing.T) {
298+ parentNode := &paneNode{
299+ isLeaf: true,
300+ activePopupChildren: make([]string, 0),
301+ }
302+
303+ popupNode := &paneNode{
304+ isLeaf: true,
305+ windowType: webkit.WindowTypePopup,
306+ isPopup: true,
307+ parentPane: parentNode,
308+ isRelated: true,
309+ }
310+
311+ // Verify parent relationship
312+ if popupNode.parentPane != parentNode {
313+ t.Errorf("Expected popup's parent to be parentNode")
314+ }
315+
316+ // Verify popup is marked as related
317+ if !popupNode.isRelated {
318+ t.Errorf("Expected popup to be marked as related")
319+ }
320+
321+ // Add popup to parent's children
322+ popupWebViewID := "test-popup-id"
323+ parentNode.activePopupChildren = append(parentNode.activePopupChildren, popupWebViewID)
324+
325+ // Verify parent tracks popup
326+ if len(parentNode.activePopupChildren) != 1 {
327+ t.Errorf("Expected parent to track 1 popup child, got %d", len(parentNode.activePopupChildren))
328+ }
329+
330+ if parentNode.activePopupChildren[0] != popupWebViewID {
331+ t.Errorf("Expected parent to track popup ID %s, got %s",
332+ popupWebViewID, parentNode.activePopupChildren[0])
333+ }
334+}
335+
336+// TestPopupCleanupGeneration tests cleanup generation tracking
337+func TestPopupCleanupGeneration(t *testing.T) {
338+ node := &paneNode{
339+ isLeaf: true,
340+ windowType: webkit.WindowTypePopup,
341+ isPopup: true,
342+ widgetValid: true,
343+ cleanupGeneration: 0,
344+ }
345+
346+ // Simulate lifecycle progression
347+ node.cleanupGeneration = 1
348+ if node.cleanupGeneration != 1 {
349+ t.Errorf("Expected cleanupGeneration 1, got %d", node.cleanupGeneration)
350+ }
351+
352+ // Simulate widget becoming invalid
353+ node.widgetValid = false
354+ if node.widgetValid {
355+ t.Errorf("Expected widgetValid to be false")
356+ }
357+
358+ // Increment generation for new lifecycle
359+ node.cleanupGeneration = 2
360+ if node.cleanupGeneration != 2 {
361+ t.Errorf("Expected cleanupGeneration 2, got %d", node.cleanupGeneration)
362+ }
363+}
364+
365+// TestMultiplePendingPopups tests handling multiple pending popups simultaneously
366+func TestMultiplePendingPopups(t *testing.T) {
367+ wm := &WorkspaceManager{
368+ pendingPopups: make(map[uint64]*pendingPopup),
369+ }
370+
371+ // Add multiple pending popups
372+ popups := map[uint64]string{
373+ 1: "https://popup1.com",
374+ 2: "https://popup2.com",
375+ 3: "https://popup3.com",
376+ }
377+
378+ for id, url := range popups {
379+ wm.pendingPopups[id] = &pendingPopup{
380+ url: url,
381+ }
382+ }
383+
384+ // Verify all stored
385+ if len(wm.pendingPopups) != 3 {
386+ t.Errorf("Expected 3 pending popups, got %d", len(wm.pendingPopups))
387+ }
388+
389+ // Verify each URL
390+ for id, expectedURL := range popups {
391+ pending, ok := wm.pendingPopups[id]
392+ if !ok {
393+ t.Errorf("Expected popup ID %d to be stored", id)
394+ continue
395+ }
396+ if pending.url != expectedURL {
397+ t.Errorf("Expected URL %s for popup %d, got %s", expectedURL, id, pending.url)
398+ }
399+ }
400+
401+ // Simulate ready-to-show for popup 2 (remove from pending)
402+ delete(wm.pendingPopups, 2)
403+
404+ // Verify remaining
405+ if len(wm.pendingPopups) != 2 {
406+ t.Errorf("Expected 2 pending popups after ready-to-show, got %d", len(wm.pendingPopups))
407+ }
408+
409+ // Verify popup 2 is gone but 1 and 3 remain
410+ if _, ok := wm.pendingPopups[2]; ok {
411+ t.Errorf("Expected popup 2 to be removed after ready-to-show")
412+ }
413+ if _, ok := wm.pendingPopups[1]; !ok {
414+ t.Errorf("Expected popup 1 to still be pending")
415+ }
416+ if _, ok := wm.pendingPopups[3]; !ok {
417+ t.Errorf("Expected popup 3 to still be pending")
418+ }
419+}
M internal/app/browser/workspace_types.go +34 -19
@@ -5,6 +5,7 @@ import (
55 "os"
66
77 "github.com/bnema/dumber/pkg/webkit"
8+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
89 )
910
1011 // DebugLevel controls validation and safety checks
@@ -49,31 +50,35 @@ type paneNode struct {
4950 left *paneNode
5051 right *paneNode
5152
52- // Widget management - direct GTK pointers (all ops on main thread)
53- container uintptr // Main container: GtkPaned (branch), GtkBox (stack), or WebView container (leaf)
54- orientation webkit.Orientation
53+ // Widget management - gotk4 widgets (all ops on main thread)
54+ container gtk.Widgetter // Main container: *gtk.Paned (branch), *gtk.Box (stack), or WebView widget (leaf)
55+ orientation gtk.Orientation
5556 isLeaf bool
5657 isPopup bool // Deprecated: use windowType instead
5758
5859 // Window type tracking
59- windowType webkit.WindowType // Tab or Popup
60- windowFeatures *webkit.WindowFeatures // Features if popup
61- isRelated bool // Shares context
62- parentPane *paneNode // Parent for related views
63- activePopupChildren []string // WebView IDs of active popup children (for tracking related popups)
64- autoClose bool // Auto-close on OAuth success
65- requestID string // Request ID for popup (used for localStorage cleanup)
66- hoverToken uintptr
67- focusControllerToken uintptr
68- pendingHoverReattach bool
69- pendingFocusReattach bool
60+ windowType webkit.WindowType // Tab or Popup
61+ windowFeatures *webkit.WindowFeatures // Features if popup
62+ isRelated bool // Shares context
63+ parentPane *paneNode // Parent for related views
64+ activePopupChildren []string // WebView IDs of active popup children (for tracking related popups)
65+ autoClose bool // Auto-close on OAuth success
66+ requestID string // Request ID for popup (used for localStorage cleanup)
67+ popupID uint64 // Unique popup ID (for bare WebView lifecycle tracking)
68+ hoverToken *gtk.EventControllerMotion // Hover event controller for mouse-based focus
69+ focusControllerToken uintptr // Focus controller token (stored as native pointer)
70+ pendingHoverReattach bool // Needs hover reattachment after reparent
71+ pendingFocusReattach bool // Needs focus reattachment after reparent
72+ lastHoverTime int64 // Timestamp of last hover event (for debouncing)
73+ lastFocusEnterTime int64 // Timestamp of last focus enter event (for deduplication)
74+ lastFocusLeaveTime int64 // Timestamp of last focus leave event (for deduplication)
7075
7176 // Stacked panes support - terminal branch nodes
72- isStacked bool // Whether this node contains stacked panes
73- stackedPanes []*paneNode // List of stacked panes (if isStacked)
74- activeStackIndex int // Index of currently visible pane in stack
75- titleBar uintptr // GtkBox for title bar (when collapsed)
76- stackWrapper uintptr // Internal GtkBox containing the actual stacked widgets (titles + webviews)
77+ isStacked bool // Whether this node contains stacked panes
78+ stackedPanes []*paneNode // List of stacked panes (if isStacked)
79+ activeStackIndex int // Index of currently visible pane in stack
80+ titleBar gtk.Widgetter // *gtk.Box for title bar (when collapsed)
81+ stackWrapper gtk.Widgetter // Internal *gtk.Box containing the actual stacked widgets (titles + webviews)
7782
7883 // Cleanup tracking
7984 widgetValid bool // Guard flagged before GTK destruction
@@ -90,5 +95,15 @@ const (
9095 stackContainerClass = "stacked-pane-container"
9196 )
9297
98+// pendingPopup tracks a popup WebView that has been created by WebKit but not
99+// yet fully initialized/inserted into the workspace. It allows the ready-to-show
100+// handler to finish setup once WebKit has configured window properties.
101+type pendingPopup struct {
102+ wrappedView *webkit.WebView
103+ parentView *webkit.WebView
104+ parentNode *paneNode
105+ url string
106+}
107+
93108 // Focus calculation epsilon for geometric comparisons
94109 const focusEpsilon = 1e-3
M internal/app/browser/workspace_utils.go +89 -106
@@ -6,13 +6,10 @@ import (
66 "log"
77
88 "github.com/bnema/dumber/pkg/webkit"
9+ "github.com/diamondburned/gotk4/pkg/glib/v2"
10+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
911 )
1012
11-// hasMultiplePanes returns true if there are multiple panes in the workspace
12-func (wm *WorkspaceManager) hasMultiplePanes() bool {
13- return wm != nil && wm.app != nil && len(wm.app.panes) > 1
14-}
15-
1613 // UpdateTitleBar updates the title bar label for a WebView in stacked panes
1714 func (wm *WorkspaceManager) UpdateTitleBar(webView *webkit.WebView, title string) {
1815 // Delegate to StackedPaneManager which has the correct logic
@@ -36,171 +33,157 @@ func (wm *WorkspaceManager) ensureGUIInPane(pane *BrowserPane) {
3633 }
3734
3835 // mapDirection maps a direction string to GTK orientation and positioning
39-func mapDirection(direction string) (webkit.Orientation, bool) {
36+func mapDirection(direction string) (gtk.Orientation, bool) {
4037 switch direction {
4138 case "left":
42- return webkit.OrientationHorizontal, false
39+ return gtk.OrientationHorizontal, false
4340 case "up":
44- return webkit.OrientationVertical, false
41+ return gtk.OrientationVertical, false
4542 case "down":
46- return webkit.OrientationVertical, true
43+ return gtk.OrientationVertical, true
4744 default:
48- return webkit.OrientationHorizontal, true
45+ return gtk.OrientationHorizontal, true
4946 }
5047 }
5148
5249 // Widget management utilities
5350
5451 // initializePaneWidgets sets up widgets for a paneNode
55-func (wm *WorkspaceManager) initializePaneWidgets(node *paneNode, containerPtr uintptr) {
56- node.container = containerPtr
52+func (wm *WorkspaceManager) initializePaneWidgets(node *paneNode, widget gtk.Widgetter) {
53+ node.container = widget
5754
5855 // Add base CSS class to the container
59- if node.container != 0 && webkit.WidgetIsValid(node.container) {
56+ if node.container != nil {
6057 webkit.WidgetAddCSSClass(node.container, basePaneClass)
6158 }
6259
63- // Initialize other widget fields as zero (will be set when needed)
64- node.titleBar = 0
65- node.stackWrapper = 0
60+ // Initialize other widget fields as nil (will be set when needed)
61+ node.titleBar = nil
62+ node.stackWrapper = nil
6663
6764 // Initialize cleanup tracking fields
6865 node.widgetValid = true
6966 node.cleanupGeneration = 0
7067 }
7168
72-// setContainer sets the container widget pointer
73-func (wm *WorkspaceManager) setContainer(node *paneNode, ptr uintptr, typeInfo string) {
69+// setContainer sets the container widget
70+func (wm *WorkspaceManager) setContainer(node *paneNode, widget gtk.Widgetter, typeInfo string) {
7471 _ = typeInfo // Type info kept for API compatibility but not used
75- node.container = ptr
72+ node.container = widget
7673
7774 // Mark widget validity based on container presence so idle guards run for stacks too
78- if ptr != 0 {
75+ if widget != nil {
7976 node.widgetValid = true
8077 } else {
8178 node.widgetValid = false
8279 }
8380 }
8481
85-// setTitleBar sets the titleBar widget pointer
86-func (wm *WorkspaceManager) setTitleBar(node *paneNode, ptr uintptr) {
87- node.titleBar = ptr
82+// setTitleBar sets the titleBar widget
83+func (wm *WorkspaceManager) setTitleBar(node *paneNode, widget gtk.Widgetter) {
84+ node.titleBar = widget
8885 }
8986
90-// setStackWrapper sets the stackWrapper widget pointer
91-func (wm *WorkspaceManager) setStackWrapper(node *paneNode, ptr uintptr) {
92- node.stackWrapper = ptr
87+// setStackWrapper sets the stackWrapper widget
88+func (wm *WorkspaceManager) setStackWrapper(node *paneNode, widget gtk.Widgetter) {
89+ node.stackWrapper = widget
9390 }
9491
9592 // Centralized Active Pane Border Management System
9693 // Handles all pane types: regular, popup, stacked, split panes
9794
98-// PaneBorderContext holds the context for applying borders to any pane type
99-type PaneBorderContext struct {
100- webViewWidget uintptr // The WebView's native widget (for margin)
101- borderContainer uintptr // The container that gets background color
102- cssClasses []string // Additional CSS classes to apply
103- paneType string // Description for debugging
104-}
105-
106-// determineBorderContext analyzes a pane node and determines the correct border context
107-func (wm *WorkspaceManager) determineBorderContext(node *paneNode) *PaneBorderContext {
95+// borderTargets returns the widgets that should reflect the pane border state.
96+func (wm *WorkspaceManager) borderTargets(node *paneNode) []gtk.Widgetter {
10897 if node == nil {
10998 return nil
11099 }
111100
112- ctx := &PaneBorderContext{}
113-
114- // Step 1: Get WebView widget for margin (same for all pane types)
115- if node.pane != nil && node.pane.webView != nil {
116- ctx.webViewWidget = node.pane.webView.RootWidget()
101+ targets := make([]gtk.Widgetter, 0, 4)
102+ seen := make(map[*gtk.Widget]struct{})
103+ add := func(widget gtk.Widgetter) {
104+ if widget == nil {
105+ return
106+ }
107+ if base := gtk.BaseWidget(widget); base != nil {
108+ if _, exists := seen[base]; exists {
109+ return
110+ }
111+ seen[base] = struct{}{}
112+ }
113+ targets = append(targets, widget)
117114 }
118115
119- // Step 2: Determine the border container based on pane type
120- switch {
121- case node.windowType == webkit.WindowTypePopup:
122- // Popup panes: Border on popup container
123- ctx.borderContainer = node.container
124- ctx.cssClasses = []string{activePaneClass}
125- ctx.paneType = "popup"
116+ // Primary container for this node.
117+ add(node.container)
118+ add(node.stackWrapper)
126119
127- case node.parent != nil && node.parent.isStacked:
128- // Pane in stack: Border on stack wrapper container
129- ctx.borderContainer = node.parent.stackWrapper
130- ctx.cssClasses = []string{activePaneClass}
131- ctx.paneType = "stacked"
120+ // Highlight stacks via their outer container as well.
121+ if node.parent != nil && node.parent.isStacked {
122+ add(node.parent.container)
123+ add(node.parent.stackWrapper)
124+ }
132125
133- default:
134- // Regular pane: Border on pane container
135- ctx.borderContainer = node.container
136- ctx.cssClasses = []string{activePaneClass}
137- ctx.paneType = "regular"
126+ // Fallback to the WebView root widget to cover any edge cases where the
127+ // container reference is temporarily nil.
128+ if node.pane != nil && node.pane.webView != nil {
129+ add(node.pane.webView.RootWidget())
138130 }
139131
140- return ctx
132+ if len(targets) == 0 {
133+ return nil
134+ }
135+ return targets
141136 }
142137
143-// applyActivePaneBorder applies the active pane border using the border context
144-func (wm *WorkspaceManager) applyActivePaneBorder(ctx *PaneBorderContext) {
145- if ctx == nil {
146- log.Printf("[workspace] applyActivePaneBorder: nil context")
138+// applyActivePaneBorder marks the target widgets as active for CSS styling.
139+func (wm *WorkspaceManager) applyActivePaneBorder(node *paneNode) {
140+ if wm == nil || node == nil {
147141 return
148142 }
149143
150- // Skip applying border if there's only one pane
151- if !wm.hasMultiplePanes() {
152- log.Printf("[workspace] Skipping active border: only one pane exists")
144+ // Only show active border if there are 2+ panes
145+ leaves := wm.collectLeaves()
146+ if len(leaves) < 2 {
153147 return
154148 }
155149
156- // Apply margin to WebView widget (creates space for border)
157- if ctx.webViewWidget != 0 && webkit.WidgetIsValid(ctx.webViewWidget) {
158- webkit.WidgetSetMargin(ctx.webViewWidget, 2)
159- }
160-
161- // Apply CSS classes to border container
162- if ctx.borderContainer != 0 && webkit.WidgetIsValid(ctx.borderContainer) {
163- for _, class := range ctx.cssClasses {
164- webkit.WidgetAddCSSClass(ctx.borderContainer, class)
150+ targets := wm.borderTargets(node)
151+ for _, widget := range targets {
152+ if widget == nil {
153+ continue
154+ }
155+ // Only add CSS class if it doesn't already exist to prevent bloom filter errors
156+ if !webkit.WidgetHasCSSClass(widget, activePaneClass) {
157+ webkit.WidgetAddCSSClass(widget, activePaneClass)
158+ log.Printf("[workspace] Applied active border to pane %p (widget=%p)", node, widget)
165159 }
166160 }
167-
168- log.Printf("[workspace] Applied active border: type=%s webView=%#x container=%#x",
169- ctx.paneType, ctx.webViewWidget, ctx.borderContainer)
170161 }
171162
172-// removeActivePaneBorder removes the active pane border from a node
163+// removeActivePaneBorder clears the active styling from a pane.
173164 func (wm *WorkspaceManager) removeActivePaneBorder(node *paneNode) {
174- if node == nil {
175- return
176- }
177-
178- ctx := wm.determineBorderContext(node)
179- if ctx == nil {
165+ if wm == nil || node == nil {
180166 return
181167 }
182168
183- // Remove margin from WebView widget
184- if ctx.webViewWidget != 0 && webkit.WidgetIsValid(ctx.webViewWidget) {
185- webkit.WidgetSetMargin(ctx.webViewWidget, 0)
186- }
187-
188- // Remove CSS classes from border container
189- if ctx.borderContainer != 0 && webkit.WidgetIsValid(ctx.borderContainer) {
190- for _, class := range ctx.cssClasses {
191- webkit.WidgetRemoveCSSClass(ctx.borderContainer, class)
169+ targets := wm.borderTargets(node)
170+ for _, widget := range targets {
171+ if widget == nil {
172+ continue
173+ }
174+ // Only remove CSS class if it exists to prevent bloom filter errors
175+ if webkit.WidgetHasCSSClass(widget, activePaneClass) {
176+ webkit.WidgetRemoveCSSClass(widget, activePaneClass)
177+ log.Printf("[workspace] Removed active border from pane %p (widget=%p)", node, widget)
192178 }
193179 }
194-
195- log.Printf("[workspace] Removed active border: type=%s webView=%#x container=%#x",
196- ctx.paneType, ctx.webViewWidget, ctx.borderContainer)
197180 }
198181
199182 // Widget cleanup utilities
200183
201184 // cleanupWidget safely invalidates and cleans up a widget
202-func (wm *WorkspaceManager) cleanupWidget(ptr uintptr) {
203- if ptr == 0 {
185+func (wm *WorkspaceManager) cleanupWidget(widget gtk.Widgetter) {
186+ if widget == nil {
204187 return
205188 }
206189
@@ -216,7 +199,7 @@ func (wm *WorkspaceManager) scheduleIdleGuarded(fn func() bool, nodes ...*paneNo
216199 }
217200 guards := uniquePaneNodes(nodes...)
218201 var handle uintptr
219- handle = webkit.IdleAdd(func() bool {
202+ handle = uintptr(glib.IdleAdd(func() bool {
220203 defer wm.releaseIdleHandle(handle)
221204 for _, node := range guards {
222205 if node == nil {
@@ -227,7 +210,7 @@ func (wm *WorkspaceManager) scheduleIdleGuarded(fn func() bool, nodes ...*paneNo
227210 }
228211 }
229212 return fn()
230- })
213+ }))
231214 if handle != 0 {
232215 wm.registerIdleHandle(handle, guards...)
233216 }
@@ -301,7 +284,7 @@ func (wm *WorkspaceManager) cancelIdleHandle(handle uintptr) {
301284 return
302285 }
303286 wm.releaseIdleHandle(handle)
304- webkit.IdleRemove(handle)
287+ glib.SourceRemove(glib.SourceHandle(handle))
305288 }
306289
307290 func (wm *WorkspaceManager) cancelIdleHandles(node *paneNode) {
@@ -328,19 +311,19 @@ func formatPaneInfo(node *paneNode) string {
328311
329312 var info string
330313 if node.isLeaf {
331- info = fmt.Sprintf("leaf(container=%#x", node.container)
314+ info = fmt.Sprintf("leaf(container=%p", node.container)
332315 if node.pane != nil && node.pane.webView != nil {
333- info += fmt.Sprintf(" webview=%s", node.pane.webView.ID())
316+ info += fmt.Sprintf(" webview=%d", node.pane.webView.ID())
334317 }
335318 if node.windowType == webkit.WindowTypePopup {
336319 info += " popup"
337320 }
338321 info += ")"
339322 } else if node.isStacked {
340- info = fmt.Sprintf("stack(panes=%d active=%d container=%#x)",
323+ info = fmt.Sprintf("stack(panes=%d active=%d container=%p)",
341324 len(node.stackedPanes), node.activeStackIndex, node.container)
342325 } else {
343- info = fmt.Sprintf("branch(container=%#x orientation=%v)",
326+ info = fmt.Sprintf("branch(container=%p orientation=%v)",
344327 node.container, node.orientation)
345328 }
346329
D internal/app/messaging/deduplicator.go +0 -113
@@ -1,113 +0,0 @@
1-package messaging
2-
3-import (
4- "crypto/sha256"
5- "fmt"
6- "log"
7- "sync"
8- "time"
9-)
10-
11-type RequestFingerprint struct {
12- URL string
13- WebViewID string
14- Timestamp int64
15- Hash string
16-}
17-
18-type PaneRequestDeduplicator struct {
19- mu sync.RWMutex
20- recentRequests map[string]*RequestFingerprint
21- requestIDs map[string]bool
22- debounceWindow time.Duration
23- cleanupInterval time.Duration
24- lastCleanup time.Time
25-}
26-
27-func NewPaneRequestDeduplicator() *PaneRequestDeduplicator {
28- return &PaneRequestDeduplicator{
29- recentRequests: make(map[string]*RequestFingerprint),
30- requestIDs: make(map[string]bool),
31- debounceWindow: 200 * time.Millisecond,
32- cleanupInterval: 5 * time.Second,
33- lastCleanup: time.Now(),
34- }
35-}
36-
37-func (d *PaneRequestDeduplicator) generateFingerprint(intent *WindowIntent, webViewID string) string {
38- normalizedTime := intent.Timestamp / 100
39- data := fmt.Sprintf("%s:%s:%d", intent.URL, webViewID, normalizedTime)
40- hash := sha256.Sum256([]byte(data))
41- return fmt.Sprintf("%x", hash[:8])
42-}
43-
44-func (d *PaneRequestDeduplicator) IsDuplicate(intent *WindowIntent, webViewID string) (bool, string) {
45- d.mu.Lock()
46- defer d.mu.Unlock()
47-
48- if time.Since(d.lastCleanup) > d.cleanupInterval {
49- d.cleanup()
50- }
51-
52- if intent.RequestID != "" {
53- if d.requestIDs[intent.RequestID] {
54- return true, fmt.Sprintf("duplicate request ID: %s", intent.RequestID)
55- }
56- }
57-
58- fingerprint := d.generateFingerprint(intent, webViewID)
59-
60- if existing, exists := d.recentRequests[fingerprint]; exists {
61- timeDiff := time.Duration(intent.Timestamp-existing.Timestamp) * time.Millisecond
62- if timeDiff < d.debounceWindow {
63- return true, fmt.Sprintf("duplicate content fingerprint: %s (within %v)", fingerprint, timeDiff)
64- }
65- }
66-
67- d.recentRequests[fingerprint] = &RequestFingerprint{
68- URL: intent.URL,
69- WebViewID: webViewID,
70- Timestamp: intent.Timestamp,
71- Hash: fingerprint,
72- }
73-
74- if intent.RequestID != "" {
75- d.requestIDs[intent.RequestID] = true
76- }
77-
78- return false, ""
79-}
80-
81-func (d *PaneRequestDeduplicator) cleanup() {
82- now := time.Now().UnixMilli()
83- cutoff := now - int64(d.debounceWindow*3/time.Millisecond)
84-
85- for key, req := range d.recentRequests {
86- if now-req.Timestamp > cutoff {
87- delete(d.recentRequests, key)
88- }
89- }
90-
91- if len(d.requestIDs) > 100 {
92- d.requestIDs = make(map[string]bool)
93- }
94-
95- d.lastCleanup = time.Now()
96- log.Printf("[deduplicator] Cleanup completed: %d fingerprints, %d request IDs", len(d.recentRequests), len(d.requestIDs))
97-}
98-
99-// ClearRequestID removes a specific request ID from the deduplication cache
100-// This allows the same RequestID to be used again (e.g., for OAuth verification -> real popup flow)
101-func (d *PaneRequestDeduplicator) ClearRequestID(requestID string) {
102- if requestID == "" {
103- return
104- }
105-
106- d.mu.Lock()
107- defer d.mu.Unlock()
108-
109- if d.requestIDs[requestID] {
110- delete(d.requestIDs, requestID)
111- log.Printf("[deduplicator] Cleared request ID: %s", requestID)
112- }
113-}
M internal/app/messaging/handler.go +2 -51
@@ -53,26 +53,6 @@ type Message struct {
5353 Payload json.RawMessage `json:"payload"`
5454 }
5555
56-// WindowIntent represents an intercepted window.open call from JavaScript
57-type WindowIntent struct {
58- URL string `json:"url"`
59- Target string `json:"target"`
60- Features string `json:"features"`
61- Timestamp int64 `json:"timestamp"`
62- WindowType string `json:"windowType"` // "tab", "popup", "unknown"
63- IsPopup bool `json:"isPopup"`
64- IsTab bool `json:"isTab"`
65- RequestID string `json:"requestId"` // NEW: Unique request identifier
66- UserTriggered bool `json:"userTriggered"` // NEW: Whether this was user-initiated
67- // Parsed features
68- Width *int `json:"width,omitempty"`
69- Height *int `json:"height,omitempty"`
70- Toolbar *bool `json:"toolbar,omitempty"`
71- Location *bool `json:"location,omitempty"`
72- Menubar *bool `json:"menubar,omitempty"`
73- Resizable *bool `json:"resizable,omitempty"`
74-}
75-
7656 // NewHandler creates a new message handler
7757 func NewHandler(parserService *services.ParserService, browserService *services.BrowserService) *Handler {
7858 return &Handler{
@@ -123,8 +103,6 @@ func (h *Handler) Handle(payload string) {
123103 h.handleHistoryDelete(msg)
124104 case "workspace":
125105 h.handleWorkspace(msg)
126- case "handle-window-open":
127- h.handleWindowOpen(msg)
128106 case "close-popup":
129107 h.handleClosePopup(msg)
130108 case "console-message":
@@ -194,7 +172,7 @@ func (h *Handler) handleWorkspace(msg Message) {
194172 log.Printf("[workspace] Ignoring workspace event %q: webview not attached", msg.Event)
195173 return
196174 }
197- log.Printf("[workspace] Forwarding workspace event: event=%s direction=%s action=%s", msg.Event, msg.Direction, msg.Action)
175+ log.Printf("[workspace] Forwarding workspace event: event=%s direction=%s action=%s webviewId=%s", msg.Event, msg.Direction, msg.Action, msg.WebViewID)
198176 h.workspaceObserver.OnWorkspaceMessage(h.webView, msg)
199177 }
200178
@@ -530,33 +508,6 @@ func (h *Handler) handleHistoryDelete(msg Message) {
530508 _ = h.webView.InjectScript("window.__dumber_history_deleted && window.__dumber_history_deleted(" + string(b) + ")")
531509 }
532510
533-// handleWindowOpen processes handle-window-open messages to create panes directly
534-func (h *Handler) handleWindowOpen(msg Message) {
535- var intent WindowIntent
536- if err := json.Unmarshal(msg.Payload, &intent); err != nil {
537- log.Printf("[messaging] Failed to unmarshal handle-window-open: %v", err)
538- return
539- }
540-
541- log.Printf("[messaging] Handling direct window.open request: url=%s type=%s requestId=%s", intent.URL, intent.WindowType, intent.RequestID)
542-
543- // Forward to workspace observer if available
544- if h.workspaceObserver != nil && h.webView != nil {
545- // Create a workspace message to trigger pane creation
546- workspaceMsg := Message{
547- Type: "workspace",
548- Event: "create-pane",
549- URL: intent.URL,
550- Action: intent.WindowType, // "tab" or "popup"
551- RequestID: intent.RequestID, // NEW: Pass through request ID
552- }
553- log.Printf("[messaging] Forwarding to workspace: %+v", workspaceMsg)
554- h.workspaceObserver.OnWorkspaceMessage(h.webView, workspaceMsg)
555- } else {
556- log.Printf("[messaging] No workspace observer available for direct window.open")
557- }
558-}
559-
560511 // handleConsoleMessage processes console-message from JavaScript
561512 func (h *Handler) handleConsoleMessage(msg Message) {
562513 // Check if console capture is enabled
@@ -595,7 +546,7 @@ func (h *Handler) handleWebViewIDRequest(msg Message) {
595546 }
596547
597548 webViewID := h.webView.ID()
598- log.Printf("[messaging] Sending webview ID %s to JavaScript", webViewID)
549+ log.Printf("[messaging] Sending webview ID %d to JavaScript", webViewID)
599550
600551 // Send the webview ID back to JavaScript via custom event
601552 if err := h.webView.DispatchCustomEvent("dumber:webview-id", map[string]any{
M internal/cache/favicon.go +27 -7
@@ -14,6 +14,7 @@ import (
1414 "os"
1515 "path/filepath"
1616 "strings"
17+ "sync"
1718 "time"
1819
1920 "github.com/bnema/dumber/internal/config"
@@ -22,7 +23,8 @@ import (
2223
2324 // FaviconCache manages favicon caching to local files
2425 type FaviconCache struct {
25- cacheDir string
26+ cacheDir string
27+ downloadMutex sync.Mutex // Prevents concurrent downloads of the same favicon
2628 }
2729
2830 // NewFaviconCache creates a new favicon cache
@@ -123,7 +125,13 @@ func (fc *FaviconCache) downloadFavicon(faviconURL string) error {
123125 filename := fc.getFilename(faviconURL)
124126 cachedPath := filepath.Join(fc.cacheDir, filename)
125127
126- // Don't download if already exists and is recent
128+ // Lock to prevent concurrent downloads of the same favicon
129+ // This prevents race conditions on temp files and duplicate downloads
130+ fc.downloadMutex.Lock()
131+ defer fc.downloadMutex.Unlock()
132+
133+ // Double-check if already exists after acquiring lock
134+ // Another goroutine might have downloaded it while we were waiting
127135 if info, err := os.Stat(cachedPath); err == nil {
128136 if time.Since(info.ModTime()) < 7*24*time.Hour {
129137 return nil
@@ -175,8 +183,11 @@ func (fc *FaviconCache) downloadFavicon(faviconURL string) error {
175183 }
176184 }()
177185 defer func() {
178- if err := os.Remove(tempDownloadFile); err != nil {
179- log.Printf("[favicon] failed to remove temp download file: %v", err)
186+ // Clean up temp download file if it still exists
187+ if _, err := os.Stat(tempDownloadFile); err == nil {
188+ if err := os.Remove(tempDownloadFile); err != nil {
189+ log.Printf("[favicon] failed to remove temp download file: %v", err)
190+ }
180191 }
181192 }()
182193
@@ -235,9 +246,14 @@ func (fc *FaviconCache) savePNG(img image.Image, outputPath string) error {
235246 if err != nil {
236247 return fmt.Errorf("failed to create temp file: %w", err)
237248 }
249+
250+ // Track if we've already closed the file
251+ closed := false
238252 defer func() {
239- if err := file.Close(); err != nil {
240- log.Printf("[favicon] failed to close file: %v", err)
253+ if !closed {
254+ if err := file.Close(); err != nil {
255+ log.Printf("[favicon] failed to close file: %v", err)
256+ }
241257 }
242258 }()
243259
@@ -248,7 +264,11 @@ func (fc *FaviconCache) savePNG(img image.Image, outputPath string) error {
248264 return fmt.Errorf("failed to encode PNG: %w", err)
249265 }
250266
251- file.Close()
267+ // Close the file before renaming (required on Windows, good practice on all platforms)
268+ if err := file.Close(); err != nil {
269+ return fmt.Errorf("failed to close file before rename: %w", err)
270+ }
271+ closed = true
252272
253273 // Atomic move
254274 if err := os.Rename(tempFile, outputPath); err != nil {
M internal/cli/root.go +4 -9
@@ -11,7 +11,6 @@ import (
1111 "github.com/bnema/dumber/internal/cache"
1212 "github.com/bnema/dumber/internal/config"
1313 "github.com/bnema/dumber/internal/db"
14- "github.com/bnema/dumber/internal/migrations"
1514 "github.com/bnema/dumber/internal/services"
1615
1716 _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver for database/sql
@@ -193,16 +192,12 @@ func NewRootCmd(version, commit, buildDate string) *cobra.Command {
193192 return rootCmd
194193 }
195194
196-// initializeDatabase runs embedded migrations and ensures database is up to date
197-func initializeDatabase(db *sql.DB, cfg *config.Config) error {
198- // Run embedded migrations - this will create all tables and apply any new migrations
199- if err := migrations.RunEmbeddedMigrations(db); err != nil {
200- return fmt.Errorf("failed to run database migrations: %w", err)
201- }
202-
195+// initializeDatabase ensures configured shortcuts are inserted
196+// Migrations are run automatically in db.InitDB()
197+func initializeDatabase(database *sql.DB, cfg *config.Config) error {
203198 // Insert configured shortcuts (these are additive, won't override existing)
204199 for shortcut, shortcutCfg := range cfg.SearchShortcuts {
205- _, err := db.Exec(
200+ _, err := database.Exec(
206201 "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)",
207202 shortcut, shortcutCfg.URL, shortcutCfg.Description,
208203 )
M internal/config/config.go +33 -4
@@ -282,18 +282,42 @@ type TabKeyConfig struct {
282282 PreviousTab string `mapstructure:"previous_tab" yaml:"previous_tab" json:"previous_tab"`
283283 }
284284
285+// PopupBehavior defines how popup windows should be opened
286+type PopupBehavior string
287+
288+const (
289+ // PopupBehaviorSplit opens popups in a split pane (default)
290+ PopupBehaviorSplit PopupBehavior = "split"
291+ // PopupBehaviorStacked opens popups in a stacked pane
292+ PopupBehaviorStacked PopupBehavior = "stacked"
293+ // PopupBehaviorTabbed opens popups as a new tab
294+ PopupBehaviorTabbed PopupBehavior = "tabbed"
295+ // PopupBehaviorWindowed opens popups in a new workspace window
296+ PopupBehaviorWindowed PopupBehavior = "windowed"
297+)
298+
285299 // PopupBehaviorConfig defines handling for popup windows.
286300 type PopupBehaviorConfig struct {
287- Placement string `mapstructure:"placement" yaml:"placement" json:"placement"`
288- OpenInNewPane bool `mapstructure:"open_in_new_pane" yaml:"open_in_new_pane" json:"open_in_new_pane"`
289- FollowPaneContext bool `mapstructure:"follow_pane_context" yaml:"follow_pane_context" json:"follow_pane_context"`
301+ // Behavior determines how popups are opened (split/stacked/tabbed/windowed)
302+ Behavior PopupBehavior `mapstructure:"behavior" yaml:"behavior" json:"behavior"`
303+
304+ // Placement specifies direction for split behavior ("right", "left", "top", "bottom")
305+ // Only used when Behavior is "split"
306+ Placement string `mapstructure:"placement" yaml:"placement" json:"placement"`
307+
308+ // OpenInNewPane controls whether popups are opened in workspace or blocked
309+ OpenInNewPane bool `mapstructure:"open_in_new_pane" yaml:"open_in_new_pane" json:"open_in_new_pane"`
310+
311+ // FollowPaneContext determines if popup placement follows parent pane context
312+ FollowPaneContext bool `mapstructure:"follow_pane_context" yaml:"follow_pane_context" json:"follow_pane_context"`
290313
291- // New fields for distinguishing window types and behaviors
292314 // BlankTargetBehavior determines how to handle window.open(url, "_blank") intents
293315 // Accepted values: "pane" (default) or "tab" (future support)
294316 BlankTargetBehavior string `mapstructure:"blank_target_behavior" yaml:"blank_target_behavior" json:"blank_target_behavior"`
317+
295318 // EnableSmartDetection uses WebKitWindowProperties to detect popup vs tab intents
296319 EnableSmartDetection bool `mapstructure:"enable_smart_detection" yaml:"enable_smart_detection" json:"enable_smart_detection"`
320+
297321 // OAuthAutoClose enables auto-closing OAuth popups after successful auth redirects
298322 OAuthAutoClose bool `mapstructure:"oauth_auto_close" yaml:"oauth_auto_close" json:"oauth_auto_close"`
299323 }
@@ -304,6 +328,9 @@ type WorkspaceStylingConfig struct {
304328 BorderWidth int `mapstructure:"border_width" yaml:"border_width" json:"border_width"`
305329 // BorderColor for focused panes (CSS color value or theme variable)
306330 BorderColor string `mapstructure:"border_color" yaml:"border_color" json:"border_color"`
331+ // PaneModeBorderColor for the pane mode indicator border (CSS color value or theme variable)
332+ // Defaults to "#FFA500" (orange) if not set
333+ PaneModeBorderColor string `mapstructure:"pane_mode_border_color" yaml:"pane_mode_border_color" json:"pane_mode_border_color"`
307334 // TransitionDuration in milliseconds for border animations
308335 TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" json:"transition_duration"`
309336 // BorderRadius in pixels for pane border corners
@@ -733,6 +760,7 @@ func (m *Manager) setDefaults() {
733760 m.viper.SetDefault("workspace.tabs.close_tab", defaults.Workspace.Tabs.CloseTab)
734761 m.viper.SetDefault("workspace.tabs.next_tab", defaults.Workspace.Tabs.NextTab)
735762 m.viper.SetDefault("workspace.tabs.previous_tab", defaults.Workspace.Tabs.PreviousTab)
763+ m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior))
736764 m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)
737765 m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)
738766 m.viper.SetDefault("workspace.popups.follow_pane_context", defaults.Workspace.Popups.FollowPaneContext)
@@ -742,6 +770,7 @@ func (m *Manager) setDefaults() {
742770 m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose)
743771 m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)
744772 m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor)
773+ m.viper.SetDefault("workspace.styling.pane_mode_border_color", defaults.Workspace.Styling.PaneModeBorderColor)
745774 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
746775 m.viper.SetDefault("workspace.styling.border_radius", defaults.Workspace.Styling.BorderRadius)
747776 }
M internal/config/defaults.go +11 -8
@@ -32,10 +32,11 @@ const (
3232 defaultPopupPlacement = "right"
3333
3434 // Workspace styling defaults
35- defaultBorderWidth = 2
36- defaultBorderColor = "@theme_selected_bg_color"
37- defaultTransitionDuration = 120
38- defaultBorderRadius = 0
35+ defaultBorderWidth = 2
36+ defaultBorderColor = "@theme_selected_bg_color"
37+ defaultPaneModeBorderColor = "#FFA500" // Orange for pane mode indicator
38+ defaultTransitionDuration = 120
39+ defaultBorderRadius = 0
3940 )
4041
4142 // getDefaultLogDir returns the default log directory, falls back to empty string on error
@@ -191,6 +192,7 @@ func DefaultConfig() *Config {
191192 PreviousTab: "cmdorctrl+shift+tab",
192193 },
193194 Popups: PopupBehaviorConfig{
195+ Behavior: PopupBehaviorSplit, // Default: open popups in split panes
194196 Placement: defaultPopupPlacement,
195197 OpenInNewPane: true,
196198 FollowPaneContext: true,
@@ -199,10 +201,11 @@ func DefaultConfig() *Config {
199201 OAuthAutoClose: true, // Auto-close OAuth popups on success
200202 },
201203 Styling: WorkspaceStylingConfig{
202- BorderWidth: defaultBorderWidth,
203- BorderColor: defaultBorderColor,
204- TransitionDuration: defaultTransitionDuration,
205- BorderRadius: defaultBorderRadius,
204+ BorderWidth: defaultBorderWidth,
205+ BorderColor: defaultBorderColor,
206+ PaneModeBorderColor: defaultPaneModeBorderColor,
207+ TransitionDuration: defaultTransitionDuration,
208+ BorderRadius: defaultBorderRadius,
206209 },
207210 },
208211 ContentFilteringWhitelist: []string{
M internal/db/certificate_validations.sql.go +24 -2
@@ -31,8 +31,8 @@ func (q *Queries) DeleteExpiredCertificateValidations(ctx context.Context) error
3131 }
3232
3333 const GetCertificateValidation = `-- name: GetCertificateValidation :one
34-SELECT id, hostname, certificate_hash, user_decision, created_at, expires_at FROM certificate_validations
35-WHERE hostname = ? AND certificate_hash = ?
34+SELECT id, hostname, certificate_hash, user_decision, created_at, expires_at FROM certificate_validations
35+WHERE hostname = ? AND certificate_hash = ?
3636 AND (expires_at IS NULL OR expires_at > datetime('now'))
3737 LIMIT 1
3838 `
@@ -51,6 +51,28 @@ func (q *Queries) GetCertificateValidation(ctx context.Context, hostname string,
5151 return i, err
5252 }
5353
54+const GetCertificateValidationByHostname = `-- name: GetCertificateValidationByHostname :one
55+SELECT id, hostname, certificate_hash, user_decision, created_at, expires_at FROM certificate_validations
56+WHERE hostname = ?
57+AND (expires_at IS NULL OR expires_at > datetime('now'))
58+ORDER BY created_at DESC
59+LIMIT 1
60+`
61+
62+func (q *Queries) GetCertificateValidationByHostname(ctx context.Context, hostname string) (CertificateValidation, error) {
63+ row := q.db.QueryRowContext(ctx, GetCertificateValidationByHostname, hostname)
64+ var i CertificateValidation
65+ err := row.Scan(
66+ &i.ID,
67+ &i.Hostname,
68+ &i.CertificateHash,
69+ &i.UserDecision,
70+ &i.CreatedAt,
71+ &i.ExpiresAt,
72+ )
73+ return i, err
74+}
75+
5476 const StoreCertificateValidation = `-- name: StoreCertificateValidation :exec
5577 INSERT OR REPLACE INTO certificate_validations (hostname, certificate_hash, user_decision, expires_at)
5678 VALUES (?, ?, ?, ?)
M internal/db/init.go +12 -43
@@ -8,6 +8,7 @@ import (
88 "path/filepath"
99
1010 "github.com/bnema/dumber/internal/config"
11+ "github.com/bnema/dumber/internal/migrations"
1112 _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver
1213 _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite
1314 )
@@ -37,12 +38,20 @@ func InitDB(dbPath string) (*sql.DB, error) {
3738 return nil, fmt.Errorf("failed to connect to database: %w", err)
3839 }
3940
40- // Initialize database schema
41- if err := initializeSchema(database); err != nil {
41+ // Run embedded migrations - single source of truth for schema initialization
42+ if err := migrations.RunEmbeddedMigrations(database); err != nil {
4243 if err := database.Close(); err != nil {
4344 log.Printf("Warning: failed to close database: %v", err)
4445 }
45- return nil, fmt.Errorf("failed to initialize database schema: %w", err)
46+ return nil, fmt.Errorf("failed to run database migrations: %w", err)
47+ }
48+
49+ // Verify all migrations are applied
50+ if err := migrations.VerifyAllMigrationsApplied(database); err != nil {
51+ if err := database.Close(); err != nil {
52+ log.Printf("Warning: failed to close database: %v", err)
53+ }
54+ return nil, fmt.Errorf("migration verification failed: %w", err)
4655 }
4756
4857 return database, nil
@@ -66,46 +75,6 @@ func InitDBWithConfig(dbPath string, cfg *config.Config) (*sql.DB, error) {
6675 return db, nil
6776 }
6877
69-// initializeSchema creates the database schema if it doesn't exist
70-func initializeSchema(db *sql.DB) error {
71- schema := `
72- -- History tracking for visited URLs
73- CREATE TABLE IF NOT EXISTS history (
74- id INTEGER PRIMARY KEY AUTOINCREMENT,
75- url TEXT NOT NULL UNIQUE,
76- title TEXT,
77- visit_count INTEGER DEFAULT 1,
78- last_visited DATETIME DEFAULT CURRENT_TIMESTAMP,
79- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
80- );
81-
82- CREATE INDEX IF NOT EXISTS idx_history_url ON history(url);
83- CREATE INDEX IF NOT EXISTS idx_history_last_visited ON history(last_visited);
84-
85- -- URL shortcuts configuration
86- CREATE TABLE IF NOT EXISTS shortcuts (
87- id INTEGER PRIMARY KEY AUTOINCREMENT,
88- shortcut TEXT NOT NULL UNIQUE,
89- url_template TEXT NOT NULL,
90- description TEXT,
91- created_at DATETIME DEFAULT CURRENT_TIMESTAMP
92- );
93-
94- -- Zoom persistence per domain (not full URL)
95- CREATE TABLE IF NOT EXISTS zoom_levels (
96- domain TEXT PRIMARY KEY,
97- zoom_factor REAL NOT NULL DEFAULT 1.0 CHECK(zoom_factor >= 0.3 AND zoom_factor <= 5.0),
98- updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
99- );
100-
101- CREATE INDEX IF NOT EXISTS idx_zoom_levels_updated_at ON zoom_levels(updated_at);
102- `
103-
104- // Execute schema creation
105- _, err := db.Exec(schema)
106- return err
107-}
108-
10978 // initializeShortcuts inserts configured shortcuts into the database
11079 func initializeShortcuts(db *sql.DB, cfg *config.Config) error {
11180 if cfg == nil || cfg.SearchShortcuts == nil {
M internal/db/querier.go +1 -0
@@ -19,6 +19,7 @@ type Querier interface {
1919 // Delete zoom level setting for a domain
2020 DeleteZoomLevel(ctx context.Context, domain string) error
2121 GetCertificateValidation(ctx context.Context, hostname string, certificateHash string) (CertificateValidation, error)
22+ GetCertificateValidationByHostname(ctx context.Context, hostname string) (CertificateValidation, error)
2223 GetHistory(ctx context.Context, limit int64) ([]History, error)
2324 GetHistoryEntry(ctx context.Context, url string) (History, error)
2425 GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error)
M internal/db/queries/certificate_validations.sql +9 -2
@@ -1,9 +1,16 @@
11 -- name: GetCertificateValidation :one
2-SELECT * FROM certificate_validations
3-WHERE hostname = ? AND certificate_hash = ?
2+SELECT * FROM certificate_validations
3+WHERE hostname = ? AND certificate_hash = ?
44 AND (expires_at IS NULL OR expires_at > datetime('now'))
55 LIMIT 1;
66
7+-- name: GetCertificateValidationByHostname :one
8+SELECT * FROM certificate_validations
9+WHERE hostname = ?
10+AND (expires_at IS NULL OR expires_at > datetime('now'))
11+ORDER BY created_at DESC
12+LIMIT 1;
13+
714 -- name: StoreCertificateValidation :exec
815 INSERT OR REPLACE INTO certificate_validations (hostname, certificate_hash, user_decision, expires_at)
916 VALUES (?, ?, ?, ?);
M internal/migrations/migrations.go +46 -0
@@ -78,6 +78,10 @@ func RunEmbeddedMigrations(db *sql.DB) error {
7878 return fmt.Errorf("failed to create migrations table: %w", err)
7979 }
8080
81+ // NOTE: Legacy databases without migration tracking should be deleted and recreated.
82+ // All new databases will have migration tracking from the start.
83+ // Backfill logic was removed to maintain idempotency - migrations are the single source of truth.
84+
8185 migrations, err := GetMigrations()
8286 if err != nil {
8387 return fmt.Errorf("failed to load migrations: %w", err)
@@ -188,3 +192,45 @@ func GetAppliedMigrations(db *sql.DB) ([]int, error) {
188192
189193 return versions, nil
190194 }
195+
196+// VerifyAllMigrationsApplied scans all embedded migrations and verifies they have been applied
197+// Returns an error if any migrations are missing or if verification fails
198+func VerifyAllMigrationsApplied(db *sql.DB) error {
199+ // Get all embedded migrations
200+ allMigrations, err := GetMigrations()
201+ if err != nil {
202+ return fmt.Errorf("failed to get embedded migrations: %w", err)
203+ }
204+
205+ // Get applied migrations from database
206+ appliedVersions, err := GetAppliedMigrations(db)
207+ if err != nil {
208+ return fmt.Errorf("failed to get applied migrations: %w", err)
209+ }
210+
211+ // Create map for quick lookup
212+ appliedMap := make(map[int]bool)
213+ for _, version := range appliedVersions {
214+ appliedMap[version] = true
215+ }
216+
217+ // Check each migration is applied
218+ var missingMigrations []Migration
219+ for _, migration := range allMigrations {
220+ if !appliedMap[migration.Version] {
221+ missingMigrations = append(missingMigrations, migration)
222+ }
223+ }
224+
225+ // Report results
226+ if len(missingMigrations) > 0 {
227+ log.Printf("WARNING: %d migrations are not applied:", len(missingMigrations))
228+ for _, migration := range missingMigrations {
229+ log.Printf(" - Migration %03d: %s", migration.Version, migration.Name)
230+ }
231+ return fmt.Errorf("%d migrations are not applied", len(missingMigrations))
232+ }
233+
234+ log.Printf("Migration verification: All %d migrations are applied ✓", len(allMigrations))
235+ return nil
236+}
A internal/migrations/migrations_test.go +438 -0
@@ -0,0 +1,438 @@
1+package migrations
2+
3+import (
4+ "database/sql"
5+ "path/filepath"
6+ "testing"
7+
8+ _ "github.com/ncruces/go-sqlite3/driver"
9+ _ "github.com/ncruces/go-sqlite3/embed"
10+)
11+
12+// TestMigrationsAreIdempotent verifies that running migrations multiple times doesn't cause errors
13+func TestMigrationsAreIdempotent(t *testing.T) {
14+ // Create temporary database
15+ tmpDir := t.TempDir()
16+ dbPath := filepath.Join(tmpDir, "test.db")
17+
18+ // Open database
19+ db, err := sql.Open("sqlite3", dbPath)
20+ if err != nil {
21+ t.Fatalf("Failed to open test database: %v", err)
22+ }
23+ defer db.Close()
24+
25+ // Run migrations first time
26+ if err := RunEmbeddedMigrations(db); err != nil {
27+ t.Fatalf("First migration run failed: %v", err)
28+ }
29+
30+ // Get applied migrations after first run
31+ appliedFirst, err := GetAppliedMigrations(db)
32+ if err != nil {
33+ t.Fatalf("Failed to get applied migrations after first run: %v", err)
34+ }
35+
36+ if len(appliedFirst) == 0 {
37+ t.Fatal("No migrations were applied on first run")
38+ }
39+
40+ // Run migrations second time - should be idempotent
41+ if err := RunEmbeddedMigrations(db); err != nil {
42+ t.Fatalf("Second migration run failed (not idempotent): %v", err)
43+ }
44+
45+ // Get applied migrations after second run
46+ appliedSecond, err := GetAppliedMigrations(db)
47+ if err != nil {
48+ t.Fatalf("Failed to get applied migrations after second run: %v", err)
49+ }
50+
51+ // Verify same migrations are applied
52+ if len(appliedFirst) != len(appliedSecond) {
53+ t.Errorf("Migration count changed: first=%d, second=%d", len(appliedFirst), len(appliedSecond))
54+ }
55+
56+ for i, v := range appliedFirst {
57+ if i >= len(appliedSecond) || v != appliedSecond[i] {
58+ t.Errorf("Applied migrations differ at index %d: first=%d, second=%d", i, v, appliedSecond[i])
59+ }
60+ }
61+
62+ // Run migrations third time to be extra sure
63+ if err := RunEmbeddedMigrations(db); err != nil {
64+ t.Fatalf("Third migration run failed (not idempotent): %v", err)
65+ }
66+
67+ appliedThird, err := GetAppliedMigrations(db)
68+ if err != nil {
69+ t.Fatalf("Failed to get applied migrations after third run: %v", err)
70+ }
71+
72+ if len(appliedThird) != len(appliedFirst) {
73+ t.Errorf("Migration count changed on third run: expected=%d, got=%d", len(appliedFirst), len(appliedThird))
74+ }
75+}
76+
77+// TestMigrationTrackingPreventsReapplication verifies that applied migrations are tracked and not re-run
78+func TestMigrationTrackingPreventsReapplication(t *testing.T) {
79+ tmpDir := t.TempDir()
80+ dbPath := filepath.Join(tmpDir, "test.db")
81+
82+ db, err := sql.Open("sqlite3", dbPath)
83+ if err != nil {
84+ t.Fatalf("Failed to open test database: %v", err)
85+ }
86+ defer db.Close()
87+
88+ // Run migrations
89+ if err := RunEmbeddedMigrations(db); err != nil {
90+ t.Fatalf("Migration run failed: %v", err)
91+ }
92+
93+ // Check that schema_migrations table exists and has entries
94+ var count int
95+ err = db.QueryRow("SELECT COUNT(*) FROM schema_migrations").Scan(&count)
96+ if err != nil {
97+ t.Fatalf("Failed to query schema_migrations table: %v", err)
98+ }
99+
100+ if count == 0 {
101+ t.Fatal("No migration tracking entries found")
102+ }
103+
104+ // Verify each migration has exactly one entry
105+ rows, err := db.Query("SELECT version, COUNT(*) FROM schema_migrations GROUP BY version HAVING COUNT(*) > 1")
106+ if err != nil {
107+ t.Fatalf("Failed to check for duplicate migration entries: %v", err)
108+ }
109+ defer rows.Close()
110+
111+ duplicates := []int{}
112+ for rows.Next() {
113+ var version, dupeCount int
114+ if err := rows.Scan(&version, &dupeCount); err != nil {
115+ t.Fatalf("Failed to scan duplicate check: %v", err)
116+ }
117+ duplicates = append(duplicates, version)
118+ }
119+
120+ if len(duplicates) > 0 {
121+ t.Errorf("Found duplicate migration entries for versions: %v", duplicates)
122+ }
123+}
124+
125+// TestAllMigrationsApplyOnFreshDatabase verifies all migrations work on a new database
126+func TestAllMigrationsApplyOnFreshDatabase(t *testing.T) {
127+ tmpDir := t.TempDir()
128+ dbPath := filepath.Join(tmpDir, "test.db")
129+
130+ db, err := sql.Open("sqlite3", dbPath)
131+ if err != nil {
132+ t.Fatalf("Failed to open test database: %v", err)
133+ }
134+ defer db.Close()
135+
136+ // Get expected migrations
137+ expectedMigrations, err := GetMigrations()
138+ if err != nil {
139+ t.Fatalf("Failed to get migrations: %v", err)
140+ }
141+
142+ if len(expectedMigrations) == 0 {
143+ t.Fatal("No migration files found")
144+ }
145+
146+ // Run migrations
147+ if err := RunEmbeddedMigrations(db); err != nil {
148+ t.Fatalf("Failed to run migrations: %v", err)
149+ }
150+
151+ // Verify all migrations were applied
152+ appliedVersions, err := GetAppliedMigrations(db)
153+ if err != nil {
154+ t.Fatalf("Failed to get applied migrations: %v", err)
155+ }
156+
157+ if len(appliedVersions) != len(expectedMigrations) {
158+ t.Errorf("Expected %d migrations to be applied, got %d", len(expectedMigrations), len(appliedVersions))
159+ }
160+
161+ // Verify each expected migration was applied
162+ for i, migration := range expectedMigrations {
163+ if i >= len(appliedVersions) {
164+ t.Errorf("Migration %d (%s) was not applied", migration.Version, migration.Name)
165+ continue
166+ }
167+ if appliedVersions[i] != migration.Version {
168+ t.Errorf("Migration version mismatch at index %d: expected %d, got %d", i, migration.Version, appliedVersions[i])
169+ }
170+ }
171+}
172+
173+// TestMigrationOrdering verifies migrations are applied in correct order
174+func TestMigrationOrdering(t *testing.T) {
175+ migrations, err := GetMigrations()
176+ if err != nil {
177+ t.Fatalf("Failed to get migrations: %v", err)
178+ }
179+
180+ if len(migrations) == 0 {
181+ t.Fatal("No migrations found")
182+ }
183+
184+ // Verify migrations are in ascending order
185+ for i := 1; i < len(migrations); i++ {
186+ if migrations[i].Version <= migrations[i-1].Version {
187+ t.Errorf("Migrations not in ascending order: migration[%d].Version=%d, migration[%d].Version=%d",
188+ i-1, migrations[i-1].Version, i, migrations[i].Version)
189+ }
190+ }
191+
192+ // Verify no gaps in version numbers (should be 1, 2, 3, 4, 5...)
193+ for i, migration := range migrations {
194+ expectedVersion := i + 1
195+ if migration.Version != expectedVersion {
196+ t.Errorf("Migration version gap detected: expected version %d, got %d", expectedVersion, migration.Version)
197+ }
198+ }
199+}
200+
201+// TestMigrationTableCreationIsIdempotent verifies the tracking table can be created multiple times
202+func TestMigrationTableCreationIsIdempotent(t *testing.T) {
203+ tmpDir := t.TempDir()
204+ dbPath := filepath.Join(tmpDir, "test.db")
205+
206+ db, err := sql.Open("sqlite3", dbPath)
207+ if err != nil {
208+ t.Fatalf("Failed to open test database: %v", err)
209+ }
210+ defer db.Close()
211+
212+ // Create tracking table multiple times
213+ for i := 0; i < 3; i++ {
214+ if err := createMigrationsTable(db); err != nil {
215+ t.Fatalf("Failed to create migrations table on attempt %d: %v", i+1, err)
216+ }
217+ }
218+
219+ // Verify table exists and has correct schema
220+ var tableName string
221+ err = db.QueryRow(`
222+ SELECT name FROM sqlite_master
223+ WHERE type='table' AND name='schema_migrations'
224+ `).Scan(&tableName)
225+
226+ if err != nil {
227+ t.Fatalf("Migrations table not found: %v", err)
228+ }
229+
230+ if tableName != "schema_migrations" {
231+ t.Errorf("Expected table name 'schema_migrations', got '%s'", tableName)
232+ }
233+
234+ // Verify table has expected columns
235+ rows, err := db.Query("PRAGMA table_info(schema_migrations)")
236+ if err != nil {
237+ t.Fatalf("Failed to get table info: %v", err)
238+ }
239+ defer rows.Close()
240+
241+ expectedColumns := map[string]bool{
242+ "version": false,
243+ "name": false,
244+ "applied_at": false,
245+ }
246+
247+ for rows.Next() {
248+ var cid int
249+ var name, ctype string
250+ var notnull, pk int
251+ var dfltValue sql.NullString
252+
253+ if err := rows.Scan(&cid, &name, &ctype, &notnull, &dfltValue, &pk); err != nil {
254+ t.Fatalf("Failed to scan table info: %v", err)
255+ }
256+
257+ if _, expected := expectedColumns[name]; expected {
258+ expectedColumns[name] = true
259+ }
260+ }
261+
262+ for col, found := range expectedColumns {
263+ if !found {
264+ t.Errorf("Expected column '%s' not found in schema_migrations table", col)
265+ }
266+ }
267+}
268+
269+// TestPartialMigrationState verifies that a database with some migrations can have new ones applied
270+func TestPartialMigrationState(t *testing.T) {
271+ tmpDir := t.TempDir()
272+ dbPath := filepath.Join(tmpDir, "test.db")
273+
274+ db, err := sql.Open("sqlite3", dbPath)
275+ if err != nil {
276+ t.Fatalf("Failed to open test database: %v", err)
277+ }
278+ defer db.Close()
279+
280+ // Create tracking table
281+ if err := createMigrationsTable(db); err != nil {
282+ t.Fatalf("Failed to create migrations table: %v", err)
283+ }
284+
285+ // Get all migrations
286+ allMigrations, err := GetMigrations()
287+ if err != nil {
288+ t.Fatalf("Failed to get migrations: %v", err)
289+ }
290+
291+ if len(allMigrations) < 2 {
292+ t.Skip("Need at least 2 migrations for this test")
293+ }
294+
295+ // Manually apply only the first migration
296+ firstMigration := allMigrations[0]
297+ tx, err := db.Begin()
298+ if err != nil {
299+ t.Fatalf("Failed to begin transaction: %v", err)
300+ }
301+
302+ if _, err := tx.Exec(firstMigration.SQL); err != nil {
303+ tx.Rollback()
304+ t.Fatalf("Failed to execute first migration: %v", err)
305+ }
306+
307+ if _, err := tx.Exec("INSERT INTO schema_migrations (version, name) VALUES (?, ?)", firstMigration.Version, firstMigration.Name); err != nil {
308+ tx.Rollback()
309+ t.Fatalf("Failed to record first migration: %v", err)
310+ }
311+
312+ if err := tx.Commit(); err != nil {
313+ t.Fatalf("Failed to commit first migration: %v", err)
314+ }
315+
316+ // Verify only one migration is applied
317+ applied, err := GetAppliedMigrations(db)
318+ if err != nil {
319+ t.Fatalf("Failed to get applied migrations: %v", err)
320+ }
321+
322+ if len(applied) != 1 {
323+ t.Fatalf("Expected 1 applied migration, got %d", len(applied))
324+ }
325+
326+ // Now run all migrations - should apply the remaining ones
327+ if err := RunEmbeddedMigrations(db); err != nil {
328+ t.Fatalf("Failed to run remaining migrations: %v", err)
329+ }
330+
331+ // Verify all migrations are now applied
332+ appliedAfter, err := GetAppliedMigrations(db)
333+ if err != nil {
334+ t.Fatalf("Failed to get applied migrations after full run: %v", err)
335+ }
336+
337+ if len(appliedAfter) != len(allMigrations) {
338+ t.Errorf("Expected %d migrations applied, got %d", len(allMigrations), len(appliedAfter))
339+ }
340+
341+ // Verify the first migration wasn't re-applied
342+ if appliedAfter[0] != firstMigration.Version {
343+ t.Errorf("First migration version changed: expected %d, got %d", firstMigration.Version, appliedAfter[0])
344+ }
345+}
346+
347+// TestGetAppliedMigrationsEmptyDatabase verifies behavior on a database with no migrations
348+func TestGetAppliedMigrationsEmptyDatabase(t *testing.T) {
349+ tmpDir := t.TempDir()
350+ dbPath := filepath.Join(tmpDir, "test.db")
351+
352+ db, err := sql.Open("sqlite3", dbPath)
353+ if err != nil {
354+ t.Fatalf("Failed to open test database: %v", err)
355+ }
356+ defer db.Close()
357+
358+ // Query applied migrations on empty database
359+ applied, err := GetAppliedMigrations(db)
360+ if err != nil {
361+ t.Fatalf("GetAppliedMigrations failed on empty database: %v", err)
362+ }
363+
364+ if len(applied) != 0 {
365+ t.Errorf("Expected 0 applied migrations on empty database, got %d", len(applied))
366+ }
367+}
368+
369+// TestVerifyAllMigrationsApplied verifies the verification function works correctly
370+func TestVerifyAllMigrationsApplied(t *testing.T) {
371+ tmpDir := t.TempDir()
372+ dbPath := filepath.Join(tmpDir, "test.db")
373+
374+ db, err := sql.Open("sqlite3", dbPath)
375+ if err != nil {
376+ t.Fatalf("Failed to open test database: %v", err)
377+ }
378+ defer db.Close()
379+
380+ // Run migrations
381+ if err := RunEmbeddedMigrations(db); err != nil {
382+ t.Fatalf("Failed to run migrations: %v", err)
383+ }
384+
385+ // Verify should pass
386+ if err := VerifyAllMigrationsApplied(db); err != nil {
387+ t.Errorf("VerifyAllMigrationsApplied() failed after running all migrations: %v", err)
388+ }
389+
390+ // Test with missing migration - manually delete one from tracking
391+ _, err = db.Exec("DELETE FROM schema_migrations WHERE version = 3")
392+ if err != nil {
393+ t.Fatalf("Failed to delete migration record: %v", err)
394+ }
395+
396+ // Verify should now fail
397+ err = VerifyAllMigrationsApplied(db)
398+ if err == nil {
399+ t.Error("VerifyAllMigrationsApplied() should have failed with missing migration, but it passed")
400+ }
401+}
402+
403+// TestExpectedTablesExistAfterMigrations verifies that all expected tables are created
404+func TestExpectedTablesExistAfterMigrations(t *testing.T) {
405+ tmpDir := t.TempDir()
406+ dbPath := filepath.Join(tmpDir, "test.db")
407+
408+ db, err := sql.Open("sqlite3", dbPath)
409+ if err != nil {
410+ t.Fatalf("Failed to open test database: %v", err)
411+ }
412+ defer db.Close()
413+
414+ // Run migrations
415+ if err := RunEmbeddedMigrations(db); err != nil {
416+ t.Fatalf("Failed to run migrations: %v", err)
417+ }
418+
419+ // Expected tables based on current migrations
420+ expectedTables := []string{
421+ "schema_migrations", // Migration tracking
422+ "history", // 001_initial.sql
423+ "shortcuts", // 001_initial.sql
424+ "zoom_levels", // 003_add_zoom_levels.sql
425+ "certificate_validations", // 005_add_certificate_validations.sql
426+ }
427+
428+ // Check each expected table exists
429+ for _, tableName := range expectedTables {
430+ var name string
431+ err := db.QueryRow("SELECT name FROM sqlite_master WHERE type='table' AND name=?", tableName).Scan(&name)
432+ if err == sql.ErrNoRows {
433+ t.Errorf("Expected table '%s' does not exist", tableName)
434+ } else if err != nil {
435+ t.Fatalf("Failed to check for table '%s': %v", tableName, err)
436+ }
437+ }
438+}
M internal/services/browser.go +6 -27
@@ -93,6 +93,8 @@ func (s *BrowserService) AttachWebView(view *webkit.WebView) {
9393 s.webView = view
9494
9595 // Register legacy favicon change handler (binary data - fallback)
96+ // This is kept for backward compatibility but is rarely used
97+ // Modern favicon handling uses the URI-based handler registered in buildPane()
9698 view.RegisterFaviconChangedHandler(func(data []byte) {
9799 if currentURL := view.GetCurrentURL(); currentURL != "" {
98100 log.Printf("[favicon] Favicon detected for %s, data size: %d bytes", currentURL, len(data))
@@ -100,10 +102,9 @@ func (s *BrowserService) AttachWebView(view *webkit.WebView) {
100102 }
101103 })
102104
103- // Register favicon URI change handler (WebKit native)
104- view.RegisterFaviconURIChangedHandler(func(pageURI, faviconURI string) {
105- s.handleFaviconURIChanged(pageURI, faviconURI)
106- })
105+ // Note: RegisterFaviconURIChangedHandler is now only called in buildPane()
106+ // to avoid duplicate handler registration which causes multiple downloads
107+ // of the same favicon and file handle race conditions
107108 }
108109
109110 // LoadGUIBundle loads the unified GUI bundle from assets
@@ -124,28 +125,6 @@ func (s *BrowserService) GetGUIBundle() string {
124125 return s.guiBundle
125126 }
126127
127-// InjectGUIBundle injects the unified GUI bundle and initializes controls
128-func (s *BrowserService) InjectGUIBundle(ctx context.Context) error {
129- _ = ctx
130- if s.webView == nil {
131- return fmt.Errorf("webview not attached")
132- }
133- if s.guiBundle == "" {
134- return fmt.Errorf("GUI bundle not loaded")
135- }
136-
137- // Inject the unified GUI bundle first
138- bundleScript := s.guiBundle +
139- ";(function(){" +
140- "if(window.__dumber_gui && window.__dumber_gui.initializeControls){" +
141- "window.__dumber_gui.initializeControls();" +
142- "console.log('✅ Controls initialized via GUI bundle');" +
143- "}else{console.error('❌ GUI bundle not properly loaded');}" +
144- "})();"
145-
146- return s.webView.InjectScript(bundleScript)
147-}
148-
149128 // InjectToastSystem injects the GUI bundle and initializes the toast system
150129 func (s *BrowserService) InjectToastSystem(ctx context.Context, theme string) error {
151130 _ = ctx
@@ -628,7 +607,7 @@ func (s *BrowserService) handleFaviconChanged(pageURL string, pngData []byte) {
628607
629608 // Store a reference to the cached file in the database
630609 // We use a consistent path format for the favicon URL
631- faviconCacheURL := fmt.Sprintf("dumber://favicon/%x.png", md5.Sum([]byte(pageURL)))
610+ faviconCacheURL := fmt.Sprintf("dumb://favicon/%x.png", md5.Sum([]byte(pageURL)))
632611 faviconNullString := sql.NullString{String: faviconCacheURL, Valid: true}
633612 if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
634613 // Silently fail - favicon is not critical
D pkg/webkit/build_cgo_stub.go +0 -15
@@ -1,15 +0,0 @@
1-//go:build webkit_cgo
2-
3-package webkit
4-
5-/*
6-#cgo pkg-config: webkitgtk-6.0 gtk4
7-#cgo CFLAGS: -I/usr/include/webkitgtk-6.0
8-#include <webkit/webkit.h>
9-#include <gtk/gtk.h>
10-*/
11-import "C"
12-
13-// This file documents intended CGO linkage. It is excluded from builds unless
14-// the 'webkit_cgo' build tag is specified. Real implementations will move APIs
15-// from the stubs into CGO-backed functions and types.
D pkg/webkit/build_flags_cgo.go +0 -6
@@ -1,6 +0,0 @@
1-//go:build webkit_cgo
2-
3-package webkit
4-
5-// IsNativeAvailable reports whether the native WebKit2GTK backend is compiled in.
6-func IsNativeAvailable() bool { return true }
D pkg/webkit/build_flags_stub.go +0 -22
@@ -1,22 +0,0 @@
1-//go:build !webkit_cgo
2-
3-package webkit
4-
5-// IsNativeAvailable reports whether the native WebKit2GTK backend is compiled in.
6-// In non-CGO builds, this returns false and WebView methods are logical no-ops.
7-func IsNativeAvailable() bool { return false }
8-
9-// InitMainThread is a no-op in non-CGO builds.
10-func InitMainThread() {}
11-
12-// IsMainThread always returns true in non-CGO builds since there's no GTK main thread.
13-func IsMainThread() bool { return true }
14-
15-// IterateMainLoop is a no-op in non-CGO builds and always returns false.
16-func IterateMainLoop() bool { return false }
17-
18-// PanedGetStartChild is a no-op in non-CGO builds and returns 0.
19-func PanedGetStartChild(paned uintptr) uintptr { return 0 }
20-
21-// PanedGetEndChild is a no-op in non-CGO builds and returns 0.
22-func PanedGetEndChild(paned uintptr) uintptr { return 0 }
D pkg/webkit/codec_control.go +0 -521
@@ -1,521 +0,0 @@
1-package webkit
2-
3-import (
4- "strings"
5-)
6-
7-// GenerateCodecControlScript creates JavaScript code to control codec preferences
8-// This script overrides MediaCapabilities API and platform-specific player configurations
9-func GenerateCodecControlScript(prefs CodecPreferencesConfig) string {
10- // Build blocked codecs regex pattern
11- var blockedCodecs []string
12- for _, codec := range prefs.BlockedCodecs {
13- switch strings.ToLower(codec) {
14- case "vp9":
15- blockedCodecs = append(blockedCodecs, "vp9", "vp09")
16- case "vp8":
17- blockedCodecs = append(blockedCodecs, "vp8", "vp08")
18- case "h264":
19- blockedCodecs = append(blockedCodecs, "h264", "avc1")
20- case "h265", "hevc":
21- blockedCodecs = append(blockedCodecs, "h265", "hevc", "hev1", "hvc1")
22- default:
23- blockedCodecs = append(blockedCodecs, strings.ToLower(codec))
24- }
25- }
26-
27- // Build preferred codecs list
28- var preferredCodecs []string
29- for _, codec := range prefs.PreferredCodecs {
30- switch strings.ToLower(codec) {
31- case "av1":
32- preferredCodecs = append(preferredCodecs, "av01", "av1")
33- case "h264":
34- preferredCodecs = append(preferredCodecs, "h264", "avc1")
35- case "vp9":
36- preferredCodecs = append(preferredCodecs, "vp9", "vp09")
37- case "vp8":
38- preferredCodecs = append(preferredCodecs, "vp8", "vp08")
39- default:
40- preferredCodecs = append(preferredCodecs, strings.ToLower(codec))
41- }
42- }
43-
44- blockedPattern := strings.Join(blockedCodecs, "|")
45- hasBlockedCodecs := len(blockedCodecs) > 0
46- hasPreferredCodecs := len(preferredCodecs) > 0
47-
48- // Convert preferredCodecs slice to proper JavaScript array syntax
49- preferredCodecsJS := "[" + strings.Join(func() []string {
50- var quoted []string
51- for _, codec := range preferredCodecs {
52- quoted = append(quoted, `"`+codec+`"`)
53- }
54- return quoted
55- }(), ", ") + "]"
56-
57- // Build JavaScript directly without format specifiers
58- var js strings.Builder
59-
60- js.WriteString(`(() => {
61- 'use strict';
62-
63- const isMainFrame = (window.self === window.top);
64- function log(msg) {
65- if (isMainFrame) console.log('[dumber-codec] ' + msg);
66- }
67-
68- log('Initializing codec control');
69-
70- // Smart fullscreen handling that works with WebKit's native behavior
71- const videoFullscreenStates = new WeakMap();
72- let fullscreenTransitionInProgress = false;
73-
74- // Minimal state tracking - only what's essential
75- function trackVideoForFullscreen(video) {
76- if (!video || video.tagName !== 'VIDEO') return;
77-
78- // Only track if video has meaningful content
79- if (video.readyState < 2 || !video.duration) return;
80-
81- const state = {
82- wasPlaying: !video.paused && !video.ended,
83- currentTime: video.currentTime,
84- playbackRate: video.playbackRate
85- };
86-
87- videoFullscreenStates.set(video, state);
88- log('Tracking video for fullscreen - playing: ' + state.wasPlaying + ', time: ' + state.currentTime);
89- }
90-
91- // Gentle recovery without forcing reloads
92- function recoverVideoAfterFullscreen(video) {
93- const state = videoFullscreenStates.get(video);
94- if (!state) return;
95-
96- log('Recovering video after fullscreen transition');
97-
98- // Only intervene if video is stuck in a loading state
99- const isStuck = video.readyState < 2 && video.networkState === 2; // NETWORK_LOADING but not ready
100- const hasLostTime = Math.abs(video.currentTime - state.currentTime) > 2;
101-
102- if (isStuck || hasLostTime) {
103- log('Video appears stuck, applying gentle recovery');
104-
105- // Gentle nudge - just try to resume if it was playing
106- if (state.wasPlaying && video.paused) {
107- setTimeout(() => {
108- if (video.readyState >= 2) {
109- video.play().catch(e => log('Play failed: ' + e));
110- }
111- }, 200);
112- }
113- }
114-
115- videoFullscreenStates.delete(video);
116- }
117-
118- // WebKit-aware fullscreen change detection
119- function handleWebKitFullscreenTransition() {
120- if (fullscreenTransitionInProgress) return;
121- fullscreenTransitionInProgress = true;
122-
123- log('WebKit fullscreen transition detected');
124-
125- // Skip fullscreen recovery on Twitch to prevent theater/fullscreen freezing
126- if (location.hostname.includes('twitch.tv')) {
127- log('Skipping fullscreen recovery on Twitch for stability');
128- fullscreenTransitionInProgress = false;
129- return;
130- }
131-
132- const videos = document.querySelectorAll('video');
133- const isEntering = !!(document.fullscreenElement || document.webkitFullscreenElement);
134-
135- videos.forEach(video => {
136- if (isEntering) {
137- trackVideoForFullscreen(video);
138- } else {
139- // Give WebKit time to complete its native fullscreen handling
140- setTimeout(() => recoverVideoAfterFullscreen(video), 500);
141- }
142- });
143-
144- setTimeout(() => {
145- fullscreenTransitionInProgress = false;
146- }, 1000);
147- }
148-
149- // Use passive listeners to avoid interfering with WebKit's native handling
150- document.addEventListener('fullscreenchange', handleWebKitFullscreenTransition, { passive: true });
151- document.addEventListener('webkitfullscreenchange', handleWebKitFullscreenTransition, { passive: true });
152-
153- // Override canPlayType for HTMLMediaElement (primary codec detection)
154- const originalCanPlayType = HTMLMediaElement.prototype.canPlayType;
155- HTMLMediaElement.prototype.canPlayType = function(type) {
156- log('canPlayType called with: ' + type);
157-
158- // Block unwanted codecs`)
159-
160- if hasBlockedCodecs {
161- js.WriteString(`
162- const blockedRegex = /` + blockedPattern + `/i;
163- if (blockedRegex.test(type)) {
164- log('Blocking codec via canPlayType: ' + type);
165- return '';
166- }`)
167- }
168-
169- if hasPreferredCodecs {
170- js.WriteString(`
171-
172- // Boost preferred codecs
173- const preferredCodecs = ` + preferredCodecsJS + `;
174- for (const preferred of preferredCodecs) {
175- if (type.toLowerCase().includes(preferred)) {
176- log('Boosting preferred codec via canPlayType: ' + type);
177- return 'probably';
178- }
179- }`)
180- }
181-
182- js.WriteString(`
183-
184- // Call original for other codecs
185- return originalCanPlayType.call(this, type);
186- };
187-
188- // Override MediaSource.isTypeSupported for more aggressive codec blocking
189- if (window.MediaSource && MediaSource.isTypeSupported) {
190- const originalIsTypeSupported = MediaSource.isTypeSupported.bind(MediaSource);
191- MediaSource.isTypeSupported = function(type) {
192- log('MediaSource.isTypeSupported called with: ' + type);`)
193-
194- if hasBlockedCodecs {
195- js.WriteString(`
196-
197- // Block unwanted codecs
198- const blockedRegex = /` + blockedPattern + `/i;
199- if (blockedRegex.test(type)) {
200- log('Blocking codec via MediaSource.isTypeSupported: ' + type);
201- return false;
202- }`)
203- }
204-
205- if hasPreferredCodecs {
206- js.WriteString(`
207-
208- // Boost preferred codecs
209- const preferredCodecs = ` + preferredCodecsJS + `;
210- for (const preferred of preferredCodecs) {
211- if (type.toLowerCase().includes(preferred)) {
212- log('Boosting preferred codec via MediaSource.isTypeSupported: ' + type);
213- return true;
214- }
215- }`)
216- }
217-
218- js.WriteString(`
219-
220- // Call original for other codecs
221- return originalIsTypeSupported(type);
222- };
223- }
224-
225- // Override MediaCapabilities API
226- if (navigator.mediaCapabilities && navigator.mediaCapabilities.decodingInfo) {
227- const originalDecodingInfo = navigator.mediaCapabilities.decodingInfo.bind(navigator.mediaCapabilities);
228-
229- navigator.mediaCapabilities.decodingInfo = async function(config) {
230- const contentType = config.video?.contentType || config.type || '';
231-
232- // Parse resolution from video config for resolution-aware decisions
233- let width = 0, height = 0;
234- if (config.video) {
235- width = config.video.width || 0;
236- height = config.video.height || 0;
237- }
238-
239- // Determine if this is a high resolution request
240- const isHighRes = height >= 1440 || width >= 2560; // 1440p/1080p ultrawide+
241- const is4K = height >= 2160 || width >= 3840;
242- const is1080p60 = (height >= 1080 || width >= 1920) &&
243- (config.video?.framerate >= 50 || config.video?.frameRate >= 50);
244-
245- log('MediaCapabilities query - contentType: ' + contentType +
246- ', resolution: ' + width + 'x' + height + ', highRes: ' + isHighRes + ', 4K: ' + is4K + ', 1080p60: ' + is1080p60);
247-
248- // Block unwanted codecs`)
249-
250- if hasBlockedCodecs {
251- js.WriteString(`
252- const blockedRegex = /` + blockedPattern + `/i;
253- if (blockedRegex.test(contentType)) {
254- log('Blocking codec: ' + contentType);
255- return {
256- supported: false,
257- smooth: false,
258- powerEfficient: false
259- };
260- }`)
261- }
262-
263- js.WriteString(`
264-
265- // Resolution-aware preferred codec handling`)
266-
267- if hasPreferredCodecs {
268- js.WriteString(`
269- const preferredCodecs = ` + preferredCodecsJS + `;
270- for (const preferred of preferredCodecs) {
271- if (contentType.toLowerCase().includes(preferred)) {
272- log('Boosting preferred codec: ' + contentType);
273-
274- // Resolution-aware capability reporting for AV1
275- if (preferred === 'av01' || preferred === 'av1') {
276- // AV1 handling based on resolution
277- if (is4K) {
278- // 4K AV1: supported but not smooth (encourage VP9 fallback)
279- return {
280- supported: true,
281- smooth: false,
282- powerEfficient: false
283- };
284- } else if (is1080p60) {
285- // 1080p60 AV1: supported but not power efficient
286- return {
287- supported: true,
288- smooth: true,
289- powerEfficient: false
290- };
291- } else if (isHighRes) {
292- // 1440p AV1: supported but not optimal
293- return {
294- supported: true,
295- smooth: false,
296- powerEfficient: true
297- };
298- } else {
299- // <= 1080p30 AV1: fully supported
300- return {
301- supported: true,
302- smooth: true,
303- powerEfficient: true
304- };
305- }
306- } else {
307- // Other preferred codecs (VP9, H264) - always report as capable
308- return {
309- supported: true,
310- smooth: true,
311- powerEfficient: true
312- };
313- }
314- }
315- }`)
316- }
317-
318- js.WriteString(`
319-
320- // Call original for other codecs
321- try {
322- return await originalDecodingInfo(config);
323- } catch (e) {
324- console.warn('[dumber-codec] MediaCapabilities error:', e);
325- return {
326- supported: false,
327- smooth: false,
328- powerEfficient: false
329- };
330- }
331- };
332- }
333-
334- // YouTube-specific codec forcing
335- if (location.hostname.includes('youtube.com')) {
336- log('Applying YouTube codec preferences');
337-
338- // Set YouTube's localStorage preferences for smart AV1 usage
339- // 2048 = prefer AV1 for lower resolutions, fallback to VP9 for higher res
340- // This allows YouTube to make smarter codec decisions based on our MediaCapabilities
341- try {
342- localStorage.setItem('yt-player-av1-pref', '2048');
343- Object.defineProperty(localStorage, 'yt-player-av1-pref', {
344- value: '2048',
345- writable: false,
346- configurable: false
347- });
348- log('YouTube: Set smart AV1 localStorage preference (2048)');
349- } catch (e) {
350- console.warn('[dumber-codec] YouTube: Failed to set localStorage:', e);
351- }
352-
353- // Override ytInitialPlayerResponse parsing
354- Object.defineProperty(window, 'ytplayer', {
355- configurable: true,
356- get() {
357- return this._ytplayer || null;
358- },
359- set(player) {
360- if (player && player.config && player.config.args) {`)
361-
362- if prefs.ForceAV1 {
363- js.WriteString(`
364- // Force AV1 if enabled
365- player.config.args.preferred_codecs = 'av01';
366- log('YouTube: Forced AV1 codec');`)
367- }
368-
369- if hasPreferredCodecs {
370- js.WriteString(`
371- // Set codec preference order
372- const preferredCodecs = ` + preferredCodecsJS + `;
373- const codecOrder = preferredCodecs.map(c => {
374- switch(c.toLowerCase()) {
375- case 'av1': case 'av01': return 'av01';
376- case 'h264': case 'avc1': return 'avc1';
377- case 'vp9': case 'vp09': return 'vp9';
378- case 'vp8': case 'vp08': return 'vp8';
379- default: return c;
380- }
381- }).join(',');
382- player.config.args.preferred_codecs = codecOrder;
383- log('YouTube: Set codec order: ' + codecOrder);`)
384- }
385-
386- js.WriteString(`
387- }
388- this._ytplayer = player;
389- }
390- });
391-
392- // Intercept YouTube's format selection
393- const originalFetch = window.fetch;
394- window.fetch = function(input, init) {
395- if (typeof input === 'string' && input.includes('/youtubei/v1/player')) {
396- log('YouTube: Intercepting player request');`)
397-
398- if hasPreferredCodecs {
399- js.WriteString(`
400-
401- // Modify request to prefer our codecs
402- if (init && init.body) {
403- try {
404- const body = JSON.parse(init.body);
405- if (body.videoId) {
406- // Add codec preferences to the request
407- body.codecPreferences = ` + preferredCodecsJS + `;
408- init.body = JSON.stringify(body);
409- }
410- } catch (e) {
411- // Non-JSON body, ignore
412- }
413- }`)
414- }
415-
416- js.WriteString(`
417- }
418-
419- // Process YouTube player API responses for format manipulation
420- if (typeof input === 'string' && input.includes('/youtubei/v1/player')) {
421- return originalFetch.call(this, input, init).then(response => {
422- if (!response.ok) return response;
423-
424- return response.text().then(text => {
425- try {
426- const data = JSON.parse(text);
427-
428- // Manipulate streaming data if present
429- if (data.streamingData && data.streamingData.formats) {
430- log('YouTube: Processing format manifest');
431-
432- // Sort formats to prioritize AV1 for lower resolutions, VP9 for higher
433- data.streamingData.formats.sort((a, b) => {
434- const aHeight = a.height || 0;
435- const bHeight = b.height || 0;
436- const aIsAV1 = a.mimeType && a.mimeType.includes('av01');
437- const bIsAV1 = b.mimeType && b.mimeType.includes('av01');
438- const aIsVP9 = a.mimeType && a.mimeType.includes('vp9');
439- const bIsVP9 = b.mimeType && b.mimeType.includes('vp9');
440-
441- // For resolutions <= 1080p, prioritize AV1
442- if (Math.max(aHeight, bHeight) <= 1080) {
443- if (aIsAV1 && !bIsAV1) return -1;
444- if (!aIsAV1 && bIsAV1) return 1;
445- }
446- // For resolutions > 1080p, prioritize VP9
447- else {
448- if (aIsVP9 && !bIsVP9) return -1;
449- if (!aIsVP9 && bIsVP9) return 1;
450- }
451-
452- return 0;
453- });
454-
455- log('YouTube: Reordered formats for optimal codec selection');
456- }
457-
458- return new Response(JSON.stringify(data), {
459- status: response.status,
460- statusText: response.statusText,
461- headers: response.headers
462- });
463- } catch (e) {
464- console.warn('[dumber-codec] YouTube: Failed to process response:', e);
465- return new Response(text, {
466- status: response.status,
467- statusText: response.statusText,
468- headers: response.headers
469- });
470- }
471- });
472- });
473- }
474-
475- return originalFetch.call(this, input, init);
476- };
477- }
478-
479- // Twitch: No codec interference - let Twitch handle codec selection natively
480- if (location.hostname.includes('twitch.tv')) {
481- log('Detected Twitch domain - no codec interference for stability');
482- // Twitch codec control completely removed to prevent theater/fullscreen freezing
483- }
484-
485- // Passive video monitoring without interfering with element creation
486- const monitorExistingVideos = () => {
487- const videos = document.querySelectorAll('video');
488- videos.forEach(video => {
489- if (!video.__codecMonitored) {
490- video.__codecMonitored = true;
491- log('Found video element, adding passive monitoring');
492-
493- // Passive codec detection only
494- video.addEventListener('canplay', function() {
495- log('Video can play - resolution: ' + this.videoWidth + 'x' + this.videoHeight);
496- }, { passive: true, once: true });
497- }
498- });
499- };
500-
501- // Monitor for video elements periodically without overriding createElement
502- const videoObserver = new MutationObserver(monitorExistingVideos);
503-
504- // Wait for document.body to be available before observing
505- function startVideoObserver() {
506- if (document.body) {
507- videoObserver.observe(document.body, { childList: true, subtree: true });
508- monitorExistingVideos(); // Check existing videos
509- log('Video observer started');
510- } else {
511- // Retry after a short delay if body not ready
512- setTimeout(startVideoObserver, 50);
513- }
514- }
515- startVideoObserver();
516-
517- log('Codec control initialization complete');
518-})();`)
519-
520- return js.String()
521-}
A pkg/webkit/content_blocking.go +128 -0
@@ -0,0 +1,128 @@
1+package webkit
2+
3+import (
4+ "context"
5+ "fmt"
6+ "log"
7+ "path/filepath"
8+
9+ webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
10+ "github.com/diamondburned/gotk4/pkg/gio/v2"
11+ "github.com/diamondburned/gotk4/pkg/glib/v2"
12+)
13+
14+// ContentBlockingManager handles WebKit content filtering
15+type ContentBlockingManager struct {
16+ filterStore *webkit.UserContentFilterStore
17+ storagePath string
18+}
19+
20+// NewContentBlockingManager creates a new content blocking manager
21+func NewContentBlockingManager(storagePath string) (*ContentBlockingManager, error) {
22+ // Create the filter store
23+ filterStore := webkit.NewUserContentFilterStore(storagePath)
24+ if filterStore == nil {
25+ return nil, fmt.Errorf("failed to create UserContentFilterStore")
26+ }
27+
28+ log.Printf("[webkit] Created UserContentFilterStore at: %s", storagePath)
29+
30+ return &ContentBlockingManager{
31+ filterStore: filterStore,
32+ storagePath: storagePath,
33+ }, nil
34+}
35+
36+// ApplyFiltersFromJSON compiles and applies WebKit JSON filter rules to a UserContentManager
37+// The jsonRules parameter should be a JSON array of WebKit content blocker rules
38+func (cbm *ContentBlockingManager) ApplyFiltersFromJSON(ucm *webkit.UserContentManager, identifier string, jsonRules []byte) error {
39+ if ucm == nil {
40+ return fmt.Errorf("UserContentManager is nil")
41+ }
42+
43+ if len(jsonRules) == 0 {
44+ return fmt.Errorf("empty filter rules")
45+ }
46+
47+ log.Printf("[webkit] Applying content filters (identifier: %s, rules size: %d bytes)", identifier, len(jsonRules))
48+
49+ // Convert []byte to *glib.Bytes for WebKit API
50+ // NewBytesWithGo keeps the Go slice alive for the GBytes lifetime
51+ gBytes := glib.NewBytesWithGo(jsonRules)
52+
53+ // Save filters to store asynchronously
54+ // This compiles the JSON rules into WebKit's internal format
55+ ctx := context.Background()
56+
57+ // Use a channel to wait for the async operation
58+ done := make(chan error, 1)
59+
60+ cbm.filterStore.Save(ctx, identifier, gBytes, func(result gio.AsyncResulter) {
61+ // This callback runs when compilation is complete
62+ filter, err := cbm.filterStore.SaveFinish(result)
63+ if err != nil {
64+ log.Printf("[webkit] Failed to compile content filter: %v", err)
65+ done <- fmt.Errorf("failed to compile filter: %w", err)
66+ return
67+ }
68+
69+ if filter == nil {
70+ log.Printf("[webkit] Filter compilation returned nil")
71+ done <- fmt.Errorf("filter compilation returned nil")
72+ return
73+ }
74+
75+ // Add the compiled filter to the UserContentManager
76+ // This must be done on the main GTK thread
77+ RunOnMainThread(func() {
78+ ucm.AddFilter(filter)
79+ log.Printf("[webkit] Successfully added content filter: %s", identifier)
80+ done <- nil
81+ })
82+ })
83+
84+ // Wait for completion
85+ return <-done
86+}
87+
88+// RemoveAllFilters removes all content filters from a UserContentManager
89+func (cbm *ContentBlockingManager) RemoveAllFilters(ucm *webkit.UserContentManager) {
90+ if ucm != nil {
91+ ucm.RemoveAllFilters()
92+ log.Printf("[webkit] Removed all content filters")
93+ }
94+}
95+
96+// GetFilterStore returns the underlying UserContentFilterStore
97+func (cbm *ContentBlockingManager) GetFilterStore() *webkit.UserContentFilterStore {
98+ return cbm.filterStore
99+}
100+
101+// ApplyFiltersToWebView applies content blocking filters to a WebView
102+func ApplyFiltersToWebView(view *WebView, filterJSON []byte) error {
103+ if view == nil || view.view == nil {
104+ return fmt.Errorf("invalid WebView")
105+ }
106+
107+ // Get the UserContentManager
108+ ucm := view.view.UserContentManager()
109+ if ucm == nil {
110+ return fmt.Errorf("UserContentManager is nil")
111+ }
112+
113+ // Create content blocking manager
114+ // Use a subdirectory of the cache dir for filter storage
115+ dataDir := view.config.DataDir
116+ if dataDir == "" {
117+ return fmt.Errorf("WebView has no data directory configured")
118+ }
119+
120+ filterStorePath := filepath.Join(dataDir, "content-filters")
121+ cbm, err := NewContentBlockingManager(filterStorePath)
122+ if err != nil {
123+ return fmt.Errorf("failed to create content blocking manager: %w", err)
124+ }
125+
126+ // Apply the filters
127+ return cbm.ApplyFiltersFromJSON(ucm, "adblock-filters", filterJSON)
128+}
D pkg/webkit/debug_cgo.go +0 -137
@@ -1,137 +0,0 @@
1-//go:build webkit_cgo
2-
3-package webkit
4-
5-/*
6-#cgo pkg-config: webkitgtk-6.0 gtk4
7-#cgo CFLAGS: -I/usr/include/webkitgtk-6.0
8-#include <webkit/webkit.h>
9-#include <stdlib.h>
10-
11-// WebKit debug logging callback
12-extern void webkit_debug_log(char* message);
13-
14-// Forward declaration
15-static void webkit_debug_log_handler(const gchar *log_domain,
16- GLogLevelFlags log_level,
17- const gchar *message,
18- gpointer user_data);
19-
20-static void setup_webkit_debug_logging() {
21- // Enable WebKit debug logging if WEBKIT_DEBUG environment variable is set
22- const char* debug_env = g_getenv("WEBKIT_DEBUG");
23- if (debug_env) {
24- g_setenv("G_MESSAGES_DEBUG", "WebKit", TRUE);
25- g_setenv("WEBKIT_DEBUG", debug_env, TRUE);
26-
27- // Set up custom log handler for better integration
28- g_log_set_handler("WebKit",
29- G_LOG_LEVEL_DEBUG | G_LOG_LEVEL_INFO | G_LOG_LEVEL_MESSAGE,
30- (GLogFunc)webkit_debug_log_handler, NULL);
31- }
32-}
33-
34-static void webkit_debug_log_handler(const gchar *log_domain,
35- GLogLevelFlags log_level,
36- const gchar *message,
37- gpointer user_data) {
38- // Format log message and send to Go
39- char* formatted_msg = g_strdup_printf("[%s] %s", log_domain, message);
40- webkit_debug_log(formatted_msg);
41- g_free(formatted_msg);
42-}
43-
44-// Enable detailed content filtering debug logs
45-static void enable_filtering_debug() {
46- g_setenv("WEBKIT_DEBUG", "Network:preconnectTo,ContentFilters", TRUE);
47-}
48-
49-// Check if WebView is in a valid state for operations
50-static gboolean check_webview_state(WebKitWebView *webview) {
51- if (!webview) return FALSE;
52- if (!WEBKIT_IS_WEB_VIEW(webview)) return FALSE;
53-
54- // Check if WebView is not being destroyed
55- GObject *obj = G_OBJECT(webview);
56- if (g_object_get_data(obj, "webkit-destroying")) return FALSE;
57-
58- return TRUE;
59-}
60-*/
61-import "C"
62-
63-import (
64- "os"
65-
66- "github.com/bnema/dumber/internal/config"
67- "github.com/bnema/dumber/internal/logging"
68-)
69-
70-//export webkit_debug_log
71-func webkit_debug_log(message *C.char) {
72- msg := C.GoString(message)
73- logging.Debug("[webkit-internal] " + msg)
74-}
75-
76-// SetupWebKitDebugLogging initializes WebKit debug logging based on config and environment variables
77-func SetupWebKitDebugLogging(cfg *config.Config) {
78- // Check if debug mode is enabled via config or environment variables
79- debugEnabled := (cfg != nil && cfg.Debug.EnableWebKitDebug) ||
80- os.Getenv("WEBKIT_DEBUG") != "" ||
81- os.Getenv("DUMBER_DEBUG") != "" ||
82- (cfg != nil && cfg.Debug.EnableGeneralDebug)
83-
84- if debugEnabled {
85- logging.Info("[webkit] Enabling WebKit debug logging")
86-
87- // Set debug categories from config or environment
88- var debugCategories string
89- if cfg != nil && cfg.Debug.WebKitDebugCategories != "" {
90- debugCategories = cfg.Debug.WebKitDebugCategories
91- } else if envDebug := os.Getenv("WEBKIT_DEBUG"); envDebug != "" {
92- debugCategories = envDebug
93- } else {
94- debugCategories = "Network:preconnectTo,ContentFilters"
95- }
96-
97- // Set the environment variable for WebKit
98- os.Setenv("WEBKIT_DEBUG", debugCategories)
99-
100- C.setup_webkit_debug_logging()
101-
102- // Enable filtering-specific debug logs if configured
103- if cfg != nil && cfg.Debug.EnableFilteringDebug {
104- C.enable_filtering_debug()
105- logging.Info("[webkit] Content filtering debug logging enabled")
106- }
107-
108- logging.Info("[webkit] WebKit debug logging enabled with categories: " + debugCategories)
109- }
110-}
111-
112-// CheckWebViewState validates that a WebView is in a safe state for operations
113-func (w *WebView) CheckWebViewState() bool {
114- if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
115- return false
116- }
117-
118- return C.check_webview_state(w.native.wv) != 0
119-}
120-
121-// LogDebugInfo logs debug information about the current WebView state
122-func (w *WebView) LogDebugInfo() {
123- if !w.CheckWebViewState() {
124- logging.Debug("[webkit] WebView is not in valid state")
125- return
126- }
127-
128- logging.Debug("[webkit] WebView state: valid and ready for operations")
129-
130- // Log content manager state
131- manager := C.webkit_web_view_get_user_content_manager(w.native.wv)
132- if manager != nil {
133- logging.Debug("[webkit] User content manager: available")
134- } else {
135- logging.Debug("[webkit] User content manager: not available")
136- }
137-}
D pkg/webkit/debug_stub.go +0 -23
@@ -1,23 +0,0 @@
1-//go:build !webkit_cgo
2-
3-package webkit
4-
5-import (
6- "github.com/bnema/dumber/internal/config"
7- "github.com/bnema/dumber/internal/logging"
8-)
9-
10-// SetupWebKitDebugLogging is a stub for non-CGO builds
11-func SetupWebKitDebugLogging(cfg *config.Config) {
12- logging.Info("[webkit] Debug logging not available in stub build")
13-}
14-
15-// CheckWebViewState always returns false in stub builds
16-func (w *WebView) CheckWebViewState() bool {
17- return false
18-}
19-
20-// LogDebugInfo is a stub for non-CGO builds
21-func (w *WebView) LogDebugInfo() {
22- logging.Debug("[webkit] Debug info not available in stub build")
23-}
D pkg/webkit/devtools_cgo.go +0 -34
@@ -1,34 +0,0 @@
1-//go:build webkit_cgo
2-
3-package webkit
4-
5-/*
6-#cgo pkg-config: webkitgtk-6.0 gtk4
7-#cgo CFLAGS: -I/usr/include/webkitgtk-6.0
8-#include <webkit/webkit.h>
9-*/
10-import "C"
11-
12-func (w *WebView) ShowDevTools() error {
13- if w == nil || w.native == nil || w.native.wv == nil {
14- return ErrNotImplemented
15- }
16- insp := C.webkit_web_view_get_inspector(w.native.wv)
17- if insp == nil {
18- return ErrNotImplemented
19- }
20- C.webkit_web_inspector_show(insp)
21- return nil
22-}
23-
24-func (w *WebView) CloseDevTools() error {
25- if w == nil || w.native == nil || w.native.wv == nil {
26- return ErrNotImplemented
27- }
28- insp := C.webkit_web_view_get_inspector(w.native.wv)
29- if insp == nil {
30- return ErrNotImplemented
31- }
32- C.webkit_web_inspector_close(insp)
33- return nil
34-}
D pkg/webkit/dom_events.go +0 -25
@@ -1,25 +0,0 @@
1-package webkit
2-
3-import (
4- "encoding/json"
5- "fmt"
6-)
7-
8-// DispatchCustomEvent sends a CustomEvent into the page world with the provided detail payload.
9-func (w *WebView) DispatchCustomEvent(eventName string, detail map[string]any) error {
10- if w == nil || w.IsDestroyed() {
11- return ErrNotImplemented
12- }
13-
14- payload := "{}"
15- if detail != nil {
16- data, err := json.Marshal(detail)
17- if err != nil {
18- return fmt.Errorf("failed to marshal event detail: %w", err)
19- }
20- payload = string(data)
21- }
22-
23- script := fmt.Sprintf(`(function(){try{var detail=%s;document.dispatchEvent(new CustomEvent(%q,{detail:detail}));}catch(e){console.error('[dumber] Failed to dispatch %s', e);}})();`, payload, eventName, eventName)
24- return w.InjectScript(script)
25-}
M pkg/webkit/errors.go +14 -7
@@ -1,13 +1,20 @@
1-// Package webkit provides a WebKit2GTK-backed browser view and window.
21 package webkit
32
43 import "errors"
54
6-// ErrNotImplemented is returned by stub implementations pending WebKit2GTK migration.
7-var ErrNotImplemented = errors.New("webkit: not implemented")
5+var (
6+ // ErrNotImplemented indicates a feature is not yet implemented
7+ ErrNotImplemented = errors.New("webkit: not implemented")
88
9-// ErrWebViewNotInitialized is returned when WebView operations are attempted on uninitialized WebView
10-var ErrWebViewNotInitialized = errors.New("webkit: webview not initialized")
9+ // ErrWebViewNotInitialized indicates the WebView was not properly initialized
10+ ErrWebViewNotInitialized = errors.New("webkit: webview not initialized")
1111
12-// ErrContentManagerNotFound is returned when WebKit user content manager cannot be retrieved
13-var ErrContentManagerNotFound = errors.New("webkit: user content manager not found")
12+ // ErrWebViewDestroyed indicates the WebView has been destroyed
13+ ErrWebViewDestroyed = errors.New("webkit: webview destroyed")
14+
15+ // ErrInvalidURL indicates an invalid URL was provided
16+ ErrInvalidURL = errors.New("webkit: invalid URL")
17+
18+ // ErrMainThreadRequired indicates an operation must be called from the main thread
19+ ErrMainThreadRequired = errors.New("webkit: operation must be called from main thread")
20+)
A pkg/webkit/evaluate.go +78 -0
@@ -0,0 +1,78 @@
1+package webkit
2+
3+/*
4+#cgo pkg-config: webkitgtk-6.0
5+#include <webkit/webkit.h>
6+#include <stdlib.h>
7+*/
8+import "C"
9+import (
10+ "unsafe"
11+
12+ javascriptcore "github.com/diamondburned/gotk4-webkitgtk/pkg/javascriptcore/v6"
13+ webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
14+ "github.com/diamondburned/gotk4/pkg/core/glib"
15+)
16+
17+// EvaluateJavascript executes JavaScript code in the WebView in the default world
18+// This is a wrapper around webkit_web_view_evaluate_javascript that wasn't generated by gotk4
19+func EvaluateJavascript(view *webkit.WebView, script string) {
20+ EvaluateJavascriptInWorld(view, script, "")
21+}
22+
23+// EvaluateJavascriptInWorld executes JavaScript code in a specific world
24+// Pass empty string for worldName to use the main/page world
25+func EvaluateJavascriptInWorld(view *webkit.WebView, script string, worldName string) {
26+ if view == nil {
27+ return
28+ }
29+
30+ // Get the underlying GObject pointer from the gotk4 WebView
31+ obj := glib.BaseObject(view)
32+ if obj == nil {
33+ return
34+ }
35+
36+ nativePtr := obj.Native()
37+ if nativePtr == 0 {
38+ return
39+ }
40+
41+ cScript := C.CString(script)
42+ defer C.free(unsafe.Pointer(cScript))
43+
44+ var cWorldName *C.char
45+ if worldName != "" {
46+ cWorldName = C.CString(worldName)
47+ defer C.free(unsafe.Pointer(cWorldName))
48+ }
49+
50+ // Call webkit_web_view_evaluate_javascript
51+ // Parameters: web_view, script, length, world_name, source_uri, cancellable, callback, user_data
52+ C.webkit_web_view_evaluate_javascript(
53+ (*C.WebKitWebView)(unsafe.Pointer(nativePtr)),
54+ cScript,
55+ C.gssize(-1), // -1 means null-terminated string
56+ cWorldName, // world_name (NULL for main world, or specific world name)
57+ nil, // source_uri (NULL)
58+ nil, // cancellable (NULL)
59+ nil, // callback (NULL for fire-and-forget)
60+ nil, // user_data (NULL)
61+ )
62+}
63+
64+// JSCValueToString converts a JSCValue from a GTK signal to a Go string
65+func JSCValueToString(value interface{}) string {
66+ if value == nil {
67+ return ""
68+ }
69+
70+ // Type assert to *javascriptcore.Value from gotk4 bindings
71+ jscValue, ok := value.(*javascriptcore.Value)
72+ if !ok {
73+ return ""
74+ }
75+
76+ // Use the gotk4 String() method to convert JSCValue to string
77+ return jscValue.String()
78+}
D pkg/webkit/filtering_cgo.go +0 -249
@@ -1,249 +0,0 @@
1-//go:build webkit_cgo
2-
3-package webkit
4-
5-/*
6-#cgo pkg-config: webkitgtk-6.0 gtk4
7-#cgo CFLAGS: -I/usr/include/webkitgtk-6.0
8-#include <webkit/webkit.h>
9-#include <stdlib.h>
10-
11-// Global filter store instance
12-static WebKitUserContentFilterStore *filter_store = NULL;
13-
14-// External Go functions
15-extern char* get_webkit_filter_store_path();
16-extern void log_filter_success(char* identifier);
17-extern void log_filter_error(char* error_msg, char* identifier);
18-
19-// Callback for save operation completion
20-static void filter_save_callback(GObject *source_object,
21- GAsyncResult *result,
22- gpointer user_data) {
23- WebKitUserContentFilterStore *store = WEBKIT_USER_CONTENT_FILTER_STORE(source_object);
24- WebKitUserContentManager *manager = WEBKIT_USER_CONTENT_MANAGER(user_data);
25- GError *error = NULL;
26-
27- WebKitUserContentFilter *filter = webkit_user_content_filter_store_save_finish(store, result, &error);
28-
29- if (filter && !error) {
30- webkit_user_content_manager_add_filter(manager, filter);
31- const char *id = webkit_user_content_filter_get_identifier(filter);
32- log_filter_success((char*)id);
33- webkit_user_content_filter_unref(filter);
34- }
35-
36- if (error) {
37- char *msg = g_strdup(error->message);
38- log_filter_error(msg, "unknown");
39- g_free(msg);
40- g_error_free(error);
41- }
42-}
43-
44-// Initialize filter store if not already done
45-static void ensure_filter_store() {
46- if (filter_store == NULL) {
47- // Get XDG-compliant path from Go (with tmp fallback)
48- char *store_path = get_webkit_filter_store_path();
49- filter_store = webkit_user_content_filter_store_new(store_path);
50- free(store_path); // Free the C string returned by Go
51- }
52-}
53-
54-static void apply_content_filter(WebKitUserContentManager *manager,
55- const char *filter_json,
56- const char *identifier) {
57- ensure_filter_store();
58-
59- GBytes *bytes = g_bytes_new(filter_json, strlen(filter_json));
60-
61- // Save filter to store asynchronously - it will be applied in the callback
62- webkit_user_content_filter_store_save(filter_store,
63- identifier,
64- bytes,
65- NULL,
66- filter_save_callback,
67- manager);
68-
69- g_bytes_unref(bytes);
70-}
71-
72-static void inject_cosmetic_script(WebKitUserContentManager *manager,
73- const char *script_source) {
74- // Use isolated script world to prevent interference with page JavaScript
75- WebKitUserScript *script = webkit_user_script_new_for_world(
76- script_source,
77- WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
78- WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_END, // Changed from DOCUMENT_START to avoid preconnect interference
79- "dumber-cosmetic", // Isolated world name
80- NULL, NULL
81- );
82-
83- webkit_user_content_manager_add_script(manager, script);
84- webkit_user_script_unref(script);
85-}
86-
87-static void clear_all_filters(WebKitUserContentManager *manager) {
88- webkit_user_content_manager_remove_all_filters(manager);
89-}
90-
91-static void clear_all_scripts(WebKitUserContentManager *manager) {
92- webkit_user_content_manager_remove_all_scripts(manager);
93-}
94-*/
95-import "C"
96-
97-import (
98- "encoding/json"
99- "fmt"
100- "os"
101- "path/filepath"
102- "unsafe"
103-
104- "github.com/bnema/dumber/internal/config"
105- "github.com/bnema/dumber/internal/logging"
106-)
107-
108-//export get_webkit_filter_store_path
109-func get_webkit_filter_store_path() *C.char {
110- // Try XDG directories first
111- if filterCacheDir, err := config.GetFilterCacheDir(); err == nil {
112- webkitFilterDir := filepath.Join(filterCacheDir, "webkit-store")
113- if err := os.MkdirAll(webkitFilterDir, 0755); err == nil {
114- return C.CString(webkitFilterDir)
115- }
116- }
117-
118- // Fallback to tmp if XDG fails
119- tmpDir := os.TempDir()
120- fallbackDir := filepath.Join(tmpDir, "dumber-filters")
121- if err := os.MkdirAll(fallbackDir, 0755); err == nil {
122- logging.Warn("[webkit] Using fallback tmp directory for filter store: " + fallbackDir)
123- return C.CString(fallbackDir)
124- }
125-
126- // Last resort - return tmp dir
127- logging.Error("[webkit] Failed to create filter store directory, using tmp")
128- return C.CString(tmpDir)
129-}
130-
131-//export log_filter_success
132-func log_filter_success(identifier *C.char) {
133- id := C.GoString(identifier)
134- logging.Info("[webkit] Network filter applied successfully: " + id)
135-}
136-
137-//export log_filter_error
138-func log_filter_error(errorMsg *C.char, identifier *C.char) {
139- msg := C.GoString(errorMsg)
140- id := C.GoString(identifier)
141- logging.Error("[webkit] Failed to apply network filter '" + id + "': " + msg)
142-}
143-
144-// ApplyContentFilters applies WebKit native content filters with error recovery
145-func (w *WebView) ApplyContentFilters(filters []byte, identifier string) error {
146- // Check WebView state with debug support
147- if !w.CheckWebViewState() {
148- w.LogDebugInfo()
149- return ErrWebViewNotInitialized
150- }
151-
152- // Validate input
153- if len(filters) == 0 || identifier == "" {
154- return fmt.Errorf("invalid filter parameters: empty filters or identifier")
155- }
156-
157- // Check if filters are valid JSON
158- if !json.Valid(filters) {
159- return fmt.Errorf("invalid filter JSON for identifier: %s", identifier)
160- }
161-
162- defer func() {
163- if r := recover(); r != nil {
164- logging.Error(fmt.Sprintf("[webkit] Panic in ApplyContentFilters: %v", r))
165- }
166- }()
167-
168- manager := C.webkit_web_view_get_user_content_manager(w.native.wv)
169- if manager == nil {
170- return ErrContentManagerNotFound
171- }
172-
173- cFilters := C.CString(string(filters))
174- cID := C.CString(identifier)
175- defer C.free(unsafe.Pointer(cFilters))
176- defer C.free(unsafe.Pointer(cID))
177-
178- logging.Info("[webkit] Applying network filters: " + identifier + " (" + fmt.Sprintf("%d", len(filters)) + " bytes)")
179-
180- // Apply filter with error checking
181- C.apply_content_filter(manager, cFilters, cID)
182- return nil
183-}
184-
185-// InjectCosmeticFilter injects JavaScript for cosmetic filtering with error recovery
186-func (w *WebView) InjectCosmeticFilter(script string) error {
187- // Check WebView state with debug support
188- if !w.CheckWebViewState() {
189- w.LogDebugInfo()
190- return ErrWebViewNotInitialized
191- }
192-
193- // Validate input
194- if len(script) == 0 {
195- return fmt.Errorf("empty cosmetic filter script")
196- }
197-
198- defer func() {
199- if r := recover(); r != nil {
200- logging.Error(fmt.Sprintf("[webkit] Panic in InjectCosmeticFilter: %v", r))
201- }
202- }()
203-
204- manager := C.webkit_web_view_get_user_content_manager(w.native.wv)
205- if manager == nil {
206- return ErrContentManagerNotFound
207- }
208-
209- cScript := C.CString(script)
210- defer C.free(unsafe.Pointer(cScript))
211-
212- logging.Info("[webkit] Injecting cosmetic filter script (" + fmt.Sprintf("%d", len(script)) + " chars)")
213-
214- // Inject script with error recovery
215- C.inject_cosmetic_script(manager, cScript)
216- return nil
217-}
218-
219-// ClearAllFilters removes all content filters
220-func (w *WebView) ClearAllFilters() error {
221- if w.native == nil || w.native.wv == nil {
222- return ErrWebViewNotInitialized
223- }
224-
225- manager := C.webkit_web_view_get_user_content_manager(w.native.wv)
226- if manager == nil {
227- return ErrContentManagerNotFound
228- }
229-
230- logging.Info("[webkit] Clearing all content filters")
231- C.clear_all_filters(manager)
232- return nil
233-}
234-
235-// ClearAllScripts removes all injected scripts
236-func (w *WebView) ClearAllScripts() error {
237- if w.native == nil || w.native.wv == nil {
238- return ErrWebViewNotInitialized
239- }
240-
241- manager := C.webkit_web_view_get_user_content_manager(w.native.wv)
242- if manager == nil {
243- return ErrContentManagerNotFound
244- }
245-
246- logging.Info("[webkit] Clearing all injected scripts")
247- C.clear_all_scripts(manager)
248- return nil
249-}
D pkg/webkit/find.go +0 -35
@@ -1,35 +0,0 @@
1-package webkit
2-
3-import (
4- "fmt"
5- "strconv"
6-)
7-
8-// OpenFind opens the in-page find overlay (reusing omnibox) with an optional initial query.
9-func (w *WebView) OpenFind(initial string) error {
10- if w == nil {
11- return ErrNotImplemented
12- }
13- quoted := strconv.Quote(initial)
14- // Prefer unified omnibox open via keyboard shortcut; fallback to legacy helper
15- js := fmt.Sprintf("(window.__dumber?.omnibox?.open ? window.__dumber.omnibox.open('find', %s) : (window.__dumber_find_open && window.__dumber_find_open(%s)))", quoted, quoted)
16- return w.InjectScript(js)
17-}
18-
19-// FindQuery sets or updates the current find query and applies highlighting.
20-func (w *WebView) FindQuery(q string) error {
21- if w == nil {
22- return ErrNotImplemented
23- }
24- quoted := strconv.Quote(q)
25- js := fmt.Sprintf("(window.__dumber?.omnibox?.findQuery ? window.__dumber.omnibox.findQuery(%s) : (window.__dumber_find_query && window.__dumber_find_query(%s)))", quoted, quoted)
26- return w.InjectScript(js)
27-}
28-
29-// CloseFind closes the find overlay and clears highlights.
30-func (w *WebView) CloseFind() error {
31- if w == nil {
32- return ErrNotImplemented
33- }
34- return w.InjectScript("(window.__dumber?.omnibox?.close ? window.__dumber.omnibox.close() : (window.__dumber_find_close && window.__dumber_find_close()))")
35-}
A pkg/webkit/helpers.go +269 -0
@@ -0,0 +1,269 @@
1+package webkit
2+
3+import (
4+ "github.com/diamondburned/gotk4/pkg/gdk/v4"
5+ "github.com/diamondburned/gotk4/pkg/glib/v2"
6+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
7+)
8+
9+// IsNativeAvailable returns true if the native GTK/WebKit libraries are available
10+// In gotk4, this is always true if the import succeeded
11+func IsNativeAvailable() bool {
12+ return true
13+}
14+
15+// IdleAdd adds a function to be called in an idle callback
16+// Returns an idle source ID that can be used with IdleRemove
17+func IdleAdd(fn func() bool) uint {
18+ return uint(glib.IdleAdd(fn))
19+}
20+
21+// IdleRemove removes an idle callback by its source ID
22+func IdleRemove(sourceID uint) {
23+ glib.SourceRemove(glib.SourceHandle(sourceID))
24+}
25+
26+// WidgetIsValid checks if a widget pointer is valid (non-nil)
27+func WidgetIsValid(w gtk.Widgetter) bool {
28+ return w != nil
29+}
30+
31+// WidgetSetMargin sets all margins for a widget
32+func WidgetSetMargin(w gtk.Widgetter, margin int) {
33+ if widget := getWidget(w); widget != nil {
34+ widget.SetMarginStart(margin)
35+ widget.SetMarginEnd(margin)
36+ widget.SetMarginTop(margin)
37+ widget.SetMarginBottom(margin)
38+ }
39+}
40+
41+// WidgetSetMarginTop sets the top margin for a widget
42+func WidgetSetMarginTop(w gtk.Widgetter, margin int) {
43+ if widget := getWidget(w); widget != nil {
44+ widget.SetMarginTop(margin)
45+ }
46+}
47+
48+// WidgetSetMarginBottom sets the bottom margin for a widget
49+func WidgetSetMarginBottom(w gtk.Widgetter, margin int) {
50+ if widget := getWidget(w); widget != nil {
51+ widget.SetMarginBottom(margin)
52+ }
53+}
54+
55+// WidgetSetMarginStart sets the start margin for a widget
56+func WidgetSetMarginStart(w gtk.Widgetter, margin int) {
57+ if widget := getWidget(w); widget != nil {
58+ widget.SetMarginStart(margin)
59+ }
60+}
61+
62+// WidgetSetMarginEnd sets the end margin for a widget
63+func WidgetSetMarginEnd(w gtk.Widgetter, margin int) {
64+ if widget := getWidget(w); widget != nil {
65+ widget.SetMarginEnd(margin)
66+ }
67+}
68+
69+// WidgetAddCSSClass adds a CSS class to a widget
70+func WidgetAddCSSClass(w gtk.Widgetter, className string) {
71+ if widget := getWidget(w); widget != nil {
72+ widget.AddCSSClass(className)
73+ }
74+}
75+
76+// WidgetRemoveCSSClass removes a CSS class from a widget
77+func WidgetRemoveCSSClass(w gtk.Widgetter, className string) {
78+ if widget := getWidget(w); widget != nil {
79+ widget.RemoveCSSClass(className)
80+ }
81+}
82+
83+// WidgetHasCSSClass checks if a widget has a CSS class
84+func WidgetHasCSSClass(w gtk.Widgetter, className string) bool {
85+ if widget := getWidget(w); widget != nil {
86+ return widget.HasCSSClass(className)
87+ }
88+ return false
89+}
90+
91+// WidgetSetHExpand sets whether the widget should expand horizontally
92+func WidgetSetHExpand(w gtk.Widgetter, expand bool) {
93+ if widget := getWidget(w); widget != nil {
94+ widget.SetHExpand(expand)
95+ }
96+}
97+
98+// WidgetSetVExpand sets whether the widget should expand vertically
99+func WidgetSetVExpand(w gtk.Widgetter, expand bool) {
100+ if widget := getWidget(w); widget != nil {
101+ widget.SetVExpand(expand)
102+ }
103+}
104+
105+// WidgetRealizeInContainer realizes a widget within a container
106+func WidgetRealizeInContainer(w gtk.Widgetter, container gtk.Widgetter) {
107+ if widget := getWidget(w); widget != nil {
108+ widget.Realize()
109+ }
110+}
111+
112+// WidgetRealize realizes a widget
113+func WidgetRealize(w gtk.Widgetter) {
114+ if widget := getWidget(w); widget != nil {
115+ widget.Realize()
116+ }
117+}
118+
119+// WidgetGetParent returns the parent widget
120+func WidgetGetParent(w gtk.Widgetter) gtk.Widgetter {
121+ if widget := getWidget(w); widget != nil {
122+ return widget.Parent()
123+ }
124+ return nil
125+}
126+
127+// WidgetUnparent removes a widget from its parent
128+func WidgetUnparent(w gtk.Widgetter) {
129+ if widget := getWidget(w); widget != nil {
130+ widget.Unparent()
131+ }
132+}
133+
134+// WidgetQueueAllocate queues an allocate operation
135+func WidgetQueueAllocate(w gtk.Widgetter) {
136+ if widget := getWidget(w); widget != nil {
137+ widget.QueueAllocate()
138+ }
139+}
140+
141+// WidgetQueueDraw queues a redraw operation
142+func WidgetQueueDraw(w gtk.Widgetter) {
143+ if widget := getWidget(w); widget != nil {
144+ widget.QueueDraw()
145+ }
146+}
147+
148+// WidgetGetAllocation returns the widget's allocated area
149+func WidgetGetAllocation(w gtk.Widgetter) (x, y, width, height int) {
150+ if widget := getWidget(w); widget != nil {
151+ alloc := widget.Allocation()
152+ return alloc.X(), alloc.Y(), alloc.Width(), alloc.Height()
153+ }
154+ return 0, 0, 0, 0
155+}
156+
157+// WidgetGetBounds returns the widget's bounds relative to its parent
158+func WidgetGetBounds(w gtk.Widgetter) (x, y, width, height float64) {
159+ if widget := getWidget(w); widget != nil {
160+ parent := widget.Parent()
161+ if parent != nil {
162+ rect, ok := widget.ComputeBounds(parent)
163+ if ok && rect != nil {
164+ // Get bounds directly from graphene.Rect
165+ return float64(rect.X()), float64(rect.Y()),
166+ float64(rect.Width()), float64(rect.Height())
167+ }
168+ }
169+ }
170+ return 0, 0, 0, 0
171+}
172+
173+// WidgetSetFocusChild sets the focus child of a widget
174+func WidgetSetFocusChild(w gtk.Widgetter, child gtk.Widgetter) {
175+ if widget := getWidget(w); widget != nil {
176+ widget.SetFocusChild(child)
177+ }
178+}
179+
180+// WidgetAddFocusController adds a focus event controller to a widget
181+func WidgetAddFocusController(w gtk.Widgetter, onEnter, onLeave func()) *gtk.EventControllerFocus {
182+ if widget := getWidget(w); widget != nil {
183+ controller := gtk.NewEventControllerFocus()
184+ if onEnter != nil {
185+ controller.ConnectEnter(onEnter)
186+ }
187+ if onLeave != nil {
188+ controller.ConnectLeave(onLeave)
189+ }
190+ widget.AddController(controller)
191+ return controller
192+ }
193+ return nil
194+}
195+
196+// WidgetRemoveFocusController removes a focus event controller from a widget
197+func WidgetRemoveFocusController(w gtk.Widgetter, controller *gtk.EventControllerFocus) {
198+ if widget := getWidget(w); widget != nil && controller != nil {
199+ widget.RemoveController(controller)
200+ }
201+}
202+
203+// WidgetAddHoverHandler registers a hover callback for the given widget and
204+// returns a controller that can be used to detach it later.
205+func WidgetAddHoverHandler(w gtk.Widgetter, fn func()) *gtk.EventControllerMotion {
206+ if widget := getWidget(w); widget != nil && fn != nil {
207+ motion := gtk.NewEventControllerMotion()
208+ motion.SetPropagationPhase(gtk.PhaseCapture)
209+
210+ // Connect to enter signal - triggered when mouse enters the widget
211+ motion.ConnectEnter(func(x, y float64) {
212+ fn()
213+ })
214+
215+ widget.AddController(motion)
216+ return motion
217+ }
218+ return nil
219+}
220+
221+// WidgetRemoveHoverHandler removes a previously registered hover controller
222+func WidgetRemoveHoverHandler(w gtk.Widgetter, controller *gtk.EventControllerMotion) {
223+ if widget := getWidget(w); widget != nil && controller != nil {
224+ widget.RemoveController(controller)
225+ }
226+}
227+
228+// WidgetAddController adds an event controller to a widget
229+func WidgetAddController(w gtk.Widgetter, controller gtk.EventControllerer) {
230+ if widget := getWidget(w); widget != nil && controller != nil {
231+ widget.AddController(controller)
232+ }
233+}
234+
235+// WidgetAllocation returns the widget's allocation (alias for WidgetGetAllocation)
236+func WidgetAllocation(w gtk.Widgetter) (x, y, width, height int) {
237+ return WidgetGetAllocation(w)
238+}
239+
240+// PrefersDarkTheme checks if the system prefers dark theme
241+func PrefersDarkTheme() bool {
242+ settings := gtk.SettingsGetDefault()
243+ if settings != nil {
244+ // In GTK4, check the color scheme preference
245+ // The property is "gtk-application-prefer-dark-theme"
246+ obj := settings.Object
247+ if obj != nil {
248+ prop := obj.ObjectProperty("gtk-application-prefer-dark-theme")
249+ if v, ok := prop.(bool); ok && v {
250+ return true
251+ }
252+ }
253+ }
254+ return false
255+}
256+
257+// AddCSSProvider adds a CSS provider for styling
258+func AddCSSProvider(css string) error {
259+ provider := gtk.NewCSSProvider()
260+ provider.LoadFromString(css)
261+
262+ gtk.StyleContextAddProviderForDisplay(
263+ gdk.DisplayGetDefault(),
264+ provider,
265+ gtk.STYLE_PROVIDER_PRIORITY_APPLICATION,
266+ )
267+
268+ return nil
269+}
D pkg/webkit/keyboard.go +0 -36
@@ -1,36 +0,0 @@
1-//go:build !webkit_cgo
2-
3-package webkit
4-
5-import "fmt"
6-
7-// RegisterKeyboardShortcut binds a global or window-scoped accelerator to a callback.
8-func (w *WebView) RegisterKeyboardShortcut(accel string, callback func()) error {
9- if w == nil || w.destroyed {
10- return ErrNotImplemented
11- }
12- if accel == "" || callback == nil {
13- return fmt.Errorf("invalid shortcut registration")
14- }
15- // Placeholder: in real impl, bind GTK accelerator; for now, store for tests.
16- if shortcuts := getShortcutRegistry(w); shortcuts != nil {
17- shortcuts[accel] = callback
18- }
19- return nil
20-}
21-
22-// internal registry per view while no GTK binding exists.
23-type shortcutRegistry map[string]func()
24-
25-var viewShortcuts = make(map[*WebView]shortcutRegistry)
26-
27-func getShortcutRegistry(w *WebView) shortcutRegistry {
28- reg, ok := viewShortcuts[w]
29- if !ok {
30- reg = make(shortcutRegistry)
31- viewShortcuts[w] = reg
32- }
33- return reg
34-}
35-
36-// No layout-specific remaps in non-CGO build either.
A pkg/webkit/keyboard_bridge.go +284 -0
@@ -0,0 +1,284 @@
1+package webkit
2+
3+import (
4+ "encoding/json"
5+ "fmt"
6+ "log"
7+
8+ "github.com/diamondburned/gotk4/pkg/gdk/v4"
9+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
10+)
11+
12+// Keyboard shortcut modifier string constants
13+const (
14+ modifierCmdOrCtrl = "cmdorctrl"
15+ modifierShift = "shift"
16+ modifierAlt = "alt"
17+)
18+
19+// GDK key constants for special keys
20+const (
21+ gdkKeyLeft = 0xff51
22+ gdkKeyRight = 0xff53
23+ gdkKeyUp = 0xff52
24+ gdkKeyDown = 0xff54
25+ gdkKeyEscape = 0xff1b
26+ gdkKeyF12 = 0xffbe
27+ gdkKeyPlus = 0x2b
28+ gdkKeyEqual = 0x3d
29+ gdkKeyMinus = 0x2d
30+)
31+
32+// Key name constants for shortcut mapping
33+const (
34+ keyP = "p"
35+ keyL = "l"
36+ keyR = "r"
37+ keyD = "d"
38+ keyU = "u"
39+ keyF = "f"
40+ keyC = "c"
41+ keyW = "w"
42+ keyX = "x"
43+ keyS = "s"
44+ keyEscape = "escape"
45+ keyArrowLeft = "arrowleft"
46+ keyArrowRight = "arrowright"
47+ keyArrowUp = "arrowup"
48+ keyArrowDown = "arrowdown"
49+ keyF12 = "F12"
50+ keyPlus = "plus"
51+ keyMinus = "minus"
52+ keyZero = "0"
53+ altmod = "alt"
54+)
55+
56+// Pane mode action constants
57+const (
58+ actionClose = "close"
59+ actionSplitLeft = "split-left"
60+ actionSplitRight = "split-right"
61+ actionSplitDown = "split-down"
62+ actionSplitUp = "split-up"
63+ actionStack = "stack"
64+ actionExit = "exit"
65+ actionEnter = "enter"
66+)
67+
68+// Navigation direction constants
69+const (
70+ directionLeft = "left"
71+ directionRight = "right"
72+ directionUp = "up"
73+ directionDown = "down"
74+)
75+
76+// Map shortcuts to actions (unified for all keyboard handling)
77+var shortcutActions = map[string]string{
78+ // Pane mode actions (no modifier)
79+ keyX: actionClose,
80+ keyS: actionStack,
81+ keyL: actionSplitLeft,
82+ keyR: actionSplitRight,
83+ keyD: actionSplitDown,
84+ keyU: actionSplitUp,
85+ keyArrowLeft: actionSplitLeft,
86+ keyArrowRight: actionSplitRight,
87+ keyArrowDown: actionSplitDown,
88+ keyArrowUp: actionSplitUp,
89+
90+ // Navigation (alt modifier)
91+ altmod + "+" + keyArrowLeft: directionLeft,
92+ altmod + "+" + keyArrowRight: directionRight,
93+ altmod + "+" + keyArrowDown: directionDown,
94+ altmod + "+" + keyArrowUp: directionUp,
95+}
96+
97+// AttachKeyboardBridge attaches an EventControllerKey to bridge keyboard events to JavaScript
98+// This is critical for allowing JavaScript KeyboardService to receive keyboard events
99+// that would otherwise be consumed by GTK shortcuts
100+func (w *WebView) AttachKeyboardBridge() {
101+ if w == nil || w.view == nil {
102+ return
103+ }
104+
105+ // Create event controller for keyboard
106+ keyController := gtk.NewEventControllerKey()
107+
108+ // Set to capture phase so we see events before WebView processes them
109+ keyController.SetPropagationPhase(gtk.PhaseCapture)
110+
111+ // Connect to key-pressed signal
112+ keyController.ConnectKeyPressed(func(keyval, keycode uint, state gdk.ModifierType) bool {
113+ // Check if pane mode is active - block ALL keys except pane mode actions
114+ w.mu.RLock()
115+ paneModeChecker := w.isPaneModeActive
116+ w.mu.RUnlock()
117+
118+ isPaneModeActive := paneModeChecker != nil && paneModeChecker()
119+
120+ // Build normalized shortcut string
121+ var parts []string
122+
123+ ctrl := state.Has(gdk.ControlMask)
124+ alt := state.Has(gdk.AltMask)
125+ shift := state.Has(gdk.ShiftMask)
126+
127+ // Build modifier prefix using switch on modifier combination
128+ switch {
129+ case ctrl && shift:
130+ parts = append(parts, modifierCmdOrCtrl, modifierShift)
131+ case ctrl && !shift:
132+ parts = append(parts, modifierCmdOrCtrl)
133+ case alt:
134+ parts = append(parts, modifierAlt)
135+ case shift:
136+ parts = append(parts, modifierShift)
137+ default:
138+ // No modifiers - only forward if we match a key below
139+ }
140+
141+ // Map keyval to key name
142+ var keyName string
143+ switch keyval {
144+ case 'p', 'P':
145+ keyName = keyP
146+ case 'l', 'L':
147+ keyName = keyL
148+ case 'r', 'R':
149+ keyName = keyR
150+ case 'd', 'D':
151+ keyName = keyD
152+ case 'u', 'U':
153+ keyName = keyU
154+ case 'f', 'F':
155+ keyName = keyF
156+ case 'c', 'C':
157+ keyName = keyC
158+ case 'w', 'W':
159+ keyName = keyW
160+ case 'x', 'X':
161+ keyName = keyX
162+ case 's', 'S':
163+ keyName = keyS
164+ case gdkKeyLeft:
165+ keyName = keyArrowLeft
166+ case gdkKeyRight:
167+ keyName = keyArrowRight
168+ case gdkKeyUp:
169+ keyName = keyArrowUp
170+ case gdkKeyDown:
171+ keyName = keyArrowDown
172+ case gdkKeyEscape:
173+ keyName = keyEscape
174+ case gdkKeyF12:
175+ keyName = keyF12
176+ case gdkKeyPlus, gdkKeyEqual:
177+ keyName = keyPlus
178+ case gdkKeyMinus:
179+ keyName = keyMinus
180+ case '0':
181+ keyName = keyZero
182+ default:
183+ // Only forward shortcuts with modifiers
184+ if !ctrl && !alt && !shift {
185+ return false
186+ }
187+ return false
188+ }
189+
190+ if keyName != "" {
191+ parts = append(parts, keyName)
192+ }
193+
194+ shortcut := ""
195+ if len(parts) > 0 {
196+ shortcut = parts[0]
197+ for i := 1; i < len(parts); i++ {
198+ shortcut += "+" + parts[i]
199+ }
200+ }
201+
202+ if shortcut != "" {
203+ // Special case: Ctrl+P enters pane mode
204+ if shortcut == modifierCmdOrCtrl+"+"+keyP {
205+ w.mu.RLock()
206+ handler := w.onPaneModeShortcut
207+ w.mu.RUnlock()
208+ if handler != nil && handler(actionEnter) {
209+ log.Printf("[keyboard-bridge] Pane mode: enter")
210+ return true
211+ }
212+ return true // Block even if no handler
213+ }
214+
215+ // Special case: Escape exits active mode
216+ if shortcut == keyEscape {
217+ w.mu.RLock()
218+ handler := w.onPaneModeShortcut
219+ w.mu.RUnlock()
220+ if handler != nil && handler(actionExit) {
221+ log.Printf("[keyboard-bridge] Escape: exited mode")
222+ return true
223+ }
224+ return false // Pass through to DOM if no active mode
225+ }
226+
227+ // Check if shortcut maps to an action
228+ if action, exists := shortcutActions[shortcut]; exists {
229+ // Navigation actions (alt+arrow)
230+ if action == directionLeft || action == directionRight || action == directionUp || action == directionDown {
231+ w.mu.RLock()
232+ handler := w.onWorkspaceNavigation
233+ w.mu.RUnlock()
234+ if handler != nil && handler(action) {
235+ log.Printf("[keyboard-bridge] Navigation: %s", action)
236+ return true
237+ }
238+ return true // Block anyway to prevent scrolling
239+ }
240+
241+ // Pane mode actions (x, l, r, d, u, arrows)
242+ w.mu.RLock()
243+ handler := w.onPaneModeShortcut
244+ w.mu.RUnlock()
245+ if handler != nil && handler(action) {
246+ log.Printf("[keyboard-bridge] Pane mode action: %s", action)
247+ return true
248+ }
249+ return false // Not in pane mode, pass through
250+ }
251+
252+ // Pane mode active: block all other shortcuts
253+ if isPaneModeActive {
254+ log.Printf("[keyboard-bridge] Blocking '%s' during pane mode", shortcut)
255+ return true
256+ }
257+
258+ // Forward to JavaScript using proper JSON marshaling to prevent any potential injection
259+ shortcutJSON, err := json.Marshal(shortcut)
260+ if err != nil {
261+ log.Printf("[keyboard-bridge] Failed to marshal shortcut: %v", err)
262+ return false
263+ }
264+ script := fmt.Sprintf(`document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:%s}}));`, shortcutJSON)
265+ if err := w.InjectScript(script); err != nil {
266+ log.Printf("[keyboard-bridge] Failed to dispatch: %v", err)
267+ }
268+ return false // Allow WebKit to handle
269+ }
270+
271+ // Pane mode active: block ALL non-shortcut keys
272+ if isPaneModeActive {
273+ log.Printf("[keyboard-bridge] Blocking key during pane mode: keyval=%d", keyval)
274+ return true // Block all other keys
275+ }
276+
277+ // No shortcut matched and not in pane mode - allow WebKit to handle normally
278+ return false
279+ })
280+
281+ // Attach controller to WebView
282+ w.view.AddController(keyController)
283+ log.Printf("[keyboard-bridge] EventControllerKey attached to WebView ID %d", w.id)
284+}
D pkg/webkit/keyboard_cgo.go +0 -813
@@ -1,813 +0,0 @@
1-//go:build webkit_cgo
2-
3-package webkit
4-
5-/*
6-#cgo pkg-config: gtk4 webkitgtk-6.0
7-#include <gtk/gtk.h>
8-#include <gdk/gdk.h>
9-#include <webkit/webkit.h>
10-
11-// Declarations for Go callbacks
12-extern gboolean goOnKeyPress(unsigned long id, unsigned int keyval, GdkModifierType state);
13-extern void goOnButtonPress(unsigned long id, unsigned int button, GdkModifierType state);
14-extern void goOnScroll(unsigned long id, double dx, double dy, GdkModifierType state);
15-
16-// Key controller callback -> forward to Go
17-static gboolean on_key_pressed(GtkEventControllerKey* controller, guint keyval, guint keycode, GdkModifierType state, gpointer user_data) {
18- (void)controller; (void)keycode;
19- return goOnKeyPress((unsigned long)user_data, keyval, state);
20-}
21-
22-// Mouse click gesture -> forward side buttons to Go
23-static void on_click_pressed(GtkGestureClick* gesture, gint n_press, gdouble x, gdouble y, gpointer user_data) {
24- (void)n_press; (void)x; (void)y;
25- guint button = gtk_gesture_single_get_current_button(GTK_GESTURE_SINGLE(gesture));
26- // Pass 0 for modifier state (not provided via gesture API)
27- goOnButtonPress((unsigned long)user_data, button, 0);
28-}
29-
30-// Attach controllers to a widget (GTK4)
31-static void attach_key_controller(GtkWidget* widget, unsigned long id) {
32- if (!widget) return;
33- GtkEventController* keyc = gtk_event_controller_key_new();
34- // Capture phase so we see keys even if child consumes them (e.g., WebKit)
35- gtk_event_controller_set_propagation_phase(keyc, GTK_PHASE_CAPTURE);
36- g_signal_connect_data(keyc, "key-pressed", G_CALLBACK(on_key_pressed), (gpointer)id, NULL, 0);
37- gtk_widget_add_controller(widget, keyc);
38-}
39-
40-static void attach_mouse_gesture(GtkWidget* widget, unsigned long id) {
41- if (!widget) return;
42- GtkGesture* click = gtk_gesture_click_new();
43- g_signal_connect_data(click, "pressed", G_CALLBACK(on_click_pressed), (gpointer)id, NULL, 0);
44- gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(click));
45-}
46-
47-// Legacy controller to capture side buttons (8/9) reliably in GTK4
48-static gboolean on_legacy_event(GtkEventController* controller, GdkEvent* event, gpointer user_data) {
49- (void)controller;
50- if (!event) return FALSE;
51- GdkEventType type = gdk_event_get_event_type(event);
52- if (type == GDK_BUTTON_PRESS) {
53- guint button = gdk_button_event_get_button(event);
54- GdkModifierType state = gdk_event_get_modifier_state(event);
55- if (button == 8 || button == 9) {
56- goOnButtonPress((unsigned long)user_data, button, state);
57- return TRUE; // consume side buttons
58- }
59- return FALSE; // let WebKit handle normal clicks
60- } else if (type == GDK_SCROLL) {
61- double dx = 0.0, dy = 0.0;
62- gdk_scroll_event_get_deltas(event, &dx, &dy);
63- GdkModifierType state = gdk_event_get_modifier_state(event);
64- goOnScroll((unsigned long)user_data, dx, dy, state);
65- return FALSE; // don't steal regular scrolling
66- }
67- return FALSE;
68-}
69-
70-static void attach_mouse_legacy(GtkWidget* widget, unsigned long id) {
71- if (!widget) return;
72- GtkEventController* legacy = gtk_event_controller_legacy_new();
73- gtk_event_controller_set_propagation_phase(legacy, GTK_PHASE_CAPTURE);
74- g_signal_connect_data(legacy, "event", G_CALLBACK(on_legacy_event), (gpointer)id, NULL, 0);
75- gtk_widget_add_controller(widget, legacy);
76-}
77-*/
78-import "C"
79-
80-import (
81- "encoding/json"
82- "fmt"
83- "log"
84- "strings"
85- "sync"
86- "sync/atomic"
87- "time"
88-)
89-
90-// Registry of accelerators per view id.
91-type shortcutRegistry map[string]func()
92-
93-var (
94- regMu sync.RWMutex
95- viewShortcuts = make(map[uintptr]shortcutRegistry)
96- viewIDCounter uint64
97- viewByID = make(map[uintptr]*WebView)
98- lastKeyTime = make(map[uintptr]map[string]time.Time)
99-)
100-
101-const shortcutDebounceWindow = 120 * time.Millisecond
102-
103-// shouldDebounceShortcut returns true when the given key should be skipped
104-// because it was delivered within the debounce window for the same view.
105-func shouldDebounceShortcut(uid uintptr, key string, now time.Time) bool {
106- regMu.Lock()
107- defer regMu.Unlock()
108-
109- if _, ok := lastKeyTime[uid]; !ok {
110- lastKeyTime[uid] = make(map[string]time.Time)
111- }
112- last := lastKeyTime[uid][key]
113- if now.Sub(last) < shortcutDebounceWindow {
114- return true
115- }
116- lastKeyTime[uid][key] = now
117- return false
118-}
119-
120-// Locale hint function removed; no layout-specific remaps.
121-
122-// RegisterKeyboardShortcut registers a callback under an accelerator string.
123-func (w *WebView) RegisterKeyboardShortcut(accel string, callback func()) error {
124- if w == nil || w.destroyed {
125- return ErrNotImplemented
126- }
127- if accel == "" || callback == nil {
128- return fmt.Errorf("invalid shortcut registration")
129- }
130- regMu.Lock()
131- if _, ok := viewShortcuts[w.id]; !ok {
132- viewShortcuts[w.id] = make(shortcutRegistry)
133- }
134- viewShortcuts[w.id][accel] = callback
135- regMu.Unlock()
136-
137- // Register in global shortcuts registry for automatic webpage blocking
138- RegisterGlobalShortcut(accel)
139-
140- return nil
141-}
142-
143-//export goOnKeyPress
144-func goOnKeyPress(id C.ulong, keyval C.guint, state C.GdkModifierType) C.gboolean {
145- // Normalize and dispatch
146- uid := uintptr(id)
147- kv := uint(keyval)
148- st := uint(state)
149-
150- if dispatchAccelerator(uid, kv, st) {
151- return C.gboolean(1)
152- }
153- return C.gboolean(0)
154-}
155-
156-// getCLevelBridgeShortcut returns the shortcut string for shortcuts that need C-level bridge handling.
157-// These shortcuts are blocked at the webpage level and have their callbacks triggered directly from C code.
158-// Returns empty string if the shortcut doesn't need C-level handling.
159-func getCLevelBridgeShortcut(ctrl, alt, shift bool, keyName string) string {
160- // Alt+Arrow shortcuts - for workspace pane navigation
161- if alt && !ctrl && !shift {
162- switch keyName {
163- case "ArrowLeft":
164- return "alt+arrowleft"
165- case "ArrowRight":
166- return "alt+arrowright"
167- case "ArrowUp":
168- return "alt+arrowup"
169- case "ArrowDown":
170- return "alt+arrowdown"
171- }
172- }
173-
174- // Ctrl+W shortcut - for closing current pane via workspace manager
175- if ctrl && !alt && !shift && keyName == "w" {
176- return "ctrl+w"
177- }
178-
179- // Add other C-level bridge shortcuts here as needed
180- // Note: Most shortcuts (Ctrl+L, Ctrl+F, zoom) should NOT be here
181- // They work fine through normal GTK4 handling without C-level intervention
182-
183- return ""
184-}
185-
186-func dispatchAccelerator(uid uintptr, keyval uint, state uint) bool {
187- ctrl := (state & uint(C.GDK_CONTROL_MASK)) != 0
188- alt := (state & uint(C.GDK_ALT_MASK)) != 0
189- shift := (state & uint(C.GDK_SHIFT_MASK)) != 0
190- // Map keyval to string names
191- var keyName string
192- logMinusEvent := false
193- switch keyval {
194- case uint(C.GDK_KEY_minus), uint(C.GDK_KEY_KP_Subtract), uint(C.GDK_KEY_underscore):
195- // Normalize minus; log raw detection for diagnostics
196- logMinusEvent = true
197- keyName = "-"
198- case uint(C.GDK_KEY_equal), uint(C.GDK_KEY_KP_Add):
199- keyName = "="
200- case uint(C.GDK_KEY_0), uint(C.GDK_KEY_KP_0):
201- keyName = "0"
202- case uint(C.GDK_KEY_Left):
203- keyName = "ArrowLeft"
204- case uint(C.GDK_KEY_Right):
205- keyName = "ArrowRight"
206- case uint(C.GDK_KEY_Up):
207- keyName = "ArrowUp"
208- case uint(C.GDK_KEY_Down):
209- keyName = "ArrowDown"
210- case uint(C.GDK_KEY_KP_Left):
211- keyName = "ArrowLeft"
212- case uint(C.GDK_KEY_KP_Right):
213- keyName = "ArrowRight"
214- case uint(C.GDK_KEY_KP_Up):
215- keyName = "ArrowUp"
216- case uint(C.GDK_KEY_KP_Down):
217- keyName = "ArrowDown"
218- case uint(C.GDK_KEY_F12):
219- keyName = "F12"
220- case uint(C.GDK_KEY_F5):
221- keyName = "F5"
222- case uint(C.GDK_KEY_c), uint(C.GDK_KEY_C):
223- keyName = "c"
224- case uint(C.GDK_KEY_r), uint(C.GDK_KEY_R):
225- keyName = "r"
226- case uint(C.GDK_KEY_l), uint(C.GDK_KEY_L):
227- keyName = "l"
228- case uint(C.GDK_KEY_f), uint(C.GDK_KEY_F):
229- keyName = "f"
230- case uint(C.GDK_KEY_p), uint(C.GDK_KEY_P):
231- keyName = "p"
232- case uint(C.GDK_KEY_w), uint(C.GDK_KEY_W):
233- keyName = "w"
234- default:
235- // Filter out modifier keys themselves (like Ctrl, Alt, Shift)
236- // These keyvals shouldn't be logged as misses
237- switch keyval {
238- case 0xffe1, 0xffe2: // GDK_KEY_Shift_L, GDK_KEY_Shift_R
239- return false
240- case 0xffe3, 0xffe4: // GDK_KEY_Control_L, GDK_KEY_Control_R
241- return false
242- case 0xffe9, 0xffea: // GDK_KEY_Alt_L, GDK_KEY_Alt_R
243- return false
244- case 0xffeb, 0xffec: // GDK_KEY_Super_L, GDK_KEY_Super_R
245- return false
246- }
247-
248- // If Control held, log unknown keyval for diagnostics (but not modifier keys)
249- if ctrl {
250- log.Printf("[accelerator-miss] ctrl keyval=0x%x", keyval)
251- }
252- return false
253- }
254-
255- // Check for shortcuts that need C-level handling and trigger if found
256- shortcut := getCLevelBridgeShortcut(ctrl, alt, shift, keyName)
257- if shortcut != "" {
258- // For all C-level bridge shortcuts (Alt+Arrow and Ctrl+W), use global window shortcut handler
259- // The window shortcut handler now properly handles both popup and regular panes
260- shortcutsRegistryMu.RLock()
261- handle, exists := globalShortcutHandles[shortcut]
262- shortcutsRegistryMu.RUnlock()
263-
264- if exists && handle != 0 {
265- invokeWindowShortcutCallback(handle)
266- return true
267- }
268- }
269-
270- now := time.Now()
271- rawKey := fmt.Sprintf("raw:%v:%v:%v:%s", ctrl, alt, shift, keyName)
272- if shouldDebounceShortcut(uid, rawKey, now) {
273- return false
274- }
275-
276- if logMinusEvent {
277- log.Printf("[accelerator-raw] minus key detected ctrl=%v alt=%v keyval=0x%x", ctrl, alt, keyval)
278- }
279-
280- // Candidate accelerator strings in order
281- handled := false
282- candidates := []string{keyName}
283- if ctrl && shift {
284- // Ctrl+Shift combinations
285- candidates = append([]string{"cmdorctrl+shift+" + keyName, "ctrl+shift+" + keyName}, candidates...)
286- } else if ctrl {
287- // Common style: modifier + '+' + keyName (e.g., cmdorctrl+=)
288- candidates = append([]string{"cmdorctrl+" + keyName, "ctrl+" + keyName}, candidates...)
289- // Also accept style without the extra '+' for punctuation keys (e.g., cmdorctrl-)
290- candidates = append([]string{"cmdorctrl" + keyName, "ctrl" + keyName}, candidates...)
291- }
292- if alt {
293- candidates = append([]string{"alt+" + keyName}, candidates...)
294- }
295-
296- // Compute a single normalized shortcut string to forward to GUI (single source of truth)
297- normalized := ""
298- if ctrl && shift {
299- normalized = "cmdorctrl+shift+" + keyName
300- } else if ctrl {
301- normalized = "cmdorctrl+" + keyName
302- } else if alt {
303- normalized = "alt+" + keyName
304- } else if keyName != "" {
305- // For future modes like Vim, we may want to forward non-modified keys
306- // For now, only forward if a modifier is present to avoid noise
307- normalized = ""
308- }
309-
310- // Forward normalized shortcut to GUI KeyboardService via DOM event if present
311- if normalized != "" && !shouldDebounceShortcut(uid, "dom:"+normalized, now) {
312- regMu.RLock()
313- vw := viewByID[uid]
314- regMu.RUnlock()
315- if vw != nil {
316- _ = vw.InjectScript(fmt.Sprintf("document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'%s'}}));", normalized))
317- }
318- }
319-
320- regMu.RLock()
321- reg := viewShortcuts[uid]
322- regMu.RUnlock()
323- if reg == nil {
324- return handled
325- }
326-
327- for _, name := range candidates {
328- if cb, ok := reg[name]; ok {
329- if shouldDebounceShortcut(uid, "shortcut:"+name, now) {
330- return handled
331- }
332- log.Printf("[accelerator] %s", name)
333- cb()
334- handled = true
335- break
336- }
337- }
338-
339- return handled
340-}
341-
342-func nextViewID() uintptr {
343- return uintptr(atomic.AddUint64(&viewIDCounter, 1))
344-}
345-
346-func registerView(id uintptr, w *WebView) {
347- regMu.Lock()
348- viewByID[id] = w
349- regMu.Unlock()
350-}
351-
352-func unregisterView(id uintptr) {
353- regMu.Lock()
354- delete(viewByID, id)
355- delete(viewShortcuts, id)
356- regMu.Unlock()
357-}
358-
359-// AttachKeyboardControllers attaches GTK4 key and mouse controllers to the view widget.
360-func AttachKeyboardControllers(w *WebView) {
361- if w == nil || w.native == nil || w.native.view == nil {
362- return
363- }
364- C.attach_key_controller(w.native.view, C.ulong(w.id))
365- if w.native.win != nil {
366- C.attach_key_controller(w.native.win, C.ulong(w.id))
367- }
368- C.attach_mouse_gesture(w.native.view, C.ulong(w.id))
369- // Legacy controller captures raw button presses (8/9) for back/forward reliably
370- C.attach_mouse_legacy(w.native.view, C.ulong(w.id))
371- log.Printf("[input] GTK4 key/mouse controllers attached")
372-}
373-
374-//export goOnScroll
375-func goOnScroll(id C.ulong, dx C.double, dy C.double, state C.GdkModifierType) {
376- uid := uintptr(id)
377- regMu.RLock()
378- vw := viewByID[uid]
379- regMu.RUnlock()
380- if vw == nil {
381- return
382- }
383- // Ctrl+scroll zooms
384- if (uint(state) & uint(C.GDK_CONTROL_MASK)) != 0 {
385- if float64(dy) < 0 {
386- // scroll up -> zoom in
387- nz := vw.zoom
388- if nz <= 0 {
389- nz = 1.0
390- }
391- nz *= 1.1
392- if nz < 0.25 {
393- nz = 0.25
394- }
395- if nz > 5.0 {
396- nz = 5.0
397- }
398- _ = vw.SetZoom(nz)
399- } else if float64(dy) > 0 {
400- // scroll down -> zoom out
401- nz := vw.zoom
402- if nz <= 0 {
403- nz = 1.0
404- }
405- nz /= 1.1
406- if nz < 0.25 {
407- nz = 0.25
408- }
409- if nz > 5.0 {
410- nz = 5.0
411- }
412- _ = vw.SetZoom(nz)
413- }
414- }
415-}
416-
417-//export goOnThemeChanged
418-func goOnThemeChanged(id C.ulong, preferDark C.int) {
419- uid := uintptr(id)
420- regMu.RLock()
421- vw := viewByID[uid]
422- regMu.RUnlock()
423- if vw == nil {
424- return
425- }
426- // Inject runtime theme update into the page
427- dval := "false"
428- if int(preferDark) != 0 {
429- dval = "true"
430- }
431- js := fmt.Sprintf(`(() => { try { const d=%s; const cs=d?'dark':'light'; console.log('[dumber] theme change:'+cs);
432-let m=document.querySelector('meta[name="color-scheme"]');
433-if(!m){ m=document.createElement('meta'); m.name='color-scheme'; document.head.appendChild(m); }
434-m.setAttribute('content', d ? 'dark light' : 'light dark');
435-let s=document.getElementById('__dumber_theme_style');
436-if(!s){ s=document.createElement('style'); s.id='__dumber_theme_style'; document.documentElement.appendChild(s); }
437-s.textContent=':root{color-scheme:' + (d?'dark':'light') + ';}';
438-// Call the unified theme setter for Tailwind dark mode
439-if (window.__dumber_setTheme) {
440- window.__dumber_setTheme(cs);
441-} else {
442- // Retry after a short delay in case bridge hasn't loaded yet
443- setTimeout(() => {
444- if (window.__dumber_setTheme) {
445- window.__dumber_setTheme(cs);
446- } else {
447- // Fallback: directly apply dark class
448- console.warn('[dumber] Theme setter not available, using fallback');
449- if (d) {
450- document.documentElement.classList.add('dark');
451- } else {
452- document.documentElement.classList.remove('dark');
453- }
454- }
455- }, 100);
456-}
457-} catch(e) { console.warn('[dumber] theme runtime update failed', e); } })();`, dval)
458- _ = vw.InjectScript(js)
459-}
460-
461-//export goOnUcmMessage
462-func goOnUcmMessage(id C.ulong, json *C.char) {
463- uid := uintptr(id)
464- regMu.RLock()
465- vw := viewByID[uid]
466- regMu.RUnlock()
467- if vw == nil || json == nil {
468- return
469- }
470- goPayload := C.GoString(json)
471-
472- // Handle rendering backend detection messages specially
473- if handleRenderingBackendMessage(goPayload) {
474- return
475- }
476-
477- // Handle keyboard blocking messages
478- if handleKeyboardBlockingMessage(vw, goPayload) {
479- return
480- }
481-
482- vw.dispatchScriptMessage(goPayload)
483-}
484-
485-//export goOnTitleChanged
486-func goOnTitleChanged(id C.ulong, ctitle *C.char) {
487- uid := uintptr(id)
488- regMu.RLock()
489- vw := viewByID[uid]
490- regMu.RUnlock()
491- if vw == nil || ctitle == nil {
492- return
493- }
494- title := C.GoString(ctitle)
495- vw.dispatchTitleChanged(title)
496-}
497-
498-//export goOnURIChanged
499-func goOnURIChanged(id C.ulong, curi *C.char) {
500- uid := uintptr(id)
501- regMu.RLock()
502- vw := viewByID[uid]
503- regMu.RUnlock()
504- if vw == nil || curi == nil {
505- return
506- }
507- uri := C.GoString(curi)
508- vw.dispatchURIChanged(uri)
509-}
510-
511-//export goOnFaviconURIChanged
512-func goOnFaviconURIChanged(id C.ulong, pageURI *C.char, faviconURI *C.char) {
513- uid := uintptr(id)
514- pageURLStr := C.GoString(pageURI)
515- faviconURLStr := C.GoString(faviconURI)
516-
517- log.Printf("[favicon] WebKit favicon URI changed for WebView ID %d - favicon: %s", uid, faviconURLStr)
518-
519- regMu.RLock()
520- vw := viewByID[uid]
521- regMu.RUnlock()
522- if vw == nil {
523- log.Printf("[favicon] WebView not found for ID %d", uid)
524- return
525- }
526-
527- vw.dispatchFaviconURIChanged(pageURLStr, faviconURLStr)
528-}
529-
530-//export goHandleNewWindowPolicy
531-func goHandleNewWindowPolicy(id C.ulong, curi *C.char, navType C.int, cframeName *C.char, isUserGesture C.gboolean, modifiers C.guint, mouseButton C.guint) C.gboolean {
532- uid := uintptr(id)
533- regMu.RLock()
534- vw := viewByID[uid]
535- regMu.RUnlock()
536-
537- if vw == nil {
538- log.Printf("[webkit] Policy decision: no WebView found for ID %d", uid)
539- return 0 // FALSE
540- }
541-
542- uri := ""
543- if curi != nil {
544- uri = C.GoString(curi)
545- }
546-
547- frameName := ""
548- if cframeName != nil {
549- frameName = C.GoString(cframeName)
550- }
551-
552- // Navigation type constants from WebKit headers
553- navTypeNames := map[int]string{
554- 0: "LINK_CLICKED",
555- 1: "FORM_SUBMITTED",
556- 2: "BACK_FORWARD",
557- 3: "RELOAD",
558- 4: "FORM_RESUBMITTED",
559- 5: "OTHER",
560- }
561-
562- navTypeName := navTypeNames[int(navType)]
563- if navTypeName == "" {
564- navTypeName = fmt.Sprintf("UNKNOWN(%d)", int(navType))
565- }
566-
567- // Convert parameters for analysis
568- userGesture := isUserGesture != 0
569- mods := uint(modifiers)
570- button := uint(mouseButton)
571-
572- // Check for modifier keys that indicate new tab intent
573- const (
574- GDK_CONTROL_MASK = 0x04
575- GDK_META_MASK = 0x10000000 // Command key on macOS
576- )
577- hasCtrlOrCmd := (mods&GDK_CONTROL_MASK != 0) || (mods&GDK_META_MASK != 0)
578-
579- log.Printf("[webkit] Policy decision for new window: URI=%s, NavType=%s, FrameName=%s, UserGesture=%v, Modifiers=0x%x, Button=%d, CtrlOrCmd=%v",
580- uri, navTypeName, frameName, userGesture, mods, button, hasCtrlOrCmd)
581-
582- // Enhanced detection logic for _blank vs popup using reliable heuristics
583- isRegularPane := false
584-
585- // Criteria for treating as regular pane (new panel):
586- // 1. Explicit _blank frame name (target="_blank" links)
587- // 2. User gesture with Ctrl/Cmd+click (open in new panel intent)
588- // 3. User-initiated OTHER navigation with no frame name (window.open("url", "_blank"))
589- if frameName == "_blank" {
590- isRegularPane = true
591- log.Printf("[webkit] _blank frame detected - will handle as new panel")
592- } else if userGesture && hasCtrlOrCmd && (navType == 0 || navType == 5) {
593- // WEBKIT_NAVIGATION_TYPE_LINK_CLICKED = 0, WEBKIT_NAVIGATION_TYPE_OTHER = 5
594- isRegularPane = true
595- log.Printf("[webkit] Ctrl/Cmd+click detected - will handle as new panel")
596- } else if userGesture && navType == 5 && frameName == "" {
597- // window.open("url", "_blank") typically: user gesture + OTHER + no frame name
598- isRegularPane = true
599- log.Printf("[webkit] User-initiated window.open() detected - will handle as new panel")
600- }
601-
602- // Handle _blank targets by creating new panels directly (prevent WindowFeatures crash)
603- if isRegularPane {
604- log.Printf("[webkit] Creating new panel for _blank target: %s", uri)
605-
606- // Create new panel through workspace manager using existing popup handler
607- if vw.popupHandler != nil {
608- // Call popup handler to create a new panel
609- // The workspace manager will handle this as a regular panel based on URI markers
610- vw.popupHandler(uri + "#__dumber_frame_blank")
611- }
612-
613- return 1 // TRUE - we handled it by creating a new panel
614- }
615-
616- // For actual popups, let the create signal handle it
617- if vw.popupHandler != nil {
618- log.Printf("[webkit] Letting popup go through create signal for WindowFeatures handling: %s", uri)
619- return 0 // FALSE - let WebKit call create signal for popup handling
620- } else {
621- log.Printf("[webkit] No popup handler registered - allowing native popup for: %s", uri)
622- return 0 // FALSE - no handler, allow default popup
623- }
624-}
625-
626-//export goHandleLoadChanged
627-func goHandleLoadChanged(id C.ulong, curi *C.char, loadEvent C.int) {
628- uid := uintptr(id)
629- regMu.RLock()
630- vw := viewByID[uid]
631- regMu.RUnlock()
632-
633- if vw == nil {
634- return
635- }
636-
637- // OAuth auto-close is handled via WebKit's "close" signal when providers call window.close()
638- // No need to detect OAuth callback URLs - let the providers handle it
639- _ = curi // Suppress unused parameter warning
640-}
641-
642-//export goHandleWebViewClose
643-func goHandleWebViewClose(id C.ulong) {
644- uid := uintptr(id)
645- regMu.RLock()
646- vw := viewByID[uid]
647- regMu.RUnlock()
648-
649- if vw == nil {
650- log.Printf("[webkit] WebView close signal: no WebView found for ID %d", uid)
651- return
652- }
653-
654- log.Printf("[webkit] WebView close signal received for view_id: %d", uid)
655-
656- if vw.closeHandler != nil {
657- log.Printf("[webkit] Calling close handler for view_id: %d", uid)
658- vw.closeHandler()
659- } else {
660- log.Printf("[webkit] No close handler registered for view_id: %d", uid)
661- }
662-}
663-
664-//export goCreateNewPanel
665-func goCreateNewPanel(id C.ulong, curi *C.char) {
666- uid := uintptr(id)
667- regMu.RLock()
668- vw := viewByID[uid]
669- regMu.RUnlock()
670-
671- if vw == nil {
672- log.Printf("[webkit] Create panel: no WebView found for ID %d", uid)
673- return
674- }
675-
676- uri := ""
677- if curi != nil {
678- uri = C.GoString(curi)
679- }
680-
681- log.Printf("[webkit] Creating new panel for _blank target: %s", uri)
682-
683- // Create new panel through popup handler if available
684- if vw.popupHandler != nil {
685- vw.popupHandler(uri)
686- log.Printf("[webkit] New panel creation handled by popup handler")
687- } else {
688- log.Printf("[webkit] No popup handler available for panel creation")
689- }
690-}
691-
692-//export goHandlePopupGeometry
693-func goHandlePopupGeometry(id C.ulong, x, y, width, height C.int) {
694- // Store geometry for workspace manager to use
695- uid := uintptr(id)
696- regMu.Lock()
697- if vw := viewByID[uid]; vw != nil {
698- // For now, we'll store this in a simple way
699- // The workspace manager will handle the geometry
700- log.Printf("[webkit] Popup geometry: %dx%d at (%d,%d)",
701- int(width), int(height), int(x), int(y))
702- }
703- regMu.Unlock()
704-}
705-
706-//export goOnButtonPress
707-func goOnButtonPress(id C.ulong, button C.guint, state C.GdkModifierType) {
708- uid := uintptr(id)
709- btn := uint(button)
710- log.Printf("[mouse] button=%d state=0x%x", btn, uint(state))
711- regMu.RLock()
712- vw := viewByID[uid]
713- regMu.RUnlock()
714- if vw == nil {
715- return
716- }
717- switch btn {
718- case 8: // Back button on many mice
719- _ = vw.GoBack()
720- case 9: // Forward button on many mice
721- _ = vw.GoForward()
722- }
723-}
724-
725-//export goQuitMainLoop
726-func goQuitMainLoop() {
727- log.Printf("[webkit] Window close requested - quitting main loop")
728- QuitMainLoop()
729-}
730-
731-// handleRenderingBackendMessage processes rendering backend detection messages
732-func handleRenderingBackendMessage(payload string) bool {
733- // Parse JSON to check if this is a rendering backend detection message
734- if !strings.Contains(payload, "rendering_backend_detection") {
735- return false
736- }
737-
738- // Simple JSON parsing for the specific structure we expect
739- // {"type":"rendering_backend_detection","data":{...}}
740- var msg struct {
741- Type string `json:"type"`
742- Data struct {
743- WebGLAvailable bool `json:"webgl_available"`
744- Renderer string `json:"renderer"`
745- Vendor string `json:"vendor"`
746- Version string `json:"version"`
747- ShadingLanguageVersion string `json:"shading_language_version"`
748- MaxTextureSize int `json:"max_texture_size"`
749- HardwareAccelerated bool `json:"hardware_accelerated"`
750- } `json:"data"`
751- }
752-
753- if err := json.Unmarshal([]byte(payload), &msg); err != nil {
754- log.Printf("[webkit] Failed to parse rendering backend message: %v", err)
755- return false
756- }
757-
758- if msg.Type != "rendering_backend_detection" {
759- return false
760- }
761-
762- // Log the detected rendering backend information
763- backendType := "SOFTWARE"
764- if msg.Data.HardwareAccelerated {
765- backendType = "HARDWARE"
766- }
767-
768- log.Printf("[webkit] Detected rendering backend: %s", backendType)
769- log.Printf("[webkit] WebGL available: %v", msg.Data.WebGLAvailable)
770- log.Printf("[webkit] Renderer: %s", msg.Data.Renderer)
771- log.Printf("[webkit] Vendor: %s", msg.Data.Vendor)
772- log.Printf("[webkit] Version: %s", msg.Data.Version)
773- log.Printf("[webkit] Max texture size: %d", msg.Data.MaxTextureSize)
774-
775- return true
776-}
777-
778-// handleKeyboardBlockingMessage handles keyboard blocking control messages from omnibox
779-func handleKeyboardBlockingMessage(vw *WebView, payload string) bool {
780- if vw == nil || vw.IsDestroyed() {
781- return false
782- }
783-
784- var msg struct {
785- Type string `json:"type"`
786- Action string `json:"action"`
787- }
788-
789- if err := json.Unmarshal([]byte(payload), &msg); err != nil {
790- return false
791- }
792-
793- if msg.Type != "keyboard_blocking" {
794- return false
795- }
796-
797- switch msg.Action {
798- case "enable":
799- log.Printf("[webkit] Enabling page keyboard blocking for omnibox")
800- if err := vw.EnablePageKeyboardBlocking(); err != nil {
801- log.Printf("[webkit] Failed to enable keyboard blocking: %v", err)
802- }
803- case "disable":
804- log.Printf("[webkit] Disabling page keyboard blocking")
805- if err := vw.DisablePageKeyboardBlocking(); err != nil {
806- log.Printf("[webkit] Failed to disable keyboard blocking: %v", err)
807- }
808- default:
809- log.Printf("[webkit] Unknown keyboard blocking action: %s", msg.Action)
810- }
811-
812- return true
813-}
D pkg/webkit/log_capture.go +0 -125
@@ -1,125 +0,0 @@
1-//go:build webkit_cgo
2-
3-package webkit
4-
5-/*
6-#include "log_capture.h"
7-*/
8-import "C"
9-
10-import (
11- "time"
12- "unsafe"
13-
14- "github.com/bnema/dumber/internal/logging"
15-)
16-
17-var webkitCaptureActive = false
18-var webkitCaptureChan chan struct{}
19-
20-//export goWebKitLogHandler
21-func goWebKitLogHandler(domain *C.char, level C.int, message *C.char) {
22- goDomain := C.GoString(domain)
23- goMessage := C.GoString(message)
24-
25- var levelStr string
26- switch level {
27- case C.G_LOG_LEVEL_ERROR:
28- levelStr = "ERROR"
29- case C.G_LOG_LEVEL_CRITICAL:
30- levelStr = "CRITICAL"
31- case C.G_LOG_LEVEL_WARNING:
32- levelStr = "WARNING"
33- case C.G_LOG_LEVEL_MESSAGE:
34- levelStr = "MESSAGE"
35- case C.G_LOG_LEVEL_INFO:
36- levelStr = "INFO"
37- case C.G_LOG_LEVEL_DEBUG:
38- levelStr = "DEBUG"
39- default:
40- levelStr = "UNKNOWN"
41- }
42-
43- logMessage := goMessage
44- if goDomain != "" {
45- logMessage = "[" + goDomain + "] " + goMessage
46- }
47-
48- // Send to logging system with appropriate level
49- if logger := logging.GetLogger(); logger != nil {
50- logger.WriteTagged("WEBKIT-"+levelStr, logMessage)
51- }
52-}
53-
54-// InitWebKitLogCapture initializes the WebKit log capture system
55-func InitWebKitLogCapture() error {
56- if webkitCaptureActive {
57- return nil
58- }
59-
60- // Initialize C-level log capture
61- C.webkit_init_log_capture()
62-
63- // Set up GLib log handlers
64- C.webkit_setup_glib_log_handlers()
65-
66- // Create channel for stopping capture
67- webkitCaptureChan = make(chan struct{})
68-
69- // Start goroutine to read captured C output
70- go webkitLogReader()
71-
72- webkitCaptureActive = true
73- return nil
74-}
75-
76-// StopWebKitLogCapture stops the WebKit log capture
77-func StopWebKitLogCapture() {
78- if !webkitCaptureActive {
79- return
80- }
81-
82- // Signal the reader goroutine to stop
83- close(webkitCaptureChan)
84-
85- // Stop C-level capture
86- C.webkit_stop_log_capture()
87-
88- webkitCaptureActive = false
89-}
90-
91-// webkitLogReader reads captured C output in a goroutine
92-func webkitLogReader() {
93- buffer := make([]byte, 4096)
94-
95- for {
96- select {
97- case <-webkitCaptureChan:
98- return
99- default:
100- // Try to read captured output
101- n := int(C.webkit_read_captured_log((*C.char)(unsafe.Pointer(&buffer[0])), C.int(len(buffer))))
102- if n > 0 {
103- message := string(buffer[:n])
104- if logger := logging.GetLogger(); logger != nil {
105- logger.WriteTagged("WEBKIT-PRINTF", message)
106- }
107- }
108-
109- // Small delay to prevent busy waiting
110- time.Sleep(10 * time.Millisecond)
111- }
112- }
113-}
114-
115-// StartWebKitOutputCapture starts capturing webkit stdout/stderr
116-func StartWebKitOutputCapture() {
117- if webkitCaptureActive {
118- C.webkit_start_log_capture()
119- }
120-}
121-
122-// IsWebKitLogCaptureActive returns whether WebKit log capture is active
123-func IsWebKitLogCaptureActive() bool {
124- return webkitCaptureActive
125-}
D pkg/webkit/log_capture.h +0 -108
@@ -1,108 +0,0 @@
1-#ifndef WEBKIT_LOG_CAPTURE_H
2-#define WEBKIT_LOG_CAPTURE_H
3-
4-#include <stdio.h>
5-#include <stdlib.h>
6-#include <string.h>
7-#include <unistd.h>
8-#include <glib.h>
9-
10-static int webkit_log_pipe[2];
11-static FILE* webkit_original_stdout;
12-static FILE* webkit_original_stderr;
13-static gboolean webkit_capture_enabled = FALSE;
14-
15-// Initialize log capture pipes
16-static inline void webkit_init_log_capture() {
17- if (webkit_capture_enabled) return;
18-
19- // Create pipe for capturing C output
20- if (pipe(webkit_log_pipe) == 0) {
21- // Duplicate stdout/stderr for restoration later
22- webkit_original_stdout = fdopen(dup(1), "w");
23- webkit_original_stderr = fdopen(dup(2), "w");
24- webkit_capture_enabled = TRUE;
25-
26- g_debug("WebKit log capture initialized");
27- } else {
28- g_warning("Failed to initialize WebKit log capture");
29- }
30-}
31-
32-// Start capturing WebKit output
33-static inline void webkit_start_log_capture() {
34- if (!webkit_capture_enabled) return;
35-
36- // Redirect stdout/stderr to pipe (for C printf statements)
37- dup2(webkit_log_pipe[1], 1);
38- dup2(webkit_log_pipe[1], 2);
39-}
40-
41-// Stop capturing and restore original output
42-static inline void webkit_stop_log_capture() {
43- if (!webkit_capture_enabled) return;
44-
45- // Restore original stdout/stderr
46- if (webkit_original_stdout) {
47- dup2(fileno(webkit_original_stdout), 1);
48- fclose(webkit_original_stdout);
49- }
50- if (webkit_original_stderr) {
51- dup2(fileno(webkit_original_stderr), 2);
52- fclose(webkit_original_stderr);
53- }
54-
55- close(webkit_log_pipe[0]);
56- close(webkit_log_pipe[1]);
57- webkit_capture_enabled = FALSE;
58-}
59-
60-// Read captured output (non-blocking)
61-static inline int webkit_read_captured_log(char* buffer, int buffer_size) {
62- if (!webkit_capture_enabled) return 0;
63-
64- fd_set read_fds;
65- struct timeval timeout;
66-
67- FD_ZERO(&read_fds);
68- FD_SET(webkit_log_pipe[0], &read_fds);
69-
70- // Non-blocking check
71- timeout.tv_sec = 0;
72- timeout.tv_usec = 0;
73-
74- int result = select(webkit_log_pipe[0] + 1, &read_fds, NULL, NULL, &timeout);
75- if (result > 0 && FD_ISSET(webkit_log_pipe[0], &read_fds)) {
76- int n = read(webkit_log_pipe[0], buffer, buffer_size - 1);
77- if (n > 0) {
78- buffer[n] = '\0';
79- return n;
80- }
81- }
82- return 0;
83-}
84-
85-// GLib log handler to capture GLib/GTK/WebKit messages
86-static inline void webkit_glib_log_handler(const gchar *log_domain,
87- GLogLevelFlags log_level,
88- const gchar *message,
89- gpointer user_data) {
90- // Forward to Go callback
91- extern void goWebKitLogHandler(char* domain, int level, char* message);
92- goWebKitLogHandler((char*)log_domain, (int)log_level, (char*)message);
93-}
94-
95-// Set up GLib log handlers for various domains
96-static inline void webkit_setup_glib_log_handlers() {
97- // Set up log handlers for WebKit and GTK domains
98- g_log_set_handler("WebKit", G_LOG_LEVEL_MASK | G_LOG_FLAG_FATAL | G_LOG_FLAG_RECURSION,
99- webkit_glib_log_handler, NULL);
100- g_log_set_handler("Gtk", G_LOG_LEVEL_MASK | G_LOG_FLAG_FATAL | G_LOG_FLAG_RECURSION,
101- webkit_glib_log_handler, NULL);
102- g_log_set_handler("GLib", G_LOG_LEVEL_MASK | G_LOG_FLAG_FATAL | G_LOG_FLAG_RECURSION,
103- webkit_glib_log_handler, NULL);
104- g_log_set_handler(NULL, G_LOG_LEVEL_MASK | G_LOG_FLAG_FATAL | G_LOG_FLAG_RECURSION,
105- webkit_glib_log_handler, NULL);
106-}
107-
108-#endif // WEBKIT_LOG_CAPTURE_H⏎̸
D pkg/webkit/log_capture_stub.go +0 -24
@@ -1,24 +0,0 @@
1-//go:build !webkit_cgo
2-
3-package webkit
4-
5-// InitWebKitLogCapture initializes the WebKit log capture system (stub version)
6-func InitWebKitLogCapture() error {
7- // No-op for stub version
8- return nil
9-}
10-
11-// StopWebKitLogCapture stops the WebKit log capture (stub version)
12-func StopWebKitLogCapture() {
13- // No-op for stub version
14-}
15-
16-// StartWebKitOutputCapture starts capturing webkit stdout/stderr (stub version)
17-func StartWebKitOutputCapture() {
18- // No-op for stub version
19-}
20-
21-// IsWebKitLogCaptureActive returns whether WebKit log capture is active (stub version)
22-func IsWebKitLogCaptureActive() bool {
23- return false
24-}
A pkg/webkit/logging.go +29 -0
@@ -0,0 +1,29 @@
1+package webkit
2+
3+// Logging and debugging functions for WebKit
4+
5+// InitWebKitLogCapture initializes WebKit log capture
6+// In gotk4, WebKit logging is handled differently
7+func InitWebKitLogCapture() error {
8+ // WebKit logging in gotk4 uses environment variables:
9+ // WEBKIT_DEBUG for debug output
10+ // G_MESSAGES_DEBUG for GLib messages
11+ return nil
12+}
13+
14+// StopWebKitLogCapture stops WebKit log capture
15+func StopWebKitLogCapture() {
16+ // No-op in gotk4 - logging is controlled via environment variables
17+}
18+
19+// StartWebKitOutputCapture starts capturing WebKit output
20+func StartWebKitOutputCapture() {
21+ // No-op in gotk4
22+}
23+
24+// SetupWebKitDebugLogging enables WebKit debug logging
25+func SetupWebKitDebugLogging() {
26+ // In gotk4, enable debugging via environment variables before starting the app:
27+ // export WEBKIT_DEBUG=all
28+ // export G_MESSAGES_DEBUG=all
29+}
D pkg/webkit/loop.go +0 -13
@@ -1,13 +0,0 @@
1-//go:build !webkit_cgo
2-
3-package webkit
4-
5-import "log"
6-
7-// RunMainLoop is a no-op in non-CGO builds.
8-func RunMainLoop() {
9- log.Printf("[webkit] RunMainLoop (non-CGO): no GUI loop available")
10-}
11-
12-// QuitMainLoop is a no-op in non-CGO builds.
13-func QuitMainLoop() {}
D pkg/webkit/loop_cgo.go +0 -27
@@ -1,27 +0,0 @@
1-//go:build webkit_cgo
2-
3-package webkit
4-
5-/*
6-#cgo pkg-config: gtk4
7-#include <gtk/gtk.h>
8-#include <glib.h>
9-*/
10-import "C"
11-
12-var mainLoop *C.GMainLoop
13-
14-// RunMainLoop enters the GLib main loop (GTK4) and blocks until quit.
15-func RunMainLoop() {
16- if mainLoop == nil {
17- mainLoop = C.g_main_loop_new(nil, C.gboolean(0))
18- }
19- C.g_main_loop_run(mainLoop)
20-}
21-
22-// QuitMainLoop requests the main loop to exit.
23-func QuitMainLoop() {
24- if mainLoop != nil {
25- C.g_main_loop_quit(mainLoop)
26- }
27-}
A pkg/webkit/mainloop.go +67 -0
@@ -0,0 +1,67 @@
1+package webkit
2+
3+import (
4+ "runtime"
5+
6+ "github.com/diamondburned/gotk4/pkg/glib/v2"
7+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
8+)
9+
10+var (
11+ mainLoop *glib.MainLoop
12+ mainThreadID = runtime.LockOSThread // Store the main thread
13+ isInitialized bool
14+)
15+
16+// InitMainThread locks the current goroutine to the OS thread for GTK operations
17+// and initializes GTK. This must be called before any GTK operations.
18+func InitMainThread() {
19+ if !isInitialized {
20+ runtime.LockOSThread()
21+
22+ // Initialize GTK - this is required before creating any GTK widgets
23+ gtk.Init()
24+
25+ isInitialized = true
26+ }
27+}
28+
29+// RunMainLoop starts the GTK main event loop.
30+// This function blocks until QuitMainLoop is called.
31+func RunMainLoop() {
32+ InitMainThread()
33+
34+ if mainLoop == nil {
35+ mainLoop = glib.NewMainLoop(nil, false)
36+ }
37+
38+ mainLoop.Run()
39+}
40+
41+// QuitMainLoop stops the GTK main event loop.
42+func QuitMainLoop() {
43+ if mainLoop != nil {
44+ mainLoop.Quit()
45+ }
46+}
47+
48+// IsMainThread returns true if called from the GTK main thread.
49+func IsMainThread() bool {
50+ // In gotk4, we're always on the main thread since we lock it
51+ return isInitialized
52+}
53+
54+// RunOnMainThread executes a function on the GTK main thread.
55+// If already on the main thread, executes immediately.
56+// Otherwise, schedules the function via glib.IdleAdd.
57+func RunOnMainThread(fn func()) {
58+ if IsMainThread() {
59+ fn()
60+ return
61+ }
62+
63+ glib.IdleAdd(func() bool {
64+ fn()
65+ return false // Remove the idle handler after execution
66+ })
67+}
D pkg/webkit/memory.go +0 -286
@@ -1,286 +0,0 @@
1-//go:build webkit_cgo
2-
3-package webkit
4-
5-import (
6- "fmt"
7- "log"
8- "os"
9- "strconv"
10- "strings"
11- "time"
12-)
13-
14-// ProcessMemoryInfo contains memory information for a WebKit process
15-type ProcessMemoryInfo struct {
16- PID int `json:"pid"`
17- VmRSS int64 `json:"vm_rss"` // Resident Set Size in KB
18- VmSize int64 `json:"vm_size"` // Virtual Memory Size in KB
19- VmPeak int64 `json:"vm_peak"` // Peak Virtual Memory in KB
20- ProcessName string `json:"process_name"`
21-}
22-
23-// WebViewMemoryManager handles memory monitoring and lifecycle management for WebViews
24-type WebViewMemoryManager struct {
25- views map[uintptr]*WebView
26- enableMonitoring bool
27- recycleThreshold int
28- monitoringInterval time.Duration
29- stopMonitoring chan struct{}
30- lastLoggedMemory int64
31-}
32-
33-// NewWebViewMemoryManager creates a new memory manager
34-func NewWebViewMemoryManager(enableMonitoring bool, recycleThreshold int, monitoringInterval time.Duration) *WebViewMemoryManager {
35- return &WebViewMemoryManager{
36- views: make(map[uintptr]*WebView),
37- enableMonitoring: enableMonitoring,
38- recycleThreshold: recycleThreshold,
39- monitoringInterval: monitoringInterval,
40- stopMonitoring: make(chan struct{}),
41- }
42-}
43-
44-// RegisterWebView registers a WebView for monitoring
45-func (mgr *WebViewMemoryManager) RegisterWebView(view *WebView) {
46- if mgr == nil || view == nil {
47- return
48- }
49- mgr.views[view.id] = view
50-
51- if mgr.enableMonitoring && len(mgr.views) == 1 {
52- // Start monitoring when first view is registered
53- go mgr.startMemoryMonitoring()
54- }
55-}
56-
57-// UnregisterWebView removes a WebView from monitoring
58-func (mgr *WebViewMemoryManager) UnregisterWebView(viewID uintptr) {
59- if mgr == nil {
60- return
61- }
62- delete(mgr.views, viewID)
63-
64- if len(mgr.views) == 0 {
65- // Stop monitoring when no views left
66- close(mgr.stopMonitoring)
67- mgr.stopMonitoring = make(chan struct{})
68- }
69-}
70-
71-// startMemoryMonitoring starts the background memory monitoring routine
72-func (mgr *WebViewMemoryManager) startMemoryMonitoring() {
73- if mgr == nil || mgr.monitoringInterval <= 0 {
74- return
75- }
76-
77- ticker := time.NewTicker(mgr.monitoringInterval)
78- defer ticker.Stop()
79-
80- for {
81- select {
82- case <-ticker.C:
83- mgr.checkMemoryUsage()
84- case <-mgr.stopMonitoring:
85- return
86- }
87- }
88-}
89-
90-// checkMemoryUsage monitors memory usage of WebKit processes
91-func (mgr *WebViewMemoryManager) checkMemoryUsage() {
92- if mgr == nil {
93- return
94- }
95-
96- processes, err := mgr.getWebKitProcesses()
97- if err != nil {
98- if mgr.enableMonitoring {
99- log.Printf("[webkit] Error monitoring processes: %v", err)
100- }
101- return
102- }
103-
104- totalMemory := int64(0)
105- for _, proc := range processes {
106- totalMemory += proc.VmRSS
107- }
108-
109- // Only log if memory usage changed by more than 10MB (10240 KB)
110- const memoryChangeThreshold = 10240 // 10MB in KB
111- if mgr.enableMonitoring {
112- memoryDiff := totalMemory - mgr.lastLoggedMemory
113- if memoryDiff < 0 {
114- memoryDiff = -memoryDiff
115- }
116-
117- if mgr.lastLoggedMemory == 0 || memoryDiff >= memoryChangeThreshold {
118- log.Printf("[webkit] Memory usage: %d processes, total %.1f MB RSS",
119- len(processes), float64(totalMemory)/1024.0)
120- mgr.lastLoggedMemory = totalMemory
121- }
122- }
123-
124- // Check for recycling needs
125- for _, view := range mgr.views {
126- if view.memStats != nil && mgr.recycleThreshold > 0 {
127- if view.memStats.pageLoadCount >= mgr.recycleThreshold {
128- if mgr.enableMonitoring {
129- log.Printf("[webkit] WebView %d needs recycling (%d pages loaded)",
130- view.id, view.memStats.pageLoadCount)
131- }
132- }
133- }
134- }
135-}
136-
137-// getWebKitProcesses returns memory info for all WebKit-related processes
138-func (mgr *WebViewMemoryManager) getWebKitProcesses() ([]ProcessMemoryInfo, error) {
139- var processes []ProcessMemoryInfo
140-
141- // Find all WebKit processes by scanning /proc
142- procDir, err := os.ReadDir("/proc")
143- if err != nil {
144- return nil, fmt.Errorf("failed to read /proc: %w", err)
145- }
146-
147- for _, entry := range procDir {
148- if !entry.IsDir() {
149- continue
150- }
151-
152- pid, err := strconv.Atoi(entry.Name())
153- if err != nil {
154- continue // Not a PID directory
155- }
156-
157- // Check if this is a WebKit process
158- cmdlinePath := fmt.Sprintf("/proc/%d/cmdline", pid)
159- cmdlineBytes, err := os.ReadFile(cmdlinePath)
160- if err != nil {
161- continue // Process might have exited
162- }
163-
164- cmdline := string(cmdlineBytes)
165- if !strings.Contains(cmdline, "WebKit") && !strings.Contains(cmdline, "dumber") {
166- continue // Not a WebKit or dumber process
167- }
168-
169- // Get memory information
170- statusPath := fmt.Sprintf("/proc/%d/status", pid)
171- statusData, err := os.ReadFile(statusPath)
172- if err != nil {
173- continue
174- }
175-
176- memInfo := mgr.parseMemoryStatus(string(statusData), pid)
177- if memInfo != nil {
178- // Extract process name from cmdline
179- parts := strings.Split(cmdline, "\x00")
180- if len(parts) > 0 {
181- memInfo.ProcessName = parts[0]
182- }
183- processes = append(processes, *memInfo)
184- }
185- }
186-
187- return processes, nil
188-}
189-
190-// parseMemoryStatus parses /proc/[pid]/status for memory information
191-func (mgr *WebViewMemoryManager) parseMemoryStatus(status string, pid int) *ProcessMemoryInfo {
192- lines := strings.Split(status, "\n")
193- info := &ProcessMemoryInfo{PID: pid}
194-
195- for _, line := range lines {
196- if strings.HasPrefix(line, "VmRSS:") {
197- if val := mgr.extractMemoryValue(line); val > 0 {
198- info.VmRSS = val
199- }
200- } else if strings.HasPrefix(line, "VmSize:") {
201- if val := mgr.extractMemoryValue(line); val > 0 {
202- info.VmSize = val
203- }
204- } else if strings.HasPrefix(line, "VmPeak:") {
205- if val := mgr.extractMemoryValue(line); val > 0 {
206- info.VmPeak = val
207- }
208- }
209- }
210-
211- // Only return if we found at least VmRSS
212- if info.VmRSS > 0 {
213- return info
214- }
215-
216- return nil
217-}
218-
219-// extractMemoryValue extracts memory value in KB from status lines like "VmRSS: 12345 kB"
220-func (mgr *WebViewMemoryManager) extractMemoryValue(line string) int64 {
221- parts := strings.Fields(line)
222- if len(parts) >= 2 {
223- val, err := strconv.ParseInt(parts[1], 10, 64)
224- if err == nil {
225- return val
226- }
227- }
228- return 0
229-}
230-
231-// GetTotalMemoryUsage returns total memory usage of all WebKit processes in MB
232-func (mgr *WebViewMemoryManager) GetTotalMemoryUsage() (float64, error) {
233- if mgr == nil {
234- return 0, fmt.Errorf("memory manager is nil")
235- }
236-
237- processes, err := mgr.getWebKitProcesses()
238- if err != nil {
239- return 0, err
240- }
241-
242- totalKB := int64(0)
243- for _, proc := range processes {
244- totalKB += proc.VmRSS
245- }
246-
247- return float64(totalKB) / 1024.0, nil
248-}
249-
250-// GetDetailedMemoryInfo returns detailed memory information for all WebKit processes
251-func (mgr *WebViewMemoryManager) GetDetailedMemoryInfo() ([]ProcessMemoryInfo, error) {
252- if mgr == nil {
253- return nil, fmt.Errorf("memory manager is nil")
254- }
255-
256- return mgr.getWebKitProcesses()
257-}
258-
259-// ShouldRecycleWebView checks if a WebView should be recycled based on memory pressure
260-func (mgr *WebViewMemoryManager) ShouldRecycleWebView(view *WebView) bool {
261- if mgr == nil || view == nil || view.memStats == nil {
262- return false
263- }
264-
265- if mgr.recycleThreshold > 0 && view.memStats.pageLoadCount >= mgr.recycleThreshold {
266- return true
267- }
268-
269- return false
270-}
271-
272-// Global memory manager instance
273-var globalMemoryManager *WebViewMemoryManager
274-
275-// InitializeGlobalMemoryManager initializes the global memory manager
276-func InitializeGlobalMemoryManager(enableMonitoring bool, recycleThreshold int, monitoringInterval time.Duration) {
277- if monitoringInterval == 0 {
278- monitoringInterval = 30 * time.Second // Default 30 seconds
279- }
280- globalMemoryManager = NewWebViewMemoryManager(enableMonitoring, recycleThreshold, monitoringInterval)
281-}
282-
283-// GetGlobalMemoryManager returns the global memory manager
284-func GetGlobalMemoryManager() *WebViewMemoryManager {
285- return globalMemoryManager
286-}
D pkg/webkit/memory_example.go +0 -189
@@ -1,189 +0,0 @@
1-package webkit
2-
3-// GetMemoryOptimizedConfig returns a Config optimized for low memory usage
4-// This configuration aims to reduce memory usage by 40-60% compared to defaults
5-func GetMemoryOptimizedConfig() *Config {
6- return &Config{
7- Memory: MemoryConfig{
8- // Limit each WebView to 256MB (default: unlimited)
9- MemoryLimitMB: 256,
10-
11- // Aggressive memory pressure thresholds
12- ConservativeThreshold: 0.2, // Trigger cleanup at 20% (default: 33%)
13- StrictThreshold: 0.35, // Aggressive cleanup at 35% (default: 50%)
14- KillThreshold: 0.8, // Kill process at 80% to prevent OOM
15-
16- // Check memory usage every 2 seconds
17- PollIntervalSeconds: 2.0,
18-
19- // Use minimal caching for lowest memory usage
20- CacheModel: CacheModelDocumentViewer,
21-
22- // Disable memory-intensive caches
23- EnablePageCache: false, // Saves ~30-50MB but slower back/forward
24- EnableOfflineAppCache: false, // Saves ~10-20MB
25-
26- // Recycle WebView after 50 page loads to prevent memory accumulation
27- ProcessRecycleThreshold: 50,
28-
29- // Trigger JavaScript GC every 60 seconds
30- EnableGCInterval: 60,
31-
32- // Enable detailed memory monitoring and logging
33- EnableMemoryMonitoring: true,
34- },
35-
36- // Disable developer tools to save memory (unless debugging)
37- EnableDeveloperExtras: false,
38-
39- // Use CPU rendering to save GPU memory (trade-off: slower rendering)
40- Rendering: RenderingConfig{
41- Mode: "cpu",
42- },
43- }
44-}
45-
46-// GetBalancedConfig returns a Config with moderate memory optimizations
47-// This configuration aims to reduce memory usage by 20-30% with minimal performance impact
48-func GetBalancedConfig() *Config {
49- return &Config{
50- Memory: MemoryConfig{
51- // Limit each WebView to 512MB
52- MemoryLimitMB: 512,
53-
54- // Moderate memory pressure thresholds
55- ConservativeThreshold: 0.25,
56- StrictThreshold: 0.4,
57- KillThreshold: 0.7,
58-
59- PollIntervalSeconds: 5.0,
60-
61- // Use default web browser caching
62- CacheModel: CacheModelWebBrowser,
63-
64- // Keep page cache but disable offline app cache
65- EnablePageCache: true,
66- EnableOfflineAppCache: false,
67-
68- // Recycle after more page loads
69- ProcessRecycleThreshold: 100,
70-
71- // Less frequent GC
72- EnableGCInterval: 120,
73-
74- EnableMemoryMonitoring: true,
75- },
76-
77- EnableDeveloperExtras: false,
78-
79- // Use auto rendering (WebKit decides GPU vs CPU)
80- Rendering: RenderingConfig{
81- Mode: "auto",
82- },
83- }
84-}
85-
86-// GetHighPerformanceConfig returns a Config optimized for performance over memory
87-// This uses default/high memory settings for maximum performance
88-func GetHighPerformanceConfig() *Config {
89- return &Config{
90- Memory: MemoryConfig{
91- // No memory limit (use system default)
92- MemoryLimitMB: 0,
93-
94- // Relaxed memory pressure thresholds
95- ConservativeThreshold: 0.4,
96- StrictThreshold: 0.6,
97- KillThreshold: 0, // Disable kill threshold
98-
99- PollIntervalSeconds: 10.0,
100-
101- // Maximum caching for best performance
102- CacheModel: CacheModelPrimaryWebBrowser,
103-
104- // Enable all caches
105- EnablePageCache: true,
106- EnableOfflineAppCache: true,
107-
108- // Disable recycling
109- ProcessRecycleThreshold: 0,
110-
111- // Disable periodic GC
112- EnableGCInterval: 0,
113-
114- EnableMemoryMonitoring: false,
115- },
116-
117- EnableDeveloperExtras: true,
118-
119- // Use GPU rendering for best performance
120- Rendering: RenderingConfig{
121- Mode: "gpu",
122- },
123- }
124-}
125-
126-// ValidateMemoryConfig validates memory configuration parameters
127-func ValidateMemoryConfig(config *MemoryConfig) error {
128- if config == nil {
129- return nil // Use defaults
130- }
131-
132- if config.ConservativeThreshold < 0 || config.ConservativeThreshold > 1 {
133- return &ValidationError{Field: "ConservativeThreshold", Message: "must be between 0.0 and 1.0"}
134- }
135-
136- if config.StrictThreshold < 0 || config.StrictThreshold > 1 {
137- return &ValidationError{Field: "StrictThreshold", Message: "must be between 0.0 and 1.0"}
138- }
139-
140- if config.KillThreshold < 0 || config.KillThreshold > 1 {
141- return &ValidationError{Field: "KillThreshold", Message: "must be between 0.0 and 1.0"}
142- }
143-
144- if config.ConservativeThreshold >= config.StrictThreshold {
145- return &ValidationError{Field: "StrictThreshold", Message: "must be greater than ConservativeThreshold"}
146- }
147-
148- if config.PollIntervalSeconds < 0 {
149- return &ValidationError{Field: "PollIntervalSeconds", Message: "must be non-negative"}
150- }
151-
152- return nil
153-}
154-
155-// ValidationError represents a configuration validation error
156-type ValidationError struct {
157- Field string
158- Message string
159-}
160-
161-func (e *ValidationError) Error() string {
162- return "validation error for " + e.Field + ": " + e.Message
163-}
164-
165-// LogMemoryConfiguration logs the current memory configuration for debugging
166-func LogMemoryConfiguration(config *Config) {
167- if config == nil || !config.Memory.EnableMemoryMonitoring {
168- return
169- }
170-
171- log := func(format string, args ...interface{}) {
172- // Use Go's standard log package
173- // log.Printf("[webkit-config] "+format, args...)
174- // For now, we'll just ignore since we don't have access to log here
175- _ = format
176- _ = args
177- }
178-
179- log("Memory configuration:")
180- log(" MemoryLimitMB: %d", config.Memory.MemoryLimitMB)
181- log(" ConservativeThreshold: %.2f", config.Memory.ConservativeThreshold)
182- log(" StrictThreshold: %.2f", config.Memory.StrictThreshold)
183- log(" KillThreshold: %.2f", config.Memory.KillThreshold)
184- log(" CacheModel: %s", config.Memory.CacheModel)
185- log(" EnablePageCache: %t", config.Memory.EnablePageCache)
186- log(" EnableOfflineAppCache: %t", config.Memory.EnableOfflineAppCache)
187- log(" ProcessRecycleThreshold: %d", config.Memory.ProcessRecycleThreshold)
188- log(" EnableGCInterval: %d seconds", config.Memory.EnableGCInterval)
189-}
A pkg/webkit/network_session.go +104 -0
@@ -0,0 +1,104 @@
1+package webkit
2+
3+import (
4+ "fmt"
5+ "log"
6+
7+ webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
8+)
9+
10+var (
11+ // globalNetworkSession holds the persistent network session to prevent garbage collection
12+ // This must be kept alive for the entire application lifetime
13+ globalNetworkSession *webkit.NetworkSession
14+)
15+
16+// InitPersistentSession creates and initializes a persistent NetworkSession
17+// with the provided data and cache directories.
18+// This should be called before creating any WebViews to ensure they use persistent storage.
19+// Returns an error if initialization fails.
20+//
21+// IMPORTANT: Once created, this session is stored globally and will be used by all
22+// WebViews created afterward, as per WebKitGTK 6.0's design where the first created
23+// session becomes the default for the network process.
24+func InitPersistentSession(dataDir, cacheDir string) error {
25+ // If session already exists, return success (idempotent)
26+ // This ensures we only create ONE session for the entire application
27+ if globalNetworkSession != nil {
28+ log.Printf("[webkit] Using existing persistent network session")
29+ return nil
30+ }
31+
32+ // Create persistent network session using gotk4 API
33+ // Per WebKitGTK 6.0 docs: "The first WebKitNetworkSession created becomes the default"
34+ session := webkit.NewNetworkSession(dataDir, cacheDir)
35+ if session == nil {
36+ return fmt.Errorf("failed to create persistent network session")
37+ }
38+
39+ // Store the session globally to prevent garbage collection
40+ // This is CRITICAL - if the session is GC'd, WebKit falls back to ephemeral storage
41+ globalNetworkSession = session
42+
43+ log.Printf("[webkit] Persistent network session created: data=%s, cache=%s", dataDir, cacheDir)
44+
45+ // Verify the session is not ephemeral
46+ if session.IsEphemeral() {
47+ return fmt.Errorf("created session is ephemeral despite providing data directories")
48+ }
49+
50+ // Verify the WebsiteDataManager is not ephemeral
51+ websiteDataManager := session.WebsiteDataManager()
52+ if websiteDataManager == nil {
53+ return fmt.Errorf("failed to get website data manager from network session")
54+ }
55+ if websiteDataManager.IsEphemeral() {
56+ return fmt.Errorf("website data manager is ephemeral despite providing data directories")
57+ }
58+ log.Printf("[webkit] WebsiteDataManager verified as non-ephemeral")
59+
60+ // Configure CookieManager for persistent cookie storage
61+ // This is REQUIRED - without this, cookies are not persisted to disk
62+ cookieManager := session.CookieManager()
63+ if cookieManager == nil {
64+ return fmt.Errorf("failed to get cookie manager from network session")
65+ }
66+
67+ // Set cookie persistent storage to SQLite database
68+ // Format: filepath.Join(dataDir, "cookies.db")
69+ cookiePath := dataDir + "/cookies.db"
70+ cookieManager.SetPersistentStorage(cookiePath, webkit.CookiePersistentStorageSqlite)
71+
72+ // Set cookie accept policy to no third-party (default)
73+ cookieManager.SetAcceptPolicy(webkit.CookiePolicyAcceptNoThirdParty)
74+
75+ log.Printf("[webkit] Cookie manager configured: storage=%s, policy=no-third-party", cookiePath)
76+
77+ // Enable persistent credential storage (HTTP auth, etc.)
78+ session.SetPersistentCredentialStorageEnabled(true)
79+
80+ // Configure TLS errors to emit signals instead of failing silently
81+ // This enables the load-failed-with-tls-errors signal
82+ session.SetTLSErrorsPolicy(webkit.TLSErrorsPolicyFail)
83+ log.Printf("[webkit] TLS errors policy set to FAIL (signals enabled)")
84+
85+ // Verify this is indeed the default session now
86+ defaultSession := webkit.NetworkSessionGetDefault()
87+ if defaultSession == nil {
88+ return fmt.Errorf("default network session is nil after creating persistent session")
89+ }
90+
91+ // Check if they're the same session by comparing ephemeral status
92+ if defaultSession.IsEphemeral() {
93+ return fmt.Errorf("default network session is ephemeral after creating persistent session")
94+ }
95+
96+ log.Printf("[webkit] Network session verified as persistent and set as default")
97+ return nil
98+}
99+
100+// GetGlobalNetworkSession returns the global network session.
101+// Returns nil if InitPersistentSession hasn't been called successfully.
102+func GetGlobalNetworkSession() *webkit.NetworkSession {
103+ return globalNetworkSession
104+}
D pkg/webkit/print_cgo.go +0 -46
@@ -1,46 +0,0 @@
1-//go:build webkit_cgo
2-
3-package webkit
4-
5-/*
6-#cgo pkg-config: webkitgtk-6.0 gtk4
7-#cgo CFLAGS: -I/usr/include/webkitgtk-6.0
8-#include <webkit/webkit.h>
9-#include <gtk/gtk.h>
10-*/
11-import "C"
12-import "unsafe"
13-
14-// ShowPrintDialog shows the print dialog for the WebView
15-func (w *WebView) ShowPrintDialog() error {
16- if w == nil || w.native == nil || w.native.wv == nil {
17- return ErrNotImplemented
18- }
19-
20- // Create print operation
21- printOp := C.webkit_print_operation_new(w.native.wv)
22- if printOp == nil {
23- return ErrNotImplemented
24- }
25-
26- // Pass nil for parent window - print dialog will work without it
27- var parentWindow *C.GtkWindow = nil
28-
29- // Show print dialog
30- response := C.webkit_print_operation_run_dialog(printOp, parentWindow)
31-
32- // Clean up print operation
33- C.g_object_unref(C.gpointer(unsafe.Pointer(printOp)))
34-
35- // Handle the response
36- switch response {
37- case C.WEBKIT_PRINT_OPERATION_RESPONSE_PRINT:
38- // User clicked print - the operation will proceed
39- return nil
40- case C.WEBKIT_PRINT_OPERATION_RESPONSE_CANCEL:
41- // User cancelled - not an error
42- return nil
43- default:
44- return ErrNotImplemented
45- }
46-}
D pkg/webkit/scheme.go +0 -9
@@ -1,9 +0,0 @@
1-//go:build !webkit_cgo
2-
3-package webkit
4-
5-// SchemeResolver provides bytes and mime for a given custom URI.
6-type SchemeResolver func(uri string) (mime string, data []byte, ok bool)
7-
8-// SetURISchemeResolver is a no-op in non-CGO builds.
9-func SetURISchemeResolver(r SchemeResolver) { /* no-op */ }
D pkg/webkit/scheme_cgo.go +0 -51
@@ -1,51 +0,0 @@
1-//go:build webkit_cgo
2-
3-package webkit
4-
5-/*
6-#include <stdlib.h>
7-*/
8-import "C"
9-
10-import (
11- "unsafe"
12-)
13-
14-// Resolver provides bytes and mime for a given custom URI.
15-type SchemeResolver func(uri string) (mime string, data []byte, ok bool)
16-
17-var globalResolver SchemeResolver
18-
19-// SetURISchemeResolver sets the resolver used by the custom URI scheme handler.
20-func SetURISchemeResolver(r SchemeResolver) { globalResolver = r }
21-
22-//export goResolveURIScheme
23-func goResolveURIScheme(curi *C.char, outLen *C.size_t, outMime **C.char) unsafe.Pointer {
24- if curi == nil || outLen == nil || outMime == nil {
25- return nil
26- }
27- uri := C.GoString(curi)
28- if globalResolver == nil {
29- *outLen = 0
30- *outMime = nil
31- return nil
32- }
33- mime, data, ok := globalResolver(uri)
34- if !ok || len(data) == 0 {
35- *outLen = 0
36- *outMime = nil
37- return nil
38- }
39- buf := C.malloc(C.size_t(len(data)))
40- if buf == nil {
41- *outLen = 0
42- *outMime = nil
43- return nil
44- }
45- // Copy data into C buffer
46- dst := (*[1 << 30]byte)(buf)[:len(data):len(data)]
47- copy(dst, data)
48- *outLen = C.size_t(len(data))
49- *outMime = C.CString(mime)
50- return buf
51-}
D pkg/webkit/script.go +0 -13
@@ -1,13 +0,0 @@
1-//go:build !webkit_cgo
2-
3-package webkit
4-
5-// InjectScript evaluates JavaScript in the active WebView context.
6-func (w *WebView) InjectScript(js string) error {
7- if w == nil || w.destroyed {
8- return ErrNotImplemented
9- }
10- // Store last script for potential debugging/testing.
11- _ = js
12- return nil
13-}
D pkg/webkit/script_cgo.go +0 -81
@@ -1,81 +0,0 @@
1-//go:build webkit_cgo
2-
3-package webkit
4-
5-/*
6-#cgo pkg-config: webkitgtk-6.0 gtk4
7-#cgo CFLAGS: -I/usr/include/webkitgtk-6.0
8-#include <stdlib.h>
9-#include <webkit/webkit.h>
10-
11-static gboolean dumber_is_valid_web_view(WebKitWebView *wv) {
12- return WEBKIT_IS_WEB_VIEW(wv);
13-}
14-*/
15-import "C"
16-
17-import "unsafe"
18-
19-func (w *WebView) canEvaluateJavaScript() bool {
20- if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
21- return false
22- }
23- if C.dumber_is_valid_web_view(w.native.wv) == C.gboolean(0) {
24- if w.native != nil {
25- w.native.wv = nil
26- w.native.view = nil
27- }
28- w.destroyed = true
29- return false
30- }
31- return true
32-}
33-
34-func (w *WebView) InjectScript(js string) error {
35- if !w.canEvaluateJavaScript() {
36- return ErrNotImplemented
37- }
38- cjs := C.CString(js)
39- defer C.free(unsafe.Pointer(cjs))
40- // Fire-and-forget evaluation using the modern API
41- // length: -1 (NUL-terminated), world_name/source_uri: NULL, starting_line_number: 0
42- C.webkit_web_view_evaluate_javascript(
43- w.native.wv,
44- (*C.gchar)(cjs),
45- C.gssize(-1),
46- nil, // world_name
47- nil, // source_uri
48- nil, // cancellable
49- nil, // callback
50- nil, // user_data
51- )
52- return nil
53-}
54-
55-// InjectScriptIntoWorld evaluates JavaScript in the specified script world.
56-func (w *WebView) InjectScriptIntoWorld(js string, worldName string) error {
57- if !w.canEvaluateJavaScript() {
58- return ErrNotImplemented
59- }
60- cjs := C.CString(js)
61- defer C.free(unsafe.Pointer(cjs))
62-
63- var cworld *C.gchar
64- if worldName != "" {
65- cworld = (*C.gchar)(C.CString(worldName))
66- defer C.free(unsafe.Pointer(cworld))
67- }
68-
69- // Evaluate JavaScript in the specified world
70- C.webkit_web_view_evaluate_javascript(
71- w.native.wv,
72- (*C.gchar)(cjs),
73- C.gssize(-1),
74- cworld, // world_name
75- nil, // source_uri
76- nil, // cancellable
77- nil, // callback
78- nil, // user_data
79- )
80- return nil
81-}
D pkg/webkit/settings.go +0 -145
@@ -1,145 +0,0 @@
1-package webkit
2-
3-// Config holds initialization settings for the WebKit-based browser components.
4-// This will map to WebKit2GTK and GTK settings in the real implementation.
5-type Config struct {
6- // Assets provides access to embedded GUI files
7- Assets interface{ ReadFile(string) ([]byte, error) }
8-
9- // InitialURL is the first URL to load when creating a WebView.
10- InitialURL string
11-
12- // UserAgent allows overriding the default user agent string.
13- UserAgent string
14-
15- // EnableDeveloperExtras enables devtools/inspector.
16- EnableDeveloperExtras bool
17-
18- // ZoomDefault sets an initial zoom factor (1.0 = 100%).
19- ZoomDefault float64
20-
21- // DataDir is the base directory for persistent website data (cookies, localStorage, etc.).
22- DataDir string
23- // CacheDir is the base directory for cache data.
24- CacheDir string
25-
26- // Fonts apply to pages that don't specify fonts (browser defaults).
27- DefaultSansFont string
28- DefaultSerifFont string
29- DefaultMonospaceFont string
30- DefaultFontSize int // CSS px (~points)
31-
32- // Rendering controls GPU/CPU selection and debug options
33- Rendering RenderingConfig
34-
35- // VideoAcceleration controls video hardware acceleration settings
36- VideoAcceleration VideoAccelerationConfig
37-
38- // Memory controls memory optimization settings
39- Memory MemoryConfig
40-
41- // CodecPreferences for media playback
42- CodecPreferences CodecPreferencesConfig
43-
44- // APIToken holds the per-session token used to authenticate dumb://api requests
45- // It's optional; if empty, API token enforcement may be disabled by the app.
46- APIToken string
47-
48- // UseDomZoom toggles CSS/DOM-based zoom instead of WebKit's native zoom.
49- UseDomZoom bool
50-
51- // Colors defines the light/dark palette tokens for the GUI.
52- Colors ColorPalettes
53-
54- // CreateWindow controls whether to create a window during WebView initialization.
55- // Set to false when creating WebViews for workspace panes that will be reparented.
56- CreateWindow bool
57-}
58-
59-// ColorPalette holds semantic UI tokens for a theme.
60-type ColorPalette struct {
61- Background string
62- Surface string
63- SurfaceVariant string
64- Text string
65- Muted string
66- Accent string
67- Border string
68-}
69-
70-// ColorPalettes hosts both light and dark color palettes.
71-type ColorPalettes struct {
72- Light ColorPalette
73- Dark ColorPalette
74-}
75-
76-// CodecPreferencesConfig holds codec preferences for WebKit media playback
77-type CodecPreferencesConfig struct {
78- PreferredCodecs []string
79- BlockedCodecs []string
80- ForceAV1 bool
81- CustomUserAgent string
82- DisableTwitchCodecControl bool
83-}
84-
85-// RenderingConfig controls hardware acceleration preferences.
86-// Mode accepts: "auto" (default), "gpu", or "cpu".
87-// DebugGPU enables compositing indicators if supported.
88-type RenderingConfig struct {
89- Mode string
90- DebugGPU bool
91-}
92-
93-// VideoAccelerationConfig controls video hardware acceleration settings.
94-type VideoAccelerationConfig struct {
95- EnableVAAPI bool
96- AutoDetectGPU bool
97- VAAPIDriverName string
98- EnableAllDrivers bool
99- LegacyVAAPI bool
100-}
101-
102-// CacheModel represents WebKit cache model options for memory optimization.
103-type CacheModel string
104-
105-const (
106- CacheModelDocumentViewer CacheModel = "document_viewer" // Minimal caching, lowest memory
107- CacheModelWebBrowser CacheModel = "web_browser" // Default, balanced caching
108- CacheModelPrimaryWebBrowser CacheModel = "primary_web_browser" // Maximum caching, highest memory
109-)
110-
111-// MemoryConfig controls memory optimization settings.
112-type MemoryConfig struct {
113- // MemoryLimitMB sets the maximum memory limit per WebView in MB (0 = unlimited)
114- MemoryLimitMB int
115-
116- // ConservativeThreshold triggers early memory cleanup (0.0-1.0, default 0.33)
117- ConservativeThreshold float64
118-
119- // StrictThreshold triggers aggressive memory cleanup (0.0-1.0, default 0.5)
120- StrictThreshold float64
121-
122- // KillThreshold kills processes at this memory usage (0.0-1.0, 0 = disabled)
123- KillThreshold float64
124-
125- // PollIntervalSeconds sets how often to check memory usage (default 2.0)
126- PollIntervalSeconds float64
127-
128- // CacheModel determines caching strategy (default: web_browser)
129- CacheModel CacheModel
130-
131- // EnablePageCache enables back/forward page caching (default: true)
132- EnablePageCache bool
133-
134- // EnableOfflineAppCache enables offline application caching (default: true)
135- EnableOfflineAppCache bool
136-
137- // ProcessRecycleThreshold recycles WebView after N page loads (0 = disabled)
138- ProcessRecycleThreshold int
139-
140- // EnableGCInterval enables periodic JS garbage collection in seconds (0 = disabled)
141- EnableGCInterval int
142-
143- // EnableMemoryMonitoring logs memory usage and pressure events
144- EnableMemoryMonitoring bool
145-}
A pkg/webkit/shortcuts.go +161 -0
@@ -0,0 +1,161 @@
1+package webkit
2+
3+import (
4+ "log"
5+ "strings"
6+
7+ "github.com/diamondburned/gotk4/pkg/gdk/v4"
8+ "github.com/diamondburned/gotk4/pkg/glib/v2"
9+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
10+)
11+
12+// WindowShortcuts manages keyboard shortcuts for a window
13+type WindowShortcuts struct {
14+ window *Window
15+ shortcuts map[string]*gtk.Shortcut
16+ controller *gtk.ShortcutController
17+}
18+
19+// NewWindowShortcuts creates a new WindowShortcuts manager
20+func NewWindowShortcuts(win *Window) *WindowShortcuts {
21+ controller := gtk.NewShortcutController()
22+
23+ // Set scope to GLOBAL so shortcuts work at the window level
24+ // and aren't captured by child widgets (like the WebView)
25+ controller.SetScope(gtk.ShortcutScopeGlobal)
26+
27+ if win != nil && win.win != nil {
28+ win.win.AddController(controller)
29+ }
30+
31+ return &WindowShortcuts{
32+ window: win,
33+ shortcuts: make(map[string]*gtk.Shortcut),
34+ controller: controller,
35+ }
36+}
37+
38+// InitializeGlobalShortcuts initializes global keyboard shortcuts for a window
39+func (w *Window) InitializeGlobalShortcuts(shortcuts map[string]func()) {
40+ if w == nil || w.win == nil {
41+ return
42+ }
43+
44+ // Create event controller for keyboard events
45+ controller := gtk.NewEventControllerKey()
46+
47+ controller.ConnectKeyPressed(func(keyval, keycode uint, state gdk.ModifierType) bool {
48+ // Handle keyboard shortcuts
49+ // TODO: Implement proper shortcut matching
50+ return false
51+ })
52+
53+ w.win.AddController(controller)
54+}
55+
56+// RegisterShortcut registers a keyboard shortcut
57+func (ws *WindowShortcuts) RegisterShortcut(accelerator string, handler func()) {
58+ if ws == nil || ws.controller == nil {
59+ return
60+ }
61+
62+ // Convert to GTK format (e.g., "ctrl+plus" -> "<Control>plus")
63+ gtkFormat := convertToGtkFormat(accelerator)
64+ if gtkFormat == "" {
65+ log.Printf("[shortcuts] Invalid key format: %s", accelerator)
66+ return
67+ }
68+
69+ // Create a callback action that will trigger our handler
70+ // ShortcutFunc signature: func(widget Widgetter, args *glib.Variant) (ok bool)
71+ action := gtk.NewCallbackAction(func(widget gtk.Widgetter, args *glib.Variant) bool {
72+ if handler != nil {
73+ log.Printf("[shortcuts] Shortcut triggered: %s", accelerator)
74+ handler()
75+ }
76+ // Return false to allow event propagation to WebView
77+ // This lets JavaScript KeyboardService also handle events if needed
78+ return false
79+ })
80+
81+ // Parse the accelerator string into a GDK key trigger
82+ // GTK4 uses gtk.ShortcutTrigger for key combinations
83+ trigger := gtk.NewShortcutTriggerParseString(gtkFormat)
84+ if trigger == nil {
85+ log.Printf("[shortcuts] Failed to parse trigger: %s", gtkFormat)
86+ return
87+ }
88+
89+ // Create the shortcut with trigger and action
90+ shortcut := gtk.NewShortcut(trigger, action)
91+
92+ // Add shortcut to the shared controller
93+ ws.controller.AddShortcut(shortcut)
94+
95+ // Store the shortcut for potential cleanup
96+ ws.shortcuts[accelerator] = shortcut
97+
98+ log.Printf("[shortcuts] Registered: %s -> %s", accelerator, gtkFormat)
99+}
100+
101+// convertToGtkFormat converts common key formats to GTK shortcut format
102+// Examples: "ctrl+plus" -> "<Control>plus", "ctrl+l" -> "<Control>l"
103+func convertToGtkFormat(key string) string {
104+ key = strings.ToLower(key)
105+
106+ // Handle cmdorctrl -> Control
107+ key = strings.ReplaceAll(key, "cmdorctrl+", "ctrl+")
108+ key = strings.ReplaceAll(key, "cmd+", "ctrl+")
109+
110+ // Split by + to get components
111+ components := strings.Split(key, "+")
112+ if len(components) == 0 {
113+ return ""
114+ }
115+
116+ var parts []string
117+ for i, part := range components {
118+ if i == len(components)-1 {
119+ // Last part is the actual key
120+ switch part {
121+ case "f12":
122+ parts = append(parts, "F12")
123+ case "f5":
124+ parts = append(parts, "F5")
125+ case "plus":
126+ parts = append(parts, "plus")
127+ case "equal":
128+ parts = append(parts, "equal")
129+ case "minus":
130+ parts = append(parts, "minus")
131+ case "0":
132+ parts = append(parts, "0")
133+ case "left", "arrowleft":
134+ parts = append(parts, "Left")
135+ case "right", "arrowright":
136+ parts = append(parts, "Right")
137+ case "up", "arrowup":
138+ parts = append(parts, "Up")
139+ case "down", "arrowdown":
140+ parts = append(parts, "Down")
141+ default:
142+ // Single letter keys like l, f, c, w, etc.
143+ parts = append(parts, part)
144+ }
145+ } else {
146+ // Modifier keys
147+ switch part {
148+ case "ctrl":
149+ parts = append(parts, "<Control>")
150+ case "shift":
151+ parts = append(parts, "<Shift>")
152+ case "alt":
153+ parts = append(parts, "<Alt>")
154+ case "super", "meta":
155+ parts = append(parts, "<Super>")
156+ }
157+ }
158+ }
159+
160+ return strings.Join(parts, "")
161+}
D pkg/webkit/shortcuts_cgo.go +0 -565
@@ -1,565 +0,0 @@
1-//go:build webkit_cgo
2-
3-package webkit
4-
5-import (
6- "fmt"
7- "log"
8- "strings"
9- "sync"
10- "sync/atomic"
11- "unsafe"
12-)
13-
14-/*
15-#cgo pkg-config: gtk4 webkitgtk-6.0
16-#include <gtk/gtk.h>
17-#include <webkit/webkit.h>
18-#include <stdint.h>
19-#include <stdlib.h>
20-
21-extern void goHandleWindowShortcut(uintptr_t handle);
22-
23-static GtkShortcutController* create_global_shortcut_controller(GtkWindow* window) {
24- if (!window) {
25- return NULL;
26- }
27- GtkEventController* event_controller = gtk_shortcut_controller_new();
28- if (!event_controller) {
29- return NULL;
30- }
31- GtkShortcutController* controller = GTK_SHORTCUT_CONTROLLER(event_controller);
32- gtk_shortcut_controller_set_scope(controller, GTK_SHORTCUT_SCOPE_GLOBAL);
33- gtk_widget_add_controller(GTK_WIDGET(window), event_controller);
34- return controller;
35-}
36-
37-static gboolean shortcut_callback_bridge(GtkWidget* widget, GVariant* args, gpointer user_data) {
38- (void)widget;
39- // Properly handle the GVariant to avoid NULL string assertion
40- if (args != NULL && g_variant_is_of_type(args, G_VARIANT_TYPE_STRING)) {
41- // If args contains a string, we can safely ignore it
42- const gchar* str = g_variant_get_string(args, NULL);
43- (void)str;
44- }
45- uintptr_t handle = (uintptr_t)user_data;
46- if (handle == 0) {
47- return FALSE;
48- }
49- goHandleWindowShortcut(handle);
50- return TRUE; // Indicate we handled the shortcut
51-}
52-
53-// Alternative callback that doesn't use GVariant at all
54-static gboolean simple_shortcut_callback(GtkWidget* widget, gpointer user_data) {
55- (void)widget;
56- uintptr_t handle = (uintptr_t)user_data;
57- if (handle == 0) {
58- return FALSE;
59- }
60- goHandleWindowShortcut(handle);
61- return TRUE;
62-}
63-
64-static void add_shortcut_to_controller(GtkShortcutController* controller,
65- const char* trigger_str,
66- uintptr_t handle) {
67- if (!controller || !trigger_str || handle == 0) {
68- return;
69- }
70- GtkShortcutTrigger* trigger = gtk_shortcut_trigger_parse_string(trigger_str);
71- if (!trigger) {
72- return;
73- }
74-
75- // Try to create an action that doesn't involve problematic GVariant handling
76- // Use gtk_nothing_action for the shortcut and handle activation separately
77- GtkShortcutAction* action = gtk_callback_action_new((GtkShortcutFunc)shortcut_callback_bridge,
78- (gpointer)handle,
79- NULL);
80- if (!action) {
81- g_object_unref(trigger);
82- return;
83- }
84- GtkShortcut* shortcut = gtk_shortcut_new(trigger, action);
85- gtk_shortcut_controller_add_shortcut(controller, shortcut);
86-}
87-*/
88-import "C"
89-
90-var (
91- globalShortcutMu sync.Mutex
92- globalShortcutCounter uint64
93- globalShortcutCallbacks = make(map[uintptr]func())
94-)
95-
96-func registerWindowShortcutCallback(cb func()) uintptr {
97- if cb == nil {
98- return 0
99- }
100- id := uintptr(atomic.AddUint64(&globalShortcutCounter, 1))
101- if id == 0 {
102- // Should never happen, but guard against wrapping to 0
103- id = uintptr(atomic.AddUint64(&globalShortcutCounter, 1))
104- }
105- globalShortcutMu.Lock()
106- globalShortcutCallbacks[id] = cb
107- globalShortcutMu.Unlock()
108- return id
109-}
110-
111-func unregisterWindowShortcutCallback(id uintptr) {
112- if id == 0 {
113- return
114- }
115- globalShortcutMu.Lock()
116- delete(globalShortcutCallbacks, id)
117- globalShortcutMu.Unlock()
118-}
119-
120-func invokeWindowShortcutCallback(id uintptr) {
121- if id == 0 {
122- return
123- }
124- globalShortcutMu.Lock()
125- cb := globalShortcutCallbacks[id]
126- globalShortcutMu.Unlock()
127- if cb == nil {
128- log.Printf("[shortcuts] no handler for shortcut id=%d", id)
129- return
130- }
131- cb()
132-}
133-
134-//export goHandleWindowShortcut
135-func goHandleWindowShortcut(handle C.uintptr_t) {
136- invokeWindowShortcutCallback(uintptr(handle))
137-}
138-
139-// Go wrapper functions for CGO - these can be called from other Go files
140-
141-// CreateGlobalShortcutController creates a GTK4 global shortcut controller for a window
142-func CreateGlobalShortcutController(window uintptr) uintptr {
143- if window == 0 {
144- return 0
145- }
146- controller := C.create_global_shortcut_controller((*C.GtkWindow)(unsafe.Pointer(window)))
147- return uintptr(unsafe.Pointer(controller))
148-}
149-
150-// AddShortcutToController adds a shortcut to the controller with a callback handle
151-func AddShortcutToController(controller uintptr, key string, handle uintptr) error {
152- if controller == 0 || key == "" || handle == 0 {
153- return ErrNotImplemented
154- }
155-
156- keyStr := C.CString(key)
157- defer C.free(unsafe.Pointer(keyStr))
158-
159- C.add_shortcut_to_controller(
160- (*C.GtkShortcutController)(unsafe.Pointer(controller)),
161- keyStr,
162- C.uintptr_t(handle),
163- )
164-
165- return nil
166-}
167-
168-// Global shortcuts registry to track all registered shortcuts for automatic blocking
169-var (
170- globalShortcutsRegistry = make(map[string]bool)
171- shortcutsRegistryMu sync.RWMutex
172- // Map from shortcut key to callback handle for C-level triggering
173- globalShortcutHandles = make(map[string]uintptr)
174-)
175-
176-// WebView tracking for dynamic protection refresh
177-var (
178- activeWebViews = make(map[*WebView]bool)
179- activeWebViewsMu sync.RWMutex
180-)
181-
182-// RegisterGlobalShortcut adds a shortcut to the global registry for automatic webpage blocking
183-func RegisterGlobalShortcut(shortcut string) {
184- shortcutsRegistryMu.Lock()
185- // Normalize shortcut format (convert to lowercase, standardize modifiers)
186- normalized := normalizeShortcut(shortcut)
187-
188- // Only proceed if this is a new shortcut registration
189- wasNew := !globalShortcutsRegistry[normalized]
190- if wasNew {
191- globalShortcutsRegistry[normalized] = true
192- log.Printf("[shortcuts] Registered global shortcut for blocking: %s -> %s", shortcut, normalized)
193- }
194- shortcutsRegistryMu.Unlock()
195-
196- // If we added a new shortcut, refresh protection on all active WebViews
197- if wasNew {
198- RefreshGlobalShortcutsProtection()
199- }
200-}
201-
202-// RegisterGlobalShortcutWithHandle adds a shortcut with its callback handle for C-level triggering
203-func RegisterGlobalShortcutWithHandle(shortcut string, handle uintptr) {
204- // First register the shortcut for blocking
205- RegisterGlobalShortcut(shortcut)
206-
207- // Then store the callback handle for C-level triggering
208- shortcutsRegistryMu.Lock()
209- normalized := normalizeShortcut(shortcut)
210- globalShortcutHandles[normalized] = handle
211- log.Printf("[shortcuts] Registered callback handle for shortcut: %s", shortcut)
212- shortcutsRegistryMu.Unlock()
213-}
214-
215-// GetRegisteredShortcuts returns a copy of all registered shortcuts
216-func GetRegisteredShortcuts() []string {
217- shortcutsRegistryMu.RLock()
218- defer shortcutsRegistryMu.RUnlock()
219-
220- shortcuts := make([]string, 0, len(globalShortcutsRegistry))
221- for shortcut := range globalShortcutsRegistry {
222- shortcuts = append(shortcuts, shortcut)
223- }
224- return shortcuts
225-}
226-
227-// RegisterActiveWebView adds a WebView to the active registry for dynamic protection updates
228-func RegisterActiveWebView(w *WebView) {
229- if w == nil {
230- return
231- }
232- activeWebViewsMu.Lock()
233- defer activeWebViewsMu.Unlock()
234- activeWebViews[w] = true
235- // WebView registered for protection updates
236-}
237-
238-// UnregisterActiveWebView removes a WebView from the active registry
239-func UnregisterActiveWebView(w *WebView) {
240- if w == nil {
241- return
242- }
243- activeWebViewsMu.Lock()
244- defer activeWebViewsMu.Unlock()
245- delete(activeWebViews, w)
246- // WebView unregistered from protection updates
247-}
248-
249-// RefreshGlobalShortcutsProtection updates all active WebViews with current shortcut protection
250-func RefreshGlobalShortcutsProtection() {
251- activeWebViewsMu.RLock()
252- webViews := make([]*WebView, 0, len(activeWebViews))
253- for wv := range activeWebViews {
254- webViews = append(webViews, wv)
255- }
256- activeWebViewsMu.RUnlock()
257-
258- if len(webViews) == 0 {
259- return
260- }
261-
262- for _, wv := range webViews {
263- if wv != nil && !wv.destroyed {
264- // Re-enable protection which will inject updated shortcut list
265- if err := wv.EnableGlobalShortcutsProtection(); err != nil {
266- log.Printf("[shortcuts] Failed to refresh protection for WebView: %v", err)
267- }
268- }
269- }
270-}
271-
272-// normalizeShortcut converts shortcuts to a standardized format for JavaScript blocking
273-func normalizeShortcut(shortcut string) string {
274- // Convert to lowercase and replace common variations
275- normalized := strings.ToLower(shortcut)
276-
277- // Standardize modifier keys
278- normalized = strings.ReplaceAll(normalized, "cmdorctrl+", "ctrl+")
279- normalized = strings.ReplaceAll(normalized, "cmd+", "ctrl+") // Treat cmd as ctrl for blocking
280- normalized = strings.ReplaceAll(normalized, "control+", "ctrl+")
281-
282- // Standardize arrow key names (handle both formats)
283- normalized = strings.ReplaceAll(normalized, "left", "arrowleft")
284- normalized = strings.ReplaceAll(normalized, "right", "arrowright")
285- normalized = strings.ReplaceAll(normalized, "up", "arrowup")
286- normalized = strings.ReplaceAll(normalized, "down", "arrowdown")
287-
288- // Fix double arrow prefixes that might occur from above replacements
289- normalized = strings.ReplaceAll(normalized, "arrowarrowleft", "arrowleft")
290- normalized = strings.ReplaceAll(normalized, "arrowarrowright", "arrowright")
291- normalized = strings.ReplaceAll(normalized, "arrowarrowup", "arrowup")
292- normalized = strings.ReplaceAll(normalized, "arrowarrowdown", "arrowdown")
293-
294- // Other key normalizations
295- normalized = strings.ReplaceAll(normalized, "equal", "equal") // For Ctrl+=
296- normalized = strings.ReplaceAll(normalized, "plus", "equal") // Normalize + to =
297-
298- return normalized
299-}
300-
301-// Keyboard event blocking functionality for global shortcuts protection
302-
303-// keyboardBlockerScript holds reference to the main-world keyboard blocking script
304-var keyboardBlockerScript *C.WebKitUserScript = nil
305-
306-// EnableGlobalShortcutsProtection injects a script to block all registered global shortcuts from reaching webpages
307-func (w *WebView) EnableGlobalShortcutsProtection() error {
308- if w == nil || w.destroyed || w.native == nil || w.native.ucm == nil {
309- return ErrWebViewNotInitialized
310- }
311-
312- // Get all registered shortcuts
313- shortcuts := GetRegisteredShortcuts()
314- if len(shortcuts) == 0 {
315- log.Printf("[shortcuts] No global shortcuts registered, skipping protection")
316- return nil
317- }
318-
319- // Generate JavaScript array of shortcuts to block
320- shortcutsJS := "["
321- for i, shortcut := range shortcuts {
322- if i > 0 {
323- shortcutsJS += ","
324- }
325- shortcutsJS += `"` + shortcut + `"`
326- }
327- shortcutsJS += "]"
328-
329- // Create a version-based blocking script that can be updated dynamically
330- protectionVersion := len(shortcuts) // Use shortcuts count as version
331- globalProtectionScript := `
332- (() => {
333- const newVersion = ` + fmt.Sprintf("%d", protectionVersion) + `;
334- const currentVersion = window.__dumber_shortcuts_protection_version || 0;
335-
336- // Only update if this is a newer version or first time
337- if (newVersion > currentVersion) {
338- // Clean up old protection if it exists
339- if (window.__dumber_cleanup_global_shortcuts) {
340- window.__dumber_cleanup_global_shortcuts();
341- }
342-
343- window.__dumber_shortcuts_protection_version = newVersion;
344-
345- // List of global shortcuts to block from reaching the webpage
346- const globalShortcuts = ` + shortcutsJS + `;
347- console.log('[dumber] Protecting global shortcuts (v' + newVersion + '):', globalShortcuts);
348-
349- // Function to check if a key event matches any global shortcut
350- const isGlobalShortcut = (e) => {
351- const key = e.key.toLowerCase();
352- const code = e.code.toLowerCase();
353-
354- // Build shortcut string (similar to GTK format)
355- let shortcut = '';
356- if (e.ctrlKey) shortcut += 'ctrl+';
357- if (e.altKey) shortcut += 'alt+';
358- if (e.shiftKey) shortcut += 'shift+';
359- if (e.metaKey) shortcut += 'meta+';
360-
361- // Try both key and code for matching
362- const keyShortcut = shortcut + key;
363- const codeShortcut = shortcut + code;
364-
365- // Special case mappings for common shortcuts
366- const specialMappings = {
367- 'ctrl+equal': ['ctrl+=', 'ctrl+plus'],
368- 'ctrl+minus': ['ctrl+-'],
369- 'f12': ['f12'],
370- 'ctrl+r': ['ctrl+r'],
371- 'ctrl+shift+r': ['ctrl+shift+r'],
372- 'f5': ['f5'],
373- // Alt+Arrow key mappings (handle both key and code variations)
374- 'alt+arrowleft': ['alt+left', 'alt+arrowleft'],
375- 'alt+arrowright': ['alt+right', 'alt+arrowright'],
376- 'alt+arrowup': ['alt+up', 'alt+arrowup'],
377- 'alt+arrowdown': ['alt+down', 'alt+arrowdown']
378- };
379-
380- // Check direct matches
381- if (globalShortcuts.includes(keyShortcut) || globalShortcuts.includes(codeShortcut)) {
382- return true;
383- }
384-
385- // Check special mappings
386- for (const [canonical, variants] of Object.entries(specialMappings)) {
387- if (variants.includes(keyShortcut) || variants.includes(codeShortcut)) {
388- if (globalShortcuts.includes(canonical)) {
389- return true;
390- }
391- }
392- }
393-
394- return false;
395- };
396-
397- // Block only global shortcuts at capture phase
398- const blockGlobalShortcut = (e) => {
399- if (isGlobalShortcut(e)) {
400- console.log('[dumber] Blocked global shortcut from reaching page:', e.key, e.ctrlKey, e.altKey, e.shiftKey);
401- e.stopImmediatePropagation();
402- e.preventDefault();
403- e.stopPropagation();
404- return false;
405- }
406- // Let other key events through normally
407- };
408-
409- // Install listeners for keyboard events
410- document.addEventListener('keydown', blockGlobalShortcut, true);
411- document.addEventListener('keyup', blockGlobalShortcut, true);
412- document.addEventListener('keypress', blockGlobalShortcut, true);
413-
414- // Store cleanup function
415- window.__dumber_cleanup_global_shortcuts = () => {
416- document.removeEventListener('keydown', blockGlobalShortcut, true);
417- document.removeEventListener('keyup', blockGlobalShortcut, true);
418- document.removeEventListener('keypress', blockGlobalShortcut, true);
419- delete window.__dumber_shortcuts_protection_version;
420- delete window.__dumber_cleanup_global_shortcuts;
421- console.log('[dumber] Global shortcuts protection disabled');
422- };
423-
424- console.log('[dumber] Global shortcuts protection enabled for', globalShortcuts.length, 'shortcuts (v' + newVersion + ')');
425- } else {
426- console.log('[dumber] Global shortcuts protection already up to date (v' + currentVersion + ')');
427- }
428- })();
429- `
430-
431- // Inject the script into the main world at document start
432- cScript := C.CString(globalProtectionScript)
433- defer C.free(unsafe.Pointer(cScript))
434-
435- script := C.webkit_user_script_new(
436- (*C.gchar)(cScript),
437- C.WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
438- C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
439- nil, nil) // nil = main world
440-
441- if script == nil {
442- return ErrNotImplemented
443- }
444-
445- C.webkit_user_content_manager_add_script(w.native.ucm, script)
446- C.webkit_user_script_unref(script) // Clean up reference
447-
448- log.Printf("[shortcuts] Global shortcuts protection enabled for %d shortcuts", len(shortcuts))
449- return nil
450-}
451-
452-// DisableGlobalShortcutsProtection removes the global shortcuts protection
453-func (w *WebView) DisableGlobalShortcutsProtection() error {
454- if w == nil || w.destroyed || w.native == nil {
455- return ErrWebViewNotInitialized
456- }
457-
458- // Execute cleanup script
459- cleanupScript := `
460- if (window.__dumber_cleanup_global_shortcuts) {
461- window.__dumber_cleanup_global_shortcuts();
462- }
463- `
464-
465- return w.InjectScript(cleanupScript)
466-}
467-
468-// EnablePageKeyboardBlocking injects a script into the main world to block all keyboard events
469-// This prevents page JavaScript from receiving keyboard events while omnibox is active
470-func (w *WebView) EnablePageKeyboardBlocking() error {
471- if w == nil || w.destroyed || w.native == nil || w.native.ucm == nil {
472- return ErrWebViewNotInitialized
473- }
474-
475- // Remove any existing blocker script first
476- w.DisablePageKeyboardBlocking()
477-
478- // Script to block all keyboard and related events in the main world
479- blockingScript := `
480- (() => {
481- // Store original state to restore later
482- if (!window.__dumber_event_blocker_installed) {
483- window.__dumber_event_blocker_installed = true;
484-
485- // List of events to block
486- const eventTypes = [
487- 'keydown', 'keyup', 'keypress',
488- 'input', 'beforeinput',
489- 'compositionstart', 'compositionupdate', 'compositionend'
490- ];
491-
492- // Block all keyboard-related events at capture phase
493- const blockEvent = (e) => {
494- e.stopImmediatePropagation();
495- e.preventDefault();
496- e.stopPropagation();
497- return false;
498- };
499-
500- // Install capture-phase event blockers
501- eventTypes.forEach(type => {
502- document.addEventListener(type, blockEvent, true);
503- });
504-
505- // Store cleanup function
506- window.__dumber_cleanup_event_blocker = () => {
507- eventTypes.forEach(type => {
508- document.removeEventListener(type, blockEvent, true);
509- });
510- delete window.__dumber_event_blocker_installed;
511- delete window.__dumber_cleanup_event_blocker;
512- };
513-
514- console.log('[dumber] Page keyboard event blocking enabled');
515- }
516- })();
517- `
518-
519- cScript := C.CString(blockingScript)
520- defer C.free(unsafe.Pointer(cScript))
521-
522- // Inject into main world (nil world parameter = main world)
523- keyboardBlockerScript = C.webkit_user_script_new(
524- (*C.gchar)(cScript),
525- C.WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
526- C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
527- nil, nil) // nil = main world
528-
529- if keyboardBlockerScript == nil {
530- return ErrNotImplemented
531- }
532-
533- C.webkit_user_content_manager_add_script(w.native.ucm, keyboardBlockerScript)
534- log.Printf("[webkit] Page keyboard event blocking enabled")
535- return nil
536-}
537-
538-// DisablePageKeyboardBlocking removes the keyboard blocking script and restores normal page behavior
539-func (w *WebView) DisablePageKeyboardBlocking() error {
540- if w == nil || w.destroyed || w.native == nil || w.native.ucm == nil {
541- return ErrWebViewNotInitialized
542- }
543-
544- // Execute cleanup script to remove event listeners
545- cleanupScript := `
546- if (window.__dumber_cleanup_event_blocker) {
547- window.__dumber_cleanup_event_blocker();
548- console.log('[dumber] Page keyboard event blocking disabled');
549- }
550- `
551- // Double-check WebView is still valid before script injection
552- if !w.IsDestroyed() {
553- w.InjectScript(cleanupScript)
554- }
555-
556- // Remove the user script if it exists
557- if keyboardBlockerScript != nil {
558- C.webkit_user_content_manager_remove_script(w.native.ucm, keyboardBlockerScript)
559- C.webkit_user_script_unref(keyboardBlockerScript)
560- keyboardBlockerScript = nil
561- log.Printf("[webkit] Keyboard blocker script removed")
562- }
563-
564- return nil
565-}
D pkg/webkit/thread_helpers.c +0 -20
@@ -1,20 +0,0 @@
1-//go:build webkit_cgo
2-// +build webkit_cgo
3-
4-#include <glib.h>
5-#include <pthread.h>
6-
7-static pthread_t main_thread_id;
8-
9-void store_main_thread_id() {
10- main_thread_id = pthread_self();
11-}
12-
13-int is_main_thread() {
14- return pthread_equal(pthread_self(), main_thread_id);
15-}
16-
17-int iterate_main_loop() {
18- GMainContext *ctx = g_main_context_default();
19- return g_main_context_iteration(ctx, FALSE);
20-}
A pkg/webkit/tls_dialog_cgo.go +34 -0
@@ -0,0 +1,34 @@
1+package webkit
2+
3+/*
4+#cgo pkg-config: gtk4
5+#include <gtk/gtk.h>
6+#include <stdlib.h>
7+
8+// Create a GtkAlertDialog with a message
9+static GtkAlertDialog* create_alert_dialog(const char* message) {
10+ return gtk_alert_dialog_new("%s", message);
11+}
12+*/
13+import "C"
14+import (
15+ "unsafe"
16+
17+ "github.com/diamondburned/gotk4/pkg/core/glib"
18+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
19+)
20+
21+// newAlertDialog creates a new GTK4 AlertDialog using CGO
22+func newAlertDialog(message string) *gtk.AlertDialog {
23+ cstr := C.CString(message)
24+ defer C.free(unsafe.Pointer(cstr))
25+
26+ cDialog := C.create_alert_dialog(cstr)
27+ if cDialog == nil {
28+ return nil
29+ }
30+
31+ // Wrap the C object in a Go AlertDialog
32+ obj := glib.AssumeOwnership(unsafe.Pointer(cDialog))
33+ return &gtk.AlertDialog{Object: obj}
34+}
A pkg/webkit/tls_handler.go +352 -0
@@ -0,0 +1,352 @@
1+package webkit
2+
3+import (
4+ "context"
5+ "crypto/sha256"
6+ "database/sql"
7+ "fmt"
8+ "log"
9+ "net/url"
10+ "strings"
11+ "time"
12+
13+ "github.com/bnema/dumber/internal/config"
14+ "github.com/bnema/dumber/internal/db"
15+ "github.com/diamondburned/gotk4/pkg/gio/v2"
16+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
17+)
18+
19+// TLSCertificateInfo holds information about a TLS certificate for display
20+type TLSCertificateInfo struct {
21+ Subject string
22+ Issuer string
23+ NotBefore string
24+ NotAfter string
25+ IsExpired bool
26+ Errors []string
27+}
28+
29+// setupTLSErrorHandler sets up the TLS error handler for the WebView
30+func (wv *WebView) setupTLSErrorHandler() {
31+ wv.view.ConnectLoadFailedWithTLSErrors(func(failingUri string, certificate gio.TLSCertificater, errors gio.TLSCertificateFlags) bool {
32+ log.Printf("[tls] Load failed with TLS errors for: %s, errors: %v", failingUri, errors)
33+
34+ // Extract hostname from URI
35+ hostname := extractHostname(failingUri)
36+ if hostname == "" {
37+ log.Printf("[tls] Failed to extract hostname from URI: %s", failingUri)
38+ return false // Let WebKit handle it
39+ }
40+
41+ // Check if user has previously made a decision for this hostname
42+ // Note: We use hostname-only matching because certificate properties
43+ // from GIO are unstable (GDateTime objects change on each access)
44+ decision, exists := checkStoredCertificateDecision(hostname)
45+ if exists {
46+ if decision == "accepted" {
47+ log.Printf("[tls] Certificate previously accepted for %s, allowing", hostname)
48+ wv.allowCertificateAndReload(certificate, hostname, failingUri)
49+ return true // Signal handled
50+ } else {
51+ log.Printf("[tls] Certificate previously rejected for %s, blocking", hostname)
52+ return false // Let WebKit show error
53+ }
54+ }
55+
56+ // No stored decision - show dialog to user
57+ log.Printf("[tls] No stored decision for %s, showing dialog", hostname)
58+ wv.showTLSErrorDialog(failingUri, hostname, certificate, errors)
59+
60+ return true // Signal handled - we're managing the error
61+ })
62+}
63+
64+// extractHostname extracts the hostname from a URI
65+func extractHostname(uri string) string {
66+ parsed, err := url.Parse(uri)
67+ if err != nil {
68+ // Try to extract manually
69+ if strings.Contains(uri, "://") {
70+ parts := strings.SplitN(uri, "://", 2)
71+ if len(parts) == 2 {
72+ host := strings.Split(parts[1], "/")[0]
73+ // Remove port if present
74+ host = strings.Split(host, ":")[0]
75+ return host
76+ }
77+ }
78+ return ""
79+ }
80+
81+ // Remove port if present
82+ host := parsed.Hostname()
83+ return host
84+}
85+
86+// formatCertificateInfo formats certificate information for display and hashing
87+func formatCertificateInfo(certificate gio.TLSCertificater) string {
88+ // Convert to TLSCertificate to access methods
89+ tlsCert := gio.BaseTLSCertificate(certificate)
90+ if tlsCert == nil {
91+ // Fallback: use the object pointer address as a weak identifier
92+ return fmt.Sprintf("%p", certificate)
93+ }
94+
95+ // Build a unique string from certificate properties
96+ // Use subject, issuer, and validity period
97+ subject := tlsCert.SubjectName()
98+ issuer := ""
99+ if issuerCert := tlsCert.Issuer(); issuerCert != nil {
100+ issuer = gio.BaseTLSCertificate(issuerCert).SubjectName()
101+ }
102+
103+ notBefore := tlsCert.NotValidBefore()
104+ notAfter := tlsCert.NotValidAfter()
105+
106+ // Combine properties into a unique string
107+ return fmt.Sprintf("subject=%s,issuer=%s,notBefore=%v,notAfter=%v",
108+ subject, issuer, notBefore, notAfter)
109+}
110+
111+// getCertificateHash generates a SHA256 hash of the certificate info
112+func getCertificateHash(certificateInfo string) string {
113+ hash := sha256.Sum256([]byte(certificateInfo))
114+ return fmt.Sprintf("%x", hash)
115+}
116+
117+// checkStoredCertificateDecision checks if user has previously decided on this hostname
118+func checkStoredCertificateDecision(hostname string) (string, bool) {
119+ cfg := config.Get()
120+ if cfg == nil {
121+ log.Printf("[tls] Config not available, cannot check stored decisions")
122+ return "", false
123+ }
124+
125+ dbPath := cfg.Database.Path
126+ if dbPath == "" {
127+ log.Printf("[tls] Database path not configured")
128+ return "", false
129+ }
130+
131+ database, err := sql.Open("sqlite3", dbPath)
132+ if err != nil {
133+ log.Printf("[tls] Failed to open database: %v", err)
134+ return "", false
135+ }
136+ defer database.Close()
137+
138+ queries := db.New(database)
139+ ctx := context.Background()
140+
141+ // Get any validation for this hostname (we don't verify cert hash anymore)
142+ validation, err := queries.GetCertificateValidationByHostname(ctx, hostname)
143+ if err != nil {
144+ if err == sql.ErrNoRows {
145+ return "", false
146+ }
147+ log.Printf("[tls] Error checking certificate validation: %v", err)
148+ return "", false
149+ }
150+
151+ return validation.UserDecision, true
152+}
153+
154+// storeCertificateDecision stores the user's decision about a hostname
155+func storeCertificateDecision(hostname, decision string, expiresAt sql.NullTime) error {
156+ cfg := config.Get()
157+ if cfg == nil {
158+ return fmt.Errorf("config not available")
159+ }
160+
161+ dbPath := cfg.Database.Path
162+ if dbPath == "" {
163+ return fmt.Errorf("database path not configured")
164+ }
165+
166+ database, err := sql.Open("sqlite3", dbPath)
167+ if err != nil {
168+ return fmt.Errorf("failed to open database: %w", err)
169+ }
170+ defer database.Close()
171+
172+ queries := db.New(database)
173+ ctx := context.Background()
174+
175+ log.Printf("[tls] Saving certificate decision to database: hostname=%s, decision=%s, expiresAt=%v",
176+ hostname, decision, expiresAt)
177+
178+ // Use empty string for cert hash since we're doing hostname-only matching
179+ if err := queries.StoreCertificateValidation(ctx, hostname, "", decision, expiresAt); err != nil {
180+ log.Printf("[tls] Failed to save certificate decision to database: %v", err)
181+ return err
182+ }
183+
184+ log.Printf("[tls] Certificate decision saved successfully in database")
185+ return nil
186+}
187+
188+// TLS Certificate Flag constants from GIO
189+// These match the C enum values from gioenums.h
190+const (
191+ tlsCertificateUnknownCA gio.TLSCertificateFlags = 1 << 0 // G_TLS_CERTIFICATE_UNKNOWN_CA
192+ tlsCertificateBadIdentity gio.TLSCertificateFlags = 1 << 1 // G_TLS_CERTIFICATE_BAD_IDENTITY
193+ tlsCertificateNotActivated gio.TLSCertificateFlags = 1 << 2 // G_TLS_CERTIFICATE_NOT_ACTIVATED
194+ tlsCertificateExpired gio.TLSCertificateFlags = 1 << 3 // G_TLS_CERTIFICATE_EXPIRED
195+ tlsCertificateRevoked gio.TLSCertificateFlags = 1 << 4 // G_TLS_CERTIFICATE_REVOKED
196+ tlsCertificateInsecure gio.TLSCertificateFlags = 1 << 5 // G_TLS_CERTIFICATE_INSECURE
197+ tlsCertificateGenericError gio.TLSCertificateFlags = 1 << 6 // G_TLS_CERTIFICATE_GENERIC_ERROR
198+)
199+
200+// getTLSErrorMessages converts TLS certificate flags to human-readable messages
201+func getTLSErrorMessages(errors gio.TLSCertificateFlags) []string {
202+ var messages []string
203+
204+ if errors&tlsCertificateUnknownCA != 0 {
205+ messages = append(messages, "• The certificate authority is not trusted")
206+ }
207+ if errors&tlsCertificateBadIdentity != 0 {
208+ messages = append(messages, "• The certificate does not match the site identity")
209+ }
210+ if errors&tlsCertificateNotActivated != 0 {
211+ messages = append(messages, "• The certificate is not yet valid")
212+ }
213+ if errors&tlsCertificateExpired != 0 {
214+ messages = append(messages, "• The certificate has expired")
215+ }
216+ if errors&tlsCertificateRevoked != 0 {
217+ messages = append(messages, "• The certificate has been revoked")
218+ }
219+ if errors&tlsCertificateInsecure != 0 {
220+ messages = append(messages, "• The certificate uses an insecure algorithm")
221+ }
222+ if errors&tlsCertificateGenericError != 0 {
223+ messages = append(messages, "• A generic error occurred validating the certificate")
224+ }
225+
226+ if len(messages) == 0 {
227+ messages = append(messages, "• Unknown certificate error")
228+ }
229+
230+ return messages
231+}
232+
233+// showTLSErrorDialog shows a dialog asking the user how to handle the TLS error
234+func (wv *WebView) showTLSErrorDialog(failingUri, hostname string, certificate gio.TLSCertificater, errors gio.TLSCertificateFlags) {
235+ // This must run on the main thread
236+ wv.RunOnMainThread(func() {
237+ // Get error messages
238+ errorMessages := getTLSErrorMessages(errors)
239+
240+ // Build detailed message
241+ detail := fmt.Sprintf("The site %s has a certificate error:\n\n%s\n\nProceeding is unsafe and may expose your data to attackers.",
242+ hostname,
243+ strings.Join(errorMessages, "\n"))
244+
245+ // Create AlertDialog using CGO wrapper
246+ dialog := newAlertDialog(fmt.Sprintf("Certificate Error for %s", hostname))
247+ if dialog == nil {
248+ log.Printf("[tls] Failed to create alert dialog")
249+ return
250+ }
251+
252+ // Set dialog properties
253+ dialog.SetDetail(detail)
254+ dialog.SetModal(true)
255+
256+ // Set buttons: Go Back (0), Proceed Once (1), Always Accept (2)
257+ dialog.SetButtons([]string{"Go Back", "Proceed Once (Unsafe)", "Always Accept This Site"})
258+ dialog.SetDefaultButton(0) // Default to "Go Back"
259+ dialog.SetCancelButton(0) // Cancel = "Go Back"
260+
261+ // Get parent window
262+ var parentWindow *gtk.Window
263+ if wv.window != nil {
264+ parentWindow = wv.window.AsWindow()
265+ }
266+
267+ // Show dialog and handle response
268+ dialog.Choose(context.Background(), parentWindow, func(result gio.AsyncResulter) {
269+ buttonIndex, err := dialog.ChooseFinish(result)
270+ if err != nil {
271+ log.Printf("[tls] Dialog error: %v", err)
272+ return
273+ }
274+
275+ log.Printf("[tls] User chose button %d for %s", buttonIndex, hostname)
276+
277+ switch buttonIndex {
278+ case 0: // Go Back
279+ log.Printf("[tls] User chose to go back")
280+ // Do nothing - let the load fail
281+
282+ case 1: // Proceed Once
283+ log.Printf("[tls] User chose to proceed once")
284+ wv.allowCertificateAndReload(certificate, hostname, failingUri)
285+
286+ case 2: // Always Accept This Site
287+ log.Printf("[tls] User chose to always accept for %s", hostname)
288+ // Store decision in database (expires in 30 days)
289+ expiresAt := sql.NullTime{
290+ Time: time.Now().Add(30 * 24 * time.Hour),
291+ Valid: true,
292+ }
293+ if err := storeCertificateDecision(hostname, "accepted", expiresAt); err != nil {
294+ log.Printf("[tls] Failed to store certificate decision: %v", err)
295+ } else {
296+ log.Printf("[tls] Stored certificate acceptance for %s (expires in 30 days)", hostname)
297+ }
298+ wv.allowCertificateAndReload(certificate, hostname, failingUri)
299+ }
300+ })
301+ })
302+}
303+
304+// allowCertificateAndReload allows the certificate for the host and reloads the page
305+func (wv *WebView) allowCertificateAndReload(certificate gio.TLSCertificater, hostname, uri string) {
306+ wv.RunOnMainThread(func() {
307+ // Get the network session
308+ session := wv.view.NetworkSession()
309+ if session == nil {
310+ log.Printf("[tls] Failed to get network session")
311+ return
312+ }
313+
314+ // Allow the certificate for this host
315+ session.AllowTLSCertificateForHost(certificate, hostname)
316+ log.Printf("[tls] Certificate exception added for host: %s", hostname)
317+
318+ // Reload the page
319+ wv.view.LoadURI(uri)
320+ log.Printf("[tls] Reloading %s with certificate exception", uri)
321+ })
322+}
323+
324+// CleanupExpiredCertificateValidations removes expired certificate validations from the database
325+// This should be called on application startup
326+func CleanupExpiredCertificateValidations() error {
327+ cfg := config.Get()
328+ if cfg == nil {
329+ return fmt.Errorf("config not available")
330+ }
331+
332+ dbPath := cfg.Database.Path
333+ if dbPath == "" {
334+ return fmt.Errorf("database path not configured")
335+ }
336+
337+ database, err := sql.Open("sqlite3", dbPath)
338+ if err != nil {
339+ return fmt.Errorf("failed to open database: %w", err)
340+ }
341+ defer database.Close()
342+
343+ queries := db.New(database)
344+ ctx := context.Background()
345+
346+ if err := queries.DeleteExpiredCertificateValidations(ctx); err != nil {
347+ return fmt.Errorf("failed to delete expired validations: %w", err)
348+ }
349+
350+ log.Printf("[tls] Cleaned up expired certificate validations")
351+ return nil
352+}
D pkg/webkit/toast_svelte_cgo.go +0 -73
@@ -1,73 +0,0 @@
1-//go:build webkit_cgo
2-
3-package webkit
4-
5-import (
6- "fmt"
7- "log"
8-)
9-
10-// getToastScript returns the unified GUI bundle-based toast notification script with GTK theme integration.
11-// This function is deprecated and should use the new unified approach via BrowserService.InjectToastSystem
12-func getToastScript(assets interface{ ReadFile(string) ([]byte, error) }) string {
13- // Read the unified GUI bundle from assets
14- var guiBundle string
15- if assets != nil {
16- bundleBytes, err := assets.ReadFile("gui/gui.min.js")
17- if err != nil {
18- // Fallback to error message if file not found
19- guiBundle = `console.error("❌ Failed to load unified GUI bundle: ` + err.Error() + `");`
20- log.Printf("[webkit] Failed to read GUI bundle: %v", err)
21- } else {
22- guiBundle = string(bundleBytes)
23- log.Printf("[webkit] Successfully loaded unified GUI bundle (%d bytes)", len(bundleBytes))
24- }
25- } else {
26- guiBundle = `console.error("❌ Failed to load unified GUI bundle: assets not provided");`
27- log.Printf("[webkit] GUI bundle error: assets not provided")
28- }
29-
30- // GTK Theme Integration with new unified approach
31- themeIntegration := `
32-// Set up theme change listener for dynamic updates
33-window.__dumber_setTheme = (theme) => {
34- window.__dumber_initial_theme = theme;
35- if (theme === 'dark') {
36- document.documentElement.classList.add('dark');
37- } else {
38- document.documentElement.classList.remove('dark');
39- }
40-};
41-`
42-
43- // Use unified GUI bundle approach
44- return fmt.Sprintf(`(function() {
45- function initializeToast() {
46- try {
47- %s
48-
49- %s
50-
51- // Initialize toast system via unified GUI bundle
52- if (window.__dumber_gui && window.__dumber_gui.initializeToast) {
53- window.__dumber_gui.initializeToast().then(() => {
54- console.log('✅ Toast system initialized via unified GUI bundle');
55- }).catch((e) => {
56- console.error('❌ Toast initialization failed:', e);
57- });
58- } else {
59- console.error('❌ Unified GUI bundle not properly loaded');
60- }
61- } catch (e) {
62- console.error('❌ Failed to initialize toast system:', e);
63- }
64- }
65-
66- // Ensure DOM is ready before initializing
67- if (document.readyState === 'loading') {
68- document.addEventListener('DOMContentLoaded', initializeToast);
69- } else {
70- initializeToast();
71- }
72-})();`, themeIntegration, guiBundle)
73-}
A pkg/webkit/types.go +75 -0
@@ -0,0 +1,75 @@
1+package webkit
2+
3+// WindowType indicates how a new WebView should be treated
4+type WindowType int
5+
6+const (
7+ // WindowTypeTab represents an independent WebView (future: tab)
8+ WindowTypeTab WindowType = iota
9+ // WindowTypePopup represents a related WebView (shares process/context)
10+ WindowTypePopup
11+ // WindowTypeUnknown indicates type not detected yet
12+ WindowTypeUnknown
13+)
14+
15+// WindowFeatures describes the window features detected from WebKitWindowProperties
16+type WindowFeatures struct {
17+ Width int
18+ Height int
19+ ToolbarVisible bool
20+ LocationbarVisible bool
21+ MenubarVisible bool
22+ Resizable bool
23+}
24+
25+// Config holds WebView configuration
26+type Config struct {
27+ // UserAgent string for the WebView
28+ UserAgent string
29+
30+ // EnableJavaScript controls JavaScript execution
31+ EnableJavaScript bool
32+
33+ // EnableWebGL controls WebGL support
34+ EnableWebGL bool
35+
36+ // EnableMediaStream controls media stream support
37+ EnableMediaStream bool
38+
39+ // HardwareAcceleration controls GPU acceleration
40+ HardwareAcceleration bool
41+
42+ // DefaultFontSize in pixels
43+ DefaultFontSize int
44+
45+ // MinimumFontSize in pixels
46+ MinimumFontSize int
47+
48+ // DataDir is the directory for persistent data (cookies, localStorage, etc.)
49+ DataDir string
50+
51+ // CacheDir is the directory for HTTP cache
52+ CacheDir string
53+
54+ // AppearanceConfigJSON is the JSON string for appearance configuration
55+ // This will be injected at document-start via UserContentManager
56+ AppearanceConfigJSON string
57+
58+ // CreateWindow controls whether to create a standalone GTK Window for this WebView
59+ // Set to false for WebViews that will be embedded in workspace panes
60+ CreateWindow bool
61+}
62+
63+// GetDefaultConfig returns a Config with sensible defaults
64+func GetDefaultConfig() *Config {
65+ return &Config{
66+ UserAgent: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36",
67+ EnableJavaScript: true,
68+ EnableWebGL: true,
69+ EnableMediaStream: true,
70+ HardwareAcceleration: true,
71+ DefaultFontSize: 16,
72+ MinimumFontSize: 8,
73+ CreateWindow: true, // Default to standalone window
74+ }
75+}
D pkg/webkit/ucm_js_cgo.go.deprecated +0 -335
@@ -1,335 +0,0 @@
1-//go:build webkit_cgo
2-
3-package webkit
4-
5-// getOmniboxScript returns the injected omnibox/find component script.
6-// Kept in a cgo-tagged file to ensure availability when referenced
7-// from other cgo files in this package.
8-func getOmniboxScript() string {
9- return `(() => {
10- try {
11- if (window.__dumber_omnibox_loaded) return; // idempotent
12- window.__dumber_omnibox_loaded = true;
13-
14- const MAX_MATCHES = 2000;
15- const H = {
16- el: null,
17- box: null,
18- input: null,
19- list: null,
20- visible: false,
21- mode: 'omnibox', // 'omnibox' | 'find'
22- suggestions: [],
23- matches: [], // {el, context}
24- selectedIndex: -1,
25- activeIndex: -1,
26- debounceTimer: 0,
27- highlightNodes: [],
28- faded: false,
29- prevOverflow: '',
30- post(msg){ try { window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify(msg)); } catch(_){} },
31- render(){
32- // Performance optimization: elements are pre-mounted, just show/hide
33- H.el.style.display = H.visible ? 'block' : 'none';
34- if (!H.visible) return;
35- H.input.placeholder = H.mode === 'find' ? 'Find in page…' : 'Type URL or search…';
36- H.input.focus();
37- },
38- setMode(m){ H.mode = m === 'find' ? 'find' : 'omnibox'; H.selectedIndex = -1; H.setFaded(false); H.paintList(); },
39- open(mode, initial){ H.setMode(mode||'omnibox'); H.toggle(true); if (typeof initial==='string') { H.input.value = initial; H.onInput(); } },
40- close(){ if (H.mode==='find') H.clearHighlights(); H.setFaded(false); H.toggle(false); },
41- toggle(v){
42- const newState = (typeof v==='boolean')? v : !H.visible;
43- if (newState && !H.visible) {
44- // Disable page scrolling while overlay is visible
45- H.prevOverflow = document.documentElement.style.overflow;
46- document.documentElement.style.overflow = 'hidden';
47- } else if (!newState && H.visible) {
48- // Restore page scrolling on close
49- document.documentElement.style.overflow = H.prevOverflow || '';
50- H.prevOverflow = '';
51- }
52- H.visible = newState;
53- H.render();
54- },
55- setFaded(v){
56- H.faded = !!v;
57- if (!H.box || !H.input || !H.list) return;
58- if (H.faded) {
59- H.box.style.background = 'rgba(27,27,27,0.25)';
60- H.box.style.backdropFilter = 'blur(2px) saturate(110%)';
61- H.box.style.webkitBackdropFilter = 'blur(2px) saturate(110%)';
62- H.input.style.background = 'rgba(18,18,18,0.35)';
63- H.input.style.color = '#eee';
64- } else {
65- H.box.style.background = '#1b1b1b';
66- H.box.style.backdropFilter = '';
67- H.box.style.webkitBackdropFilter = '';
68- H.input.style.background = '#121212';
69- H.input.style.color = '#eee';
70- }
71- },
72- scrollListToSelection(){
73- if (!H.list) return;
74- let idx = H.selectedIndex|0;
75- if (idx < 0) return;
76- // Account for header in find mode
77- const childIdx = (H.mode==='find' ? 1 : 0) + idx;
78- const el = H.list.children[childIdx];
79- if (el && el.scrollIntoView) {
80- try { el.scrollIntoView({block:'nearest'}); } catch(_) { el.scrollIntoView(); }
81- }
82- },
83- mount(){
84- const root = document.createElement('div');
85- root.id = 'dumber-omnibox-root';
86- root.style.cssText = 'position:fixed;inset:0;z-index:2147483647;display:none;';
87- const box = document.createElement('div');
88- box.style.cssText = 'width:min(90vw, 720px);margin:8vh auto;padding:8px 12px;background:#1b1b1b;color:#eee;border:1px solid #444;border-radius:8px;box-shadow:0 10px 30px rgba(0,0,0,.6);font-family:system-ui,-apple-system,Segoe UI,Roboto,Ubuntu,"Helvetica Neue",Arial,sans-serif;';
89- const input = document.createElement('input');
90- input.type = 'text';
91- input.placeholder = 'Type URL or search…';
92- input.style.cssText = 'width:100%;box-sizing:border-box;padding:10px 12px;border-radius:6px;border:1px solid #555;background:#121212;color:#eee;font-size:16px;outline:none;';
93- const list = document.createElement('div');
94- list.style.cssText = 'margin-top:8px;max-height:50vh;overflow:auto;';
95- const style = document.createElement('style');
96- style.textContent = '.dumber-find-highlight{background:#ffeb3b;color:#000;padding:0 1px;border-radius:2px;box-shadow:0 0 0 1px #c8b900 inset}.dumber-find-active{background:#ff9800 !important;color:#000;box-shadow:0 0 0 1px #b36b00 inset}';
97- document.documentElement.appendChild(style);
98- box.appendChild(input); box.appendChild(list); root.appendChild(box); document.documentElement.appendChild(root);
99- H.box = box;
100- // Dynamic style updater for responsive behavior
101- const updateStyles = () => {
102- const vw = window.innerWidth;
103- const isSmall = vw < 640;
104- const isLarge = vw > 1920;
105-
106- if (box) {
107- box.style.width = isSmall ? '95vw' : isLarge ? 'min(90vw, 840px)' : 'min(90vw, 720px)';
108- box.style.padding = isSmall ? '6px 8px' : '8px 12px';
109- }
110-
111- if (input) {
112- input.style.fontSize = isSmall ? '14px' : isLarge ? '17px' : '16px';
113- input.style.padding = isSmall ? '8px 10px' : '10px 12px';
114- input.style.boxSizing = 'border-box';
115- }
116- };
117- updateStyles();
118- window.addEventListener('resize', updateStyles);
119- // Click outside the box closes the overlay
120- root.addEventListener('mousedown', (e)=>{
121- const tgt = e.target;
122- if (tgt && box && !box.contains(tgt)) {
123- e.preventDefault();
124- e.stopPropagation();
125- H.close();
126- }
127- }, true);
128- box.addEventListener('mousedown', (e)=>{ e.stopPropagation(); }, true);
129- // Focus and select input on hover for quick typing
130- input.addEventListener('mouseenter', ()=>{ if (H.visible) { try { input.focus({preventScroll:true}); input.select(); } catch(_) { input.focus(); } } });
131- root.addEventListener('mouseenter', ()=>{ if (H.visible) { try { input.focus({preventScroll:true}); } catch(_) { input.focus(); } H.setFaded(false); } });
132- // Clear fading when user goes back to input typing
133- input.addEventListener('focus', ()=> H.setFaded(false));
134- input.addEventListener('keydown', (e)=>{
135- if (e.key === 'Escape'){ H.close(); }
136- else if (H.mode==='omnibox' && e.key === 'Enter'){
137- const pick = H.suggestions && H.suggestions[H.selectedIndex|0];
138- const v = (pick && pick.url) || input.value || '';
139- if (v) H.post({type:'navigate', url:v});
140- H.toggle(false);
141- } else if (H.mode==='find' && e.key === 'Enter'){
142- e.preventDefault();
143- if (e.shiftKey) {
144- H.jump(-1);
145- } else if (e.altKey) {
146- // Center on current match but keep overlay open for further navigation
147- H.revealSelection();
148- } else {
149- // Default: center and close
150- H.revealSelection();
151- H.close();
152- }
153- } else if (e.key === 'ArrowDown' || e.key === 'ArrowUp'){
154- e.preventDefault(); e.stopPropagation();
155- const n = (H.mode==='find'? H.matches.length : H.suggestions.length);
156- if (n){
157- H.selectedIndex = (H.selectedIndex||0) + (e.key==='ArrowDown'?1:-1);
158- if (H.selectedIndex<0) H.selectedIndex = n-1; if (H.selectedIndex>=n) H.selectedIndex = 0;
159- H.paintList();
160- H.scrollListToSelection();
161- // Fade overlay while navigating results; also reveal active match in find mode
162- H.setFaded(true);
163- if (H.mode==='find') { H.revealSelection(); }
164- }
165- } else {
166- // Any other key input should restore full opacity while editing
167- H.setFaded(false);
168- }
169- });
170- input.addEventListener('input', ()=> { H.onInput(); H.setFaded(false); });
171- input.addEventListener('mousedown', ()=> H.setFaded(false));
172- input.addEventListener('click', ()=> H.setFaded(false));
173- H.el = root; H.input = input; H.list = list; H.selectedIndex = -1; H.paintList();
174- },
175- onInput(){
176- const q = H.input.value || '';
177- if (H.mode === 'omnibox'){
178- clearTimeout(H.debounceTimer);
179- H.debounceTimer = setTimeout(()=> H.post({type:'query', q, limit:10}), 120);
180- } else {
181- H.find(q);
182- }
183- },
184- paintList(){
185- const list = H.list; if (!list) return; list.textContent = '';
186-
187- // Show/hide separator based on content
188- const hasContent = (H.mode==='omnibox' && H.suggestions.length > 0) || (H.mode==='find' && H.matches.length > 0);
189- list.style.borderTop = hasContent ? '1px solid #333' : 'none';
190-
191- if (H.mode==='omnibox'){
192- H.suggestions.forEach((s, i)=>{
193- const item = document.createElement('div');
194- item.style.cssText = 'padding:8px 10px;display:flex;gap:10px;align-items:center;cursor:pointer;border-bottom:1px solid #2a2a2a;'+(i===H.selectedIndex?'background:#0a0a0a;':'');
195- // Favicon chip (wrapper ensures perfect circle sizing)
196- const chip = document.createElement('div');
197- chip.style.cssText = 'flex:0 0 20px;width:20px;height:20px;border-radius:50%;background:#ccc;border:1px solid rgba(0,0,0,.12);box-shadow:0 1px 2px rgba(0,0,0,.12);display:flex;align-items:center;justify-content:center;';
198- const icon = document.createElement('img');
199- icon.src = s.favicon || '';
200- icon.loading = 'lazy';
201- icon.style.cssText = 'width:16px;height:16px;filter:brightness(1.06) contrast(1.03);image-rendering:-webkit-optimize-contrast;';
202- icon.onerror = ()=>{ chip.style.display='none'; };
203- chip.appendChild(icon);
204- // Text line: Domain | full path (one line, fade at end)
205- let domain = '', path = '';
206- try { const u = new URL(s.url, window.location.href); domain = u.hostname; path = (u.pathname||'') + (u.search||'') + (u.hash||''); } catch(_) { domain = s.url || ''; }
207- const text = document.createElement('div');
208- text.style.cssText = 'flex:1;min-width:0;display:flex;gap:8px;align-items:center;white-space:nowrap;overflow:hidden;';
209- // Apply gradient fade using mask for WebKit
210- text.style.webkitMaskImage = 'linear-gradient(90deg, black 85%, transparent 100%)';
211- text.style.maskImage = 'linear-gradient(90deg, black 85%, transparent 100%)';
212- const domainEl = document.createElement('span'); domainEl.textContent = domain; domainEl.style.cssText = 'color:#e6e6e6;opacity:.95;';
213- const sep = document.createElement('span'); sep.textContent = ' | '; sep.style.cssText = 'color:#777;';
214- const pathEl = document.createElement('span'); pathEl.textContent = path || '/'; pathEl.style.cssText = 'color:#9ad;';
215- text.appendChild(domainEl); text.appendChild(sep); text.appendChild(pathEl);
216- item.appendChild(chip); item.appendChild(text);
217- item.addEventListener('mouseenter', ()=>{ H.selectedIndex = i; H.paintList(); H.scrollListToSelection(); H.setFaded(true); });
218- item.addEventListener('click',()=>{ H.post({type:'navigate', url:s.url}); H.toggle(false); });
219- list.appendChild(item);
220- });
221- } else {
222- const total = H.matches.length;
223- const header = document.createElement('div');
224- header.textContent = total ? total + ' matches' : 'No matches';
225- header.style.cssText = 'padding:6px 10px;color:#bbb;font-size:12px;border-bottom:1px solid #2a2a2a;';
226- list.appendChild(header);
227- H.matches.forEach((m, i)=>{
228- const item = document.createElement('div');
229- item.style.cssText = 'padding:8px 10px;cursor:pointer;border-bottom:1px solid #2a2a2a;'+(i===H.selectedIndex?'background:#0a0a0a;':'');
230- const ctx = document.createElement('div'); ctx.textContent = m.context || ''; ctx.style.cssText = 'color:#ddd;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;';
231- item.appendChild(ctx);
232- item.addEventListener('mouseenter', ()=>{ H.selectedIndex = i; H.paintList(); H.scrollListToSelection(); H.setFaded(true); });
233- item.addEventListener('click',()=>{ H.selectedIndex=i; H.revealSelection(); H.paintList(); H.close(); });
234- list.appendChild(item);
235- });
236- }
237- },
238- setSuggestions(arr){ H.suggestions = Array.isArray(arr)? arr: []; H.selectedIndex = -1; H.paintList(); },
239- // FIND MODE IMPLEMENTATION
240- clearHighlights(){
241- try {
242- H.highlightNodes.forEach(({span, text})=>{
243- const p = span.parentNode; if (!p) return; p.replaceChild(text, span); p.normalize();
244- });
245- } catch(_){}
246- H.highlightNodes = [];
247- H.matches = [];
248- H.selectedIndex = -1;
249- H.activeIndex = -1;
250- H.paintList();
251- },
252- find(q){
253- H.clearHighlights();
254- q = (q||'').trim(); if (!q) return;
255- const body = document.body; if (!body) return;
256- const root = document.getElementById('dumber-omnibox-root');
257- const walker = document.createTreeWalker(body, NodeFilter.SHOW_TEXT, {
258- acceptNode(node){
259- if (!node.nodeValue || !node.nodeValue.trim()) return NodeFilter.FILTER_REJECT;
260- let el = node.parentElement; if (!el) return NodeFilter.FILTER_REJECT;
261- if (root && root.contains(el)) return NodeFilter.FILTER_REJECT;
262- const name = el.tagName; if (name==='SCRIPT'||name==='STYLE'||name==='NOSCRIPT') return NodeFilter.FILTER_REJECT;
263- if (getComputedStyle(el).visibility==='hidden' || getComputedStyle(el).display==='none') return NodeFilter.FILTER_REJECT;
264- return NodeFilter.FILTER_ACCEPT;
265- }
266- });
267- const lc = q.toLowerCase();
268- while (walker.nextNode()){
269- const text = walker.currentNode;
270- let s = text.nodeValue; let i = 0;
271- while (s && (i = s.toLowerCase().indexOf(lc)) !== -1) {
272- const before = document.createTextNode(s.slice(0, i));
273- const match = document.createTextNode(s.slice(i, i+q.length));
274- const afterVal = s.slice(i+q.length);
275- const span = document.createElement('span'); span.className = 'dumber-find-highlight'; span.appendChild(match);
276- const parent = text.parentNode; if (!parent) break;
277- parent.insertBefore(before, text);
278- parent.insertBefore(span, text);
279- text.nodeValue = afterVal;
280- H.highlightNodes.push({span, text: match});
281- const leftCtx = before.nodeValue.slice(-30);
282- let rightRaw = afterVal.slice(0, 30);
283- const boundaryIdx = rightRaw.search(/[\.,;:\-]/);
284- if (boundaryIdx !== -1) {
285- rightRaw = rightRaw.slice(0, boundaryIdx+1);
286- }
287- const context = (leftCtx + match.nodeValue + rightRaw).replace(/\s+/g,' ').trim();
288- H.matches.push({el: span, context});
289- if (H.matches.length >= MAX_MATCHES) break;
290- s = afterVal;
291- }
292- if (H.matches.length >= MAX_MATCHES) break;
293- }
294- H.selectedIndex = H.matches.length ? 0 : -1;
295- H.paintList();
296- H.revealSelection();
297- },
298- revealSelection(){
299- const prev = H.matches[H.activeIndex|0];
300- if (prev && prev.el && prev.el.classList) { prev.el.classList.remove('dumber-find-active'); }
301- const m = H.matches[H.selectedIndex|0]; if (!m) return;
302- if (m.el && m.el.classList) { m.el.classList.add('dumber-find-active'); }
303- H.activeIndex = H.selectedIndex|0;
304- try { m.el.scrollIntoView({block:'center', inline:'nearest'}); } catch(_){ m.el.scrollIntoView(); }
305- },
306- jump(delta){
307- const n = H.matches.length; if (!n) return;
308- H.selectedIndex = ((H.selectedIndex||0) + delta) % n; if (H.selectedIndex<0) H.selectedIndex = n-1;
309- H.paintList(); H.revealSelection();
310- }
311- };
312-
313- // Keyboard hooks
314- window.addEventListener('keydown', (e)=>{
315- const mod = (e.ctrlKey||e.metaKey);
316- if (mod && (e.key==='l' || e.key==='L')) { e.preventDefault(); H.open('omnibox'); }
317- if (mod && (e.key==='f' || e.key==='F')) { e.preventDefault(); H.open('find'); }
318- }, true);
319-
320- // Public API for native
321- window.__dumber_setSuggestions = (arr)=> H.setSuggestions(arr);
322- window.__dumber_toggle = ()=> H.toggle();
323- window.__dumber_find_open = (q)=> H.open('find', q||'');
324- window.__dumber_find_close = ()=> H.close();
325- window.__dumber_find_query = (q)=> { if (H.mode!=='find') H.setMode('find'); if (!H.visible) H.toggle(true); H.input.value = q||''; H.find(q||''); };
326-
327- // Performance optimization: Pre-mount DOM elements immediately for instant show/hide
328- if (document.readyState === 'loading') {
329- document.addEventListener('DOMContentLoaded', ()=> H.mount());
330- } else {
331- H.mount(); // DOM already ready
332- }
333- } catch (e) { /* no-op */ }
334-})();`
335-}
A pkg/webkit/uri_scheme.go +71 -0
@@ -0,0 +1,71 @@
1+package webkit
2+
3+import (
4+ "fmt"
5+ "log"
6+
7+ webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
8+)
9+
10+// URISchemeRequestCallback is called when a custom URI scheme is requested
11+type URISchemeRequestCallback func(request *webkit.URISchemeRequest)
12+
13+// URISchemeHandler stores registered URI scheme handlers
14+type URISchemeHandler struct {
15+ scheme string
16+ callback URISchemeRequestCallback
17+}
18+
19+var (
20+ // pendingURISchemeHandlers stores handlers to be registered before WebView creation
21+ pendingURISchemeHandlers []*URISchemeHandler
22+)
23+
24+// RegisterURIScheme registers a custom URI scheme handler
25+// In WebKitGTK 6.0, this must be called BEFORE creating any WebViews
26+// The actual registration happens in InitPersistentSession
27+func RegisterURIScheme(scheme string, callback URISchemeRequestCallback) {
28+ log.Printf("[webkit] Registering URI scheme handler for: %s", scheme)
29+ pendingURISchemeHandlers = append(pendingURISchemeHandlers, &URISchemeHandler{
30+ scheme: scheme,
31+ callback: callback,
32+ })
33+}
34+
35+// ApplyURISchemeHandlers registers all pending URI schemes on the WebContext
36+// This should be called after creating the first WebView
37+func ApplyURISchemeHandlers(view *webkit.WebView) error {
38+ if view == nil {
39+ return fmt.Errorf("webview is nil")
40+ }
41+
42+ ctx := view.Context()
43+ if ctx == nil {
44+ return fmt.Errorf("webcontext is nil")
45+ }
46+
47+ for _, handler := range pendingURISchemeHandlers {
48+ log.Printf("[webkit] Registering URI scheme on WebContext: %s", handler.scheme)
49+
50+ // Capture handler in closure
51+ callback := handler.callback
52+ ctx.RegisterURIScheme(handler.scheme, func(req *webkit.URISchemeRequest) {
53+ if callback != nil {
54+ callback(req)
55+ }
56+ })
57+ }
58+
59+ return nil
60+}
61+
62+// FinishURISchemeRequest finishes a URI scheme request with data
63+func FinishURISchemeRequest(req *webkit.URISchemeRequest, mimeType string, data []byte) error {
64+ if req == nil {
65+ return ErrWebViewNotInitialized
66+ }
67+
68+ // Import gio for input stream
69+ // This will be handled in the caller
70+ return nil
71+}
A pkg/webkit/user_content.go +112 -0
@@ -0,0 +1,112 @@
1+package webkit
2+
3+import (
4+ "fmt"
5+ "log"
6+
7+ "github.com/bnema/dumber/assets"
8+ webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
9+)
10+
11+// SetupUserContentManager configures UserContentManager for the WebView
12+// This injects GUI scripts at document-start and registers message handlers
13+func SetupUserContentManager(view *webkit.WebView, appearanceConfigJSON string, webviewID uint64) error {
14+ if view == nil {
15+ return nil
16+ }
17+
18+ // Get the UserContentManager from the WebView
19+ ucm := view.UserContentManager()
20+ if ucm == nil {
21+ log.Printf("[webkit] UserContentManager is nil, skipping script injection")
22+ return nil
23+ }
24+
25+ // Inject webview ID FIRST, so GUI scripts can access it immediately
26+ // Note: webviewID is uint64, formatted as number (not string) to avoid any injection concerns
27+ webviewIDScript := fmt.Sprintf(`
28+ window.__dumber_webview_id = %d;
29+ console.log('[webkit] WebView ID set in JavaScript:', window.__dumber_webview_id);
30+ `, webviewID)
31+ ucm.AddScript(webkit.NewUserScript(
32+ webviewIDScript,
33+ webkit.UserContentInjectTopFrame, // Only top frame needs the webview ID
34+ webkit.UserScriptInjectAtDocumentStart,
35+ nil,
36+ nil,
37+ ))
38+ log.Printf("[webkit] Injected webview ID script for ID: %d", webviewID)
39+
40+ // Inject GTK theme detection SECOND, before color-scheme script
41+ // The color-scheme.ts expects window.__dumber_gtk_prefers_dark to be set
42+ prefersDark := PrefersDarkTheme()
43+ gtkThemeScript := fmt.Sprintf(`window.__dumber_gtk_prefers_dark = %t;`, prefersDark)
44+ ucm.AddScript(webkit.NewUserScript(
45+ gtkThemeScript,
46+ webkit.UserContentInjectTopFrame, // Only top frame needs theme preference
47+ webkit.UserScriptInjectAtDocumentStart,
48+ nil,
49+ nil,
50+ ))
51+ log.Printf("[webkit] Injected GTK theme preference: prefersDark=%t", prefersDark)
52+
53+ // Inject palette config SECOND, before GUI scripts
54+ // The GUI expects window.__dumber_palette = { "light": {...}, "dark": {...} }
55+ if appearanceConfigJSON != "" {
56+ paletteScript := fmt.Sprintf(`window.__dumber_palette = %s;`, appearanceConfigJSON)
57+ ucm.AddScript(webkit.NewUserScript(
58+ paletteScript,
59+ webkit.UserContentInjectTopFrame, // Only top frame needs palette config
60+ webkit.UserScriptInjectAtDocumentStart,
61+ nil,
62+ nil,
63+ ))
64+ log.Printf("[webkit] Injected palette config at document-start (%d bytes)", len(paletteScript))
65+ }
66+
67+ // Inject color-scheme script at document-start
68+ if assets.ColorSchemeScript != "" {
69+ ucm.AddScript(webkit.NewUserScript(
70+ assets.ColorSchemeScript,
71+ webkit.UserContentInjectTopFrame, // Only top frame needs color scheme detection
72+ webkit.UserScriptInjectAtDocumentStart,
73+ nil, // whitelist (nil = all)
74+ nil, // blacklist (nil = none)
75+ ))
76+ log.Printf("[webkit] Injected color-scheme script (%d bytes)", len(assets.ColorSchemeScript))
77+ }
78+
79+ // Inject main-world script at document-start
80+ if assets.MainWorldScript != "" {
81+ ucm.AddScript(webkit.NewUserScript(
82+ assets.MainWorldScript,
83+ webkit.UserContentInjectTopFrame, // Only top frame needs bridge/UI functionality
84+ webkit.UserScriptInjectAtDocumentStart,
85+ nil,
86+ nil,
87+ ))
88+ log.Printf("[webkit] Injected main-world script (%d bytes)", len(assets.MainWorldScript))
89+ }
90+
91+ // Inject GUI controls script at document-start
92+ if assets.GUIScript != "" {
93+ ucm.AddScript(webkit.NewUserScript(
94+ assets.GUIScript,
95+ webkit.UserContentInjectTopFrame, // Only top frame needs GUI controls
96+ webkit.UserScriptInjectAtDocumentStart,
97+ nil,
98+ nil,
99+ ))
100+ log.Printf("[webkit] Injected GUI controls script (%d bytes)", len(assets.GUIScript))
101+ }
102+
103+ // Register script message handler "dumber" for communication from JS
104+ // Pass empty string for worldName to use the default world
105+ if !ucm.RegisterScriptMessageHandler("dumber", "") {
106+ log.Printf("[webkit] Warning: failed to register 'dumber' script message handler")
107+ } else {
108+ log.Printf("[webkit] Registered 'dumber' script message handler")
109+ }
110+
111+ return nil
112+}
M pkg/webkit/webview.go +795 -198
@@ -1,317 +1,914 @@
1-//go:build !webkit_cgo
2-
31 package webkit
42
53 import (
4+ "encoding/json"
65 "fmt"
76 "log"
87 "strconv"
8+ "sync"
99 "sync/atomic"
10- "unsafe"
10+
11+ webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
12+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
1113 )
1214
13-// Package-level ID counter for stub WebViews
14-var stubViewIDCounter uint64
15+var (
16+ viewIDCounter uint64
17+ viewRegistry = make(map[uint64]*WebView)
18+ viewMu sync.RWMutex
19+)
1520
16-// nextStubViewID generates a unique ID for stub WebViews
17-func nextStubViewID() uintptr {
18- return uintptr(atomic.AddUint64(&stubViewIDCounter, 1))
21+// WebView wraps a WebKitGTK WebView
22+type WebView struct {
23+ view *webkit.WebView
24+ container *gtk.Box // Container that holds the WebView
25+ window *Window // Optional window (only if CreateWindow=true)
26+ id uint64
27+
28+ // State
29+ config *Config
30+ destroyed bool
31+ mu sync.RWMutex
32+
33+ // Event handlers
34+ onScriptMessage func(string)
35+ onTitleChanged func(string)
36+ onURIChanged func(string)
37+ onFaviconChanged func([]byte)
38+ onFaviconURIChanged func(pageURI, faviconURI string)
39+ onZoomChanged func(float64)
40+ onPopupCreate func(*webkit.NavigationAction) *WebView // New WebKit create signal handler
41+ onReadyToShow func() // WebKit ready-to-show signal handler
42+ onClose func()
43+ onNavigationPolicy func(url string, isUserGesture bool) bool
44+ onWindowTypeDetected func(WindowType, *WindowFeatures)
45+ onWorkspaceNavigation func(direction string) bool // Workspace pane navigation
46+ onPaneModeShortcut func(action string) bool // Pane mode shortcuts (enter, actions, exit)
47+ isPaneModeActive func() bool // Check if pane mode is active
1948 }
2049
21-// WebView represents a browser view powered by WebKit2GTK.
22-// Methods are currently stubs returning ErrNotImplemented to satisfy TDD ordering.
23-type WebView struct {
24- config *Config
25- visible bool
26- zoom float64
27- url string
28- destroyed bool
29- window *Window
30- msgHandler func(payload string)
31- titleHandler func(title string)
32- uriHandler func(uri string)
33- faviconHandler func(data []byte)
34- faviconURIHandler func(pageURI, faviconURI string)
35- zoomHandler func(level float64)
36- popupHandler func(string) *WebView
37- closeHandler func()
38- navigationPolicyHandler func(url string, isUserGesture bool) bool
39- useDomZoom bool
40- domZoomSeed float64
41- container uintptr
42- id uintptr // WebView unique identifier
43-
44- // Window type fields (no-op in stub)
45- windowType WindowType
46- windowFeatures *WindowFeatures
47- windowTypeCallback func(WindowType, *WindowFeatures)
48-}
49-
50-// NewWebView constructs a new WebView instance.
50+// NewWebView creates a new WebView with the given configuration
5151 func NewWebView(cfg *Config) (*WebView, error) {
5252 if cfg == nil {
53- cfg = &Config{}
53+ cfg = GetDefaultConfig()
54+ }
55+
56+ // Generate unique ID immediately
57+ id := atomic.AddUint64(&viewIDCounter, 1)
58+ log.Printf("[webkit] Generated WebView ID: %d (CreateWindow=%v)", id, cfg.CreateWindow)
59+
60+ InitMainThread()
61+
62+ // Initialize persistent session - REQUIRED, no ephemeral fallback
63+ // This must be done BEFORE creating the WebView
64+ if cfg.DataDir == "" || cfg.CacheDir == "" {
65+ return nil, fmt.Errorf("data and cache directories are required for persistent storage")
66+ }
67+
68+ if err := InitPersistentSession(cfg.DataDir, cfg.CacheDir); err != nil {
69+ return nil, fmt.Errorf("failed to initialize persistent storage: %w", err)
70+ }
71+
72+ // Create WebView - it will automatically use the persistent session
73+ // Per WebKitGTK 6.0: newly created session becomes the default for all WebViews
74+ wkView := webkit.NewWebView()
75+ if wkView == nil {
76+ return nil, ErrWebViewNotInitialized
5477 }
55- log.Printf("[webkit] NewWebView (non-CGO stub) — UI will not be displayed. Build with -tags=webkit_cgo for native window.")
56- wv := &WebView{config: cfg, useDomZoom: cfg.UseDomZoom}
57- if cfg.ZoomDefault <= 0 {
58- wv.zoom = 1.0
78+
79+ // Verify the WebView is using the persistent session
80+ viewSession := wkView.NetworkSession()
81+ if viewSession == nil {
82+ return nil, fmt.Errorf("WebView has no network session")
83+ }
84+ if viewSession.IsEphemeral() {
85+ return nil, fmt.Errorf("WebView is using ephemeral session despite persistent session initialization")
86+ }
87+
88+ // CRITICAL: Configure CookieManager on the WebView's actual session
89+ // This ensures cookies are persisted regardless of whether the WebView
90+ // uses our global session or creates its own
91+ cookieManager := viewSession.CookieManager()
92+ if cookieManager == nil {
93+ return nil, fmt.Errorf("failed to get cookie manager from WebView's network session")
94+ }
95+ cookiePath := cfg.DataDir + "/cookies.db"
96+ cookieManager.SetPersistentStorage(cookiePath, webkit.CookiePersistentStorageSqlite)
97+ cookieManager.SetAcceptPolicy(webkit.CookiePolicyAcceptNoThirdParty)
98+
99+ // Setup UserContentManager and inject GUI scripts (including webview ID)
100+ // This must be done BEFORE any pages are loaded
101+ if err := SetupUserContentManager(wkView, cfg.AppearanceConfigJSON, id); err != nil {
102+ return nil, fmt.Errorf("failed to setup user content manager: %w", err)
103+ }
104+
105+ // Create container (GtkBox) to hold the WebView
106+ // This allows the WebView to be reparented into workspace panes
107+ container := gtk.NewBox(gtk.OrientationVertical, 0)
108+ container.SetHExpand(true)
109+ container.SetVExpand(true)
110+
111+ // Configure WebView widget for expansion
112+ wkView.SetHExpand(true)
113+ wkView.SetVExpand(true)
114+
115+ // Add WebView to container
116+ container.Append(wkView)
117+
118+ wv := &WebView{
119+ view: wkView,
120+ container: container,
121+ window: nil, // Will be set below if CreateWindow is true
122+ id: id,
123+ config: cfg,
124+ }
125+
126+ // Apply configuration
127+ if err := wv.applyConfig(); err != nil {
128+ return nil, err
129+ }
130+
131+ // Setup event handlers
132+ wv.setupEventHandlers()
133+
134+ // Attach keyboard bridge to forward keyboard events to JavaScript
135+ wv.AttachKeyboardBridge()
136+
137+ // Only create window if requested (standalone WebViews)
138+ // Workspace panes will set CreateWindow=false and manage the container themselves
139+ if cfg.CreateWindow {
140+ window, err := NewWindow("Dumber Browser")
141+ if err != nil {
142+ return nil, err
143+ }
144+ wv.window = window
145+ // Add container as child of window
146+ window.SetChild(container)
147+ log.Printf("[webkit] Created standalone window for WebView ID %d", id)
59148 } else {
60- wv.zoom = cfg.ZoomDefault
149+ log.Printf("[webkit] Created WebView ID %d without window (for workspace embedding)", id)
61150 }
62- wv.domZoomSeed = wv.zoom
63- wv.id = nextStubViewID() // Assign unique ID
64- // Construction succeeds; create a logical window placeholder.
65- wv.window = &Window{Title: "Dumber Browser"}
66- wv.container = newWidgetHandle()
151+
152+ // Register in global registry
153+ viewMu.Lock()
154+ viewRegistry[id] = wv
155+ viewMu.Unlock()
156+
67157 return wv, nil
68158 }
69159
70-// NewWebViewWithRelated creates a new WebView related to an existing one (non-CGO stub)
71-func NewWebViewWithRelated(cfg *Config, relatedView *WebView) (*WebView, error) {
72- // In non-CGO build, just create a regular WebView
73- return NewWebView(cfg)
160+// applyConfig applies the configuration to the WebView settings
161+func (w *WebView) applyConfig() error {
162+ settings := w.view.Settings()
163+ if settings == nil {
164+ return fmt.Errorf("webkit: failed to get settings")
165+ }
166+
167+ // Apply settings from config
168+ settings.SetEnableJavascript(w.config.EnableJavaScript)
169+ settings.SetEnableWebgl(w.config.EnableWebGL)
170+ settings.SetDefaultFontSize(uint32(w.config.DefaultFontSize))
171+ settings.SetMinimumFontSize(uint32(w.config.MinimumFontSize))
172+
173+ if w.config.UserAgent != "" {
174+ settings.SetUserAgent(w.config.UserAgent)
175+ }
176+
177+ // Enable developer tools (F12, inspector)
178+ settings.SetEnableDeveloperExtras(true)
179+
180+ // Enable hardware acceleration if configured
181+ settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyAlways)
182+
183+ return nil
184+}
185+
186+// setupEventHandlers connects GTK signals to internal handlers
187+func (w *WebView) setupEventHandlers() {
188+ // Title changed - connect to notify::title signal
189+ w.view.Connect("notify::title", func() {
190+ if w.onTitleChanged != nil {
191+ title := w.view.Title()
192+ w.onTitleChanged(title)
193+ }
194+ })
195+
196+ // URI changed - connect to notify::uri signal
197+ w.view.Connect("notify::uri", func() {
198+ if w.onURIChanged != nil {
199+ uri := w.view.URI()
200+ w.onURIChanged(uri)
201+ }
202+ })
203+
204+ // Zoom level changed - connect to notify::zoom-level signal
205+ w.view.Connect("notify::zoom-level", func() {
206+ if w.onZoomChanged != nil {
207+ zoomLevel := w.view.ZoomLevel()
208+ w.onZoomChanged(zoomLevel)
209+ }
210+ })
211+
212+ // Favicon changed - connect to FaviconDatabase
213+ w.setupFaviconHandlers()
214+
215+ // Load changed
216+ w.view.ConnectLoadChanged(func(event webkit.LoadEvent) {
217+ // Handle load events if needed
218+ })
219+
220+ // TLS error handling - connect to load-failed-with-tls-errors signal
221+ w.setupTLSErrorHandler()
222+
223+ // Close
224+ w.view.ConnectClose(func() {
225+ if w.onClose != nil {
226+ w.onClose()
227+ }
228+ })
229+
230+ // Create signal - for popup lifecycle management
231+ w.view.ConnectCreate(func(navigationAction *webkit.NavigationAction) gtk.Widgetter {
232+ log.Printf("[webkit] ConnectCreate callback fired for parent WebView ID=%d", w.id)
233+ if w.onPopupCreate != nil {
234+ log.Printf("[webkit] Calling onPopupCreate handler")
235+ newWebView := w.onPopupCreate(navigationAction)
236+ if newWebView != nil {
237+ log.Printf("[webkit] onPopupCreate returned WebView wrapper ID=%d", newWebView.id)
238+ log.Printf("[webkit] Extracting underlying gotk4 WebView from wrapper")
239+ underlyingView := newWebView.view
240+ log.Printf("[webkit] Extracted gotk4 WebView=%p, about to return to WebKit", underlyingView)
241+ // Return the underlying WebView widget to WebKit
242+ return underlyingView
243+ }
244+ log.Printf("[webkit] onPopupCreate returned nil, blocking popup")
245+ }
246+ // Return nil to cancel popup creation
247+ log.Printf("[webkit] No onPopupCreate handler, blocking popup")
248+ return nil
249+ })
250+
251+ // Ready-to-show signal - emitted when popup is ready to be displayed
252+ w.view.ConnectReadyToShow(func() {
253+ if w.onReadyToShow != nil {
254+ w.onReadyToShow()
255+ }
256+ })
257+
258+ // Script message received - connect to UserContentManager's script-message-received signal
259+ // This receives messages from JavaScript via webkit.messageHandlers.dumber.postMessage()
260+ // The signal name includes the handler name as a detail: "script-message-received::dumber"
261+ ucm := w.view.UserContentManager()
262+ if ucm != nil {
263+ // GTK signals pass the sender as the first parameter, the actual signal data as subsequent parameters
264+ ucm.Connect("script-message-received::dumber", func(sender interface{}, jscValue interface{}) {
265+ if w.onScriptMessage != nil && jscValue != nil {
266+ // Convert JSCValue to string using gotk4 javascriptcore bindings
267+ valueStr := JSCValueToString(jscValue)
268+ if valueStr != "" {
269+ w.onScriptMessage(valueStr)
270+ }
271+ }
272+ })
273+ }
274+}
275+
276+// LoadURL loads the given URL in the WebView
277+func (w *WebView) LoadURL(url string) error {
278+ w.mu.RLock()
279+ defer w.mu.RUnlock()
280+
281+ if w.destroyed {
282+ return ErrWebViewDestroyed
283+ }
284+
285+ if url == "" {
286+ return ErrInvalidURL
287+ }
288+
289+ w.view.LoadURI(url)
290+ return nil
291+}
292+
293+// GetCurrentURL returns the current URL
294+func (w *WebView) GetCurrentURL() string {
295+ w.mu.RLock()
296+ defer w.mu.RUnlock()
297+
298+ if w.destroyed {
299+ return ""
300+ }
301+
302+ return w.view.URI()
303+}
304+
305+// GetTitle returns the current page title
306+func (w *WebView) GetTitle() string {
307+ w.mu.RLock()
308+ defer w.mu.RUnlock()
309+
310+ if w.destroyed {
311+ return ""
312+ }
313+
314+ return w.view.Title()
74315 }
75316
76-// LoadURL navigates the webview to the specified URL.
77-func (w *WebView) LoadURL(rawURL string) error {
78- if w == nil || w.destroyed {
79- return ErrNotImplemented
317+// GoBack navigates back in history
318+func (w *WebView) GoBack() error {
319+ w.mu.RLock()
320+ defer w.mu.RUnlock()
321+
322+ if w.destroyed {
323+ return ErrWebViewDestroyed
80324 }
81- if rawURL == "" {
82- return fmt.Errorf("url cannot be empty")
325+
326+ w.view.GoBack()
327+ return nil
328+}
329+
330+// GoForward navigates forward in history
331+func (w *WebView) GoForward() error {
332+ w.mu.RLock()
333+ defer w.mu.RUnlock()
334+
335+ if w.destroyed {
336+ return ErrWebViewDestroyed
337+ }
338+
339+ w.view.GoForward()
340+ return nil
341+}
342+
343+// Reload reloads the current page
344+func (w *WebView) Reload() error {
345+ w.mu.RLock()
346+ defer w.mu.RUnlock()
347+
348+ if w.destroyed {
349+ return ErrWebViewDestroyed
83350 }
84- // Minimal validation; actual navigation handled by CGO bridge later.
85- w.url = rawURL
86- log.Printf("[webkit] LoadURL (non-CGO stub): %s", rawURL)
351+
352+ w.view.Reload()
87353 return nil
88354 }
89355
90-// Show makes the WebView visible.
356+// ReloadBypassCache reloads the current page, bypassing cache
357+func (w *WebView) ReloadBypassCache() error {
358+ w.mu.RLock()
359+ defer w.mu.RUnlock()
360+
361+ if w.destroyed {
362+ return ErrWebViewDestroyed
363+ }
364+
365+ w.view.ReloadBypassCache()
366+ return nil
367+}
368+
369+// Show makes the WebView visible
91370 func (w *WebView) Show() error {
92- if w == nil || w.destroyed {
93- return ErrNotImplemented
371+ w.mu.RLock()
372+ defer w.mu.RUnlock()
373+
374+ if w.destroyed {
375+ return ErrWebViewDestroyed
376+ }
377+
378+ // Make the WebView widget visible
379+ w.view.SetVisible(true)
380+
381+ // Show the window containing the WebView
382+ if w.window != nil {
383+ w.window.Show()
94384 }
95- w.visible = true
96- log.Printf("[webkit] Show (non-CGO stub): no native window will appear")
385+
97386 return nil
98387 }
99388
100-// Hide hides the WebView window.
389+// Hide makes the WebView invisible
101390 func (w *WebView) Hide() error {
102- if w == nil || w.destroyed {
103- return ErrNotImplemented
391+ w.mu.RLock()
392+ defer w.mu.RUnlock()
393+
394+ if w.destroyed {
395+ return ErrWebViewDestroyed
104396 }
105- w.visible = false
397+
398+ w.view.SetVisible(false)
106399 return nil
107400 }
108401
109-// Destroy releases native resources.
402+// Destroy destroys the WebView and releases resources
110403 func (w *WebView) Destroy() error {
111- if w == nil {
112- return ErrNotImplemented
404+ w.mu.Lock()
405+ defer w.mu.Unlock()
406+
407+ if w.destroyed {
408+ return nil
113409 }
410+
114411 w.destroyed = true
412+
413+ // Unregister from global registry
414+ viewMu.Lock()
415+ delete(viewRegistry, w.id)
416+ viewMu.Unlock()
417+
418+ // The GTK widget will be cleaned up by Go GC
115419 return nil
116420 }
117421
118-// IsDestroyed returns whether this WebView has been destroyed
422+// IsDestroyed returns true if the WebView has been destroyed
119423 func (w *WebView) IsDestroyed() bool {
120- return w != nil && w.destroyed
424+ w.mu.RLock()
425+ defer w.mu.RUnlock()
426+ return w.destroyed
427+}
428+
429+// ID returns the unique identifier for this WebView
430+func (w *WebView) ID() uint64 {
431+ return w.id
432+}
433+
434+// IDString returns the WebView ID as a string
435+// This is a convenience method for compatibility with code expecting string IDs
436+func (w *WebView) IDString() string {
437+ return fmt.Sprintf("%d", w.id)
438+}
439+
440+// ParseWebViewID converts a string WebView ID to uint64
441+// Returns 0 if the string cannot be parsed
442+func ParseWebViewID(idStr string) uint64 {
443+ id, err := strconv.ParseUint(idStr, 10, 64)
444+ if err != nil {
445+ return 0
446+ }
447+ return id
448+}
449+
450+// AsWidget returns the WebView as a gtk.Widgetter
451+func (w *WebView) AsWidget() gtk.Widgetter {
452+ if w == nil || w.view == nil {
453+ return nil
454+ }
455+ return w.view
121456 }
122457
123-// Window returns the associated native window wrapper (non-nil).
124-func (w *WebView) Window() *Window { return w.window }
458+// Event handler registration methods
459+
460+// RegisterScriptMessageHandler registers a handler for script messages
461+func (w *WebView) RegisterScriptMessageHandler(handler func(string)) {
462+ w.mu.Lock()
463+ defer w.mu.Unlock()
464+ w.onScriptMessage = handler
465+}
125466
126-// GetCurrentURL returns the last requested URL (non-CGO build approximation).
127-func (w *WebView) GetCurrentURL() string { return w.url }
467+// RegisterTitleChangedHandler registers a handler for title changes
468+func (w *WebView) RegisterTitleChangedHandler(handler func(string)) {
469+ w.mu.Lock()
470+ defer w.mu.Unlock()
471+ w.onTitleChanged = handler
472+}
128473
129-// GetTitle returns a stub page title.
130-func (w *WebView) GetTitle() string { return "New Tab" }
474+// RegisterURIChangedHandler registers a handler for URI changes
475+func (w *WebView) RegisterURIChangedHandler(handler func(string)) {
476+ w.mu.Lock()
477+ defer w.mu.Unlock()
478+ w.onURIChanged = handler
479+}
131480
132-// GoBack is not supported in the non-CGO stub.
133-func (w *WebView) GoBack() error { return ErrNotImplemented }
481+// RegisterFaviconChangedHandler registers a handler for favicon changes
482+func (w *WebView) RegisterFaviconChangedHandler(handler func([]byte)) {
483+ w.mu.Lock()
484+ defer w.mu.Unlock()
485+ w.onFaviconChanged = handler
486+}
134487
135-// GoForward is not supported in the non-CGO stub.
136-func (w *WebView) GoForward() error { return ErrNotImplemented }
488+// RegisterFaviconURIChangedHandler registers a handler for favicon URI changes
489+func (w *WebView) RegisterFaviconURIChangedHandler(handler func(pageURI, faviconURI string)) {
490+ w.mu.Lock()
491+ defer w.mu.Unlock()
492+ w.onFaviconURIChanged = handler
493+}
137494
138-// Reload is not supported in the non-CGO stub.
139-func (w *WebView) Reload() error { return ErrNotImplemented }
495+// RegisterZoomChangedHandler registers a handler for zoom changes
496+func (w *WebView) RegisterZoomChangedHandler(handler func(float64)) {
497+ w.mu.Lock()
498+ defer w.mu.Unlock()
499+ w.onZoomChanged = handler
500+}
140501
141-// ReloadBypassCache is not supported in the non-CGO stub.
142-func (w *WebView) ReloadBypassCache() error { return ErrNotImplemented }
502+// RegisterCloseHandler registers a handler for close requests
503+func (w *WebView) RegisterCloseHandler(handler func()) {
504+ w.mu.Lock()
505+ defer w.mu.Unlock()
506+ w.onClose = handler
507+}
143508
144-// ShowDevTools is a no-op in the non-CGO build.
145-func (w *WebView) ShowDevTools() error { return nil }
509+// RegisterPopupCreateHandler registers a handler for WebKit's create signal
510+// This is called when JavaScript calls window.open() or a link with target="_blank" is clicked
511+// The handler should create and return a new WebView for the popup, or return nil to block it
512+// The returned WebView should NOT be shown yet - WebKit will emit ready-to-show when it's ready
513+func (w *WebView) RegisterPopupCreateHandler(handler func(*webkit.NavigationAction) *WebView) {
514+ w.mu.Lock()
515+ defer w.mu.Unlock()
516+ w.onPopupCreate = handler
517+}
146518
147-// CloseDevTools is a no-op in the non-CGO build.
148-func (w *WebView) CloseDevTools() error { return nil }
519+// RegisterReadyToShowHandler registers a handler for WebKit's ready-to-show signal
520+// This is called when a popup WebView is ready to be displayed
521+// At this point it's safe to insert the popup into the UI (workspace, window, etc.)
522+func (w *WebView) RegisterReadyToShowHandler(handler func()) {
523+ w.mu.Lock()
524+ defer w.mu.Unlock()
525+ w.onReadyToShow = handler
526+}
149527
150-// ShowPrintDialog is a no-op in the non-CGO build.
151-func (w *WebView) ShowPrintDialog() error { return nil }
528+// RegisterNavigationPolicyHandler registers a handler for navigation policy decisions
529+func (w *WebView) RegisterNavigationPolicyHandler(handler func(url string, isUserGesture bool) bool) {
530+ w.mu.Lock()
531+ defer w.mu.Unlock()
532+ w.onNavigationPolicy = handler
533+}
152534
153-// RegisterScriptMessageHandler registers a callback invoked when the content script posts a message.
154-func (w *WebView) RegisterScriptMessageHandler(cb func(payload string)) { w.msgHandler = cb }
535+// OnWindowTypeDetected registers a handler for window type detection
536+func (w *WebView) OnWindowTypeDetected(handler func(WindowType, *WindowFeatures)) {
537+ w.mu.Lock()
538+ defer w.mu.Unlock()
539+ w.onWindowTypeDetected = handler
540+}
155541
156-func (w *WebView) RegisterPopupHandler(cb func(string) *WebView) { w.popupHandler = cb }
542+// RunOnMainThread executes a function on the GTK main thread
543+func (w *WebView) RunOnMainThread(fn func()) {
544+ RunOnMainThread(fn)
545+}
157546
158-func (w *WebView) RegisterCloseHandler(cb func()) { w.closeHandler = cb }
547+// GetWebView returns the underlying webkit.WebView for advanced operations
548+func (w *WebView) GetWebView() *webkit.WebView {
549+ return w.view
550+}
159551
160-func (w *WebView) RegisterNavigationPolicyHandler(cb func(url string, isUserGesture bool) bool) {
161- w.navigationPolicyHandler = cb
552+// Widget returns the WebView widget (for compatibility with old code expecting uintptr)
553+// Deprecated: Use AsWidget() instead
554+func (w *WebView) Widget() gtk.Widgetter {
555+ return w.AsWidget()
162556 }
163557
164-func (w *WebView) dispatchScriptMessage(payload string) { //nolint:unused // Called from CGO WebKit callbacks
165- if w != nil && w.msgHandler != nil {
166- w.msgHandler(payload)
558+// RootWidget returns the root container widget for this WebView
559+// This is the GtkBox container that holds the WebView and can be reparented
560+func (w *WebView) RootWidget() gtk.Widgetter {
561+ if w.container != nil {
562+ return w.container
167563 }
564+ // Fallback to WebView if container is not available (shouldn't happen)
565+ return w.AsWidget()
168566 }
169567
170-func (w *WebView) dispatchPopupRequest(uri string) *WebView { //nolint:unused // Called from CGO WebKit callbacks
171- if w != nil && w.popupHandler != nil {
172- return w.popupHandler(uri)
568+// SetZoom sets the zoom level of the WebView
569+func (w *WebView) SetZoom(zoom float64) error {
570+ w.mu.RLock()
571+ defer w.mu.RUnlock()
572+
573+ if w.destroyed {
574+ return ErrWebViewDestroyed
173575 }
174- return nil
175-}
176576
177-// GetNativePointer returns nil for non-CGO build (stub implementation)
178-func (w *WebView) GetNativePointer() unsafe.Pointer { //nolint:unused // Needed for interface compatibility
577+ w.view.SetZoomLevel(zoom)
179578 return nil
180579 }
181580
182-// RegisterTitleChangedHandler registers a callback invoked when the page title changes.
183-func (w *WebView) RegisterTitleChangedHandler(cb func(title string)) { w.titleHandler = cb }
581+// GetZoom returns the current zoom level
582+func (w *WebView) GetZoom() float64 {
583+ w.mu.RLock()
584+ defer w.mu.RUnlock()
184585
185-func (w *WebView) dispatchTitleChanged(title string) { //nolint:unused // Called from CGO WebKit callbacks
186- if w != nil && w.titleHandler != nil {
187- w.titleHandler(title)
586+ if w.destroyed {
587+ return 1.0
188588 }
589+
590+ return w.view.ZoomLevel()
591+}
592+
593+// UsesDomZoom indicates if this WebView uses DOM-based zoom
594+// In gotk4/WebKitGTK, zoom is always viewport-based
595+func (w *WebView) UsesDomZoom() bool {
596+ return false
597+}
598+
599+// SeedDomZoom is a no-op in gotk4 as we use viewport zoom
600+func (w *WebView) SeedDomZoom(zoom float64) error {
601+ // Not needed in gotk4 - zoom is handled differently
602+ return nil
189603 }
190604
191-// RegisterURIChangedHandler registers a callback invoked when the current page URI changes.
192-func (w *WebView) RegisterURIChangedHandler(cb func(uri string)) { w.uriHandler = cb }
605+// InjectScript executes JavaScript in the WebView
606+func (w *WebView) InjectScript(script string) error {
607+ w.mu.RLock()
608+ defer w.mu.RUnlock()
193609
194-func (w *WebView) dispatchURIChanged(uri string) { //nolint:unused // Called from CGO WebKit callbacks
195- if w != nil && w.uriHandler != nil {
196- w.uriHandler(uri)
610+ if w.destroyed {
611+ return ErrWebViewDestroyed
197612 }
613+
614+ // Execute JavaScript using our CGO wrapper
615+ EvaluateJavascript(w.view, script)
616+ return nil
198617 }
199618
200-// RegisterFaviconChangedHandler registers a callback invoked when the page favicon changes.
201-func (w *WebView) RegisterFaviconChangedHandler(cb func(data []byte)) { w.faviconHandler = cb }
619+// DispatchCustomEvent dispatches a custom event via JavaScript
620+func (w *WebView) DispatchCustomEvent(eventName string, data interface{}) error {
621+ // Serialize data to JSON
622+ jsonData, err := json.Marshal(data)
623+ if err != nil {
624+ return fmt.Errorf("failed to marshal event data: %w", err)
625+ }
202626
203-// RegisterFaviconURIChangedHandler registers a callback invoked when WebKit detects a favicon URI.
204-func (w *WebView) RegisterFaviconURIChangedHandler(cb func(pageURI, faviconURI string)) {
205- w.faviconURIHandler = cb
627+ log.Printf("[webkit] Dispatching event '%s' to WebView ID %d with data: %s", eventName, w.id, string(jsonData))
628+
629+ // Use document.dispatchEvent (not window) so events cross JavaScript world boundaries
630+ // The GUI scripts run in an isolated world but share the same Document object
631+ script := fmt.Sprintf(`
632+ (function() {
633+ try {
634+ var detail = %s;
635+ console.log('[dumber] Dispatching event: %s', detail);
636+ document.dispatchEvent(new CustomEvent('%s', { detail: detail }));
637+ } catch (e) {
638+ console.error('[dumber] Failed to dispatch %s', e);
639+ }
640+ })();
641+ `, string(jsonData), eventName, eventName, eventName)
642+ return w.InjectScript(script)
206643 }
207644
208-func (w *WebView) dispatchFaviconChanged(data []byte) { //nolint:unused // Called from CGO WebKit callbacks
209- if w != nil && w.faviconHandler != nil {
210- w.faviconHandler(data)
645+// ShowDevTools opens the WebKit inspector/developer tools
646+func (w *WebView) ShowDevTools() error {
647+ w.mu.RLock()
648+ defer w.mu.RUnlock()
649+
650+ if w.destroyed {
651+ return ErrWebViewDestroyed
652+ }
653+
654+ inspector := w.view.Inspector()
655+ if inspector != nil {
656+ inspector.Show()
211657 }
658+ return nil
212659 }
213660
214-// RegisterZoomChangedHandler registers a callback invoked when zoom level changes.
215-func (w *WebView) RegisterZoomChangedHandler(cb func(level float64)) { w.zoomHandler = cb }
661+// ShowPrintDialog shows the print dialog for the current page
662+func (w *WebView) ShowPrintDialog() error {
663+ w.mu.RLock()
664+ defer w.mu.RUnlock()
216665
217-func (w *WebView) dispatchZoomChanged(level float64) {
218- if w != nil && w.zoomHandler != nil {
219- w.zoomHandler(level)
666+ if w.destroyed {
667+ return ErrWebViewDestroyed
220668 }
669+
670+ // Create and run print operation with GTK print dialog
671+ printOp := webkit.NewPrintOperation(w.view)
672+ printOp.RunDialog(nil) // nil = no parent window, uses active window
673+ return nil
674+}
675+
676+// RegisterKeyboardShortcut registers a keyboard shortcut handler
677+// This is a compatibility method - actual shortcut handling is done at the window level
678+func (w *WebView) RegisterKeyboardShortcut(key string, modifiers uint, handler func()) error {
679+ // TODO: Implement keyboard shortcut registration if needed
680+ // For now, shortcuts are handled at the window/application level
681+ return nil
221682 }
222683
223-// Widget returns an empty handle in stub builds.
224-func (w *WebView) Widget() uintptr { return 0 }
684+// SetWindowFeatures sets window features for popup windows
685+func (w *WebView) SetWindowFeatures(features *WindowFeatures) {
686+ // This is typically used for popup windows
687+ // The features would be applied when creating the window
688+}
225689
226-// RootWidget returns the container widget handle in CGO builds. Stub returns 0.
227-func (w *WebView) RootWidget() uintptr { return w.container }
690+// IsActive returns whether this WebView is currently active/focused
691+func (w *WebView) IsActive() bool {
692+ w.mu.RLock()
693+ defer w.mu.RUnlock()
228694
229-// DestroyWindow is a no-op in stub builds.
230-func (w *WebView) DestroyWindow() {}
695+ if w.destroyed {
696+ return false
697+ }
231698
232-// RunOnMainThread executes fn immediately in non-CGO builds.
233-func (w *WebView) RunOnMainThread(fn func()) {
234- if fn != nil {
235- fn()
699+ widget := w.view
700+ if widget != nil {
701+ return widget.IsFocus()
236702 }
703+ return false
237704 }
238705
239-// PrepareForReparenting is a no-op in stub builds.
240-func (w *WebView) PrepareForReparenting() {}
706+// Window returns the parent Window of this WebView
707+func (w *WebView) Window() *Window {
708+ w.mu.RLock()
709+ defer w.mu.RUnlock()
241710
242-// RefreshAfterReparenting is a no-op in stub builds.
243-func (w *WebView) RefreshAfterReparenting() {}
711+ if w.destroyed {
712+ return nil
713+ }
244714
245-// UsesDomZoom reports whether DOM-based zoom is enabled in this WebView.
246-func (w *WebView) UsesDomZoom() bool {
247- return w != nil && w.useDomZoom
715+ return w.window
248716 }
249717
250-// SeedDomZoom stores the desired DOM zoom level for the next navigation (stub implementation).
251-func (w *WebView) SeedDomZoom(level float64) {
252- if w == nil || !w.useDomZoom {
253- return
254- }
255- if level < 0.25 {
256- level = 0.25
257- } else if level > 5.0 {
258- level = 5.0
718+// UpdateContentFilters updates the content filtering rules
719+func (w *WebView) UpdateContentFilters(rules string) error {
720+ w.mu.RLock()
721+ defer w.mu.RUnlock()
722+
723+ if w.destroyed {
724+ return ErrWebViewDestroyed
259725 }
260- w.domZoomSeed = level
261-}
262726
263-// CreateRelatedView returns a new WebView (stub creates independent)
264-func (w *WebView) CreateRelatedView() *WebView {
265- nw, _ := NewWebView(w.config)
266- return nw
727+ // Note: Content filtering is handled via InitializeContentBlocking()
728+ // This method is kept for backward compatibility but is deprecated
729+ log.Printf("[webkit] UpdateContentFilters called but is deprecated - use InitializeContentBlocking instead")
730+ return nil
267731 }
268732
269-// OnWindowTypeDetected registers a callback (stored but never invoked in stub)
270-func (w *WebView) OnWindowTypeDetected(callback func(WindowType, *WindowFeatures)) {
271- if w != nil {
272- w.windowTypeCallback = callback
733+// InitializeContentBlocking initializes content blocking with filter lists
734+func (w *WebView) InitializeContentBlocking(filterJSON []byte) error {
735+ w.mu.RLock()
736+ defer w.mu.RUnlock()
737+
738+ if w.destroyed {
739+ return ErrWebViewDestroyed
273740 }
274-}
275741
276-// InitializeContentBlocking initializes WebKit content blocking with filter manager (stub)
277-func (w *WebView) InitializeContentBlocking(filterManager interface{}) error {
278- return ErrNotImplemented
742+ if len(filterJSON) == 0 {
743+ return fmt.Errorf("no filter rules provided")
744+ }
745+
746+ // Apply filters using the content blocking API
747+ return ApplyFiltersToWebView(w, filterJSON)
279748 }
280749
281-// OnNavigate sets up domain-specific cosmetic filtering on navigation (stub)
282-func (w *WebView) OnNavigate(url string, filterManager interface{}, whitelist []string) {
283- // No-op in stub
750+// OnNavigate registers a navigation handler
751+func (w *WebView) OnNavigate(handler func(url string)) {
752+ w.mu.Lock()
753+ defer w.mu.Unlock()
754+
755+ // This wraps the URI changed handler
756+ w.onURIChanged = handler
284757 }
285758
286-// UpdateContentFilters updates the content filters dynamically (stub)
287-func (w *WebView) UpdateContentFilters(filterManager interface{}) error {
288- return ErrNotImplemented
759+// RegisterWorkspaceNavigationHandler registers a handler for workspace pane navigation
760+// The handler receives the direction ("up", "down", "left", "right") and returns true if handled
761+func (w *WebView) RegisterWorkspaceNavigationHandler(handler func(direction string) bool) {
762+ w.mu.Lock()
763+ defer w.mu.Unlock()
764+ w.onWorkspaceNavigation = handler
289765 }
290766
291-// SetWindowFeatures sets the window features for this WebView (stub)
292-func (w *WebView) SetWindowFeatures(features *WindowFeatures) {
293- w.windowFeatures = features
767+// RegisterPaneModeHandler registers a handler for pane mode shortcuts
768+// The handler receives the action ("enter", "close", "split-right", etc.) and returns true if handled
769+func (w *WebView) RegisterPaneModeHandler(handler func(action string) bool, isActiveChecker func() bool) {
770+ w.mu.Lock()
771+ defer w.mu.Unlock()
772+ w.onPaneModeShortcut = handler
773+ w.isPaneModeActive = isActiveChecker
294774 }
295775
296-// SetActive sets whether this WebView is currently active/focused (stub)
297-func (w *WebView) SetActive(active bool) {
298- // No-op in stub
776+// setupFaviconHandlers connects to the FaviconDatabase signals
777+func (w *WebView) setupFaviconHandlers() {
778+ // Get the NetworkSession from the WebView
779+ session := w.view.NetworkSession()
780+ if session == nil {
781+ log.Printf("[webkit] Warning: No NetworkSession available for favicon handling")
782+ return
783+ }
784+
785+ // Get the WebsiteDataManager from the NetworkSession
786+ dataManager := session.WebsiteDataManager()
787+ if dataManager == nil {
788+ log.Printf("[webkit] Warning: No WebsiteDataManager available for favicon handling")
789+ return
790+ }
791+
792+ // Enable favicons if not already enabled
793+ if !dataManager.FaviconsEnabled() {
794+ dataManager.SetFaviconsEnabled(true)
795+ log.Printf("[webkit] Enabled favicons for WebView ID %d", w.id)
796+ }
797+
798+ // Get the FaviconDatabase
799+ faviconDB := dataManager.FaviconDatabase()
800+ if faviconDB == nil {
801+ log.Printf("[webkit] Warning: No FaviconDatabase available")
802+ return
803+ }
804+
805+ // Connect to the favicon-changed signal
806+ faviconDB.ConnectFaviconChanged(func(pageURI, faviconURI string) {
807+ log.Printf("[favicon] Favicon changed for %s: %s", pageURI, faviconURI)
808+
809+ // Call the URI handler if registered
810+ w.mu.RLock()
811+ handler := w.onFaviconURIChanged
812+ w.mu.RUnlock()
813+
814+ if handler != nil {
815+ handler(pageURI, faviconURI)
816+ }
817+ })
818+
819+ log.Printf("[webkit] Connected to FaviconDatabase for WebView ID %d", w.id)
299820 }
300821
301-// IsActive returns whether this WebView is currently active/focused (stub)
302-func (w *WebView) IsActive() bool {
303- return false
822+// GtkWebView returns the underlying gotk4 WebView for advanced operations
823+// This is used when creating related views (popups) that need to share the same session
824+func (w *WebView) GtkWebView() *webkit.WebView {
825+ w.mu.RLock()
826+ defer w.mu.RUnlock()
827+ return w.view
304828 }
305829
306-// ID returns the unique identifier for this WebView as a string (stub)
307-func (w *WebView) ID() string {
308- if w == nil {
309- return ""
830+// WrapBareWebView creates a minimal WebView wrapper around a bare gotk4 WebView
831+// This is used during popup creation to return a WebView to WebKit before initialization
832+// Full initialization (container, scripts, event handlers) should be done later
833+func WrapBareWebView(bareView *webkit.WebView) *WebView {
834+ if bareView == nil {
835+ return nil
310836 }
311- return strconv.FormatUint(uint64(w.id), 10)
837+
838+ // Generate unique ID
839+ id := atomic.AddUint64(&viewIDCounter, 1)
840+ log.Printf("[webkit] Created minimal wrapper for bare WebView (ID: %d)", id)
841+
842+ // Create minimal wrapper - NO initialization yet
843+ wv := &WebView{
844+ view: bareView,
845+ container: nil, // Will be created during full initialization
846+ window: nil,
847+ id: id,
848+ config: nil, // Will be set during full initialization
849+ }
850+
851+ // Register in global registry
852+ viewMu.Lock()
853+ viewRegistry[id] = wv
854+ viewMu.Unlock()
855+
856+ return wv
312857 }
313858
314-// PrefersDarkTheme returns true if GTK is configured to prefer dark theme (stub)
315-func PrefersDarkTheme() bool {
316- return false // Fallback to light theme when not using CGO
859+// InitializeFromBare completes initialization of a bare WebView wrapper
860+// This should be called in ready-to-show after WebKit has configured the view
861+func (w *WebView) InitializeFromBare(cfg *Config) error {
862+ w.mu.Lock()
863+ defer w.mu.Unlock()
864+
865+ if w.config != nil {
866+ return fmt.Errorf("WebView already initialized")
867+ }
868+
869+ if cfg == nil {
870+ cfg = GetDefaultConfig()
871+ }
872+
873+ w.config = cfg
874+
875+ // Verify persistent session
876+ viewSession := w.view.NetworkSession()
877+ if viewSession == nil {
878+ return fmt.Errorf("WebView has no network session")
879+ }
880+
881+ // Setup UserContentManager and inject GUI scripts
882+ if err := SetupUserContentManager(w.view, cfg.AppearanceConfigJSON, w.id); err != nil {
883+ return fmt.Errorf("failed to setup user content manager: %w", err)
884+ }
885+
886+ // Create container (GtkBox) to hold the WebView
887+ container := gtk.NewBox(gtk.OrientationVertical, 0)
888+ container.SetHExpand(true)
889+ container.SetVExpand(true)
890+
891+ // Configure WebView widget for expansion
892+ w.view.SetHExpand(true)
893+ w.view.SetVExpand(true)
894+
895+ // Add WebView to container
896+ container.Append(w.view)
897+
898+ w.container = container
899+
900+ // Apply configuration
901+ if err := w.applyConfig(); err != nil {
902+ return err
903+ }
904+
905+ // Setup event handlers
906+ w.setupEventHandlers()
907+
908+ // Attach keyboard bridge
909+ w.AttachKeyboardBridge()
910+
911+ log.Printf("[webkit] Completed initialization for WebView ID %d", w.id)
912+
913+ return nil
317914 }
D pkg/webkit/webview_callbacks.h +0 -110
@@ -1,110 +0,0 @@
1-#ifndef WEBVIEW_CALLBACKS_H
2-#define WEBVIEW_CALLBACKS_H
3-
4-#include <gtk/gtk.h>
5-#include <webkit/webkit.h>
6-#include <glib-object.h>
7-
8-// Forward declarations for Go callbacks
9-extern void goOnTitleChanged(unsigned long id, char* title);
10-extern void goOnURIChanged(unsigned long id, char* uri);
11-extern void goOnThemeChanged(unsigned long id, int prefer_dark);
12-extern void goOnFaviconURIChanged(unsigned long id, char* page_uri, char* favicon_uri);
13-extern void* goResolveURIScheme(char* uri, size_t* out_len, char** out_mime);
14-extern void goQuitMainLoop();
15-
16-// Static inline functions to avoid multiple definition errors
17-static inline void on_title_notify(GObject* obj, GParamSpec* pspec, gpointer user_data) {
18- (void)pspec;
19- WebKitWebView* view = WEBKIT_WEB_VIEW(obj);
20- const gchar* title = webkit_web_view_get_title(view);
21- GtkWindow* win = GTK_WINDOW(user_data);
22- if (title && win) {
23- gtk_window_set_title(win, title);
24- }
25-}
26-
27-static inline void on_title_notify_id(GObject* obj, GParamSpec* pspec, gpointer user_data) {
28- (void)pspec; (void)obj;
29- const gchar* title = webkit_web_view_get_title(WEBKIT_WEB_VIEW(obj));
30- if (title) { goOnTitleChanged((unsigned long)user_data, (char*)title); }
31-}
32-
33-static inline void on_uri_notify(GObject* obj, GParamSpec* pspec, gpointer user_data) {
34- (void)pspec; (void)obj;
35- const gchar* uri = webkit_web_view_get_uri(WEBKIT_WEB_VIEW(obj));
36- if (uri) { goOnURIChanged((unsigned long)user_data, (char*)uri); }
37-}
38-
39-static inline void on_theme_changed(GObject* obj, GParamSpec* pspec, gpointer user_data) {
40- (void)pspec;
41- GtkSettings* settings = GTK_SETTINGS(obj);
42- if (!settings) return;
43- gboolean prefer = FALSE;
44- g_object_get(settings, "gtk-application-prefer-dark-theme", &prefer, NULL);
45- goOnThemeChanged((unsigned long)user_data, prefer ? 1 : 0);
46-}
47-
48-static inline void on_uri_scheme(WebKitURISchemeRequest* request, gpointer user_data) {
49- (void)user_data;
50- const gchar* uri = webkit_uri_scheme_request_get_uri(request);
51- size_t n = 0;
52- char* mime = NULL;
53- void* buf = goResolveURIScheme((char*)uri, &n, &mime);
54- if (buf && n > 0 && mime) {
55- GInputStream* stream = g_memory_input_stream_new_from_data(buf, (gssize)n, g_free);
56- webkit_uri_scheme_request_finish(request, stream, (gint64)n, mime);
57- g_object_unref(stream);
58- g_free(mime);
59- } else {
60- GError* err = g_error_new_literal(g_quark_from_string("dumber"), 404, "Not found");
61- webkit_uri_scheme_request_finish_error(request, err);
62- g_error_free(err);
63- }
64-}
65-
66-static inline char* mask_uri_for_log(const char* uri) {
67- if (!uri) return g_strdup("NULL");
68- const char* p = strstr(uri, "://");
69- const char* start = p ? p + 3 : uri;
70- const char* slash = strchr(start, '/');
71- size_t host_len = slash ? (size_t)(slash - start) : strlen(start);
72- const size_t max_len = 64;
73- if (host_len <= max_len) {
74- return g_strndup(start, host_len);
75- } else {
76- char* prefix = g_strndup(start, max_len);
77- char* res = g_strdup_printf("%s...", prefix);
78- g_free(prefix);
79- return res;
80- }
81-}
82-
83-static inline void on_favicon_changed(WebKitFaviconDatabase* database, gchar* page_uri, gchar* favicon_uri, gpointer user_data) {
84- (void)database;
85-
86- if (!favicon_uri || !page_uri) {
87- printf("[favicon-c] Invalid favicon or page URI for WebView ID %lu\n", (unsigned long)user_data);
88- return;
89- }
90-
91- // Log only a masked/shortened form of the URIs (no full URIs)
92- char* masked_page = mask_uri_for_log(page_uri);
93- char* masked_favicon = mask_uri_for_log(favicon_uri);
94- printf("[favicon-c] Favicon changed - page: %s, favicon: %s, WebView ID: %lu\n",
95- masked_page, masked_favicon, (unsigned long)user_data);
96- g_free(masked_page);
97- g_free(masked_favicon);
98-
99- // Pass the full page URI and favicon URI to Go
100- goOnFaviconURIChanged((unsigned long)user_data, page_uri, favicon_uri);
101-}
102-
103-// GTK4 close-request signal handler
104-static inline gboolean on_close_request(GtkWindow* window, gpointer user_data) {
105- (void)window; (void)user_data;
106- goQuitMainLoop();
107- return FALSE; // Allow the window to close
108-}
109-
110-#endif // WEBVIEW_CALLBACKS_H⏎̸
D pkg/webkit/webview_cgo.go +0 -2846
@@ -1,2846 +0,0 @@
1-//go:build webkit_cgo
2-
3-package webkit
4-
5-/*
6-#cgo pkg-config: webkitgtk-6.0 gtk4 javascriptcoregtk-6.0
7-#cgo CFLAGS: -I/usr/include/webkitgtk-6.0
8-#include <stdlib.h>
9-#include <string.h>
10-#include <stdint.h>
11-#include <gtk/gtk.h>
12-#include <webkit/webkit.h>
13-#include <glib-object.h>
14-#include <gdk/gdk.h>
15-#include <jsc/jsc.h>
16-#include <glib.h>
17-#include <gio/gio.h>
18-#include "webview_callbacks.h"
19-
20-static GtkWidget* new_window() { return GTK_WIDGET(gtk_window_new()); }
21-
22-// Forward declaration
23-extern void goQuitMainLoop();
24-extern void goInvokeHandle(uintptr_t handle);
25-extern gboolean goHandleNewWindowPolicy(unsigned long id, char* uri, int nav_type, char* frame_name, gboolean is_user_gesture, guint modifiers, guint mouse_button);
26-extern void goHandlePopupGeometry(unsigned long id, int x, int y, int width, int height);
27-extern void goHandleLoadChanged(unsigned long id, char* uri, int load_event);
28-extern void goHandleWebViewClose(unsigned long id);
29-extern void goCreateNewPanel(unsigned long id, char* uri);
30-static gboolean invoke_handle_cb(gpointer data) {
31- goInvokeHandle((uintptr_t)data);
32- return G_SOURCE_REMOVE;
33-}
34-
35-static void schedule_on_main_thread(uintptr_t handle) {
36- g_idle_add_full(G_PRIORITY_DEFAULT, invoke_handle_cb, (gpointer)handle, NULL);
37-}
38-
39-// Connect window close signal to quit main loop
40-static void connect_destroy_quit(GtkWidget* w) {
41- if (!w) return;
42- g_signal_connect(G_OBJECT(w), "close-request", G_CALLBACK(on_close_request), NULL);
43-}
44-static WebKitWebView* as_webview(GtkWidget* w) { return WEBKIT_WEB_VIEW(w); }
45-// WebsiteDataManager creation will be handled via GTK4/WebKit6 APIs in Go code.
46-
47-// Forward declare helpers used below
48-static void maybe_set_cookie_policy(WebKitCookieManager* cm, int policy);
49-
50-// TLS error handling forward declarations
51-extern gboolean goHandleTLSError(char* failing_uri, char* host, int error_flags, char* cert_info);
52-
53-// Console message handling forward declarations
54-extern void goHandleConsoleMessage(char* message);
55-
56-static gboolean on_load_failed_with_tls_errors(WebKitWebView *web_view,
57- const char *failing_uri,
58- GTlsCertificate *certificate,
59- GTlsCertificateFlags errors,
60- gpointer user_data);
61-
62-
63-// Handle load_changed signal for OAuth completion detection
64-static void on_load_changed(WebKitWebView *web_view, WebKitLoadEvent load_event, gpointer user_data) {
65- const char* uri = webkit_web_view_get_uri(web_view);
66- unsigned long view_id = (unsigned long)user_data;
67-
68- // Only process WEBKIT_LOAD_FINISHED events
69- if (load_event == WEBKIT_LOAD_FINISHED && uri) {
70- printf("[webkit-load] Load finished for URI: %s\n", uri);
71- goHandleLoadChanged(view_id, (char*)uri, load_event);
72- }
73-}
74-
75-// Handle close signal when JavaScript calls window.close()
76-static void on_webview_close(WebKitWebView* web_view, gpointer user_data) {
77- unsigned long view_id = (unsigned long)user_data;
78- printf("[webkit-close] WebView close signal from view_id: %lu\n", view_id);
79- goHandleWebViewClose(view_id);
80-}
81-
82-
83-// Forward declare navigation policy handler
84-extern gboolean goHandleNavigationPolicy(unsigned long view_id, char* uri, gboolean is_user_gesture);
85-
86-// Handle decide-policy signal for new window requests and navigations
87-static gboolean on_decide_policy(WebKitWebView *web_view,
88- WebKitPolicyDecision *decision,
89- WebKitPolicyDecisionType type,
90- gpointer user_data) {
91- // Debug: log all policy decisions
92- const char* type_names[] = {"NAVIGATION_ACTION", "NEW_WINDOW_ACTION", "RESPONSE"};
93- const char* type_name = (type >= 0 && type < 3) ? type_names[type] : "UNKNOWN";
94- printf("[webkit-policy] Policy decision: type=%s (%d)\n", type_name, type);
95-
96- unsigned long view_id = (unsigned long)user_data;
97-
98- if (type == WEBKIT_POLICY_DECISION_TYPE_NAVIGATION_ACTION) {
99- WebKitNavigationPolicyDecision *nav_decision =
100- WEBKIT_NAVIGATION_POLICY_DECISION(decision);
101- WebKitNavigationAction *action =
102- webkit_navigation_policy_decision_get_navigation_action(nav_decision);
103- WebKitURIRequest *request =
104- webkit_navigation_action_get_request(action);
105- const char* uri = webkit_uri_request_get_uri(request);
106- gboolean is_user_gesture = webkit_navigation_action_is_user_gesture(action);
107-
108- // Ask Go if this navigation should be allowed
109- gboolean allowed = goHandleNavigationPolicy(view_id, (char*)uri, is_user_gesture);
110-
111- if (!allowed) {
112- printf("[webkit-policy] Navigation BLOCKED by Go handler: uri=%s is_user_gesture=%d\n", uri, is_user_gesture);
113- webkit_policy_decision_ignore(decision);
114- return TRUE;
115- }
116-
117- // Allow navigation
118- webkit_policy_decision_use(decision);
119- return TRUE;
120- }
121-
122- if (type == WEBKIT_POLICY_DECISION_TYPE_NEW_WINDOW_ACTION) {
123- WebKitNavigationPolicyDecision *nav_decision =
124- WEBKIT_NAVIGATION_POLICY_DECISION(decision);
125- WebKitNavigationAction *action =
126- webkit_navigation_policy_decision_get_navigation_action(nav_decision);
127- WebKitURIRequest *request =
128- webkit_navigation_action_get_request(action);
129- const char* uri = webkit_uri_request_get_uri(request);
130-
131- // Get navigation type to understand how popup was triggered
132- WebKitNavigationType nav_type = webkit_navigation_action_get_navigation_type(action);
133-
134- // Get frame name to distinguish _blank from other popups
135- const char* frame_name = webkit_navigation_action_get_frame_name(action);
136-
137- // Get additional properties to better distinguish _blank from popups
138- gboolean is_user_gesture = webkit_navigation_action_is_user_gesture(action);
139- guint modifiers = webkit_navigation_action_get_modifiers(action);
140- guint mouse_button = webkit_navigation_action_get_mouse_button(action);
141-
142- // Call Go handler for popup creation
143- gboolean handled = goHandleNewWindowPolicy(view_id, (char*)uri, nav_type, (char*)frame_name,
144- is_user_gesture, modifiers, mouse_button);
145-
146- if (handled) {
147- webkit_policy_decision_ignore(decision);
148- } else {
149- webkit_policy_decision_use(decision);
150- }
151- return TRUE;
152- }
153- return FALSE;
154-}
155-
156-static void connect_tls_error_handler(WebKitWebView* wv);
157-
158-
159-// Dialog callback structure for modern AlertDialog async handling
160-typedef struct {
161- int response;
162- gboolean completed;
163- GMainLoop *loop;
164-} AlertDialogResponse;
165-
166-static void on_alert_dialog_choose_done(GObject *source, GAsyncResult *result, gpointer user_data);
167-static gboolean show_tls_warning_dialog_sync(GtkWindow *parent, const char* hostname, const char* error_msg);
168-static char* extract_certificate_info(GTlsCertificate* certificate);
169-
170-// Setup video acceleration environment variables
171-static void setup_video_acceleration(const char* driver_name, int enable_all, int legacy) {
172- if (legacy) {
173- // Legacy mode: force old VA-API plugins, disable modern ones
174- setenv("WEBKIT_GST_ENABLE_LEGACY_VAAPI", "1", 1);
175- // Don't set other env vars in legacy mode to avoid conflicts
176- return;
177- }
178-
179- // Modern mode (default): use improved VA-API handling
180- if (driver_name && strlen(driver_name) > 0) {
181- if (strcmp(driver_name, "nvidia") == 0) {
182- // NVIDIA uses different env vars for hardware video acceleration
183- setenv("VDPAU_DRIVER", "nvidia", 1);
184- setenv("LIBVA_DRIVER_NAME", "vdpau", 1);
185- } else if (strcmp(driver_name, "vdpau") == 0) {
186- // Explicit VDPAU backend (for NVIDIA via VA-API)
187- setenv("LIBVA_DRIVER_NAME", "vdpau", 1);
188- } else {
189- // AMD/Intel use VA-API directly
190- setenv("LIBVA_DRIVER_NAME", driver_name, 1);
191- }
192- }
193-
194- if (enable_all) {
195- setenv("GST_VAAPI_ALL_DRIVERS", "1", 1);
196- }
197-}
198-
199-// Construct a WebKitWebView via g_object_new with a fresh UserContentManager.
200-static GtkWidget* new_webview_with_ucm_and_session(const char* data_dir, const char* cache_dir, const char* cookie_path, WebKitUserContentManager** out_ucm, WebKitMemoryPressureSettings* pressure_settings) {
201- WebKitUserContentManager* u = webkit_user_content_manager_new();
202- if (!u) return NULL;
203- // WebKitGTK 6: create NetworkSession with data/cache directories
204- WebKitNetworkSession* sess = webkit_network_session_new(
205- data_dir ? data_dir : "",
206- cache_dir ? cache_dir : "");
207- if (!sess) { g_object_unref(u); return NULL; }
208- // Apply memory pressure settings to the session when provided
209- if (pressure_settings) {
210- // WebKitGTK 6.0 API: setter expects only the settings pointer
211- webkit_network_session_set_memory_pressure_settings(pressure_settings);
212- }
213- // Configure cookie persistence and policy (no third-party by default)
214- WebKitCookieManager* cm = webkit_network_session_get_cookie_manager(sess);
215- if (cm) {
216- if (cookie_path && cookie_path[0]) {
217- webkit_cookie_manager_set_persistent_storage(cm, cookie_path, WEBKIT_COOKIE_PERSISTENT_STORAGE_SQLITE);
218- }
219- // 2 => no-third-party (default), 0 => always, 1 => never
220- maybe_set_cookie_policy(cm, 2);
221- }
222- // Persist credentials (HTTP auth, etc.)
223- webkit_network_session_set_persistent_credential_storage_enabled(sess, TRUE);
224- // Configure TLS errors to emit signals instead of failing silently
225- webkit_network_session_set_tls_errors_policy(sess, WEBKIT_TLS_ERRORS_POLICY_FAIL);
226- GtkWidget* w = GTK_WIDGET(g_object_new(WEBKIT_TYPE_WEB_VIEW,
227- "user-content-manager", u,
228- "network-session", sess,
229- NULL));
230- // WebView holds refs to provided objects; drop our temporary refs
231- g_object_unref(sess);
232- // Set WebView background to black (easier on eyes when pages are loading)
233- GdkRGBA black_color = { 0.0, 0.0, 0.0, 1.0 };
234- webkit_web_view_set_background_color(WEBKIT_WEB_VIEW(w), &black_color);
235-
236- // Connect TLS error handler
237- connect_tls_error_handler(WEBKIT_WEB_VIEW(w));
238-
239- if (out_ucm) { *out_ucm = u; }
240- return w;
241-}
242-
243-// Create a new WebView related to an existing one for process sharing
244-static GtkWidget* new_webview_related(WebKitWebView* related_view, WebKitUserContentManager** out_ucm) {
245- if (!related_view) {
246- // Fallback to regular WebView creation - we need to provide the required parameters
247- return new_webview_with_ucm_and_session(NULL, NULL, NULL, out_ucm, NULL);
248- }
249-
250- // Get the network session and user content manager from the related view
251- WebKitNetworkSession* related_session = webkit_web_view_get_network_session(related_view);
252- WebKitUserContentManager* related_ucm = webkit_web_view_get_user_content_manager(related_view);
253-
254- // Create content manager if needed
255- WebKitUserContentManager* u = related_ucm;
256- if (!u) {
257- u = webkit_user_content_manager_new();
258- } else {
259- g_object_ref(u); // Add ref since we'll pass it to new WebView
260- }
261-
262- // Create WebView with related view for process sharing (network session is inherited)
263- GtkWidget* w = GTK_WIDGET(g_object_new(WEBKIT_TYPE_WEB_VIEW,
264- "user-content-manager", u,
265- "related-view", related_view,
266- NULL));
267-
268- // Connect TLS error handler
269- connect_tls_error_handler(WEBKIT_WEB_VIEW(w));
270-
271- // WebView holds refs to provided objects; drop our temporary refs
272- g_object_unref(u);
273- if (out_ucm) { *out_ucm = u; }
274- return w;
275-}
276-
277-static void connect_policy_handler(WebKitWebView* web_view, unsigned long id) {
278- if (!web_view) return;
279- // Connect decide-policy signal for navigation control
280- g_signal_connect_data(G_OBJECT(web_view), "decide-policy", G_CALLBACK(on_decide_policy), (gpointer)id, NULL, 0);
281- printf("[webkit-debug] Policy handler connected for WebView id: %lu\n", id);
282-}
283-
284-// Connect close signal to a WebView for popup auto-close functionality
285-static void connect_close_signal(WebKitWebView* web_view, unsigned long id) {
286- if (!web_view) return;
287- g_signal_connect_data(G_OBJECT(web_view), "close", G_CALLBACK(on_webview_close), (gpointer)id, NULL, 0);
288- printf("[webkit-connect] Connected close signal to WebView with id: %lu\n", id);
289-}
290-
291-// Callback for print signal - intercepts Ctrl+P and can redirect behavior
292-static gboolean on_print_request(WebKitWebView* web_view, WebKitPrintOperation* print_operation, gpointer user_data) {
293- (void)web_view;
294- (void)print_operation;
295- (void)user_data;
296-
297- // Return TRUE to prevent default print dialog
298- // Return FALSE to allow default print behavior
299- // For now, we'll block the print dialog entirely since Ctrl+P should trigger pane mode
300- printf("[webkit-print] Print request intercepted - blocking to prevent conflict with pane mode\n");
301- return TRUE; // Block the print dialog
302-}
303-
304-// Connect print signal to a WebView to intercept Ctrl+P
305-static void connect_print_signal(WebKitWebView* web_view, unsigned long id) {
306- if (!web_view) return;
307- g_signal_connect_data(G_OBJECT(web_view), "print", G_CALLBACK(on_print_request), (gpointer)id, NULL, 0);
308- printf("[webkit-connect] Connected print signal to WebView with id: %lu\n", id);
309-}
310-
311-static gboolean gtk_prefers_dark() {
312- // Method 1: Check GNOME desktop interface color-scheme (primary method)
313- GSettings* desktop_settings = g_settings_new("org.gnome.desktop.interface");
314- if (desktop_settings) {
315- gchar* color_scheme = g_settings_get_string(desktop_settings, "color-scheme");
316- if (color_scheme) {
317- gboolean prefer_dark = (g_strcmp0(color_scheme, "prefer-dark") == 0);
318- g_free(color_scheme);
319- g_object_unref(desktop_settings);
320- if (prefer_dark) return TRUE;
321- }
322- g_object_unref(desktop_settings);
323- }
324-
325- // Method 2: Check GTK theme name for dark variants
326- GtkSettings* settings = gtk_settings_get_default();
327- if (settings) {
328- gchar* theme_name = NULL;
329- g_object_get(settings, "gtk-theme-name", &theme_name, NULL);
330- if (theme_name) {
331- gboolean is_dark = (strstr(theme_name, "-dark") != NULL ||
332- strstr(theme_name, "-Dark") != NULL);
333- g_free(theme_name);
334- if (is_dark) return TRUE;
335- }
336-
337- // Method 3: Check gtk-application-prefer-dark-theme (fallback)
338- gboolean prefer = FALSE;
339- g_object_get(settings, "gtk-application-prefer-dark-theme", &prefer, NULL);
340- if (prefer) return TRUE;
341- }
342-
343- return FALSE;
344-}
345-
346-// Note: preferred color scheme is handled via a user script injection to support
347-// older WebKitGTK versions without the color-scheme API. See enableUserContentManager.
348-
349-extern void goOnUcmMessage(unsigned long id, char* json);
350-
351-// Helper functions to connect signals using static callbacks
352-static void connect_title_notify(GtkWidget* widget, GtkWindow* window) {
353- if (!widget) return;
354- g_signal_connect_data(G_OBJECT(widget), "notify::title", G_CALLBACK(on_title_notify), G_OBJECT(window), NULL, 0);
355-}
356-
357-static void connect_title_notify_with_id(GtkWidget* widget, unsigned long id) {
358- if (!widget) return;
359- g_signal_connect_data(G_OBJECT(widget), "notify::title", G_CALLBACK(on_title_notify_id), (gpointer)id, NULL, 0);
360-}
361-
362-static void connect_uri_notify_with_id(GtkWidget* widget, unsigned long id) {
363- if (!widget) return;
364- g_signal_connect_data(G_OBJECT(widget), "notify::uri", G_CALLBACK(on_uri_notify), (gpointer)id, NULL, 0);
365-}
366-
367-static void connect_favicon_changed_with_id(GtkWidget* widget, unsigned long id) {
368- if (!widget) return;
369- WebKitWebView* web_view = WEBKIT_WEB_VIEW(widget);
370- WebKitNetworkSession* network_session = webkit_web_view_get_network_session(web_view);
371- if (network_session) {
372- WebKitWebsiteDataManager* data_manager = webkit_network_session_get_website_data_manager(network_session);
373- if (data_manager) {
374- WebKitFaviconDatabase* favicon_db = webkit_website_data_manager_get_favicon_database(data_manager);
375- if (favicon_db) {
376- g_signal_connect_data(G_OBJECT(favicon_db), "favicon-changed", G_CALLBACK(on_favicon_changed), (gpointer)id, NULL, 0);
377- printf("[favicon-c] Connected favicon-changed signal for WebView ID %lu\n", id);
378- } else {
379- printf("[favicon-c] Failed to get favicon database\n");
380- }
381- } else {
382- printf("[favicon-c] Failed to get website data manager for favicon signal\n");
383- }
384- } else {
385- printf("[favicon-c] Failed to get network session\n");
386- }
387-}
388-
389-static void connect_theme_changed_with_id(GtkSettings* settings, unsigned long id) {
390- if (!settings) return;
391- g_signal_connect_data(G_OBJECT(settings), "notify::gtk-application-prefer-dark-theme", G_CALLBACK(on_theme_changed), (gpointer)id, NULL, 0);
392-}
393-
394-static void register_uri_scheme_handler(WebKitWebContext* context, const char* scheme) {
395- if (!context || !scheme) return;
396- webkit_web_context_register_uri_scheme(context, scheme, (WebKitURISchemeRequestCallback)on_uri_scheme, NULL, NULL);
397-}
398-static void register_uri_scheme_security(WebKitWebContext* context, const char* scheme) {
399- if (!context || !scheme) return;
400- WebKitSecurityManager* sm = webkit_web_context_get_security_manager(context);
401- if (!sm) return;
402-
403- // Mark as secure to avoid mixed-content warnings when requested by https pages
404- webkit_security_manager_register_uri_scheme_as_secure(sm, scheme);
405- // Treat as local scheme
406- webkit_security_manager_register_uri_scheme_as_local(sm, scheme);
407- // Allow cross-origin requests to this scheme (bypass CORS header checks for this scheme)
408- webkit_security_manager_register_uri_scheme_as_cors_enabled(sm, scheme);
409-}
410-
411-static void enable_favicon_database(WebKitNetworkSession* session) {
412- if (!session) return;
413- WebKitWebsiteDataManager* data_manager = webkit_network_session_get_website_data_manager(session);
414- if (data_manager) {
415- webkit_website_data_manager_set_favicons_enabled(data_manager, TRUE);
416- printf("[favicon-c] Favicon database enabled\n");
417- } else {
418- printf("[favicon-c] Failed to get website data manager\n");
419- }
420-}
421-
422-// Script message handler wiring for WebKitGTK 6
423-// Forward declaration for callback used in g_signal_connect_data
424-// WebKitGTK 6 delivers JSCValue* for script-message-received::NAME
425-static void ucm_on_script_message_cb(WebKitUserContentManager* ucm, JSCValue* val, gpointer user_data);
426-// Uses WebKitScriptMessage and script-message-received::NAME signal.
427-static void ucm_on_script_message_cb(WebKitUserContentManager* ucm, JSCValue* val, gpointer user_data) {
428- (void)ucm;
429- if (!val) return;
430- gchar* s = jsc_value_to_string(val);
431- if (!s) return;
432- goOnUcmMessage((unsigned long)user_data, s);
433- g_free(s);
434-}
435-
436-static gboolean register_ucm_handler(WebKitUserContentManager* ucm, const char* name, unsigned long id) {
437- if (!ucm || !name) return FALSE;
438- // WebKitGTK 6 signature: (ucm, name, world_name)
439- webkit_user_content_manager_register_script_message_handler(ucm, name, NULL);
440- gchar* signal = g_strdup_printf("script-message-received::%s", name);
441- if (!signal) return FALSE;
442- g_signal_connect_data(G_OBJECT(ucm), signal, G_CALLBACK(ucm_on_script_message_cb), (gpointer)id, NULL, 0);
443- g_free(signal);
444- return TRUE;
445-}
446-
447-// Register a script message handler for a specific JavaScript world
448-static gboolean register_ucm_handler_world(WebKitUserContentManager* ucm, const char* name, const char* world_name, unsigned long id) {
449- if (!ucm || !name || !world_name) return FALSE;
450- webkit_user_content_manager_register_script_message_handler(ucm, name, world_name);
451- gchar* signal = g_strdup_printf("script-message-received::%s", name);
452- if (!signal) return FALSE;
453- g_signal_connect_data(G_OBJECT(ucm), signal, G_CALLBACK(ucm_on_script_message_cb), (gpointer)id, NULL, 0);
454- g_free(signal);
455- return TRUE;
456-}
457-
458-// ----- Conditional helpers for settings across WebKit versions -----
459-#ifndef WEBKIT_CHECK_VERSION
460-#define WEBKIT_CHECK_VERSION(major,minor,micro) (0)
461-#endif
462-
463-static void maybe_set_hw_policy(WebKitSettings* settings, int mode) {
464-#if WEBKIT_CHECK_VERSION(2,40,0)
465- // mode: 0=ON_DEMAND, 1=ALWAYS, 2=NEVER
466- if (!settings) return;
467- if (mode == 1) {
468- webkit_settings_set_hardware_acceleration_policy(settings, WEBKIT_HARDWARE_ACCELERATION_POLICY_ALWAYS);
469- } else if (mode == 2) {
470- webkit_settings_set_hardware_acceleration_policy(settings, WEBKIT_HARDWARE_ACCELERATION_POLICY_NEVER);
471- } else {
472- // Leave default policy (auto) without forcing a value to avoid referencing
473- // WEBKIT_HARDWARE_ACCELERATION_POLICY_ON_DEMAND on older headers.
474- }
475-#else
476- (void)settings; (void)mode;
477-#endif
478-}
479-
480-static void maybe_set_draw_indicators(WebKitSettings* settings, int enable) {
481-#if WEBKIT_CHECK_VERSION(2,36,0)
482- if (!settings) return;
483- webkit_settings_set_draw_compositing_indicators(settings, enable ? 1 : 0);
484-#else
485- (void)settings; (void)enable;
486-#endif
487-}
488-
489-static void maybe_set_webgl(WebKitSettings* settings, int enable) {
490-#if WEBKIT_CHECK_VERSION(2,20,0)
491- if (!settings) return;
492- webkit_settings_set_enable_webgl(settings, enable ? 1 : 0);
493-#else
494- (void)settings; (void)enable;
495-#endif
496-}
497-
498-static void maybe_set_canvas_accel(WebKitSettings* settings, int enable) {
499-#if WEBKIT_CHECK_VERSION(2,20,0)
500- if (!settings) return;
501- webkit_settings_set_enable_2d_canvas_acceleration(settings, enable ? 1 : 0);
502-#else
503- (void)settings; (void)enable;
504-#endif
505-}
506-
507-static void maybe_set_media_user_gesture(WebKitSettings* settings, int require) {
508-#if WEBKIT_CHECK_VERSION(2,20,0)
509- if (!settings) return;
510- webkit_settings_set_media_playback_requires_user_gesture(settings, require ? 1 : 0);
511-#else
512- (void)settings; (void)require;
513-#endif
514-}
515-
516-static void maybe_set_smooth_scrolling(WebKitSettings* settings, int enable) {
517-#if WEBKIT_CHECK_VERSION(2,10,0)
518- if (!settings) return;
519- webkit_settings_set_enable_smooth_scrolling(settings, enable ? TRUE : FALSE);
520-#else
521- (void)settings; (void)enable;
522-#endif
523-}
524-
525-// Enable two-finger swipe back/forward navigation gestures when supported
526-static void maybe_set_back_forward_gestures(WebKitSettings* settings, int enable) {
527-#if WEBKIT_CHECK_VERSION(2,38,0)
528- if (!settings) return;
529- webkit_settings_set_enable_back_forward_navigation_gestures(settings, enable ? 1 : 0);
530-#else
531- (void)settings; (void)enable;
532-#endif
533-}
534-
535-// Set cookie accept policy with compile-time compatibility across WebKit versions
536-static void maybe_set_cookie_policy(WebKitCookieManager* cm, int policy) {
537- if (!cm) return;
538-#if defined(WEBKIT_COOKIE_ACCEPT_POLICY_ALWAYS)
539- WebKitCookieAcceptPolicy p = WEBKIT_COOKIE_ACCEPT_POLICY_ALWAYS;
540- if (policy == 1) p = WEBKIT_COOKIE_ACCEPT_POLICY_NEVER; // never
541- else if (policy == 2) p = WEBKIT_COOKIE_ACCEPT_POLICY_NO_THIRD_PARTY; // no third-party
542- webkit_cookie_manager_set_accept_policy(cm, p);
543-#elif defined(WEBKIT_COOKIE_POLICY_ACCEPT_ALWAYS)
544- WebKitCookieAcceptPolicy p = WEBKIT_COOKIE_POLICY_ACCEPT_ALWAYS;
545- if (policy == 1) p = WEBKIT_COOKIE_POLICY_ACCEPT_NEVER;
546- else if (policy == 2) p = WEBKIT_COOKIE_POLICY_ACCEPT_NO_THIRD_PARTY;
547- webkit_cookie_manager_set_accept_policy(cm, p);
548-#else
549- (void)policy; // Not supported in this header version
550-#endif
551-}
552-
553-// Memory pressure settings helpers (version-guarded)
554-static WebKitMemoryPressureSettings* create_memory_pressure_settings(
555- unsigned int memory_limit_mb,
556- double conservative_threshold,
557- double strict_threshold,
558- double kill_threshold,
559- double poll_interval) {
560- // If headers don't expose memory pressure APIs, return NULL to signal unsupported
561-#if WEBKIT_CHECK_VERSION(2,44,0)
562- WebKitMemoryPressureSettings* settings = webkit_memory_pressure_settings_new();
563- if (!settings) return NULL;
564-
565- if (memory_limit_mb > 0) {
566- webkit_memory_pressure_settings_set_memory_limit(settings, (guint)(memory_limit_mb * 1024 * 1024));
567- }
568-
569- webkit_memory_pressure_settings_set_conservative_threshold(settings, conservative_threshold);
570- webkit_memory_pressure_settings_set_strict_threshold(settings, strict_threshold);
571-
572- if (kill_threshold > 0) {
573- webkit_memory_pressure_settings_set_kill_threshold(settings, kill_threshold);
574- }
575-
576- webkit_memory_pressure_settings_set_poll_interval(settings, poll_interval);
577-
578- return settings;
579-#else
580- (void)memory_limit_mb; (void)conservative_threshold; (void)strict_threshold; (void)kill_threshold; (void)poll_interval;
581- return NULL;
582-#endif
583-}
584-
585-// Cache model configuration
586-static void maybe_set_cache_model(WebKitWebContext* context, int model) {
587- if (!context) return;
588- WebKitCacheModel cache_model = WEBKIT_CACHE_MODEL_WEB_BROWSER; // default
589-
590- switch (model) {
591- case 0: cache_model = WEBKIT_CACHE_MODEL_DOCUMENT_VIEWER; break;
592- case 1: cache_model = WEBKIT_CACHE_MODEL_WEB_BROWSER; break;
593- case 2:
594-#ifdef WEBKIT_CACHE_MODEL_PRIMARY_WEB_BROWSER
595- cache_model = WEBKIT_CACHE_MODEL_PRIMARY_WEB_BROWSER;
596-#else
597- cache_model = WEBKIT_CACHE_MODEL_WEB_BROWSER;
598-#endif
599- break;
600- }
601-
602- webkit_web_context_set_cache_model(context, cache_model);
603-}
604-
605-// Additional settings for memory optimization
606-static void maybe_set_page_cache(WebKitSettings* settings, int enable) {
607-#if WEBKIT_CHECK_VERSION(2,6,0)
608- if (!settings) return;
609- webkit_settings_set_enable_page_cache(settings, enable ? TRUE : FALSE);
610-#else
611- (void)settings; (void)enable;
612-#endif
613-}
614-
615-// Offline Web Application Cache is deprecated in modern WebKit; keep as no-op to avoid warnings.
616-static void maybe_set_offline_app_cache(WebKitSettings* settings, int enable) {
617- (void)settings; (void)enable;
618-}
619-
620-// Apply memory pressure settings to context if available
621-static void maybe_set_memory_pressure_settings(WebKitWebContext* context, WebKitMemoryPressureSettings* settings) {
622- // No-op wrapper kept for compatibility; settings applied to NetworkSession instead.
623- (void)context; (void)settings;
624-}
625-
626-// Trigger JavaScript GC if supported by headers
627-static void maybe_collect_js(WebKitWebContext* context) {
628- // Disabled by default due to API availability variance across 6.0 builds.
629- (void)context;
630-}
631-
632-// TLS error handling implementation
633-static gboolean on_load_failed_with_tls_errors(WebKitWebView *web_view,
634- const char *failing_uri,
635- GTlsCertificate *certificate,
636- GTlsCertificateFlags errors,
637- gpointer user_data) {
638- (void)user_data;
639-
640- // Extract host from URI for the Go callback
641- const char* host = failing_uri;
642- char* host_copy = NULL;
643-
644- if (strstr(failing_uri, "://")) {
645- host = strstr(failing_uri, "://") + 3;
646- char* slash = strchr(host, '/');
647- if (slash) {
648- // Create a temporary null-terminated host string
649- size_t host_len = slash - host;
650- host_copy = malloc(host_len + 1);
651- if (host_copy) {
652- strncpy(host_copy, host, host_len);
653- host_copy[host_len] = '\0';
654- host = host_copy;
655- }
656- }
657- }
658-
659- // Extract certificate information
660- char* cert_info = extract_certificate_info(certificate);
661-
662- gboolean should_proceed = goHandleTLSError((char*)failing_uri, (char*)host, (int)errors, cert_info);
663-
664- // If user accepted, allow the certificate for this host
665- if (should_proceed && certificate) {
666- printf("[dumber] User accepted certificate - adding exception and triggering new load\n");
667- fflush(stdout);
668- // Get the network session from the web view
669- WebKitNetworkSession* session = webkit_web_view_get_network_session(web_view);
670- if (session) {
671- webkit_network_session_allow_tls_certificate_for_host(session, certificate, host);
672- printf("[dumber] Certificate exception added for host: %s\n", host);
673- fflush(stdout);
674-
675- // Try loading the URL again after adding the exception
676- webkit_web_view_load_uri(web_view, failing_uri);
677- printf("[dumber] Triggered new load of %s with certificate exception\n", failing_uri);
678- fflush(stdout);
679- }
680- }
681-
682- // Free the certificate info string
683- if (cert_info) {
684- g_free(cert_info);
685- }
686-
687- if (host_copy) {
688- free(host_copy);
689- }
690-
691- return should_proceed;
692-}
693-
694-// Extract certificate information for display
695-static char* extract_certificate_info(GTlsCertificate* certificate) {
696- if (!certificate) {
697- return g_strdup("Certificate information not available");
698- }
699-
700- printf("[dumber] Extracting certificate information...\n");
701- fflush(stdout);
702-
703- GString* info = g_string_new("Certificate Information:\n");
704-
705- // Try to get certificate properties - but these might not be available in all GTK versions
706- gchar* subject = NULL;
707- gchar* issuer = NULL;
708- GDateTime* not_valid_before = NULL;
709- GDateTime* not_valid_after = NULL;
710-
711- // Try property access but handle failures gracefully
712- g_object_get(certificate,
713- "subject-name", &subject,
714- "issuer-name", &issuer,
715- "not-valid-before", &not_valid_before,
716- "not-valid-after", &not_valid_after,
717- NULL);
718-
719- if (subject) {
720- g_string_append_printf(info, "Subject: %s\n", subject);
721- g_free(subject);
722- printf("[dumber] Found subject info\n");
723- } else {
724- g_string_append(info, "Subject: Information not available\n");
725- }
726-
727- if (issuer) {
728- g_string_append_printf(info, "Issued by: %s\n", issuer);
729- g_free(issuer);
730- printf("[dumber] Found issuer info\n");
731- } else {
732- g_string_append(info, "Issued by: Information not available\n");
733- }
734-
735- if (not_valid_before) {
736- gchar* date_str = g_date_time_format(not_valid_before, "%Y-%m-%d %H:%M:%S UTC");
737- g_string_append_printf(info, "Valid from: %s\n", date_str);
738- g_free(date_str);
739- g_date_time_unref(not_valid_before);
740- } else {
741- g_string_append(info, "Valid from: Information not available\n");
742- }
743-
744- if (not_valid_after) {
745- gchar* date_str = g_date_time_format(not_valid_after, "%Y-%m-%d %H:%M:%S UTC");
746- g_string_append_printf(info, "Valid until: %s\n", date_str);
747- g_free(date_str);
748- g_date_time_unref(not_valid_after);
749- } else {
750- g_string_append(info, "Valid until: Information not available\n");
751- }
752-
753- char* result = g_string_free(info, FALSE);
754- printf("[dumber] Certificate info: %s\n", result);
755- fflush(stdout);
756- return result;
757-}
758-
759-static void connect_tls_error_handler(WebKitWebView* wv) {
760- if (!wv) return;
761- g_signal_connect(G_OBJECT(wv), "load-failed-with-tls-errors",
762- G_CALLBACK(on_load_failed_with_tls_errors), NULL);
763-}
764-
765-// Modern AlertDialog async callback
766-static void on_alert_dialog_choose_done(GObject *source, GAsyncResult *result, gpointer user_data) {
767- AlertDialogResponse *data = (AlertDialogResponse*)user_data;
768- GError *error = NULL;
769- data->response = gtk_alert_dialog_choose_finish(GTK_ALERT_DIALOG(source), result, &error);
770- if (error) {
771- printf("[dumber] AlertDialog error: %s\n", error->message);
772- g_error_free(error);
773- data->response = 0;
774- }
775- data->completed = TRUE;
776- const char *response_names[] = {"GO_BACK", "PROCEED_ONCE", "ALWAYS_ACCEPT"};
777- const char *response_name = (data->response >= 0 && data->response <= 2) ?
778- response_names[data->response] : "UNKNOWN";
779- printf("[dumber] TLS dialog response: %s (button_index=%d)\n", response_name, data->response);
780- fflush(stdout);
781- if (data->loop) {
782- g_main_loop_quit(data->loop);
783- }
784-}
785-
786-// Show TLS warning dialog synchronously using modern GtkAlertDialog API
787-static gboolean show_tls_warning_dialog_sync(GtkWindow *parent, const char* hostname, const char* error_msg) {
788- printf("[dumber] Creating TLS warning dialog for hostname: %s\n", hostname);
789- printf("[dumber] Error message: %s\n", error_msg);
790- fflush(stdout);
791-
792- // Create the modern AlertDialog
793- GtkAlertDialog *dialog = gtk_alert_dialog_new("Certificate Error for %s", hostname);
794-
795- // Set modal behavior and error details
796- gtk_alert_dialog_set_modal(dialog, TRUE);
797- gtk_alert_dialog_set_detail(dialog, error_msg);
798-
799- // Configure buttons (Go Back = 0, Proceed Once = 1, Always Accept = 2)
800- const char *buttons[] = {"Go Back", "Proceed Once (Unsafe)", "Always Accept This Site", NULL};
801- gtk_alert_dialog_set_buttons(dialog, buttons);
802- gtk_alert_dialog_set_default_button(dialog, 0);
803- gtk_alert_dialog_set_cancel_button(dialog, 0);
804-
805- // Set up response data with main loop for sync behavior
806- AlertDialogResponse response_data = {0, FALSE, g_main_loop_new(NULL, FALSE)};
807-
808- // Start async operation
809- gtk_alert_dialog_choose(dialog, parent, NULL,
810- on_alert_dialog_choose_done, &response_data);
811-
812- // Run event loop until dialog completes
813- g_main_loop_run(response_data.loop);
814- g_main_loop_unref(response_data.loop);
815-
816- // Clean up
817- g_object_unref(dialog);
818-
819- // Return response code: 0=Go Back, 1=Proceed Once, 2=Always Accept
820- return response_data.response;
821-}
822-
823-*/
824-import "C"
825-
826-import (
827- "context"
828- "crypto/sha256"
829- "database/sql"
830- "encoding/json"
831- "errors"
832- "fmt"
833- "log"
834- "math"
835- "os"
836- "path/filepath"
837- "runtime"
838- "runtime/cgo"
839- "strconv"
840- "strings"
841- "sync"
842- "time"
843- "unsafe"
844-
845- "github.com/bnema/dumber/internal/config"
846- "github.com/bnema/dumber/internal/db"
847- "github.com/bnema/dumber/internal/logging"
848- _ "github.com/ncruces/go-sqlite3"
849-)
850-
851-// FilterManager interface for content blocking integration
852-type FilterManager interface {
853- GetNetworkFilters() ([]byte, error)
854- GetCosmeticScript() string
855- GetCosmeticScriptForDomain(domain string) string
856-}
857-
858-// domBridgeTemplate removed - now loading compiled main-world.min.js
859-
860-var registerSchemeOnce sync.Once
861-
862-func registerDumbScheme(ctx *C.WebKitWebContext) {
863- if ctx == nil {
864- return
865- }
866-
867- registerSchemeOnce.Do(func() {
868- sch := C.CString("dumb")
869- defer C.free(unsafe.Pointer(sch))
870- C.register_uri_scheme_handler(ctx, sch)
871- C.register_uri_scheme_security(ctx, sch)
872- })
873-}
874-
875-// Helper function to convert bool to int for C interop
876-func boolToInt(b bool) int {
877- if b {
878- return 1
879- }
880- return 0
881-}
882-
883-//export goInvokeHandle
884-func goInvokeHandle(handle C.uintptr_t) {
885- h := cgo.Handle(handle)
886- if fn, ok := h.Value().(func()); ok {
887- fn()
888- }
889- h.Delete()
890-}
891-
892-// getCertificateHash generates a SHA256 hash of the certificate info for storage
893-func getCertificateHash(certificateInfo string) string {
894- hash := sha256.Sum256([]byte(certificateInfo))
895- return fmt.Sprintf("%x", hash)
896-}
897-
898-// getDBConnection opens a database connection and returns queries
899-func getDBConnection() (*db.Queries, *sql.DB, error) {
900- cfg := config.Get()
901- if cfg == nil {
902- return nil, nil, fmt.Errorf("config not available")
903- }
904-
905- dbPath := cfg.Database.Path
906- if dbPath == "" {
907- var err error
908- dbPath, err = config.GetDatabaseFile()
909- if err != nil {
910- return nil, nil, fmt.Errorf("failed to get database path: %w", err)
911- }
912- }
913-
914- database, err := sql.Open("sqlite3", dbPath)
915- if err != nil {
916- return nil, nil, fmt.Errorf("failed to open database: %w", err)
917- }
918-
919- queries := db.New(database)
920- return queries, database, nil
921-}
922-
923-// checkStoredCertificateDecision checks if user has previously decided on this certificate
924-func checkStoredCertificateDecision(hostname, certificateInfo string) (string, bool) {
925- queries, database, err := getDBConnection()
926- if err != nil {
927- log.Printf("[tls] Failed to connect to database: %v", err)
928- return "", false
929- }
930- defer database.Close()
931-
932- certHash := getCertificateHash(certificateInfo)
933- ctx := context.Background()
934-
935- validation, err := queries.GetCertificateValidation(ctx, hostname, certHash)
936- if err != nil {
937- if err == sql.ErrNoRows {
938- return "", false // No previous decision
939- }
940- log.Printf("[tls] Failed to query certificate validation: %v", err)
941- return "", false
942- }
943-
944- log.Printf("[tls] Found stored certificate decision for %s: %s", hostname, validation.UserDecision)
945- return validation.UserDecision, true
946-}
947-
948-// storeCertificateDecision stores the user's decision about a certificate
949-func storeCertificateDecision(hostname, certificateInfo, decision string, permanent bool) error {
950- queries, database, err := getDBConnection()
951- if err != nil {
952- return fmt.Errorf("failed to connect to database: %w", err)
953- }
954- defer database.Close()
955-
956- certHash := getCertificateHash(certificateInfo)
957- ctx := context.Background()
958-
959- var expiresAt sql.NullTime
960- if !permanent {
961- // For temporary decisions, expire after 24 hours
962- expiresAt = sql.NullTime{
963- Time: time.Now().Add(24 * time.Hour),
964- Valid: true,
965- }
966- }
967-
968- err = queries.StoreCertificateValidation(ctx, hostname, certHash, decision, expiresAt)
969- if err != nil {
970- return fmt.Errorf("failed to store certificate validation: %w", err)
971- }
972-
973- log.Printf("[tls] Stored certificate decision for %s: %s (permanent: %t)", hostname, decision, permanent)
974- return nil
975-}
976-
977-type nativeView struct {
978- win *C.GtkWidget
979- container *C.GtkWidget
980- view *C.GtkWidget
981- wv *C.WebKitWebView
982- ucm *C.WebKitUserContentManager
983-}
984-
985-type memoryStats struct {
986- pageLoadCount int
987- lastGCTime time.Time
988- memoryPressureSettings *C.WebKitMemoryPressureSettings
989-}
990-
991-// WebView represents a browser view powered by WebKit2GTK.
992-type WebView struct {
993- config *Config
994- zoom float64
995- url string
996- destroyed bool
997- native *nativeView
998- window *Window
999- id uintptr
1000- msgHandler func(payload string)
1001- titleHandler func(title string)
1002- uriHandler func(uri string)
1003- faviconHandler func(data []byte)
1004- faviconURIHandler func(pageURI, faviconURI string)
1005- zoomHandler func(level float64)
1006- popupHandler func(string) *WebView
1007- closeHandler func()
1008- memStats *memoryStats
1009- gcTicker *time.Ticker
1010- gcDone chan struct{}
1011- tlsExceptions map[string]bool // host -> whether user allowed certificate exception
1012- useDomZoom bool
1013- domZoomSeed float64
1014- domBridgeScript *C.WebKitUserScript
1015- wasReparented bool // Track if this WebView has been reparented
1016-
1017- // Window type detection and relationships
1018- windowType WindowType
1019- windowFeatures *WindowFeatures
1020- windowTypeCallback func(WindowType, *WindowFeatures)
1021- parentWebView *WebView
1022- isRelated bool
1023- isActive bool // Track if this WebView is currently active/focused
1024- navigationPolicyHandler func(url string, isUserGesture bool) bool // Handler to decide if navigation should be allowed
1025-}
1026-
1027-// NewWebView constructs a new WebView instance with native WebKit2GTK widgets.
1028-func NewWebView(cfg *Config) (*WebView, error) {
1029- log.Printf("[webkit] Initializing GTK and creating WebView (CGO)")
1030- if C.gtk_init_check() == 0 {
1031- return nil, errors.New("failed to initialize GTK")
1032- }
1033-
1034- InitMainThread()
1035-
1036- if cfg == nil {
1037- cfg = &Config{}
1038- }
1039-
1040- // Setup video acceleration environment variables before creating WebView
1041- if cfg.VideoAcceleration.EnableVAAPI {
1042- driverName := C.CString(cfg.VideoAcceleration.VAAPIDriverName)
1043- enableAll := 0
1044- if cfg.VideoAcceleration.EnableAllDrivers {
1045- enableAll = 1
1046- }
1047- legacy := 0
1048- if cfg.VideoAcceleration.LegacyVAAPI {
1049- legacy = 1
1050- }
1051- C.setup_video_acceleration(driverName, C.int(enableAll), C.int(legacy))
1052- C.free(unsafe.Pointer(driverName))
1053- }
1054-
1055- // Prepare persistent website data/caches
1056- dataDir := cfg.DataDir
1057- cacheDir := cfg.CacheDir
1058- if dataDir == "" {
1059- dataDir = filepath.Join(os.TempDir(), "dumber-webkit-data")
1060- }
1061- if cacheDir == "" {
1062- cacheDir = filepath.Join(os.TempDir(), "dumber-webkit-cache")
1063- }
1064- _ = os.MkdirAll(dataDir, 0o755)
1065- _ = os.MkdirAll(cacheDir, 0o755)
1066-
1067- cData := C.CString(dataDir)
1068- cCache := C.CString(cacheDir)
1069- cookieFile := filepath.Join(dataDir, "cookies.db")
1070- cCookie := C.CString(cookieFile)
1071- defer C.free(unsafe.Pointer(cData))
1072- defer C.free(unsafe.Pointer(cCache))
1073- defer C.free(unsafe.Pointer(cCookie))
1074- // Use default WebContext to register the custom URI scheme handler; the
1075- // WebView itself is created with a WebKitNetworkSession for persistence.
1076- ctx := C.webkit_web_context_get_default()
1077-
1078- // Enable favicon database - we'll do this per WebView when created since we need NetworkSession
1079-
1080- // Apply cache model to WebContext
1081- // Default to WEBKIT_CACHE_MODEL_WEB_BROWSER to keep caching enabled
1082- cacheModel := 1
1083- if cfg.Memory.CacheModel != "" {
1084- switch cfg.Memory.CacheModel {
1085- case CacheModelWebBrowser:
1086- cacheModel = 1
1087- case CacheModelPrimaryWebBrowser:
1088- cacheModel = 2
1089- default: // CacheModelDocumentViewer or unset
1090- cacheModel = 0
1091- }
1092- }
1093- C.maybe_set_cache_model(ctx, C.int(cacheModel))
1094-
1095- // Configure memory pressure settings if specified
1096- var pressureSettings *C.WebKitMemoryPressureSettings
1097- // Only configure memory pressure if explicitly requested via config
1098- if cfg.Memory.MemoryLimitMB > 0 || cfg.Memory.ConservativeThreshold > 0 || cfg.Memory.StrictThreshold > 0 || cfg.Memory.KillThreshold > 0 || cfg.Memory.PollIntervalSeconds > 0 {
1099- memLimitMB := cfg.Memory.MemoryLimitMB
1100- conservativeThreshold := cfg.Memory.ConservativeThreshold
1101- strictThreshold := cfg.Memory.StrictThreshold
1102- killThreshold := cfg.Memory.KillThreshold
1103- pollInterval := cfg.Memory.PollIntervalSeconds
1104-
1105- pressureSettings = C.create_memory_pressure_settings(
1106- C.uint(memLimitMB),
1107- C.double(conservativeThreshold),
1108- C.double(strictThreshold),
1109- C.double(killThreshold),
1110- C.double(pollInterval))
1111-
1112- if pressureSettings != nil {
1113- C.maybe_set_memory_pressure_settings(ctx, pressureSettings)
1114- if cfg.Memory.EnableMemoryMonitoring {
1115- log.Printf("[webkit] Memory pressure settings applied: limit=%dMB, conservative=%.2f, strict=%.2f, poll=%.1fs",
1116- memLimitMB, conservativeThreshold, strictThreshold, pollInterval)
1117- }
1118- }
1119- }
1120-
1121- registerDumbScheme(ctx)
1122- // Cookie manager persistent storage handled via NetworkSession in new_webview_with_ucm_and_session
1123-
1124- // Create WebView through g_object_new with a fresh UCM (GTK4/WebKit 6 style)
1125- var createdUcm *C.WebKitUserContentManager
1126- viewWidget := C.new_webview_with_ucm_and_session(cData, cCache, cCookie, &createdUcm, pressureSettings)
1127- if viewWidget == nil {
1128- return nil, errors.New("failed to create WebKitWebView")
1129- }
1130-
1131- var win *C.GtkWidget
1132- var window *Window
1133- container := C.gtk_box_new(C.GtkOrientation(C.GTK_ORIENTATION_VERTICAL), 0)
1134- if container == nil {
1135- return nil, errors.New("failed to create GtkBox container")
1136- }
1137- // Hold a strong reference so the WebView keeps the container alive across reparenting.
1138- C.g_object_ref_sink(C.gpointer(container))
1139- C.gtk_widget_set_hexpand(container, C.gboolean(1))
1140- C.gtk_widget_set_vexpand(container, C.gboolean(1))
1141- C.gtk_widget_set_hexpand(viewWidget, C.gboolean(1))
1142- C.gtk_widget_set_vexpand(viewWidget, C.gboolean(1))
1143- C.gtk_box_append((*C.GtkBox)(unsafe.Pointer(container)), viewWidget)
1144-
1145- // Only create window if requested
1146- if cfg.CreateWindow {
1147- // Create a top-level window to host the view
1148- win = C.new_window()
1149- if win == nil {
1150- return nil, errors.New("failed to create GtkWindow")
1151- }
1152-
1153- // Pack view widget into the window
1154- // GTK4: containers removed; use gtk_window_set_child
1155- C.gtk_window_set_child((*C.GtkWindow)(unsafe.Pointer(win)), container)
1156- C.gtk_window_set_default_size((*C.GtkWindow)(unsafe.Pointer(win)), 1024, 768)
1157- C.connect_destroy_quit(win)
1158- window = &Window{win: win}
1159- }
1160-
1161- seed := 1.0
1162- if cfg.ZoomDefault > 0 {
1163- seed = cfg.ZoomDefault
1164- }
1165-
1166- v := &WebView{
1167- config: cfg,
1168- zoom: 1.0,
1169- useDomZoom: cfg.UseDomZoom,
1170- domZoomSeed: seed,
1171- native: &nativeView{win: win, container: container, view: viewWidget, wv: C.as_webview(viewWidget), ucm: createdUcm},
1172- window: window,
1173- memStats: &memoryStats{
1174- pageLoadCount: 0,
1175- lastGCTime: time.Now(),
1176- memoryPressureSettings: pressureSettings,
1177- },
1178- gcDone: make(chan struct{}),
1179- tlsExceptions: make(map[string]bool),
1180- }
1181- // Assign an ID for accelerator dispatch
1182- v.id = nextViewID()
1183- registerView(v.id, v)
1184- C.connect_policy_handler(v.native.wv, C.ulong(v.id))
1185- ConnectCreateSignal(v) // Connect create signal for window.open support
1186-
1187- // Register with global memory manager if monitoring is enabled
1188- if cfg.Memory.EnableMemoryMonitoring {
1189- if globalMemoryManager == nil {
1190- InitializeGlobalMemoryManager(
1191- cfg.Memory.EnableMemoryMonitoring,
1192- cfg.Memory.ProcessRecycleThreshold,
1193- 30*time.Second, // Default monitoring interval
1194- )
1195- }
1196- if globalMemoryManager != nil {
1197- globalMemoryManager.RegisterWebView(v)
1198- }
1199- }
1200- // Attach GTK4 input controllers (keyboard, mouse)
1201- AttachKeyboardControllers(v)
1202-
1203- // Enable print interception to prevent Ctrl+P conflicts with pane mode
1204- v.EnablePrintInterception()
1205-
1206- // Watch for GTK theme changes and propagate to page at runtime
1207- if settings := C.gtk_settings_get_default(); settings != nil {
1208- C.connect_theme_changed_with_id(settings, C.ulong(v.id))
1209- }
1210-
1211- // Zoom shortcuts are now handled by application-level workspace shortcuts
1212- // in workspace_manager.go to ensure proper active pane targeting
1213- // Find in page (Ctrl/Cmd+F): handled by window-level shortcuts in window_shortcuts.go
1214- // Navigation with Ctrl/Cmd + Arrow keys: forward to keyboard service bridge
1215- _ = v.RegisterKeyboardShortcut("cmdorctrl+ArrowLeft", func() {
1216- _ = v.InjectScript("document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'cmdorctrl+arrowleft'}}))")
1217- })
1218- _ = v.RegisterKeyboardShortcut("cmdorctrl+ArrowRight", func() {
1219- _ = v.InjectScript("document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'cmdorctrl+arrowright'}}))")
1220- })
1221- // Update window title when page title changes
1222- C.connect_title_notify(viewWidget, (*C.GtkWindow)(unsafe.Pointer(win)))
1223- // Also dispatch title change to Go with view id
1224- C.connect_title_notify_with_id(viewWidget, C.ulong(v.id))
1225- // Notify URI changes to Go to keep current URL in sync
1226- C.connect_uri_notify_with_id(viewWidget, C.ulong(v.id))
1227- // Enable favicon database for this WebView's NetworkSession
1228- networkSession := C.webkit_web_view_get_network_session(v.native.wv)
1229- C.enable_favicon_database(networkSession)
1230-
1231- // Notify favicon changes to Go for caching
1232- C.connect_favicon_changed_with_id(viewWidget, C.ulong(v.id))
1233- // Apply hardware acceleration and related settings based on cfg.Rendering
1234- if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil {
1235- // Hardware acceleration policy (guarded by version)
1236- switch cfg.Rendering.Mode {
1237- case "gpu":
1238- log.Printf("[webkit] Applying GPU rendering mode")
1239- C.maybe_set_hw_policy(settings, 1)
1240- C.maybe_set_webgl(settings, 1)
1241- C.maybe_set_canvas_accel(settings, 1)
1242- case "cpu":
1243- log.Printf("[webkit] Applying CPU rendering mode")
1244- C.maybe_set_hw_policy(settings, 2)
1245- C.maybe_set_webgl(settings, 0)
1246- C.maybe_set_canvas_accel(settings, 0)
1247- default: // auto
1248- log.Printf("[webkit] Applying AUTO rendering mode - detecting actual backend...")
1249- C.maybe_set_hw_policy(settings, 0)
1250- C.maybe_set_webgl(settings, 1)
1251- C.maybe_set_canvas_accel(settings, 1)
1252- // Detect actual rendering backend after applying auto settings
1253- v.detectAndLogRenderingBackend()
1254- }
1255- // Optional compositing indicators for debugging (guarded by version)
1256- if cfg.Rendering.DebugGPU {
1257- C.maybe_set_draw_indicators(settings, 1)
1258- }
1259- // Reduce media pipeline churn by requiring a user gesture for playback
1260- C.maybe_set_media_user_gesture(settings, 1)
1261- // Enable trackpad back/forward gestures when available
1262- C.maybe_set_back_forward_gestures(settings, 1)
1263- // Smooth scrolling for wheel/pinch gestures when supported
1264- C.maybe_set_smooth_scrolling(settings, 1)
1265-
1266- // Keep page cache enabled by default for performance
1267- enablePageCache := true
1268- if cfg.Memory.EnablePageCache {
1269- enablePageCache = true
1270- }
1271- C.maybe_set_page_cache(settings, C.int(boolToInt(enablePageCache)))
1272-
1273- // Offline app cache is deprecated; keep as no-op
1274- C.maybe_set_offline_app_cache(settings, C.int(0))
1275-
1276- if cfg.Memory.EnableMemoryMonitoring {
1277- log.Printf("[webkit] Memory settings applied: page_cache=%v", enablePageCache)
1278- }
1279- }
1280-
1281- if cfg.ZoomDefault > 0 {
1282- v.zoom = cfg.ZoomDefault
1283- C.webkit_web_view_set_zoom_level(v.native.wv, C.gdouble(v.zoom))
1284- }
1285- if cfg.EnableDeveloperExtras {
1286- settings := C.webkit_web_view_get_settings(v.native.wv)
1287- if settings != nil {
1288- C.webkit_settings_set_enable_developer_extras(settings, C.gboolean(1))
1289- }
1290- }
1291-
1292- // Enable JavaScript popup windows for workspace popup handling
1293- settings := C.webkit_web_view_get_settings(v.native.wv)
1294- if settings != nil {
1295- C.webkit_settings_set_javascript_can_open_windows_automatically(settings, C.gboolean(1))
1296- log.Printf("[webkit] Enabled JavaScript popup windows")
1297- }
1298-
1299- // Enable critical web platform features for modern websites (Twitch, YouTube, etc.)
1300- if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil {
1301- // MediaSource Extensions - CRITICAL for video streaming (Twitch, YouTube, Netflix)
1302- C.webkit_settings_set_enable_mediasource(settings, C.gboolean(1))
1303-
1304- // Encrypted Media Extensions - for DRM protected content
1305- C.webkit_settings_set_enable_encrypted_media(settings, C.gboolean(1))
1306-
1307- // Media Capabilities API - for codec/quality detection
1308- C.webkit_settings_set_enable_media_capabilities(settings, C.gboolean(1))
1309-
1310- // WebAudio API - for advanced audio processing
1311- C.webkit_settings_set_enable_webaudio(settings, C.gboolean(1))
1312-
1313- // HTML5 LocalStorage - for site data persistence
1314- C.webkit_settings_set_enable_html5_local_storage(settings, C.gboolean(1))
1315-
1316- // HTML5 Database/IndexedDB - for offline storage
1317- C.webkit_settings_set_enable_html5_database(settings, C.gboolean(1))
1318-
1319- // WebRTC - for real-time communication
1320- C.webkit_settings_set_enable_webrtc(settings, C.gboolean(1))
1321-
1322- // Media Stream API - for camera/microphone access
1323- C.webkit_settings_set_enable_media_stream(settings, C.gboolean(1))
1324-
1325- // JavaScript Clipboard API - for copy/paste in web apps
1326- C.webkit_settings_set_javascript_can_access_clipboard(settings, C.gboolean(1))
1327-
1328- log.Printf("[webkit] Enabled web platform features: MSE, EME, MediaCapabilities, WebAudio, Storage, WebRTC, Clipboard")
1329- }
1330-
1331- // Apply custom User-Agent for codec negotiation
1332- if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil {
1333- if cfg.CodecPreferences.CustomUserAgent != "" {
1334- cUserAgent := C.CString(cfg.CodecPreferences.CustomUserAgent)
1335- C.webkit_settings_set_user_agent(settings, (*C.gchar)(cUserAgent))
1336- C.free(unsafe.Pointer(cUserAgent))
1337- log.Printf("[webkit] Set custom User-Agent: %s", cfg.CodecPreferences.CustomUserAgent)
1338- } else if cfg.CodecPreferences.ForceAV1 {
1339- // Use modern Chrome UA that signals AV1 support
1340- av1UA := "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36"
1341- cAV1UA := C.CString(av1UA)
1342- C.webkit_settings_set_user_agent(settings, (*C.gchar)(cAV1UA))
1343- C.free(unsafe.Pointer(cAV1UA))
1344- log.Printf("[webkit] Set AV1-optimized User-Agent")
1345- }
1346- }
1347-
1348- // Apply default fonts if provided in cfg
1349- if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil {
1350- if cfg.DefaultSansFont != "" {
1351- csans := C.CString(cfg.DefaultSansFont)
1352- C.webkit_settings_set_sans_serif_font_family(settings, (*C.gchar)(csans))
1353- C.free(unsafe.Pointer(csans))
1354- // Also set default font family to sans
1355- csans2 := C.CString(cfg.DefaultSansFont)
1356- C.webkit_settings_set_default_font_family(settings, (*C.gchar)(csans2))
1357- C.free(unsafe.Pointer(csans2))
1358- }
1359- if cfg.DefaultSerifFont != "" {
1360- cserif := C.CString(cfg.DefaultSerifFont)
1361- C.webkit_settings_set_serif_font_family(settings, (*C.gchar)(cserif))
1362- C.free(unsafe.Pointer(cserif))
1363- }
1364- if cfg.DefaultMonospaceFont != "" {
1365- cmono := C.CString(cfg.DefaultMonospaceFont)
1366- C.webkit_settings_set_monospace_font_family(settings, (*C.gchar)(cmono))
1367- C.free(unsafe.Pointer(cmono))
1368- }
1369- if cfg.DefaultFontSize > 0 {
1370- C.webkit_settings_set_default_font_size(settings, C.guint(cfg.DefaultFontSize))
1371- }
1372- }
1373- if cfg.InitialURL != "" {
1374- _ = v.LoadURL(cfg.InitialURL)
1375- }
1376- // Ensure native resources are released if user forgets to call Destroy
1377- runtime.SetFinalizer(v, func(v *WebView) { _ = v.Destroy() })
1378- log.Printf("[webkit] WebView created (CGO)")
1379-
1380- // Initialize UCM scripts and handlers
1381- if v.native != nil && v.native.ucm != nil {
1382- v.enableUserContentManager(cfg)
1383- }
1384-
1385- // Start periodic JavaScript garbage collection if configured
1386- if cfg.Memory.EnableGCInterval > 0 {
1387- v.startPeriodicGC(time.Duration(cfg.Memory.EnableGCInterval) * time.Second)
1388- if cfg.Memory.EnableMemoryMonitoring {
1389- log.Printf("[webkit] Periodic GC enabled: interval=%ds", cfg.Memory.EnableGCInterval)
1390- }
1391- }
1392-
1393- // Setup console message logging
1394- v.setupConsoleLogging()
1395-
1396- return v, nil
1397-}
1398-
1399-// setupConsoleLogging enables console message capture for the WebView
1400-func (v *WebView) setupConsoleLogging() {
1401- if v.native == nil || v.native.wv == nil {
1402- return
1403- }
1404-
1405- // Check if console capture is enabled in config
1406- cfg := config.Get()
1407- if !cfg.Logging.CaptureConsole {
1408- // Disable console messages to stdout (always, to keep backend clean)
1409- if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil {
1410- C.webkit_settings_set_enable_write_console_messages_to_stdout(settings, C.gboolean(0))
1411- }
1412- return
1413- }
1414-
1415- // If console capture is enabled, disable stdout and enable our capture
1416- if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil {
1417- C.webkit_settings_set_enable_write_console_messages_to_stdout(settings, C.gboolean(0))
1418- log.Printf("[webkit] Console message capture enabled - redirecting to console.log file")
1419- }
1420-
1421- // TODO: Implement JavaScript injection for console capture
1422- // This will inject code to intercept console.log/error/warn and send to Go
1423-}
1424-
1425-// setupGlobalConsoleCapture sets up a global stdout filter to capture console messages
1426-func setupGlobalConsoleCapture() {
1427- // This should be called once globally, not per WebView
1428- logging.StartConsoleCapture()
1429-}
1430-
1431-// injectConsoleCapture injects JavaScript to capture console messages
1432-func (v *WebView) injectConsoleCapture() {
1433- consoleScript := `
1434-(function() {
1435- const originalLog = console.log;
1436- const originalWarn = console.warn;
1437- const originalError = console.error;
1438- const originalInfo = console.info;
1439- const originalDebug = console.debug;
1440-
1441- function sendToGo(level, args) {
1442- const message = args.map(arg =>
1443- typeof arg === 'object' ? JSON.stringify(arg) : String(arg)
1444- ).join(' ');
1445-
1446- const formattedMessage = '[' + level + '] ' + window.location.href + ' ' + message;
1447-
1448- // Send to Go via user script message
1449- if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumberBridge) {
1450- window.webkit.messageHandlers.dumberBridge.postMessage({
1451- type: 'console',
1452- data: formattedMessage
1453- });
1454- }
1455- }
1456-
1457- console.log = function(...args) {
1458- originalLog.apply(console, args);
1459- sendToGo('LOG', args);
1460- };
1461-
1462- console.warn = function(...args) {
1463- originalWarn.apply(console, args);
1464- sendToGo('WARN', args);
1465- };
1466-
1467- console.error = function(...args) {
1468- originalError.apply(console, args);
1469- sendToGo('ERROR', args);
1470- };
1471-
1472- console.info = function(...args) {
1473- originalInfo.apply(console, args);
1474- sendToGo('INFO', args);
1475- };
1476-
1477- console.debug = function(...args) {
1478- originalDebug.apply(console, args);
1479- sendToGo('DEBUG', args);
1480- };
1481-})();
1482-`
1483- _ = v.InjectScript(consoleScript)
1484-}
1485-
1486-// handleConsoleMessage processes console messages from JavaScript
1487-func (v *WebView) handleConsoleMessage(payload string) {
1488- // Parse the message payload to check if it's a console message
1489- var msg struct {
1490- Type string `json:"type"`
1491- Data string `json:"data"`
1492- }
1493-
1494- if err := json.Unmarshal([]byte(payload), &msg); err != nil {
1495- return
1496- }
1497-
1498- if msg.Type == "console" {
1499- // Send to logging system with [CONSOLE] tag
1500- logging.CaptureWebKitLog(msg.Data)
1501- }
1502-}
1503-
1504-// startConsoleCapture starts capturing stdout to filter console messages
1505-func (v *WebView) startConsoleCapture() {
1506- // This is a simplified approach - we'll rely on the existing OutputCapture
1507- // mechanism in internal/logging/capture.go to handle stdout capture
1508- // and filter for console messages in the log processing
1509-}
1510-
1511-// NewWebViewWithRelated creates a new WebView related to an existing one for process sharing
1512-func NewWebViewWithRelated(cfg *Config, relatedView *WebView) (*WebView, error) {
1513- if relatedView == nil || relatedView.native == nil || relatedView.native.wv == nil {
1514- // Fallback to regular WebView creation
1515- return NewWebView(cfg)
1516- }
1517-
1518- log.Printf("[webkit] Creating related WebView for process sharing (CGO)")
1519- if C.gtk_init_check() == 0 {
1520- return nil, errors.New("failed to initialize GTK")
1521- }
1522-
1523- InitMainThread()
1524-
1525- if cfg == nil {
1526- cfg = &Config{}
1527- }
1528-
1529- // Create WebView related to the existing one
1530- var createdUcm *C.WebKitUserContentManager
1531- viewWidget := C.new_webview_related(relatedView.native.wv, &createdUcm)
1532- if viewWidget == nil {
1533- return nil, errors.New("failed to create related WebKitWebView")
1534- }
1535-
1536- var win *C.GtkWidget
1537- var window *Window
1538- container := C.gtk_box_new(C.GtkOrientation(C.GTK_ORIENTATION_VERTICAL), 0)
1539- if container == nil {
1540- return nil, errors.New("failed to create GtkBox container")
1541- }
1542-
1543- C.gtk_box_append((*C.GtkBox)(unsafe.Pointer(container)), viewWidget)
1544-
1545- // Make the WebView widget visible and expandable for popup rendering
1546- C.gtk_widget_set_visible(viewWidget, C.gboolean(1))
1547- C.gtk_widget_set_visible(container, C.gboolean(1))
1548- C.gtk_widget_set_hexpand(viewWidget, C.gboolean(1))
1549- C.gtk_widget_set_vexpand(viewWidget, C.gboolean(1))
1550- C.gtk_widget_set_hexpand(container, C.gboolean(1))
1551- C.gtk_widget_set_vexpand(container, C.gboolean(1))
1552- // Remove any size constraints from popup WebView to let it fill the pane
1553- C.gtk_widget_set_size_request(viewWidget, -1, -1)
1554- C.gtk_widget_set_size_request(container, -1, -1)
1555-
1556- if cfg.CreateWindow {
1557- win = C.new_window()
1558- if win == nil {
1559- return nil, errors.New("failed to create native window")
1560- }
1561- C.connect_destroy_quit(win)
1562- C.gtk_window_set_child((*C.GtkWindow)(unsafe.Pointer(win)), container)
1563- window = &Window{win: win}
1564- } else {
1565- window = &Window{}
1566- }
1567-
1568- v := &WebView{
1569- config: cfg,
1570- window: window,
1571- zoom: cfg.ZoomDefault,
1572- native: &nativeView{
1573- win: win,
1574- container: container,
1575- view: viewWidget,
1576- wv: (*C.WebKitWebView)(unsafe.Pointer(viewWidget)),
1577- ucm: createdUcm,
1578- },
1579- useDomZoom: cfg.UseDomZoom,
1580- domZoomSeed: cfg.ZoomDefault,
1581- gcDone: make(chan struct{}),
1582- tlsExceptions: make(map[string]bool),
1583- }
1584-
1585- // Enable JavaScript popup windows for workspace popup handling
1586- settings := C.webkit_web_view_get_settings(v.native.wv)
1587- if settings != nil {
1588- C.webkit_settings_set_javascript_can_open_windows_automatically(settings, C.gboolean(1))
1589- log.Printf("[webkit] Enabled JavaScript popup windows for related WebView")
1590- }
1591-
1592- // Assign an ID for accelerator dispatch
1593- v.id = nextViewID()
1594- registerView(v.id, v)
1595- C.connect_policy_handler(v.native.wv, C.ulong(v.id))
1596- ConnectCreateSignal(v) // Connect create signal for window.open support
1597-
1598- // Register with memory manager if enabled
1599- if cfg.Memory.EnableMemoryMonitoring {
1600- if globalMemoryManager == nil {
1601- InitializeGlobalMemoryManager(
1602- cfg.Memory.EnableMemoryMonitoring,
1603- cfg.Memory.ProcessRecycleThreshold,
1604- 30*time.Second,
1605- )
1606- }
1607- if globalMemoryManager != nil {
1608- globalMemoryManager.RegisterWebView(v)
1609- }
1610- }
1611-
1612- if cfg.ZoomDefault <= 0 {
1613- v.zoom = 1.0
1614- }
1615- v.domZoomSeed = v.zoom
1616-
1617- // Initialize UCM scripts and handlers (same as NewWebView)
1618- if v.native != nil && v.native.ucm != nil {
1619- v.enableUserContentManager(cfg)
1620- }
1621-
1622- return v, nil
1623-}
1624-
1625-func (w *WebView) LoadURL(rawURL string) error {
1626- if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
1627- return ErrNotImplemented
1628- }
1629-
1630- // Track page loads for process recycling
1631- if w.memStats != nil {
1632- w.memStats.pageLoadCount++
1633-
1634- // Check if we need to recycle this WebView
1635- if w.config.Memory.ProcessRecycleThreshold > 0 &&
1636- w.memStats.pageLoadCount >= w.config.Memory.ProcessRecycleThreshold {
1637- if w.config.Memory.EnableMemoryMonitoring {
1638- log.Printf("[webkit] Process recycle threshold reached (%d pages), consider creating new WebView",
1639- w.memStats.pageLoadCount)
1640- }
1641- }
1642- }
1643-
1644- w.url = rawURL
1645- curl := C.CString(rawURL)
1646- defer C.free(unsafe.Pointer(curl))
1647- C.webkit_web_view_load_uri(w.native.wv, (*C.gchar)(curl))
1648- log.Printf("[webkit] LoadURL: %s", rawURL)
1649- return nil
1650-}
1651-
1652-func (w *WebView) Show() error {
1653- if w == nil || w.destroyed || w.native == nil {
1654- return ErrNotImplemented
1655- }
1656- if w.native.container != nil {
1657- C.gtk_widget_set_visible(w.native.container, C.gboolean(1))
1658- }
1659- if w.native.view != nil {
1660- C.gtk_widget_set_visible(w.native.view, C.gboolean(1))
1661- }
1662- if w.native.win == nil {
1663- return ErrNotImplemented
1664- }
1665- C.gtk_widget_set_visible(w.native.win, C.gboolean(1))
1666- C.gtk_window_present((*C.GtkWindow)(unsafe.Pointer(w.native.win)))
1667- log.Printf("[webkit] Show window")
1668- return nil
1669-}
1670-
1671-func (w *WebView) Hide() error {
1672- if w == nil || w.destroyed || w.native == nil {
1673- return ErrNotImplemented
1674- }
1675- if w.native.container != nil {
1676- C.gtk_widget_set_visible(w.native.container, C.gboolean(0))
1677- }
1678- if w.native.view != nil {
1679- C.gtk_widget_set_visible(w.native.view, C.gboolean(0))
1680- }
1681- if w.native.win == nil {
1682- return ErrNotImplemented
1683- }
1684- C.gtk_widget_set_visible(w.native.win, C.gboolean(0))
1685- return nil
1686-}
1687-
1688-func (w *WebView) Destroy() error {
1689- if w == nil || w.native == nil {
1690- return ErrNotImplemented
1691- }
1692-
1693- log.Printf("[webkit-destroy] begin destroy: id=%d container=%p win=%p view=%p", w.id, w.native.container, w.native.win, w.native.view)
1694- // Stop periodic GC if running
1695- w.stopPeriodicGC()
1696- log.Printf("[webkit-destroy] periodic GC stopped: id=%d", w.id)
1697-
1698- // Clean up memory pressure settings
1699- if w.memStats != nil && w.memStats.memoryPressureSettings != nil {
1700- // WebKit will clean up the memory pressure settings when context is destroyed
1701- w.memStats.memoryPressureSettings = nil
1702- log.Printf("[webkit-destroy] cleared memory pressure settings: id=%d", w.id)
1703- }
1704-
1705- // Unregister from memory manager
1706- if globalMemoryManager != nil {
1707- globalMemoryManager.UnregisterWebView(w.id)
1708- log.Printf("[webkit-destroy] unregistered from memory manager: id=%d", w.id)
1709- }
1710-
1711- if w.native != nil && w.native.ucm != nil && w.domBridgeScript != nil {
1712- log.Printf("[webkit-destroy] removing DOM bridge script: id=%d", w.id)
1713- C.webkit_user_content_manager_remove_script(w.native.ucm, w.domBridgeScript)
1714- C.webkit_user_script_unref(w.domBridgeScript)
1715- w.domBridgeScript = nil
1716- }
1717-
1718- // GTK4: destroy windows via gtk_window_destroy if we created one
1719- if w.native.win != nil && w.config != nil && w.config.CreateWindow {
1720- log.Printf("[webkit-destroy] destroying GTK window: id=%d win=%p", w.id, w.native.win)
1721- C.gtk_window_destroy((*C.GtkWindow)(unsafe.Pointer(w.native.win)))
1722- w.native.win = nil
1723- if w.window != nil {
1724- w.window.win = nil
1725- }
1726- log.Printf("[webkit] Destroy window")
1727- }
1728-
1729- log.Printf("[webkit-destroy] releasing native widgets: id=%d", w.id)
1730- w.releaseNativeWidgets()
1731- log.Printf("[webkit-destroy] native widgets released: id=%d", w.id)
1732- w.destroyed = true
1733-
1734- // Unregister from dynamic protection updates
1735- UnregisterActiveWebView(w)
1736- log.Printf("[webkit-destroy] unregistered active view: id=%d", w.id)
1737-
1738- unregisterView(w.id)
1739- log.Printf("[webkit-destroy] destroy complete: id=%d", w.id)
1740- return nil
1741-}
1742-
1743-func (w *WebView) releaseNativeWidgets() {
1744- if w == nil || w.native == nil {
1745- return
1746- }
1747- if w.native.container != nil {
1748- containerPtr := uintptr(unsafe.Pointer(w.native.container))
1749- if containerPtr != 0 && widgetIsValid(containerPtr) {
1750- C.g_object_unref(C.gpointer(w.native.container))
1751- } else {
1752- log.Printf("[webkit] skipping container unref; widget invalid=%#x", containerPtr)
1753- }
1754- w.native.container = nil
1755- }
1756-}
1757-
1758-// Window returns the associated native window.
1759-func (w *WebView) Window() *Window { return w.window }
1760-
1761-// GetCurrentURL returns the current URI from WebKit.
1762-func (w *WebView) GetCurrentURL() string {
1763- if w == nil || w.native == nil || w.native.wv == nil {
1764- return ""
1765- }
1766- uri := C.webkit_web_view_get_uri(w.native.wv)
1767- if uri == nil {
1768- return ""
1769- }
1770- return C.GoString((*C.char)(unsafe.Pointer(uri)))
1771-}
1772-
1773-// GetTitle returns the current page title from WebKit.
1774-func (w *WebView) GetTitle() string {
1775- if w == nil || w.native == nil || w.native.wv == nil {
1776- return ""
1777- }
1778- title := C.webkit_web_view_get_title(w.native.wv)
1779- if title == nil {
1780- return ""
1781- }
1782- return C.GoString((*C.char)(unsafe.Pointer(title)))
1783-}
1784-
1785-func (w *WebView) GoBack() error {
1786- if w == nil || w.native == nil || w.native.wv == nil {
1787- return ErrNotImplemented
1788- }
1789- C.webkit_web_view_go_back(w.native.wv)
1790- return nil
1791-}
1792-
1793-func (w *WebView) GoForward() error {
1794- if w == nil || w.native == nil || w.native.wv == nil {
1795- return ErrNotImplemented
1796- }
1797- C.webkit_web_view_go_forward(w.native.wv)
1798- return nil
1799-}
1800-
1801-func (w *WebView) Reload() error {
1802- if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
1803- return ErrNotImplemented
1804- }
1805- C.webkit_web_view_reload(w.native.wv)
1806- log.Printf("[webkit] Reload page")
1807- return nil
1808-}
1809-
1810-func (w *WebView) ReloadBypassCache() error {
1811- if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
1812- return ErrNotImplemented
1813- }
1814- C.webkit_web_view_reload_bypass_cache(w.native.wv)
1815- log.Printf("[webkit] Hard reload page (bypass cache)")
1816- return nil
1817-}
1818-
1819-// RegisterScriptMessageHandler registers a callback invoked when the content script posts a message.
1820-func (w *WebView) RegisterScriptMessageHandler(cb func(payload string)) { w.msgHandler = cb }
1821-
1822-func (w *WebView) RegisterPopupHandler(cb func(string) *WebView) { w.popupHandler = cb }
1823-
1824-func (w *WebView) RegisterCloseHandler(cb func()) {
1825- w.closeHandler = cb
1826- // Also connect the CGO close signal to ensure window.close() is detected
1827- if w.native != nil && w.native.wv != nil {
1828- C.connect_close_signal(w.native.wv, C.ulong(w.id))
1829- }
1830-}
1831-
1832-// RegisterNavigationPolicyHandler sets a handler to control navigation decisions
1833-func (w *WebView) RegisterNavigationPolicyHandler(cb func(url string, isUserGesture bool) bool) {
1834- w.navigationPolicyHandler = cb
1835-}
1836-
1837-// EnablePrintInterception connects the print signal to intercept Ctrl+P
1838-func (w *WebView) EnablePrintInterception() {
1839- if w.native != nil && w.native.wv != nil {
1840- C.connect_print_signal(w.native.wv, C.ulong(w.id))
1841- }
1842-}
1843-
1844-func (w *WebView) dispatchScriptMessage(payload string) {
1845- if w != nil && w.msgHandler != nil {
1846- w.msgHandler(payload)
1847- }
1848-}
1849-
1850-func (w *WebView) dispatchPopupRequest(uri string) *WebView {
1851- if w != nil && w.popupHandler != nil {
1852- return w.popupHandler(uri)
1853- }
1854- return nil
1855-}
1856-
1857-// CreateRelatedView creates a WebView that shares process/context with this view
1858-func (w *WebView) CreateRelatedView() *WebView {
1859- if w == nil || w.native == nil || w.native.wv == nil {
1860- return nil
1861- }
1862- // Build a minimal config inheriting from current view
1863- cfg := &Config{
1864- Assets: w.config.Assets,
1865- ZoomDefault: w.config.ZoomDefault,
1866- EnableDeveloperExtras: w.config.EnableDeveloperExtras,
1867- DataDir: w.config.DataDir,
1868- CacheDir: w.config.CacheDir,
1869- APIToken: w.config.APIToken,
1870- DefaultSansFont: w.config.DefaultSansFont,
1871- DefaultSerifFont: w.config.DefaultSerifFont,
1872- DefaultMonospaceFont: w.config.DefaultMonospaceFont,
1873- DefaultFontSize: w.config.DefaultFontSize,
1874- Rendering: w.config.Rendering,
1875- UseDomZoom: w.config.UseDomZoom,
1876- VideoAcceleration: w.config.VideoAcceleration,
1877- Memory: w.config.Memory,
1878- CodecPreferences: w.config.CodecPreferences,
1879- Colors: w.config.Colors,
1880- CreateWindow: false,
1881- }
1882- related, err := NewWebViewWithRelated(cfg, w)
1883- if err != nil {
1884- log.Printf("[webkit] CreateRelatedView failed: %v", err)
1885- return nil
1886- }
1887- return related
1888-}
1889-
1890-// OnWindowTypeDetected registers a callback invoked when window type is detected
1891-func (w *WebView) OnWindowTypeDetected(callback func(WindowType, *WindowFeatures)) {
1892- if w == nil {
1893- return
1894- }
1895- w.windowTypeCallback = callback
1896-}
1897-
1898-// GetNativePointer returns the native WebView pointer for CGO callbacks
1899-func (w *WebView) GetNativePointer() unsafe.Pointer {
1900- if w != nil && w.native != nil && w.native.wv != nil {
1901- return unsafe.Pointer(w.native.wv)
1902- }
1903- return nil
1904-}
1905-
1906-// RegisterTitleChangedHandler registers a callback invoked when the page title changes.
1907-func (w *WebView) RegisterTitleChangedHandler(cb func(title string)) { w.titleHandler = cb }
1908-
1909-func (w *WebView) dispatchTitleChanged(title string) {
1910- if w != nil && w.titleHandler != nil {
1911- w.titleHandler(title)
1912- }
1913-}
1914-
1915-// RegisterURIChangedHandler registers a callback invoked when the current page URI changes.
1916-func (w *WebView) RegisterURIChangedHandler(cb func(uri string)) { w.uriHandler = cb }
1917-
1918-func (w *WebView) dispatchURIChanged(uri string) {
1919- if w != nil && w.uriHandler != nil {
1920- w.uriHandler(uri)
1921- }
1922-}
1923-
1924-// RegisterFaviconChangedHandler registers a callback invoked when the page favicon changes.
1925-func (w *WebView) RegisterFaviconChangedHandler(cb func(data []byte)) {
1926- if w != nil {
1927- w.faviconHandler = cb
1928- }
1929-}
1930-
1931-// RegisterFaviconURIChangedHandler registers a callback invoked when WebKit detects a favicon URI.
1932-func (w *WebView) RegisterFaviconURIChangedHandler(cb func(pageURI, faviconURI string)) {
1933- if w != nil {
1934- w.faviconURIHandler = cb
1935- }
1936-}
1937-
1938-func (w *WebView) dispatchFaviconChanged(data []byte) {
1939- if w != nil && w.faviconHandler != nil {
1940- w.faviconHandler(data)
1941- }
1942-}
1943-
1944-func (w *WebView) dispatchFaviconURIChanged(pageURI, faviconURI string) {
1945- if w != nil && w.faviconURIHandler != nil {
1946- w.faviconURIHandler(pageURI, faviconURI)
1947- }
1948-}
1949-
1950-// RunOnMainThread schedules fn to execute on the GTK main thread.
1951-func (w *WebView) RunOnMainThread(fn func()) {
1952- if w == nil || fn == nil {
1953- return
1954- }
1955- h := cgo.NewHandle(fn)
1956- C.schedule_on_main_thread(C.uintptr_t(h))
1957-}
1958-
1959-// RegisterZoomChangedHandler registers a callback invoked when zoom level changes.
1960-func (w *WebView) RegisterZoomChangedHandler(cb func(level float64)) { w.zoomHandler = cb }
1961-
1962-func (w *WebView) dispatchZoomChanged(level float64) {
1963- if w != nil && w.zoomHandler != nil {
1964- w.zoomHandler(level)
1965- }
1966-}
1967-
1968-// Widget returns the underlying GtkWidget pointer for the WebView.
1969-func (w *WebView) Widget() uintptr {
1970- if w == nil || w.native == nil || w.native.view == nil {
1971- return 0
1972- }
1973- return uintptr(unsafe.Pointer(w.native.view))
1974-}
1975-
1976-// RootWidget returns the container widget that should be reparented for pane management.
1977-func (w *WebView) RootWidget() uintptr {
1978- if w == nil || w.native == nil || w.native.container == nil {
1979- return 0
1980- }
1981- return uintptr(unsafe.Pointer(w.native.container))
1982-}
1983-
1984-// DestroyWindow destroys the toplevel window hosting this WebView, if any.
1985-func (w *WebView) DestroyWindow() {
1986- if w == nil || w.native == nil || w.native.win == nil {
1987- return
1988- }
1989- // Only destroy if we actually created a window
1990- if w.config != nil && w.config.CreateWindow {
1991- C.gtk_window_destroy((*C.GtkWindow)(unsafe.Pointer(w.native.win)))
1992- w.native.win = nil
1993- if w.window != nil {
1994- w.window.win = nil
1995- }
1996- }
1997-}
1998-
1999-// PrepareForReparenting prepares the WebView widget for being moved to a new parent
2000-func (w *WebView) PrepareForReparenting() {
2001- if w == nil || w.native == nil || w.native.container == nil {
2002- return
2003- }
2004-
2005- // Mark that this view is being reparented
2006- w.wasReparented = true
2007-
2008- // Ensure the widget is visible and properly sized for reparenting
2009- C.gtk_widget_set_visible(w.native.container, C.gboolean(1))
2010- C.gtk_widget_set_size_request(w.native.container, 100, 100)
2011- C.gtk_widget_queue_draw(w.native.container)
2012-}
2013-
2014-// RefreshAfterReparenting refreshes the WebView after it has been reparented
2015-func (w *WebView) RefreshAfterReparenting() {
2016- if w == nil || w.native == nil || w.native.wv == nil {
2017- return
2018- }
2019-
2020- if w.wasReparented {
2021- widgetHandle := w.RootWidget()
2022- parentHandle := WidgetGetParent(widgetHandle)
2023- if parentHandle == 0 {
2024- log.Printf("[workspace] RefreshAfterReparenting deferred: widget=%#x parent=%#x url=%s", widgetHandle, parentHandle, w.url)
2025- return
2026- }
2027- log.Printf("[workspace] RefreshAfterReparenting: widget=%#x parent=%#x url=%s", widgetHandle, parentHandle, w.url)
2028- // Force WebKit to refresh its rendering context by triggering a reload
2029- // This ensures the rendering context is properly reinitialized after reparenting
2030- if w.url != "" {
2031- w.LoadURL(w.url) // Reload current URL to refresh rendering context
2032- }
2033- w.wasReparented = false
2034- }
2035-}
2036-
2037-// enableUserContentManager registers the 'dumber' message handler and injects the omnibox script.
2038-func (w *WebView) enableUserContentManager(cfg *Config) {
2039- if w == nil || w.native == nil || w.native.ucm == nil {
2040- return
2041- }
2042- // Register handler "dumber" for main world
2043- cname := C.CString("dumber")
2044- ok := C.register_ucm_handler(w.native.ucm, cname, C.ulong(w.id))
2045- if ok == 0 {
2046- log.Printf("[webkit] Failed to register UCM handler; fallback bridge will handle messages")
2047- }
2048- C.free(unsafe.Pointer(cname))
2049-
2050- // Also register handler for the isolated GUI world so GUI can post messages to native
2051- cworld := C.CString("dumber-gui")
2052- cname2 := C.CString("dumber")
2053- ok2 := C.register_ucm_handler_world(w.native.ucm, cname2, cworld, C.ulong(w.id))
2054- if ok2 == 0 {
2055- log.Printf("[webkit] Failed to register UCM handler for isolated world 'dumber-gui'")
2056- }
2057- C.free(unsafe.Pointer(cworld))
2058- C.free(unsafe.Pointer(cname2))
2059-
2060- // Inject color-scheme preference script at document-start to inform sites of system theme
2061- preferDark := C.gtk_prefers_dark() != 0
2062- if preferDark {
2063- log.Printf("[theme] GTK prefers: dark")
2064- } else {
2065- log.Printf("[theme] GTK prefers: light")
2066- }
2067- // Load color scheme script from compiled GUI assets instead of inline
2068- var schemeJS string
2069- if cfg != nil && cfg.Assets != nil {
2070- if schemeBytes, err := cfg.Assets.ReadFile("assets/gui/color-scheme.js"); err == nil {
2071- baseScript := string(schemeBytes)
2072- // Inject the GTK preference as a parameter
2073- if preferDark {
2074- schemeJS = fmt.Sprintf("window.__dumber_gtk_prefers_dark = true; %s", baseScript)
2075- } else {
2076- schemeJS = fmt.Sprintf("window.__dumber_gtk_prefers_dark = false; %s", baseScript)
2077- }
2078- } else {
2079- log.Printf("[webkit] Warning: Failed to load color-scheme.js from assets, falling back to basic theme detection: %v", err)
2080- // Simple fallback if the compiled script is not available
2081- if preferDark {
2082- schemeJS = `console.log('[dumber] GTK detected color mode: dark'); document.documentElement.classList.add('dark');`
2083- } else {
2084- schemeJS = `console.log('[dumber] GTK detected color mode: light'); document.documentElement.classList.remove('dark');`
2085- }
2086- }
2087- }
2088- cScheme := C.CString(schemeJS)
2089- defer C.free(unsafe.Pointer(cScheme))
2090- schemeScript := C.webkit_user_script_new((*C.gchar)(cScheme), C.WEBKIT_USER_CONTENT_INJECT_TOP_FRAME, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil)
2091- if schemeScript != nil {
2092- C.webkit_user_content_manager_add_script(w.native.ucm, schemeScript)
2093- C.webkit_user_script_unref(schemeScript)
2094- }
2095-
2096- // Color palette injection now handled in main-world.ts via __PALETTE_JSON__ placeholder
2097-
2098- // Add GUI bundle as user script at document-start (contains toast, omnibox, and controls)
2099- if cfg != nil && cfg.Assets != nil {
2100- log.Printf("[webkit] Attempting to load GUI bundle from assets/gui/gui.min.js")
2101- if guiBytes, err := cfg.Assets.ReadFile("assets/gui/gui.min.js"); err == nil {
2102- guiScript := string(guiBytes)
2103- log.Printf("[webkit] GUI bundle loaded successfully, size: %d bytes", len(guiBytes))
2104-
2105- cGui := C.CString(guiScript)
2106- defer C.free(unsafe.Pointer(cGui))
2107-
2108- log.Printf("[webkit] Creating GUI user script for document-start injection in isolated world 'dumber-gui'")
2109- world := C.CString("dumber-gui")
2110- defer C.free(unsafe.Pointer(world))
2111- guiUserScript := C.webkit_user_script_new_for_world((*C.gchar)(cGui), C.WEBKIT_USER_CONTENT_INJECT_TOP_FRAME, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, (*C.gchar)(world), nil, nil)
2112- if guiUserScript != nil {
2113- C.webkit_user_content_manager_add_script(w.native.ucm, guiUserScript)
2114- C.webkit_user_script_unref(guiUserScript)
2115- log.Printf("[webkit] GUI bundle successfully injected as user script at document-start in isolated world")
2116- } else {
2117- log.Printf("[webkit] ERROR: Failed to create GUI user script")
2118- }
2119-
2120- // Inject a unified page-world bridge to dispatch standard events and expose unified API
2121- w.installDomBridgeScript()
2122-
2123- // If an API token is provided, inject a fetch wrapper in the isolated GUI world
2124- if cfg.APIToken != "" {
2125- wrapper := "(() => { try {" +
2126- "const TOKEN='" + cfg.APIToken + "';" +
2127- "const appendToken=(u)=>{ try { let url; try { url = new URL(u); } catch(_) { url = new URL(u, 'dumb://homepage'); } if(url.protocol==='dumb:' && url.pathname.startsWith('/api')){ url.searchParams.set('token', TOKEN); return url.toString(); } } catch(_){} return u; };" +
2128- "const _fetch=window.fetch.bind(window); window.fetch=(input, init)=>{ try { if(typeof input==='string'){ input=appendToken(input); } else if(input && input.url){ const nu=appendToken(input.url); if(nu!==input.url){ input=new Request(nu, input); } } } catch(_){} return _fetch(input, init); };" +
2129- "} catch(e) { console.warn('[dumber] api token wrapper failed', e); } })();"
2130- cWrap := C.CString(wrapper)
2131- defer C.free(unsafe.Pointer(cWrap))
2132- world := C.CString("dumber-gui")
2133- defer C.free(unsafe.Pointer(world))
2134- wrapScript := C.webkit_user_script_new_for_world((*C.gchar)(cWrap), C.WEBKIT_USER_CONTENT_INJECT_TOP_FRAME, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, (*C.gchar)(world), nil, nil)
2135- if wrapScript != nil {
2136- C.webkit_user_content_manager_add_script(w.native.ucm, wrapScript)
2137- C.webkit_user_script_unref(wrapScript)
2138- log.Printf("[webkit] API token fetch wrapper injected in isolated GUI world")
2139- }
2140- }
2141- } else {
2142- log.Printf("[webkit] ERROR: Failed to load GUI bundle from assets: %v", err)
2143- }
2144-
2145- // Add GUI CSS styles as user stylesheet when a legacy bundle is present.
2146- // Modern builds inline Tailwind output inside gui.min.js, so this file is optional.
2147- if cssBytes, err := cfg.Assets.ReadFile("assets/gui/style.css"); err == nil {
2148- cssContent := string(cssBytes)
2149- cCss := C.CString(cssContent)
2150- defer C.free(unsafe.Pointer(cCss))
2151-
2152- styleSheet := C.webkit_user_style_sheet_new(
2153- (*C.gchar)(cCss),
2154- C.WEBKIT_USER_CONTENT_INJECT_TOP_FRAME,
2155- C.WEBKIT_USER_STYLE_LEVEL_AUTHOR,
2156- nil, // allow_list
2157- nil, // block_list
2158- )
2159- if styleSheet != nil {
2160- C.webkit_user_content_manager_add_style_sheet(w.native.ucm, styleSheet)
2161- C.webkit_user_style_sheet_unref(styleSheet)
2162- } else {
2163- log.Printf("[webkit] ERROR: Failed to create GUI stylesheet")
2164- }
2165- } else if errors.Is(err, os.ErrNotExist) {
2166- log.Printf("[webkit] GUI stylesheet not found; assuming inline Tailwind bundle and skipping separate injection")
2167- } else {
2168- log.Printf("[webkit] Warning: Failed to load GUI CSS from assets, skipping separate stylesheet injection: %v", err)
2169- }
2170- }
2171-
2172- // Inject Wails runtime fetch interceptor for homepage bridging
2173- wailsBridge := `(() => { try { const origFetch = window.fetch.bind(window); const waiters = Object.create(null); window.__dumber_wails_resolve = (id, json) => { const w = waiters[id]; if(!w) return; delete waiters[id]; try { const headers = new Headers({"Content-Type":"application/json"}); w.resolve(new Response(json, { headers })); } catch(e){ w.reject(e); } }; window.fetch = (input, init) => { try { const url = new URL(input instanceof Request ? input.url : input, window.location.origin); if (url.pathname === '/wails/runtime') { const args = url.searchParams.get('args'); let payload = {}; try { payload = args ? JSON.parse(args) : {}; } catch(_){} const id = String(Date.now()) + '-' + Math.random().toString(36).slice(2); return new Promise((resolve, reject) => { waiters[id] = { resolve, reject }; try { window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({ type: 'wails', id, payload })); } catch (e) { reject(e); } }); } } catch(_){} return origFetch(input, init); }; } catch(_){} })();`
2174- cWails := C.CString(wailsBridge)
2175- defer C.free(unsafe.Pointer(cWails))
2176- wailsScript := C.webkit_user_script_new((*C.gchar)(cWails), C.WEBKIT_USER_CONTENT_INJECT_TOP_FRAME, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil)
2177- if wailsScript != nil {
2178- C.webkit_user_content_manager_add_script(w.native.ucm, wailsScript)
2179- C.webkit_user_script_unref(wailsScript)
2180- }
2181-
2182- // Inject codec control script if codec preferences are configured
2183- if cfg != nil && (cfg.CodecPreferences.ForceAV1 || len(cfg.CodecPreferences.BlockedCodecs) > 0 || len(cfg.CodecPreferences.PreferredCodecs) > 0) {
2184- codecScript := GenerateCodecControlScript(cfg.CodecPreferences)
2185- cCodec := C.CString(codecScript)
2186- defer C.free(unsafe.Pointer(cCodec))
2187- codecUserScript := C.webkit_user_script_new(
2188- (*C.gchar)(cCodec),
2189- C.WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES,
2190- C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START,
2191- nil, nil)
2192- if codecUserScript != nil {
2193- C.webkit_user_content_manager_add_script(w.native.ucm, codecUserScript)
2194- C.webkit_user_script_unref(codecUserScript)
2195- log.Printf("[webkit] Codec control script injected")
2196- }
2197- }
2198-
2199- // Enable global shortcuts protection to prevent shortcuts from leaking to webpages
2200- if err := w.EnableGlobalShortcutsProtection(); err != nil {
2201- log.Printf("[webkit] Warning: Failed to enable global shortcuts protection: %v", err)
2202- }
2203-
2204- // Register this WebView for dynamic protection updates
2205- RegisterActiveWebView(w)
2206-
2207- // No JS fallback bridge — native UCM is active
2208-}
2209-
2210-func clampDomZoom(level float64) float64 {
2211- if level < 0.25 {
2212- return 0.25
2213- }
2214- if level > 5.0 {
2215- return 5.0
2216- }
2217- return level
2218-}
2219-
2220-func buildDomBridgeScript(cfg *Config, initialZoom float64, webViewId uintptr, w *WebView) string {
2221- if cfg == nil || cfg.Assets == nil {
2222- log.Printf("[webkit] Config or assets not available for main-world script")
2223- return ""
2224- }
2225-
2226- // Load the compiled main-world script
2227- scriptBytes, err := cfg.Assets.ReadFile("assets/gui/main-world.min.js")
2228- if err != nil {
2229- log.Printf("[webkit] Failed to load main-world script: %v", err)
2230- return ""
2231- }
2232-
2233- // Replace the zoom placeholder with the actual value
2234- zoom := clampDomZoom(initialZoom)
2235- zoomStr := strconv.FormatFloat(zoom, 'f', -1, 64)
2236- script := strings.ReplaceAll(string(scriptBytes), "__DOM_ZOOM_DEFAULT__", zoomStr)
2237-
2238- // Inject WebView ID and active state
2239- webViewIdStr := strconv.FormatUint(uint64(webViewId), 10)
2240- script = strings.ReplaceAll(script, "__WEBVIEW_ID__", webViewIdStr)
2241- // Default to active for the initial WebView, since it will be focused immediately
2242- // For single WebView scenarios, assume it should be active
2243- activeState := "true"
2244- log.Printf("[webkit] Building script with WebView %s, active state: %s", webViewIdStr, activeState)
2245- script = strings.ReplaceAll(script, "\"__WEBVIEW_ACTIVE__\"", activeState)
2246-
2247- // Color palettes and search shortcuts are now loaded via messaging bridge
2248-
2249- log.Printf("[webkit] Loaded main-world script successfully, size: %d bytes", len(scriptBytes))
2250- return script
2251-}
2252-
2253-func (w *WebView) UsesDomZoom() bool {
2254- return w != nil && w.useDomZoom
2255-}
2256-
2257-func (w *WebView) installDomBridgeScript() {
2258- if w == nil || w.native == nil || w.native.ucm == nil {
2259- return
2260- }
2261-
2262- w.domZoomSeed = clampDomZoom(w.domZoomSeed)
2263-
2264- if w.domBridgeScript != nil {
2265- C.webkit_user_content_manager_remove_script(w.native.ucm, w.domBridgeScript)
2266- C.webkit_user_script_unref(w.domBridgeScript)
2267- w.domBridgeScript = nil
2268- }
2269-
2270- source := buildDomBridgeScript(w.config, w.domZoomSeed, w.id, w)
2271- cBridge := C.CString(source)
2272- defer C.free(unsafe.Pointer(cBridge))
2273-
2274- script := C.webkit_user_script_new((*C.gchar)(cBridge), C.WEBKIT_USER_CONTENT_INJECT_TOP_FRAME, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil)
2275- if script == nil {
2276- log.Printf("[webkit] ERROR: Failed to create GUI user script")
2277- return
2278- }
2279-
2280- C.webkit_user_content_manager_add_script(w.native.ucm, script)
2281- w.domBridgeScript = script
2282- log.Printf("[webkit] Page-world toast bridge injected (dom zoom seed=%.2f)", w.domZoomSeed)
2283-}
2284-
2285-// SeedDomZoom updates the document-start bridge so the upcoming page paints with the saved DOM zoom level.
2286-func (w *WebView) SeedDomZoom(level float64) {
2287- if w == nil || w.destroyed || !w.useDomZoom {
2288- return
2289- }
2290-
2291- seed := clampDomZoom(level)
2292- if math.Abs(w.domZoomSeed-seed) < 1e-6 {
2293- return
2294- }
2295- w.domZoomSeed = seed
2296- // Instead of re-injecting the entire script, just update the zoom level
2297- w.RunOnMainThread(func() {
2298- zoomStr := strconv.FormatFloat(seed, 'f', -1, 64)
2299- script := fmt.Sprintf("if (window.__dumber_applyDomZoom) { window.__dumber_applyDomZoom(%s); }", zoomStr)
2300- w.InjectScript(script)
2301- })
2302-}
2303-
2304-// startPeriodicGC starts a ticker to trigger JavaScript garbage collection periodically
2305-func (w *WebView) startPeriodicGC(interval time.Duration) {
2306- if w == nil || w.gcTicker != nil {
2307- return // Already running or invalid
2308- }
2309-
2310- w.gcTicker = time.NewTicker(interval)
2311-
2312- go func() {
2313- defer func() {
2314- if w.gcTicker != nil {
2315- w.gcTicker.Stop()
2316- w.gcTicker = nil
2317- }
2318- }()
2319-
2320- for {
2321- select {
2322- case <-w.gcTicker.C:
2323- w.triggerGC()
2324- case <-w.gcDone:
2325- return
2326- }
2327- }
2328- }()
2329-}
2330-
2331-// stopPeriodicGC stops the periodic garbage collection
2332-func (w *WebView) stopPeriodicGC() {
2333- if w == nil {
2334- return
2335- }
2336-
2337- if w.gcDone != nil {
2338- close(w.gcDone)
2339- w.gcDone = nil
2340- }
2341-
2342- if w.gcTicker != nil {
2343- w.gcTicker.Stop()
2344- w.gcTicker = nil
2345- }
2346-}
2347-
2348-// triggerGC manually triggers JavaScript garbage collection
2349-func (w *WebView) triggerGC() {
2350- if w == nil || w.memStats == nil {
2351- return
2352- }
2353-
2354- // Get default WebContext and trigger GC
2355- ctx := C.webkit_web_context_get_default()
2356- if ctx != nil {
2357- C.maybe_collect_js(ctx)
2358- w.memStats.lastGCTime = time.Now()
2359-
2360- if w.config.Memory.EnableMemoryMonitoring {
2361- log.Printf("[webkit] JavaScript GC triggered (page loads: %d)", w.memStats.pageLoadCount)
2362- }
2363- }
2364-}
2365-
2366-// GetMemoryStats returns current memory statistics for this WebView
2367-func (w *WebView) GetMemoryStats() map[string]interface{} {
2368- if w == nil || w.memStats == nil {
2369- return nil
2370- }
2371-
2372- return map[string]interface{}{
2373- "page_load_count": w.memStats.pageLoadCount,
2374- "last_gc_time": w.memStats.lastGCTime,
2375- "has_memory_pressure_settings": w.memStats.memoryPressureSettings != nil,
2376- }
2377-}
2378-
2379-// TriggerMemoryCleanup manually triggers garbage collection and memory cleanup
2380-func (w *WebView) TriggerMemoryCleanup() {
2381- if w == nil {
2382- return
2383- }
2384-
2385- w.triggerGC()
2386-
2387- if w.config.Memory.EnableMemoryMonitoring {
2388- log.Printf("[webkit] Manual memory cleanup triggered")
2389- }
2390-}
2391-
2392-//export goHandleTLSError
2393-func goHandleTLSError(failingURI *C.char, host *C.char, errorFlags C.int, certInfo *C.char) C.gboolean {
2394- uri := C.GoString(failingURI)
2395- hostname := C.GoString(host)
2396- certificateInfo := C.GoString(certInfo)
2397-
2398- log.Printf("[tls] Certificate error for %s (URI: %s, flags: %d)", hostname, uri, int(errorFlags))
2399-
2400- // Find the WebView that triggered this error
2401- // For now, we'll get the most recently created view
2402- // In a more sophisticated implementation, we'd pass the view ID through user_data
2403- var webView *WebView
2404- regMu.RLock()
2405- for _, v := range viewByID {
2406- if v != nil {
2407- webView = v
2408- break
2409- }
2410- }
2411- regMu.RUnlock()
2412-
2413- if webView == nil {
2414- log.Printf("[tls] No WebView found to handle TLS error")
2415- return C.FALSE // Don't proceed
2416- }
2417-
2418- // Check if we've already allowed this host
2419- if allowed, exists := webView.tlsExceptions[hostname]; exists && allowed {
2420- log.Printf("[tls] Certificate exception already granted for %s", hostname)
2421- return C.TRUE // Proceed with the load
2422- }
2423-
2424- // Show warning dialog and get user decision
2425- if webView.showTLSWarningDialog(hostname, uri, int(errorFlags), certificateInfo) {
2426- webView.tlsExceptions[hostname] = true
2427- log.Printf("[tls] User accepted certificate exception for %s", hostname)
2428- return C.TRUE // Proceed with the load
2429- }
2430-
2431- log.Printf("[tls] User rejected certificate for %s", hostname)
2432- return C.FALSE // Don't proceed
2433-}
2434-
2435-//export goHandleConsoleMessage
2436-//export goHandleConsoleMessage
2437-func goHandleConsoleMessage(message *C.char) {
2438- messageText := C.GoString(message)
2439- logging.CaptureWebKitLog(messageText)
2440-}
2441-
2442-//export goHandleNavigationPolicy
2443-func goHandleNavigationPolicy(viewID C.ulong, uri *C.char, isUserGesture C.gboolean) C.gboolean {
2444- id := uintptr(viewID)
2445- urlStr := C.GoString(uri)
2446- userGesture := isUserGesture != 0
2447-
2448- log.Printf("[navigation-policy] WebView %d navigating to: %s (user_gesture=%t)", id, urlStr, userGesture)
2449-
2450- // Find the WebView
2451- regMu.RLock()
2452- view, exists := viewByID[id]
2453- regMu.RUnlock()
2454-
2455- if !exists || view == nil {
2456- log.Printf("[navigation-policy] WebView %d not found, allowing navigation", id)
2457- return C.TRUE // Allow by default if view not found
2458- }
2459-
2460- // Check if this webview has a navigation policy handler
2461- if view.navigationPolicyHandler != nil {
2462- allowed := view.navigationPolicyHandler(urlStr, userGesture)
2463- if !allowed {
2464- log.Printf("[navigation-policy] WebView %d: Navigation BLOCKED by handler", id)
2465- return C.FALSE
2466- }
2467- }
2468-
2469- // Allow navigation
2470- return C.TRUE
2471-}
2472-
2473-// showTLSWarningDialog displays a warning dialog for TLS certificate errors with storage capability
2474-func (w *WebView) showTLSWarningDialog(hostname, uri string, errorFlags int, certificateInfo string) bool {
2475- if w == nil || w.native == nil || w.native.win == nil {
2476- log.Printf("[tls] Cannot show dialog: WebView or window is nil")
2477- return false
2478- }
2479-
2480- // Check if we have a stored decision for this certificate
2481- if storedDecision, hasStored := checkStoredCertificateDecision(hostname, certificateInfo); hasStored {
2482- switch storedDecision {
2483- case "accepted":
2484- log.Printf("[tls] Using stored ACCEPT decision for %s", hostname)
2485- return true
2486- case "rejected":
2487- log.Printf("[tls] Using stored REJECT decision for %s", hostname)
2488- return false
2489- default:
2490- log.Printf("[tls] Unknown stored decision '%s' for %s, showing dialog", storedDecision, hostname)
2491- }
2492- }
2493-
2494- log.Printf("[tls] Showing certificate error dialog for %s", hostname)
2495-
2496- // Format the error message with certificate information
2497- errorMsg := formatTLSErrorMessage(hostname, uri, errorFlags, certificateInfo)
2498-
2499- // Convert strings to C strings
2500- cHostname := C.CString(hostname)
2501- defer C.free(unsafe.Pointer(cHostname))
2502-
2503- cErrorMsg := C.CString(errorMsg)
2504- defer C.free(unsafe.Pointer(cErrorMsg))
2505-
2506- // Show the dialog and get user response (0=Go Back, 1=Proceed Once, 2=Always Accept)
2507- result := C.show_tls_warning_dialog_sync(
2508- (*C.GtkWindow)(unsafe.Pointer(w.native.win)),
2509- cHostname,
2510- cErrorMsg,
2511- )
2512-
2513- responseCode := int(result)
2514-
2515- switch responseCode {
2516- case 0: // Go Back
2517- log.Printf("[tls] User chose to GO BACK for %s", hostname)
2518- return false
2519-
2520- case 1: // Proceed Once
2521- log.Printf("[tls] User chose to PROCEED ONCE for %s", hostname)
2522- // Store temporary decision (expires in 24 hours)
2523- if err := storeCertificateDecision(hostname, certificateInfo, "accepted", false); err != nil {
2524- log.Printf("[tls] Warning: Failed to store temporary certificate decision: %v", err)
2525- }
2526- return true
2527-
2528- case 2: // Always Accept
2529- log.Printf("[tls] User chose to ALWAYS ACCEPT for %s", hostname)
2530- // Store permanent decision
2531- if err := storeCertificateDecision(hostname, certificateInfo, "accepted", true); err != nil {
2532- log.Printf("[tls] Warning: Failed to store permanent certificate decision: %v", err)
2533- }
2534- return true
2535-
2536- default:
2537- log.Printf("[tls] Unknown response code %d for %s, defaulting to reject", responseCode, hostname)
2538- return false
2539- }
2540-}
2541-
2542-// formatTLSErrorMessage creates a detailed error message based on the error flags
2543-func formatTLSErrorMessage(hostname, uri string, errorFlags int, certificateInfo string) string {
2544- msg := "Website: " + hostname + "\n\n"
2545-
2546- // Add certificate information
2547- if certificateInfo != "" {
2548- msg += "Certificate Details:\n" + certificateInfo + "\n"
2549- }
2550-
2551- msg += "Security Issues:\n"
2552- // Decode GTlsCertificateFlags (common values)
2553- if errorFlags&1 != 0 { // G_TLS_CERTIFICATE_UNKNOWN_CA
2554- msg += "• Certificate authority is not trusted\n"
2555- }
2556- if errorFlags&2 != 0 { // G_TLS_CERTIFICATE_BAD_IDENTITY
2557- msg += "• Certificate does not match the website identity\n"
2558- }
2559- if errorFlags&4 != 0 { // G_TLS_CERTIFICATE_NOT_ACTIVATED
2560- msg += "• Certificate is not yet valid\n"
2561- }
2562- if errorFlags&8 != 0 { // G_TLS_CERTIFICATE_EXPIRED
2563- msg += "• Certificate has expired\n"
2564- }
2565- if errorFlags&16 != 0 { // G_TLS_CERTIFICATE_REVOKED
2566- msg += "• Certificate has been revoked\n"
2567- }
2568- if errorFlags&32 != 0 { // G_TLS_CERTIFICATE_INSECURE
2569- msg += "• Certificate uses weak cryptography\n"
2570- }
2571-
2572- msg += "\nProceeding may expose your data to attackers.\nOnly continue if you trust this website."
2573-
2574- return msg
2575-}
2576-
2577-// InitializeContentBlocking initializes WebKit content blocking with filter manager
2578-func (w *WebView) InitializeContentBlocking(filterManager FilterManager) error {
2579- if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
2580- return ErrWebViewNotInitialized
2581- }
2582-
2583- // Load filters asynchronously with proper error recovery
2584- go func() {
2585- defer func() {
2586- if r := recover(); r != nil {
2587- logging.Error(fmt.Sprintf("[webkit] Content blocking initialization panic: %v", r))
2588- }
2589- }()
2590-
2591- // Wait longer for WebView to be ready before applying filters on first load
2592- // This prevents interference with WebKit's preconnect operations
2593- time.Sleep(1500 * time.Millisecond)
2594-
2595- // Retry mechanism with exponential backoff
2596- maxRetries := 5
2597- baseDelay := 200 * time.Millisecond
2598-
2599- // Apply network filters with error recovery
2600- for i := 0; i < maxRetries; i++ {
2601- if w.destroyed {
2602- logging.Debug("[webkit] WebView destroyed, aborting filter loading")
2603- return
2604- }
2605-
2606- if filters, err := filterManager.GetNetworkFilters(); err == nil {
2607- if len(filters) > 10 && string(filters) != "null" {
2608- logging.Info("[webkit] Loading network filters from filter manager (" + fmt.Sprintf("%d", len(filters)) + " bytes)")
2609- if err := w.ApplyContentFilters(filters, "network-filters"); err != nil {
2610- logging.Error("Failed to apply network filters (retry " + fmt.Sprintf("%d", i+1) + "): " + err.Error())
2611- if i == maxRetries-1 {
2612- logging.Warn("[webkit] Network filtering disabled due to errors")
2613- break
2614- }
2615- time.Sleep(baseDelay * time.Duration(1<<uint(i))) // Exponential backoff
2616- continue
2617- }
2618- break
2619- }
2620- } else if i == maxRetries-1 {
2621- logging.Error("Failed to get network filters after retries: " + err.Error())
2622- }
2623- }
2624-
2625- // Apply cosmetic filtering with error recovery
2626- time.Sleep(200 * time.Millisecond) // Additional delay to ensure network setup is complete
2627-
2628- for i := 0; i < maxRetries; i++ {
2629- if w.destroyed {
2630- logging.Debug("[webkit] WebView destroyed, aborting cosmetic filter loading")
2631- return
2632- }
2633-
2634- script := filterManager.GetCosmeticScript()
2635- if len(script) > 0 {
2636- logging.Info("[webkit] Loading cosmetic script from filter manager (" + fmt.Sprintf("%d", len(script)) + " chars)")
2637- if err := w.InjectCosmeticFilter(script); err != nil {
2638- logging.Error("Failed to inject cosmetic filter (retry " + fmt.Sprintf("%d", i+1) + "): " + err.Error())
2639- if i == maxRetries-1 {
2640- logging.Warn("[webkit] Cosmetic filtering disabled due to errors")
2641- } else {
2642- time.Sleep(baseDelay * time.Duration(1<<uint(i)))
2643- continue
2644- }
2645- }
2646- break
2647- }
2648- }
2649-
2650- logging.Info("Content blocking initialization completed")
2651- }()
2652-
2653- return nil
2654-}
2655-
2656-// OnNavigate sets up domain-specific cosmetic filtering on navigation
2657-func (w *WebView) OnNavigate(url string, filterManager FilterManager, whitelist []string) {
2658- if w == nil || w.destroyed {
2659- return
2660- }
2661-
2662- domain := extractDomain(url)
2663- if domain == "" {
2664- return
2665- }
2666-
2667- // Skip cosmetic filtering for whitelisted domains (e.g., Twitch with Arkose Labs bot detection)
2668- for _, whitelistedDomain := range whitelist {
2669- if domain == whitelistedDomain || strings.HasSuffix(domain, "."+whitelistedDomain) {
2670- logging.Info("[webkit] Skipping cosmetic filtering for whitelisted domain: " + domain)
2671- return
2672- }
2673- }
2674-
2675- // Get domain-specific cosmetic rules
2676- script := filterManager.GetCosmeticScriptForDomain(domain)
2677- if script != "" {
2678- // Inject domain-specific cosmetic rules
2679- logging.Info("[webkit] Applying domain-specific cosmetic rules for: " + domain + " (" + fmt.Sprintf("%d", len(script)) + " chars)")
2680- w.InjectScript(fmt.Sprintf(
2681- "if (typeof window.__dumber_cosmetic_init === 'function') { %s }",
2682- script,
2683- ))
2684- } else {
2685- logging.Debug("[webkit] No domain-specific cosmetic rules found for: " + domain)
2686- }
2687-}
2688-
2689-// UpdateContentFilters updates the content filters dynamically
2690-func (w *WebView) UpdateContentFilters(filterManager FilterManager) error {
2691- if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
2692- return ErrWebViewNotInitialized
2693- }
2694-
2695- // Clear existing filters first
2696- if err := w.ClearAllFilters(); err != nil {
2697- return fmt.Errorf("failed to clear existing filters: %w", err)
2698- }
2699-
2700- // Apply new filters
2701- if filters, err := filterManager.GetNetworkFilters(); err == nil {
2702- logging.Info("[webkit] Updating network filters (" + fmt.Sprintf("%d", len(filters)) + " bytes)")
2703- if err := w.ApplyContentFilters(filters, "network-filters-updated"); err != nil {
2704- return fmt.Errorf("failed to apply updated network filters: %w", err)
2705- }
2706- }
2707-
2708- // Update cosmetic filtering
2709- script := filterManager.GetCosmeticScript()
2710- logging.Info("[webkit] Updating cosmetic script (" + fmt.Sprintf("%d", len(script)) + " chars)")
2711- if err := w.InjectCosmeticFilter(script); err != nil {
2712- return fmt.Errorf("failed to inject updated cosmetic filter: %w", err)
2713- }
2714-
2715- logging.Info("Content filters updated successfully")
2716- return nil
2717-}
2718-
2719-// extractDomain extracts the domain from a URL
2720-func extractDomain(url string) string {
2721- if url == "" {
2722- return ""
2723- }
2724-
2725- // Simple domain extraction - remove protocol and path
2726- start := strings.Index(url, "://")
2727- if start != -1 {
2728- url = url[start+3:]
2729- }
2730-
2731- end := strings.Index(url, "/")
2732- if end != -1 {
2733- url = url[:end]
2734- }
2735-
2736- // Remove port if present
2737- if portIdx := strings.Index(url, ":"); portIdx != -1 {
2738- url = url[:portIdx]
2739- }
2740-
2741- return url
2742-}
2743-
2744-// detectAndLogRenderingBackend detects and logs the actual rendering backend being used
2745-func (w *WebView) detectAndLogRenderingBackend() {
2746- if w == nil {
2747- return
2748- }
2749-
2750- // Use a small delay to let WebKit initialize
2751- go func() {
2752- time.Sleep(500 * time.Millisecond)
2753-
2754- // Inject a script to detect rendering capabilities
2755- detectionScript := `
2756- (() => {
2757- try {
2758- const canvas = document.createElement('canvas');
2759- const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl');
2760- const info = {
2761- webgl_available: !!gl,
2762- renderer: gl ? gl.getParameter(gl.RENDERER) : 'N/A',
2763- vendor: gl ? gl.getParameter(gl.VENDOR) : 'N/A',
2764- version: gl ? gl.getParameter(gl.VERSION) : 'N/A',
2765- shading_language_version: gl ? gl.getParameter(gl.SHADING_LANGUAGE_VERSION) : 'N/A',
2766- max_texture_size: gl ? gl.getParameter(gl.MAX_TEXTURE_SIZE) : 0,
2767- hardware_accelerated: false
2768- };
2769-
2770- // Check if renderer indicates hardware acceleration
2771- if (gl && info.renderer) {
2772- const renderer = info.renderer.toLowerCase();
2773- // Look for GPU indicators
2774- info.hardware_accelerated = (
2775- renderer.includes('nvidia') ||
2776- renderer.includes('amd') ||
2777- renderer.includes('intel') ||
2778- renderer.includes('radeon') ||
2779- renderer.includes('geforce') ||
2780- renderer.includes('quadro') ||
2781- (renderer.includes('mesa') && !renderer.includes('software')) ||
2782- !renderer.includes('software')
2783- );
2784- }
2785-
2786- window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({
2787- type: 'rendering_backend_detection',
2788- data: info
2789- }));
2790- } catch (e) {
2791- console.error('[dumber] Rendering backend detection failed:', e);
2792- }
2793- })();
2794- `
2795-
2796- w.InjectScript(detectionScript)
2797- }()
2798-}
2799-
2800-// SetWindowFeatures sets the window features for this WebView
2801-func (w *WebView) SetWindowFeatures(features *WindowFeatures) {
2802- w.windowFeatures = features
2803-}
2804-
2805-// SetActive sets whether this WebView is currently active/focused
2806-func (w *WebView) SetActive(active bool) {
2807- if w == nil || w.destroyed {
2808- return
2809- }
2810-
2811- if w.isActive != active {
2812- w.isActive = active
2813- log.Printf("[webkit] WebView %d active state changed to: %v", w.id, active)
2814-
2815- // Update the active state in the JavaScript context
2816- activeStr := "false"
2817- if active {
2818- activeStr = "true"
2819- }
2820- script := fmt.Sprintf("window.__dumber_is_active = %s;", activeStr)
2821- w.InjectScript(script)
2822- }
2823-}
2824-
2825-// IsActive returns whether this WebView is currently active/focused
2826-func (w *WebView) IsActive() bool {
2827- return w != nil && w.isActive
2828-}
2829-
2830-// IsDestroyed returns whether this WebView has been destroyed
2831-func (w *WebView) IsDestroyed() bool {
2832- return w == nil || w.destroyed
2833-}
2834-
2835-// ID returns the unique identifier for this WebView as a string
2836-func (w *WebView) ID() string {
2837- if w == nil {
2838- return ""
2839- }
2840- return strconv.FormatUint(uint64(w.id), 10)
2841-}
2842-
2843-// PrefersDarkTheme returns true if GTK is configured to prefer dark theme
2844-func PrefersDarkTheme() bool {
2845- return C.gtk_prefers_dark() != 0
2846-}
A pkg/webkit/webview_related.go +118 -0
@@ -0,0 +1,118 @@
1+package webkit
2+
3+/*
4+#cgo pkg-config: webkitgtk-6.0 gtk4
5+#include <webkit/webkit.h>
6+#include <gtk/gtk.h>
7+#include <stdlib.h>
8+
9+// create_related_web_view creates a WebView with the related-view property set
10+// This is the WebKitGTK 6.0 way to share session/cookies between views (for popups)
11+// The related-view property is construct-only, so it must be set during g_object_new
12+static inline WebKitWebView* create_related_web_view(WebKitWebView* parent) {
13+ return WEBKIT_WEB_VIEW(g_object_new(
14+ WEBKIT_TYPE_WEB_VIEW,
15+ "related-view", parent,
16+ NULL
17+ ));
18+}
19+*/
20+import "C"
21+import (
22+ "log"
23+ "runtime"
24+ "unsafe"
25+
26+ webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
27+ coreglib "github.com/diamondburned/gotk4/pkg/core/glib"
28+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
29+)
30+
31+// NewBareRelatedWebView creates a bare gotk4 WebView with the related-view property set
32+// This ensures the popup shares the same session/process context as the parent
33+// The returned WebView is bare (no wrapper, no initialization) and must be initialized later
34+//
35+// Since gotk4-webkitgtk's wrapWebView() is internal and cannot be accessed,
36+// we manually construct the WebView struct hierarchy using CGO.
37+func NewBareRelatedWebView(parentView *webkit.WebView) *webkit.WebView {
38+ if parentView == nil {
39+ log.Printf("[webkit] NewBareRelatedWebView: parent is nil, creating unrelated WebView")
40+ return webkit.NewWebView()
41+ }
42+
43+ // Get the native C pointer from the gotk4 WebView
44+ parentObj := coreglib.InternObject(parentView)
45+ if parentObj == nil {
46+ log.Printf("[webkit] NewBareRelatedWebView: failed to get parent object, creating unrelated WebView")
47+ return webkit.NewWebView()
48+ }
49+ parentNative := (*C.WebKitWebView)(unsafe.Pointer(parentObj.Native()))
50+ if parentNative == nil {
51+ log.Printf("[webkit] NewBareRelatedWebView: parent native pointer is nil, creating unrelated WebView")
52+ return webkit.NewWebView()
53+ }
54+
55+ log.Printf("[webkit] Creating bare WebView with related-view property (parent=%p)", parentNative)
56+
57+ // Create related WebView using CGO helper with g_object_new
58+ log.Printf("[webkit] Step 3: About to call C.create_related_web_view")
59+ webViewNative := C.create_related_web_view(parentNative)
60+ log.Printf("[webkit] Step 4: C.create_related_web_view returned: %p", webViewNative)
61+
62+ if webViewNative == nil {
63+ log.Printf("[webkit] NewBareRelatedWebView: C.create_related_web_view returned nil")
64+ return nil
65+ }
66+
67+ // Wrap the C object in a coreglib.Object
68+ // This takes ownership of the floating reference
69+ log.Printf("[webkit] Step 5: About to call coreglib.Take")
70+ obj := coreglib.Take(unsafe.Pointer(webViewNative))
71+ log.Printf("[webkit] Step 6: coreglib.Take succeeded, obj=%p", obj)
72+
73+ // Manually construct the WebView struct hierarchy
74+ // WebView -> WebViewBase -> gtk.Widget -> coreglib.InitiallyUnowned -> coreglib.Object
75+ //
76+ // Since we cannot call the internal wrapWebView() function, we must
77+ // manually construct the full struct hierarchy using the same pattern
78+ // that gotk4 uses internally.
79+
80+ log.Printf("[webkit] Step 7: About to create gtk.Widget base")
81+ // Create the gtk.Widget base
82+ widget := gtk.Widget{
83+ InitiallyUnowned: coreglib.InitiallyUnowned{
84+ Object: obj,
85+ },
86+ Object: obj,
87+ Accessible: gtk.Accessible{
88+ Object: obj,
89+ },
90+ Buildable: gtk.Buildable{
91+ Object: obj,
92+ },
93+ ConstraintTarget: gtk.ConstraintTarget{
94+ Object: obj,
95+ },
96+ }
97+ log.Printf("[webkit] Step 8: Created gtk.Widget base")
98+
99+ // Create the WebViewBase
100+ log.Printf("[webkit] Step 9: About to create WebViewBase")
101+ webViewBase := webkit.WebViewBase{
102+ Widget: widget,
103+ }
104+ log.Printf("[webkit] Step 10: Created WebViewBase")
105+
106+ // Create the final WebView
107+ log.Printf("[webkit] Step 11: About to create final WebView")
108+ relatedView := &webkit.WebView{
109+ WebViewBase: webViewBase,
110+ }
111+ log.Printf("[webkit] Step 12: Created final WebView: %p", relatedView)
112+
113+ // Ensure parentView is kept alive until after the CGO call completes
114+ runtime.KeepAlive(parentView)
115+
116+ log.Printf("[webkit] Created bare related WebView (parent=%p)", parentNative)
117+ return relatedView
118+}
A pkg/webkit/widgets.go +290 -0
@@ -0,0 +1,290 @@
1+package webkit
2+
3+import (
4+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
5+ "github.com/diamondburned/gotk4/pkg/pango"
6+)
7+
8+// Paned wraps gtk.Paned for split pane layouts
9+type Paned struct {
10+ paned *gtk.Paned
11+}
12+
13+// NewPaned creates a new Paned widget with the given orientation
14+func NewPaned(orientation gtk.Orientation) *Paned {
15+ return &Paned{
16+ paned: gtk.NewPaned(orientation),
17+ }
18+}
19+
20+// SetStartChild sets the first child of the paned
21+func (p *Paned) SetStartChild(child gtk.Widgetter) {
22+ if p == nil || p.paned == nil {
23+ return
24+ }
25+ p.paned.SetStartChild(child)
26+}
27+
28+// SetEndChild sets the second child of the paned
29+func (p *Paned) SetEndChild(child gtk.Widgetter) {
30+ if p == nil || p.paned == nil {
31+ return
32+ }
33+ p.paned.SetEndChild(child)
34+}
35+
36+// SetPosition sets the divider position
37+func (p *Paned) SetPosition(pos int) {
38+ if p == nil || p.paned == nil {
39+ return
40+ }
41+ p.paned.SetPosition(pos)
42+}
43+
44+// GetPosition returns the divider position
45+func (p *Paned) GetPosition() int {
46+ if p == nil || p.paned == nil {
47+ return 0
48+ }
49+ return p.paned.Position()
50+}
51+
52+// SetResizeStartChild sets whether the start child should resize
53+func (p *Paned) SetResizeStartChild(resize bool) {
54+ if p == nil || p.paned == nil {
55+ return
56+ }
57+ p.paned.SetResizeStartChild(resize)
58+}
59+
60+// SetResizeEndChild sets whether the end child should resize
61+func (p *Paned) SetResizeEndChild(resize bool) {
62+ if p == nil || p.paned == nil {
63+ return
64+ }
65+ p.paned.SetResizeEndChild(resize)
66+}
67+
68+// AsWidget returns the paned as a Widgetter
69+func (p *Paned) AsWidget() gtk.Widgetter {
70+ if p == nil || p.paned == nil {
71+ return nil
72+ }
73+ return p.paned
74+}
75+
76+// StartChild returns the start child
77+func (p *Paned) StartChild() gtk.Widgetter {
78+ if p == nil || p.paned == nil {
79+ return nil
80+ }
81+ return p.paned.StartChild()
82+}
83+
84+// EndChild returns the end child
85+func (p *Paned) EndChild() gtk.Widgetter {
86+ if p == nil || p.paned == nil {
87+ return nil
88+ }
89+ return p.paned.EndChild()
90+}
91+
92+// Box wraps gtk.Box for layout management
93+type Box struct {
94+ box *gtk.Box
95+}
96+
97+// NewBox creates a new Box with the given orientation and spacing
98+func NewBox(orientation gtk.Orientation, spacing int) *Box {
99+ return &Box{
100+ box: gtk.NewBox(orientation, spacing),
101+ }
102+}
103+
104+// Append adds a child to the end of the box
105+func (b *Box) Append(child gtk.Widgetter) {
106+ if b == nil || b.box == nil {
107+ return
108+ }
109+ b.box.Append(child)
110+}
111+
112+// Prepend adds a child to the beginning of the box
113+func (b *Box) Prepend(child gtk.Widgetter) {
114+ if b == nil || b.box == nil {
115+ return
116+ }
117+ b.box.Prepend(child)
118+}
119+
120+// Remove removes a child from the box
121+func (b *Box) Remove(child gtk.Widgetter) {
122+ if b == nil || b.box == nil {
123+ return
124+ }
125+ b.box.Remove(child)
126+}
127+
128+// InsertChildAfter inserts a child after the specified sibling
129+func (b *Box) InsertChildAfter(child, sibling gtk.Widgetter) {
130+ if b == nil || b.box == nil {
131+ return
132+ }
133+ b.box.InsertChildAfter(child, sibling)
134+}
135+
136+// AsWidget returns the box as a Widgetter
137+func (b *Box) AsWidget() gtk.Widgetter {
138+ if b == nil || b.box == nil {
139+ return nil
140+ }
141+ return b.box
142+}
143+
144+// Label wraps gtk.Label for text display
145+type Label struct {
146+ label *gtk.Label
147+}
148+
149+// NewLabel creates a new Label with the given text
150+func NewLabel(text string) *Label {
151+ return &Label{
152+ label: gtk.NewLabel(text),
153+ }
154+}
155+
156+// SetText updates the label text
157+func (l *Label) SetText(text string) {
158+ if l == nil || l.label == nil {
159+ return
160+ }
161+ l.label.SetText(text)
162+}
163+
164+// GetText returns the current label text
165+func (l *Label) GetText() string {
166+ if l == nil || l.label == nil {
167+ return ""
168+ }
169+ return l.label.Text()
170+}
171+
172+// SetEllipsize sets the ellipsization mode
173+func (l *Label) SetEllipsize(mode pango.EllipsizeMode) {
174+ if l == nil || l.label == nil {
175+ return
176+ }
177+ l.label.SetEllipsize(mode)
178+}
179+
180+// SetMaxWidthChars sets the maximum width in characters
181+func (l *Label) SetMaxWidthChars(nChars int) {
182+ if l == nil || l.label == nil {
183+ return
184+ }
185+ l.label.SetMaxWidthChars(nChars)
186+}
187+
188+// AsWidget returns the label as a Widgetter
189+func (l *Label) AsWidget() gtk.Widgetter {
190+ if l == nil || l.label == nil {
191+ return nil
192+ }
193+ return l.label
194+}
195+
196+// Image wraps gtk.Image for displaying images (e.g., favicons)
197+type Image struct {
198+ image *gtk.Image
199+}
200+
201+// NewImage creates a new empty Image
202+func NewImage() *Image {
203+ return &Image{
204+ image: gtk.NewImage(),
205+ }
206+}
207+
208+// NewImageFromFile creates a new Image from a file
209+func NewImageFromFile(filename string) *Image {
210+ return &Image{
211+ image: gtk.NewImageFromFile(filename),
212+ }
213+}
214+
215+// SetFromFile loads an image from a file
216+func (i *Image) SetFromFile(filename string) {
217+ if i == nil || i.image == nil {
218+ return
219+ }
220+ i.image.SetFromFile(filename)
221+}
222+
223+// SetPixelSize sets the pixel size for the image
224+func (i *Image) SetPixelSize(size int) {
225+ if i == nil || i.image == nil {
226+ return
227+ }
228+ i.image.SetPixelSize(size)
229+}
230+
231+// AsWidget returns the image as a Widgetter
232+func (i *Image) AsWidget() gtk.Widgetter {
233+ if i == nil || i.image == nil {
234+ return nil
235+ }
236+ return i.image
237+}
238+
239+// Widget helper functions for common operations
240+
241+// getWidget extracts the underlying *gtk.Widget from a Widgetter
242+// This is needed because some methods are only available on *gtk.Widget
243+func getWidget(w gtk.Widgetter) *gtk.Widget {
244+ if w == nil {
245+ return nil
246+ }
247+ return gtk.BaseWidget(w)
248+}
249+
250+// WidgetShow makes a widget visible
251+func WidgetShow(w gtk.Widgetter) {
252+ if widget := getWidget(w); widget != nil {
253+ widget.SetVisible(true)
254+ }
255+}
256+
257+// WidgetHide makes a widget invisible
258+func WidgetHide(w gtk.Widgetter) {
259+ if widget := getWidget(w); widget != nil {
260+ widget.SetVisible(false)
261+ }
262+}
263+
264+// WidgetSetVisible sets the visibility of a widget
265+func WidgetSetVisible(w gtk.Widgetter, visible bool) {
266+ if widget := getWidget(w); widget != nil {
267+ widget.SetVisible(visible)
268+ }
269+}
270+
271+// WidgetSetSizeRequest sets the minimum size request
272+func WidgetSetSizeRequest(w gtk.Widgetter, width, height int) {
273+ if widget := getWidget(w); widget != nil {
274+ widget.SetSizeRequest(width, height)
275+ }
276+}
277+
278+// WidgetQueueResize queues a resize operation
279+func WidgetQueueResize(w gtk.Widgetter) {
280+ if widget := getWidget(w); widget != nil {
281+ widget.QueueResize()
282+ }
283+}
284+
285+// WidgetGrabFocus gives focus to a widget
286+func WidgetGrabFocus(w gtk.Widgetter) {
287+ if widget := getWidget(w); widget != nil {
288+ widget.GrabFocus()
289+ }
290+}
M pkg/webkit/window.go +71 -38
@@ -1,59 +1,92 @@
1-//go:build !webkit_cgo
2-
31 package webkit
42
5-import "sync"
3+import (
4+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
5+)
66
7-// Window represents a top-level application window hosting a WebView.
8-// Stub for future GTK application window management.
7+// Window wraps a GTK Window for the browser
98 type Window struct {
10- Title string
11- child uintptr
12- mu sync.Mutex
9+ win *gtk.Window
1310 }
1411
15-// NewWindow constructs a Window. In real implementation, this would create a GTK window.
12+// NewWindow creates a new GTK window with the given title
1613 func NewWindow(title string) (*Window, error) {
17- _ = title
18- return &Window{Title: title}, ErrNotImplemented
14+ InitMainThread()
15+
16+ win := gtk.NewWindow()
17+ if win == nil {
18+ return nil, ErrWebViewNotInitialized
19+ }
20+
21+ win.SetTitle(title)
22+ win.SetDefaultSize(1024, 768)
23+
24+ return &Window{win: win}, nil
1925 }
2026
21-// SetTitle updates the window title (no-op in non-CGO build).
27+// SetTitle updates the window title
2228 func (w *Window) SetTitle(title string) {
23- w.Title = title
29+ if w == nil || w.win == nil {
30+ return
31+ }
32+ w.win.SetTitle(title)
2433 }
2534
26-// SetChild is a stubbed window child setter for non-CGO builds.
27-// Simulates GTK4 behavior where setting child to 0 unparents the current child.
28-func (w *Window) SetChild(child uintptr) {
29- if w == nil {
35+// SetChild sets the child widget of the window
36+func (w *Window) SetChild(child gtk.Widgetter) {
37+ if w == nil || w.win == nil {
3038 return
3139 }
40+ w.win.SetChild(child)
41+}
3242
33- w.mu.Lock()
34- defer w.mu.Unlock()
43+// Show makes the window visible
44+// Note: In GTK4, Show() is deprecated. Use SetVisible(true) instead.
45+func (w *Window) Show() {
46+ if w == nil || w.win == nil {
47+ return
48+ }
49+ w.win.SetVisible(true)
50+}
3551
36- // GTK4 behavior: Setting child to 0 removes and potentially invalidates the current child
37- if child == 0 && w.child != 0 {
38- // GTK4 automatically unparents the current child but keeps the widget alive
39- widgetMu.Lock()
40- if stub, ok := widgetState[w.child]; ok {
41- stub.parent = 0
42- }
43- widgetMu.Unlock()
52+// Present brings the window to the front
53+func (w *Window) Present() {
54+ if w == nil || w.win == nil {
55+ return
4456 }
57+ w.win.Present()
58+}
4559
46- // GTK4 validation: if child is not 0, it must be a valid widget
47- if child != 0 {
48- widgetMu.Lock()
49- childStub, childExists := widgetState[child]
50- if !childExists || childStub.isDestroyed {
51- widgetMu.Unlock()
52- panic("GTK-CRITICAL simulation: gtk_window_set_child: assertion 'GTK_IS_WIDGET (widget)' failed - child is invalid")
53- }
54- childStub.parent = 1 // Simplified window handle
55- widgetMu.Unlock()
60+// Close destroys the window
61+func (w *Window) Close() {
62+ if w == nil || w.win == nil {
63+ return
5664 }
65+ w.win.Close()
66+}
5767
58- w.child = child
68+// AsWindow returns the underlying gtk.Window for advanced operations
69+func (w *Window) AsWindow() *gtk.Window {
70+ if w == nil {
71+ return nil
72+ }
73+ return w.win
5974 }
75+
76+// SetDefaultSize sets the default window size
77+func (w *Window) SetDefaultSize(width, height int) {
78+ if w == nil || w.win == nil {
79+ return
80+ }
81+ w.win.SetDefaultSize(width, height)
82+}
83+
84+// Destroy destroys the window
85+func (w *Window) Destroy() {
86+ if w == nil || w.win == nil {
87+ return
88+ }
89+ w.win.Destroy()
90+}
91+
92+// InitializeGlobalShortcuts is defined in shortcuts.go and implemented there
D pkg/webkit/window_cgo.go +0 -62
@@ -1,62 +0,0 @@
1-//go:build webkit_cgo
2-
3-package webkit
4-
5-/*
6-#cgo pkg-config: webkitgtk-6.0 gtk4
7-#cgo CFLAGS: -I/usr/include/webkitgtk-6.0
8-#include <stdlib.h>
9-#include <gtk/gtk.h>
10-*/
11-import "C"
12-
13-import (
14- "sync"
15- "unsafe"
16-)
17-
18-type Window struct {
19- win *C.GtkWidget
20- shortcutController *C.GtkShortcutController
21- shortcutCallbacks map[string]func()
22- shortcutHandles map[string]uintptr
23- shortcutMu sync.Mutex
24-}
25-
26-func NewWindow(title string) (*Window, error) {
27- if C.gtk_init_check() == 0 {
28- return nil, ErrNotImplemented
29- }
30-
31- InitMainThread()
32- // GTK4: gtk_window_new() returns GtkWindow*, no toplevel enum
33- w := (*C.GtkWidget)(unsafe.Pointer(C.gtk_window_new()))
34- if w == nil {
35- return nil, ErrNotImplemented
36- }
37- ctitle := C.CString(title)
38- defer C.free(unsafe.Pointer(ctitle))
39- C.gtk_window_set_title((*C.GtkWindow)(unsafe.Pointer(w)), (*C.gchar)(ctitle))
40- return &Window{win: w}, nil
41-}
42-
43-func (w *Window) SetTitle(title string) {
44- if w == nil || w.win == nil {
45- return
46- }
47- ctitle := C.CString(title)
48- defer C.free(unsafe.Pointer(ctitle))
49- C.gtk_window_set_title((*C.GtkWindow)(unsafe.Pointer(w.win)), (*C.gchar)(ctitle))
50-}
51-
52-// SetChild assigns the given widget as the GtkWindow child. Passing 0 removes the child.
53-func (w *Window) SetChild(child uintptr) {
54- if w == nil || w.win == nil {
55- return
56- }
57- var widget *C.GtkWidget
58- if child != 0 {
59- widget = (*C.GtkWidget)(unsafe.Pointer(child))
60- }
61- C.gtk_window_set_child((*C.GtkWindow)(unsafe.Pointer(w.win)), widget)
62-}
D pkg/webkit/window_handler_cgo.go +0 -72
@@ -1,72 +0,0 @@
1-//go:build webkit_cgo
2-
3-package webkit
4-
5-/*
6-#cgo pkg-config: gtk4 webkitgtk-6.0
7-#include <gtk/gtk.h>
8-#include <gdk/gdk.h>
9-#include <webkit/webkit.h>
10-
11-// Declarations for Go callbacks
12-extern GtkWidget* goHandleCreateWebView(unsigned long id, char* uri);
13-
14-// Handle create signal for popup windows - essential for window.open() support
15-static GtkWidget* on_create_web_view(WebKitWebView *web_view,
16- WebKitNavigationAction *navigation_action,
17- gpointer user_data) {
18- WebKitURIRequest *request = webkit_navigation_action_get_request(navigation_action);
19- const char* uri = webkit_uri_request_get_uri(request);
20-
21- printf("[webkit-create] Create signal for URI: %s\n", uri ? uri : "NULL");
22-
23- unsigned long parent_view_id = (unsigned long)user_data;
24-
25- // Let Go handler create WebView and return it like Epiphany does
26- GtkWidget* new_webview = goHandleCreateWebView(parent_view_id, (char*)uri);
27-
28- if (new_webview) {
29- printf("[webkit-create] Go handler provided WebView - using workspace pane\n");
30- return new_webview;
31- } else {
32- printf("[webkit-create] Go handler declined - allowing native popup window\n");
33- return NULL; // Let WebKit create native popup window
34- }
35-}
36-
37-static void connect_create_signal(WebKitWebView* web_view, unsigned long id) {
38- if (!web_view) return;
39- g_signal_connect_data(G_OBJECT(web_view), "create", G_CALLBACK(on_create_web_view), (gpointer)id, NULL, 0);
40- printf("[webkit-debug] create-web-view callback ENABLED with WebKit-created WebViews\n");
41-}
42-*/
43-import "C"
44-import (
45- "log"
46- "unsafe"
47-)
48-
49-//export goHandleCreateWebView
50-func goHandleCreateWebView(parentID C.ulong, curi *C.char) *C.GtkWidget {
51- uri := ""
52- if curi != nil {
53- uri = C.GoString(curi)
54- }
55-
56- log.Printf("[webkit] Create signal bypassed: URI=%s (handled by JavaScript bypass)", uri)
57-
58- // Always return NULL - window.open is now handled entirely by JavaScript bypass
59- // This prevents duplicate pane creation from WebKit's create signal
60- return nil
61-}
62-
63-// ConnectCreateSignal connects the create signal to a WebView
64-func ConnectCreateSignal(view *WebView) {
65- if view == nil || view.native == nil || view.native.view == nil {
66- log.Printf("[webkit] ConnectCreateSignal: invalid WebView")
67- return
68- }
69-
70- webkitView := (*C.WebKitWebView)(unsafe.Pointer(view.native.view))
71- C.connect_create_signal(webkitView, C.ulong(view.id))
72-}
D pkg/webkit/window_shortcuts.go +0 -160
@@ -1,160 +0,0 @@
1-//go:build webkit_cgo
2-
3-package webkit
4-
5-import (
6- "log"
7- "strings"
8- "unsafe"
9-)
10-
11-// No C imports needed - using Go wrapper functions from shortcuts_cgo.go
12-
13-// WindowShortcuts manages global shortcuts at the window level
14-type WindowShortcuts struct {
15- window *Window
16- controller uintptr // *C.GtkShortcutController
17- callbacks map[string]uintptr
18-}
19-
20-// InitializeGlobalShortcuts sets up the GTK4 global shortcut controller
21-func (w *Window) InitializeGlobalShortcuts() *WindowShortcuts {
22- if w.win == nil {
23- log.Printf("[window-shortcuts] Cannot initialize - invalid window")
24- return nil
25- }
26-
27- controller := CreateGlobalShortcutController(uintptr(unsafe.Pointer(w.win)))
28- if controller == 0 {
29- log.Printf("[window-shortcuts] Failed to create shortcut controller")
30- return nil
31- }
32-
33- ws := &WindowShortcuts{
34- window: w,
35- controller: controller,
36- callbacks: make(map[string]uintptr),
37- }
38-
39- log.Printf("[window-shortcuts] Global shortcut controller initialized")
40- return ws
41-}
42-
43-// RegisterGlobalShortcut registers a shortcut at the window level with global scope
44-func (ws *WindowShortcuts) RegisterGlobalShortcut(key string, callback func()) error {
45- if ws == nil || ws.controller == 0 {
46- return ErrNotImplemented
47- }
48-
49- // Convert key format (e.g., "ctrl+l" -> "<Control>l")
50- gtkKey := convertToGtkFormat(key)
51- if gtkKey == "" {
52- log.Printf("[window-shortcuts] Invalid key format: %s", key)
53- return ErrNotImplemented
54- }
55-
56- // Register callback and get handle
57- handle := registerWindowShortcutCallback(callback)
58- if handle == 0 {
59- log.Printf("[window-shortcuts] Failed to register callback for %s", key)
60- return ErrNotImplemented
61- }
62-
63- // Store handle for cleanup
64- ws.callbacks[key] = handle
65-
66- // Register with GTK using Go wrapper function
67- if err := AddShortcutToController(ws.controller, gtkKey, handle); err != nil {
68- log.Printf("[window-shortcuts] Failed to add shortcut to controller: %v", err)
69- unregisterWindowShortcutCallback(handle)
70- delete(ws.callbacks, key)
71- return err
72- }
73-
74- // Only add Alt+Arrow and Ctrl+W shortcuts to global blocking registry
75- // All other window shortcuts should bubble up to GTK for proper handling
76- if strings.HasPrefix(key, "alt+Arrow") {
77- RegisterGlobalShortcutWithHandle(key, handle)
78- } else if key == "ctrl+w" {
79- // Ctrl+W should also have callback handle for consistent pane closing
80- RegisterGlobalShortcutWithHandle(key, handle)
81- }
82- // Other shortcuts (Ctrl+L, Ctrl+F, zoom) are NOT registered for blocking
83-
84- log.Printf("[window-shortcuts] Registered global shortcut: %s -> %s", key, gtkKey)
85- return nil
86-}
87-
88-// Cleanup unregisters all shortcuts and cleans up resources
89-func (ws *WindowShortcuts) Cleanup() {
90- if ws == nil {
91- return
92- }
93-
94- // Unregister all callbacks
95- for key, handle := range ws.callbacks {
96- unregisterWindowShortcutCallback(handle)
97- log.Printf("[window-shortcuts] Unregistered shortcut: %s", key)
98- }
99-
100- ws.callbacks = nil
101- ws.controller = 0
102-}
103-
104-// convertToGtkFormat converts common key formats to GTK shortcut format
105-func convertToGtkFormat(key string) string {
106- // Convert from electron/webkit format to GTK format
107- key = strings.ToLower(key)
108-
109- // Handle cmdorctrl -> Control
110- key = strings.ReplaceAll(key, "cmdorctrl+", "ctrl+")
111- key = strings.ReplaceAll(key, "cmd+", "ctrl+")
112-
113- // Build GTK format
114- var parts []string
115- components := strings.Split(key, "+")
116-
117- for i, part := range components {
118- if i == len(components)-1 {
119- // Last part is the key
120- switch part {
121- case "f12":
122- parts = append(parts, "F12")
123- case "f5":
124- parts = append(parts, "F5")
125- case "l":
126- parts = append(parts, "l")
127- case "f":
128- parts = append(parts, "f")
129- case "r":
130- parts = append(parts, "r")
131- case "c":
132- parts = append(parts, "c")
133- case "left", "arrowleft":
134- parts = append(parts, "Left")
135- case "right", "arrowright":
136- parts = append(parts, "Right")
137- case "up", "arrowup":
138- parts = append(parts, "Up")
139- case "down", "arrowdown":
140- parts = append(parts, "Down")
141- default:
142- parts = append(parts, part)
143- }
144- } else {
145- // Modifier
146- switch part {
147- case "ctrl":
148- parts = append(parts, "<Control>")
149- case "shift":
150- parts = append(parts, "<Shift>")
151- case "alt":
152- parts = append(parts, "<Alt>")
153- case "super":
154- parts = append(parts, "<Super>")
155- }
156- }
157- }
158-
159- return strings.Join(parts, "")
160-}
D pkg/webkit/window_shortcuts_stub.go +0 -45
@@ -1,45 +0,0 @@
1-//go:build !webkit_cgo
2-
3-package webkit
4-
5-import "log"
6-
7-// WindowShortcuts stub implementation for non-CGO builds
8-type WindowShortcuts struct {
9- window *Window
10-}
11-
12-// InitializeGlobalShortcuts returns a stub implementation for non-CGO builds
13-func (w *Window) InitializeGlobalShortcuts() *WindowShortcuts {
14- log.Printf("[window-shortcuts] Stub implementation - global shortcuts not available without webkit_cgo")
15- return &WindowShortcuts{
16- window: w,
17- }
18-}
19-
20-// RegisterGlobalShortcut stub - always returns nil for non-CGO builds
21-func (ws *WindowShortcuts) RegisterGlobalShortcut(key string, callback func()) error {
22- log.Printf("[window-shortcuts] Stub: would register %s (webkit_cgo required for actual functionality)", key)
23- return nil
24-}
25-
26-// Cleanup stub implementation
27-func (ws *WindowShortcuts) Cleanup() {
28- log.Printf("[window-shortcuts] Stub cleanup")
29-}
30-
31-// Stub implementations for Go wrapper functions
32-func CreateGlobalShortcutController(window uintptr) uintptr {
33- return 0
34-}
35-
36-func AddShortcutToController(controller uintptr, key string, handle uintptr) error {
37- return nil
38-}
39-
40-func registerWindowShortcutCallback(cb func()) uintptr {
41- return 0
42-}
43-
44-func unregisterWindowShortcutCallback(id uintptr) {
45-}
D pkg/webkit/window_types.go +0 -23
@@ -1,23 +0,0 @@
1-package webkit
2-
3-// WindowType indicates how a new WebView should be treated
4-type WindowType int
5-
6-const (
7- // WindowTypeTab represents an independent WebView (future: tab)
8- WindowTypeTab WindowType = iota
9- // WindowTypePopup represents a related WebView (shares process/context)
10- WindowTypePopup
11- // WindowTypeUnknown indicates type not detected yet
12- WindowTypeUnknown
13-)
14-
15-// WindowFeatures describes the window features detected from WebKitWindowProperties
16-type WindowFeatures struct {
17- Width int
18- Height int
19- ToolbarVisible bool
20- LocationbarVisible bool
21- MenubarVisible bool
22- Resizable bool
23-}
D pkg/webkit/workspace_widgets_cgo.go +0 -1350
@@ -1,1350 +0,0 @@
1-//go:build webkit_cgo
2-
3-package webkit
4-
5-/*
6-#cgo pkg-config: gtk4
7-#include <gtk/gtk.h>
8-#include <gtk/gtktestutils.h>
9-#include <gtk/gtkbox.h>
10-#include <gtk/gtkpaned.h>
11-#include <gdk/gdk.h>
12-#include <glib.h>
13-#include <glib-object.h>
14-#include <graphene.h>
15-#include <pthread.h>
16-#include <stdint.h>
17-#include <stdlib.h>
18-
19-// Logging helper for diagnostics
20-#define WORKSPACE_LOG(fmt, ...) g_printerr("[workspace-cgo] " fmt "\n", ##__VA_ARGS__)
21-
22-// Helper functions for widget type checking
23-static gboolean is_paned_widget(GtkWidget *widget) {
24- return GTK_IS_PANED(widget);
25-}
26-
27-static gboolean is_box_widget(GtkWidget *widget) {
28- return GTK_IS_BOX(widget);
29-}
30-
31-// Callback function for idle handling following the existing pattern
32-extern gboolean goIdleCallback(uintptr_t handle);
33-extern void goHoverCallback(uintptr_t handle);
34-extern void goFocusEnterCallback(uintptr_t handle);
35-extern void goFocusLeaveCallback(uintptr_t handle);
36-extern void goTitleBarClickCallback(uintptr_t handle);
37-
38-// Main thread helpers implemented in thread_helpers.c
39-void store_main_thread_id();
40-int is_main_thread();
41-int iterate_main_loop();
42-
43-static gboolean idle_callback_wrapper(gpointer data) {
44- return goIdleCallback((uintptr_t)data);
45-}
46-
47-static guint add_idle_callback(uintptr_t handle) {
48- return g_idle_add_full(G_PRIORITY_DEFAULT_IDLE, idle_callback_wrapper, (gpointer)handle, NULL);
49-}
50-
51-static gboolean remove_idle_callback(uintptr_t handle) {
52- return g_idle_remove_by_data((gpointer)handle);
53-}
54-
55-// Title bar click callback
56-static void title_bar_click_cb(GtkGestureClick* gesture, gint n_press, gdouble x, gdouble y, gpointer user_data) {
57- (void)gesture; (void)n_press; (void)x; (void)y;
58- uintptr_t handle = (uintptr_t)user_data;
59- goTitleBarClickCallback(handle);
60-}
61-
62-// Attach click handler to a widget (for title bars)
63-static void widget_attach_click_handler(GtkWidget* widget, uintptr_t handle) {
64- if (!widget) {
65- return;
66- }
67- GtkGesture* click = gtk_gesture_click_new();
68- if (!click) {
69- return;
70- }
71- g_signal_connect_data(click, "pressed", G_CALLBACK(title_bar_click_cb), (gpointer)handle, NULL, 0);
72- gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(click));
73-}
74-
75-static void motion_enter_cb(GtkEventControllerMotion* controller, gdouble x, gdouble y, gpointer user_data) {
76- (void)controller; (void)x; (void)y;
77- uintptr_t handle = (uintptr_t)user_data;
78- goHoverCallback(handle);
79-}
80-
81-static GtkEventController* widget_add_hover_controller(GtkWidget* widget, uintptr_t handle) {
82- if (!widget) {
83- return NULL;
84- }
85- GtkEventController* motion = gtk_event_controller_motion_new();
86- if (!motion) {
87- return NULL;
88- }
89- gtk_event_controller_set_propagation_phase(motion, GTK_PHASE_CAPTURE);
90- g_signal_connect_data(motion, "enter", G_CALLBACK(motion_enter_cb), (gpointer)handle, NULL, 0);
91- gtk_widget_add_controller(widget, motion);
92- return motion;
93-}
94-
95-static void widget_remove_controller(GtkWidget* widget, GtkEventController* controller) {
96- if (!widget || !controller) {
97- return;
98- }
99- gtk_widget_remove_controller(widget, controller);
100-}
101-
102-static gboolean widget_get_bounds(GtkWidget* widget, double* x, double* y, double* width, double* height) {
103- if (!widget || !x || !y || !width || !height) {
104- return FALSE;
105- }
106- GtkWidget* root = GTK_WIDGET(gtk_widget_get_root(widget));
107- if (!root) {
108- return FALSE;
109- }
110- graphene_rect_t rect;
111- if (!gtk_widget_compute_bounds(widget, root, &rect)) {
112- return FALSE;
113- }
114- *x = rect.origin.x;
115- *y = rect.origin.y;
116- *width = rect.size.width;
117- *height = rect.size.height;
118- return TRUE;
119-}
120-
121-// Widget validation helper to prevent crashes from invalid widget pointers
122-static gboolean is_valid_widget(gpointer widget) {
123- return widget != NULL && GTK_IS_WIDGET(widget);
124-}
125-
126-// Forward declarations for focus controller callbacks defined later in this file
127-static void focus_enter_cb(GtkEventControllerFocus* controller, gpointer user_data);
128-static void focus_leave_cb(GtkEventControllerFocus* controller, gpointer user_data);
129-
130-typedef struct {
131- GtkEventController* controller;
132- GtkWidget* widget;
133- gboolean is_attached;
134-} ControllerTracker;
135-
136-static GHashTable* controller_registry = NULL;
137-
138-static void init_controller_registry(void) {
139- if (controller_registry == NULL) {
140- controller_registry = g_hash_table_new_full(g_direct_hash, g_direct_equal, NULL, g_free);
141- }
142-}
143-
144-static void controller_on_widget_destroy(GtkWidget* widget, gpointer user_data) {
145- (void)widget;
146- ControllerTracker* tracker = (ControllerTracker*)user_data;
147- if (!tracker) {
148- return;
149- }
150- tracker->is_attached = FALSE;
151-}
152-
153-static GtkEventController* widget_add_focus_controller(GtkWidget* widget, uintptr_t nodePtr) {
154- if (!widget) {
155- return NULL;
156- }
157-
158- init_controller_registry();
159-
160- GtkEventController* focus = gtk_event_controller_focus_new();
161- if (!focus) {
162- return NULL;
163- }
164-
165- gtk_event_controller_set_propagation_phase(focus, GTK_PHASE_CAPTURE);
166- g_signal_connect(focus, "enter", G_CALLBACK(focus_enter_cb), (gpointer)nodePtr);
167- g_signal_connect(focus, "leave", G_CALLBACK(focus_leave_cb), (gpointer)nodePtr);
168-
169- ControllerTracker* tracker = g_new0(ControllerTracker, 1);
170- tracker->controller = focus;
171- tracker->widget = widget;
172- tracker->is_attached = TRUE;
173-
174- g_hash_table_insert(controller_registry, focus, tracker);
175-
176- g_signal_connect(widget, "destroy", G_CALLBACK(controller_on_widget_destroy), tracker);
177-
178- gtk_widget_add_controller(widget, focus);
179- return focus;
180-}
181-
182-static void widget_remove_focus_controller(GtkWidget* widget, GtkEventController* controller) {
183- if (!controller_registry || !controller) {
184- return;
185- }
186-
187- ControllerTracker* tracker = g_hash_table_lookup(controller_registry, controller);
188- if (!tracker) {
189- return;
190- }
191-
192- if (!tracker->is_attached) {
193- return;
194- }
195-
196- if (!GTK_IS_WIDGET(widget)) {
197- tracker->is_attached = FALSE;
198- return;
199- }
200-
201- GtkWidget* owner = gtk_event_controller_get_widget(controller);
202- if (GTK_IS_WIDGET(owner) && owner == widget) {
203- gtk_widget_remove_controller(widget, controller);
204- }
205-
206- tracker->is_attached = FALSE;
207-
208- // Remove from registry; GTK drops the final reference when removing controller
209- g_hash_table_remove(controller_registry, controller);
210-}
211-
212-static GtkWidget* paned_get_start_child(GtkWidget* paned) {
213- if (!paned) return NULL;
214- return gtk_paned_get_start_child(GTK_PANED(paned));
215-}
216-
217-static GtkWidget* paned_get_end_child(GtkWidget* paned) {
218- if (!paned) return NULL;
219- return gtk_paned_get_end_child(GTK_PANED(paned));
220-}
221-
222-static void on_widget_destroyed(GtkWidget* widget, gpointer user_data) {
223- (void)user_data;
224- guint ref_count = widget ? G_OBJECT(widget)->ref_count : 0;
225- WORKSPACE_LOG("widget destroyed: %p ref_count=%u", widget, ref_count);
226-}
227-
228-static void hook_destroy_signal(GtkWidget* widget) {
229- if (!widget) return;
230- g_signal_connect(widget, "destroy", G_CALLBACK(on_widget_destroyed), NULL);
231-}
232-
233-static unsigned int widget_get_ref_count(GtkWidget* widget) {
234- if (!widget) {
235- return 0;
236- }
237- return G_OBJECT(widget)->ref_count;
238-}
239-
240-// Widget realization helpers for proper WebView reparenting
241-// Force widget realization and ensure it's ready for rendering
242-static void ensure_widget_realized(GtkWidget* widget) {
243- if (!widget) return;
244-
245- // Make widget visible first
246- gtk_widget_set_visible(widget, TRUE);
247-
248- // For WebKitWebView, we need to ensure the widget is properly sized
249- gtk_widget_set_size_request(widget, 100, 100); // Minimum size to force allocation
250-
251- // Queue a redraw to force the rendering context to reinitialize
252- gtk_widget_queue_draw(widget);
253- WORKSPACE_LOG("ensure_widget_realized: widget=%p", widget);
254-}
255-
256-static void add_css_provider(const char* css) {
257- if (!css) {
258- return;
259- }
260- GtkCssProvider* provider = gtk_css_provider_new();
261- if (!provider) {
262- return;
263- }
264- gtk_css_provider_load_from_string(provider, css);
265- GdkDisplay* display = gdk_display_get_default();
266- if (display) {
267- gtk_style_context_add_provider_for_display(display, GTK_STYLE_PROVIDER(provider), GTK_STYLE_PROVIDER_PRIORITY_APPLICATION);
268- }
269- g_object_unref(provider);
270-}
271-
272-static void widget_add_css_class(GtkWidget* widget, const char* name) {
273- if (!widget || !name) return;
274- gtk_widget_add_css_class(widget, name);
275-}
276-
277-static void widget_remove_css_class(GtkWidget* widget, const char* name) {
278- if (!widget || !name) return;
279- gtk_widget_remove_css_class(widget, name);
280-}
281-
282-// GtkBox helpers for stacked panes
283-static GtkWidget* box_new(GtkOrientation orientation, int spacing) {
284- return gtk_box_new(orientation, spacing);
285-}
286-
287-static void box_append(GtkWidget* box, GtkWidget* child) {
288- if (!box || !child) return;
289- gtk_box_append(GTK_BOX(box), child);
290-}
291-
292-static void box_prepend(GtkWidget* box, GtkWidget* child) {
293- if (!box || !child) return;
294- gtk_box_prepend(GTK_BOX(box), child);
295-}
296-
297-static void box_remove(GtkWidget* box, GtkWidget* child) {
298- if (!box || !child) return;
299- gtk_box_remove(GTK_BOX(box), child);
300-}
301-
302-static void box_insert_child_after(GtkWidget* box, GtkWidget* child, GtkWidget* sibling) {
303- if (!box || !child) return;
304- gtk_box_insert_child_after(GTK_BOX(box), child, sibling);
305-}
306-
307-// Widget visibility helpers for stacked panes
308-static void widget_set_visible(GtkWidget* widget, gboolean visible) {
309- if (!widget) return;
310- gtk_widget_set_visible(widget, visible);
311-}
312-
313-
314-static gboolean widget_get_visible(GtkWidget* widget) {
315- if (!widget) return FALSE;
316- return gtk_widget_get_visible(widget);
317-}
318-
319-static void widget_hide(GtkWidget* widget) {
320- if (!widget) return;
321- gtk_widget_set_visible(widget, FALSE);
322-}
323-
324-static void widget_show(GtkWidget* widget) {
325- if (!widget) return;
326- gtk_widget_set_visible(widget, TRUE);
327-}
328-
329-// workspace_gtk_test_init removed - moved to test files only
330-
331-// Label helpers for title bars
332-static GtkWidget* label_new(const char* text) {
333- return gtk_label_new(text);
334-}
335-
336-static void label_set_text(GtkWidget* label, const char* text) {
337- if (!label || !text) return;
338- gtk_label_set_text(GTK_LABEL(label), text);
339-}
340-
341-static const char* label_get_text(GtkWidget* label) {
342- if (!label) return NULL;
343- return gtk_label_get_text(GTK_LABEL(label));
344-}
345-
346-static void label_set_ellipsize(GtkWidget* label, int mode) {
347- if (!label) return;
348- gtk_label_set_ellipsize(GTK_LABEL(label), (PangoEllipsizeMode)mode);
349-}
350-
351-static void label_set_max_width_chars(GtkWidget* label, int n_chars) {
352- if (!label) return;
353- gtk_label_set_max_width_chars(GTK_LABEL(label), n_chars);
354-}
355-
356-// GTK4 Focus Controller helpers for focus state machine
357-static void focus_enter_cb(GtkEventControllerFocus* controller, gpointer user_data) {
358- (void)controller;
359- uintptr_t nodePtr = (uintptr_t)user_data;
360- goFocusEnterCallback(nodePtr);
361-}
362-
363-static void focus_leave_cb(GtkEventControllerFocus* controller, gpointer user_data) {
364- (void)controller;
365- uintptr_t nodePtr = (uintptr_t)user_data;
366- goFocusLeaveCallback(nodePtr);
367-}
368-
369-// Widget allocation helper using modern GTK4 API
370-static void widget_get_allocation_modern(GtkWidget* widget, int* x, int* y, int* width, int* height) {
371- if (!widget || !x || !y || !width || !height) {
372- *x = *y = *width = *height = 0;
373- return;
374- }
375-
376- graphene_rect_t bounds;
377- // Compute bounds relative to the widget itself (pass widget as target)
378- if (gtk_widget_compute_bounds(widget, widget, &bounds)) {
379- // Extract integer coordinates from the graphene rect
380- *x = (int)bounds.origin.x;
381- *y = (int)bounds.origin.y;
382- *width = (int)bounds.size.width;
383- *height = (int)bounds.size.height;
384- } else {
385- // Fallback if compute_bounds fails
386- *x = *y = 0;
387- *width = gtk_widget_get_width(widget);
388- *height = gtk_widget_get_height(widget);
389- }
390-}
391-
392-// GtkImage helpers for favicon display
393-static GtkWidget* image_new(void) {
394- return gtk_image_new();
395-}
396-
397-static GtkWidget* image_new_from_file(const char* filename) {
398- if (!filename) return NULL;
399- return gtk_image_new_from_file(filename);
400-}
401-
402-static void image_set_from_file(GtkWidget* image, const char* filename) {
403- if (!image || !filename) return;
404- gtk_image_set_from_file(GTK_IMAGE(image), filename);
405-}
406-
407-static void image_set_pixel_size(GtkWidget* image, int pixel_size) {
408- if (!image) return;
409- gtk_image_set_pixel_size(GTK_IMAGE(image), pixel_size);
410-}
411-*/
412-import "C"
413-
414-import (
415- "log"
416- "sync"
417- "unsafe"
418-)
419-
420-// WidgetBounds captures absolute widget bounds relative to the toplevel window.
421-type WidgetBounds struct {
422- X float64
423- Y float64
424- Width float64
425- Height float64
426-}
427-
428-// FocusCallbacks holds the enter and leave callbacks for focus events
429-type FocusCallbacks struct {
430- OnEnter func()
431- OnLeave func()
432-}
433-
434-// WidgetAllocation represents a widget's allocation (position and size)
435-type WidgetAllocation struct {
436- X int
437- Y int
438- Width int
439- Height int
440-}
441-
442-var (
443- hoverMu sync.Mutex
444- hoverCallbacks = make(map[uintptr]func())
445- hoverControllers = make(map[uintptr]uintptr)
446- nextHoverID uintptr = 1
447- // gtkTestInitOnce removed - no longer needed
448-
449- // Focus controller management
450- focusMu sync.Mutex
451- focusCallbacks = make(map[uintptr]FocusCallbacks)
452- focusControllers = make(map[uintptr]uintptr)
453- nextFocusID uintptr = 1
454-
455- // Title bar click management
456- titleBarMu sync.Mutex
457- titleBarCallbacks = make(map[uintptr]func())
458- nextTitleBarID uintptr = 1
459-
460- mainThreadInitialized bool
461-)
462-
463-// InitMainThread should be called once during gtk_init to store the main thread ID.
464-func InitMainThread() {
465- if !mainThreadInitialized {
466- C.store_main_thread_id()
467- mainThreadInitialized = true
468- }
469-}
470-
471-// IsMainThread returns true if the current goroutine is running on the GTK main thread.
472-func IsMainThread() bool {
473- if !mainThreadInitialized {
474- return false
475- }
476- return C.is_main_thread() == 1
477-}
478-
479-func widgetIsValid(widget uintptr) bool {
480- if widget == 0 {
481- return false
482- }
483- return C.is_valid_widget(C.gpointer(unsafe.Pointer(widget))) != 0
484-}
485-
486-func widgetRefCount(widget uintptr) uint {
487- if widget == 0 || !widgetIsValid(widget) {
488- return 0
489- }
490- return uint(C.widget_get_ref_count((*C.GtkWidget)(unsafe.Pointer(widget))))
491-}
492-
493-// WidgetIsValid exposes GTK_IS_WIDGET checks for diagnostics in Go.
494-func WidgetIsValid(widget uintptr) bool {
495- return widgetIsValid(widget)
496-}
497-
498-// IsPaned checks if a widget is a GtkPaned container.
499-func IsPaned(widget uintptr) bool {
500- if widget == 0 {
501- return false
502- }
503- return C.is_paned_widget((*C.GtkWidget)(unsafe.Pointer(widget))) != 0
504-}
505-
506-// IsBox checks if a widget is a GtkBox container.
507-func IsBox(widget uintptr) bool {
508- if widget == 0 {
509- return false
510- }
511- return C.is_box_widget((*C.GtkWidget)(unsafe.Pointer(widget))) != 0
512-}
513-
514-// WidgetRefCount exposes the widget reference count for debugging.
515-func WidgetRefCount(widget uintptr) uint {
516- return widgetRefCount(widget)
517-}
518-
519-// WidgetHookDestroy attaches a destroy signal handler for diagnostics (debug builds).
520-func WidgetHookDestroy(widget uintptr) {
521- if widget == 0 || !widgetIsValid(widget) {
522- return
523- }
524- C.hook_destroy_signal((*C.GtkWidget)(unsafe.Pointer(widget)))
525-}
526-
527-// AddCSSProvider installs application-level CSS rules.
528-func AddCSSProvider(css string) {
529- if css == "" {
530- return
531- }
532- cstr := C.CString(css)
533- defer C.free(unsafe.Pointer(cstr))
534- C.add_css_provider(cstr)
535-}
536-
537-// WidgetAddCSSClass attaches a CSS class to a widget.
538-func WidgetAddCSSClass(widget uintptr, class string) {
539- if widget == 0 || class == "" || !widgetIsValid(widget) {
540- return
541- }
542- cstr := C.CString(class)
543- defer C.free(unsafe.Pointer(cstr))
544- C.widget_add_css_class((*C.GtkWidget)(unsafe.Pointer(widget)), cstr)
545-}
546-
547-// WidgetRemoveCSSClass removes a CSS class from a widget.
548-func WidgetRemoveCSSClass(widget uintptr, class string) {
549- if widget == 0 || class == "" || !widgetIsValid(widget) {
550- return
551- }
552- cstr := C.CString(class)
553- defer C.free(unsafe.Pointer(cstr))
554- C.widget_remove_css_class((*C.GtkWidget)(unsafe.Pointer(widget)), cstr)
555-}
556-
557-// Orientation mirrors GtkOrientation for workspace layout helpers.
558-type Orientation int
559-
560-const (
561- OrientationHorizontal Orientation = Orientation(C.GTK_ORIENTATION_HORIZONTAL)
562- OrientationVertical Orientation = Orientation(C.GTK_ORIENTATION_VERTICAL)
563-)
564-
565-func goBool(b bool) C.gboolean {
566- if b {
567- return C.gboolean(1)
568- }
569- return C.gboolean(0)
570-}
571-
572-// NewPaned constructs a GtkPaned widget for the given orientation.
573-func NewPaned(orientation Orientation) uintptr {
574- paned := C.gtk_paned_new(C.GtkOrientation(orientation))
575- return uintptr(unsafe.Pointer(paned))
576-}
577-
578-// PanedSetStartChild assigns the start child of a GtkPaned.
579-func PanedSetStartChild(paned uintptr, child uintptr) {
580- if child != 0 {
581- // Check if widget already has a parent and force unparent if needed
582- parent := C.gtk_widget_get_parent((*C.GtkWidget)(unsafe.Pointer(child)))
583- if parent != nil {
584- C.gtk_widget_unparent((*C.GtkWidget)(unsafe.Pointer(child)))
585- }
586- }
587- C.gtk_paned_set_start_child((*C.GtkPaned)(unsafe.Pointer(paned)), (*C.GtkWidget)(unsafe.Pointer(child)))
588-}
589-
590-// PanedSetEndChild assigns the end child of a GtkPaned.
591-func PanedSetEndChild(paned uintptr, child uintptr) {
592- if child != 0 {
593- // Check if widget already has a parent and force unparent if needed
594- parent := C.gtk_widget_get_parent((*C.GtkWidget)(unsafe.Pointer(child)))
595- if parent != nil {
596- C.gtk_widget_unparent((*C.GtkWidget)(unsafe.Pointer(child)))
597- }
598- }
599- C.gtk_paned_set_end_child((*C.GtkPaned)(unsafe.Pointer(paned)), (*C.GtkWidget)(unsafe.Pointer(child)))
600-}
601-
602-// PanedGetStartChild returns the start child pointer for diagnostics.
603-func PanedGetStartChild(paned uintptr) uintptr {
604- if paned == 0 {
605- return 0
606- }
607- child := C.paned_get_start_child((*C.GtkWidget)(unsafe.Pointer(paned)))
608- return uintptr(unsafe.Pointer(child))
609-}
610-
611-// PanedGetEndChild returns the end child pointer for diagnostics.
612-func PanedGetEndChild(paned uintptr) uintptr {
613- if paned == 0 {
614- return 0
615- }
616- child := C.paned_get_end_child((*C.GtkWidget)(unsafe.Pointer(paned)))
617- return uintptr(unsafe.Pointer(child))
618-}
619-
620-// PanedSetResizeStart configures whether the start child should expand when the paned is resized.
621-func PanedSetResizeStart(paned uintptr, resize bool) {
622- C.gtk_paned_set_resize_start_child((*C.GtkPaned)(unsafe.Pointer(paned)), goBool(resize))
623-}
624-
625-// PanedSetResizeEnd configures whether the end child should expand when the paned is resized.
626-func PanedSetResizeEnd(paned uintptr, resize bool) {
627- C.gtk_paned_set_resize_end_child((*C.GtkPaned)(unsafe.Pointer(paned)), goBool(resize))
628-}
629-
630-// PanedSetPosition sets the pixel position of the divider.
631-func PanedSetPosition(paned uintptr, pos int) {
632- if paned == 0 {
633- return
634- }
635- C.gtk_paned_set_position((*C.GtkPaned)(unsafe.Pointer(paned)), C.int(pos))
636-}
637-
638-// PanedGetPosition gets the current position of the divider in a GtkPaned
639-func PanedGetPosition(paned uintptr) int {
640- if paned == 0 {
641- return 0
642- }
643- if !widgetIsValid(paned) {
644- return 0
645- }
646- return int(C.gtk_paned_get_position((*C.GtkPaned)(unsafe.Pointer(paned))))
647-}
648-
649-// WidgetUnparent detaches a widget from its current parent.
650-func WidgetUnparent(widget uintptr) {
651- if ok := WidgetUnparentChecked(widget); !ok {
652- log.Printf("[workspace] WidgetUnparent skipped for widget=%#x", widget)
653- }
654-}
655-
656-// WidgetUnparentChecked safely detaches a widget from its parent if still valid.
657-//
658-// Return semantics:
659-// - Returns false if the widget pointer is zero or not a valid GTK widget (invalid input).
660-// - Returns true if the widget has no parent (already detached).
661-// - Returns true if the widget was successfully unparented (parent changed or is now nil).
662-// - Returns false if the widget is still valid and the parent did not change (unparent failed).
663-//
664-// Callers can use the return value to distinguish between invalid input, already-detached widgets, and successful unparenting.
665-func WidgetUnparentChecked(widget uintptr) bool {
666- if widget == 0 {
667- log.Printf("[workspace] WidgetUnparentChecked: widget=0")
668- return false
669- }
670-
671- if !widgetIsValid(widget) {
672- log.Printf("[workspace] WidgetUnparentChecked: widget=%#x invalid before unparent", widget)
673- return false
674- }
675-
676- parentBefore := WidgetGetParent(widget)
677- if parentBefore == 0 {
678- log.Printf("[workspace] WidgetUnparentChecked: widget=%#x has no parent", widget)
679- return true
680- }
681-
682- refBefore := widgetRefCount(widget)
683- log.Printf("[workspace] WidgetUnparentChecked: widget=%#x parent=%#x ref_before=%d", widget, parentBefore, refBefore)
684-
685- C.gtk_widget_unparent((*C.GtkWidget)(unsafe.Pointer(widget)))
686-
687- if !widgetIsValid(widget) {
688- log.Printf("[workspace] WidgetUnparentChecked: widget=%#x invalid after unparent", widget)
689- return true
690- }
691-
692- parentAfter := WidgetGetParent(widget)
693- refAfter := widgetRefCount(widget)
694- log.Printf("[workspace] WidgetUnparentChecked: widget=%#x parent_after=%#x ref_after=%d", widget, parentAfter, refAfter)
695-
696- return parentAfter == 0 || parentAfter != parentBefore
697-}
698-
699-// WidgetSetHExpand configures horizontal expand for a widget.
700-func WidgetSetHExpand(widget uintptr, expand bool) {
701- if widget == 0 {
702- return
703- }
704- log.Printf("[workspace] WidgetSetHExpand widget=%#x expand=%v", widget, expand)
705- C.gtk_widget_set_hexpand((*C.GtkWidget)(unsafe.Pointer(widget)), goBool(expand))
706-}
707-
708-func WidgetGetParent(widget uintptr) uintptr {
709- if widget == 0 {
710- log.Printf("[workspace] WidgetGetParent: widget=0")
711- return 0
712- }
713- if !widgetIsValid(widget) {
714- log.Printf("[workspace] WidgetGetParent: widget=%#x invalid", widget)
715- return 0
716- }
717- parent := C.gtk_widget_get_parent((*C.GtkWidget)(unsafe.Pointer(widget)))
718- // Reduced logging: only debug mode shows individual widget operations
719- return uintptr(unsafe.Pointer(parent))
720-}
721-
722-// WidgetSetVExpand configures vertical expand for a widget.
723-func WidgetSetVExpand(widget uintptr, expand bool) {
724- if widget == 0 {
725- return
726- }
727- log.Printf("[workspace] WidgetSetVExpand widget=%#x expand=%v", widget, expand)
728- C.gtk_widget_set_vexpand((*C.GtkWidget)(unsafe.Pointer(widget)), goBool(expand))
729-}
730-
731-// WidgetSetSizeRequest sets the minimum size request for a widget.
732-// Use -1 for width or height to unset that dimension.
733-func WidgetSetSizeRequest(widget uintptr, width, height int) {
734- if widget == 0 || !widgetIsValid(widget) {
735- return
736- }
737- log.Printf("[workspace] WidgetSetSizeRequest widget=%#x width=%d height=%d", widget, width, height)
738- C.gtk_widget_set_size_request((*C.GtkWidget)(unsafe.Pointer(widget)), C.int(width), C.int(height))
739-}
740-
741-// WidgetResetSizeRequest clears explicit size constraints so GTK can recalculate allocation.
742-func WidgetResetSizeRequest(widget uintptr) {
743- if widget == 0 || !widgetIsValid(widget) {
744- return
745- }
746- C.gtk_widget_set_size_request((*C.GtkWidget)(unsafe.Pointer(widget)), C.int(-1), C.int(-1))
747-}
748-
749-// WidgetShow makes the widget visible.
750-func WidgetShow(widget uintptr) {
751- if widget == 0 {
752- return
753- }
754- log.Printf("[workspace] WidgetShow widget=%#x", widget)
755- C.gtk_widget_set_visible((*C.GtkWidget)(unsafe.Pointer(widget)), C.gboolean(1))
756-}
757-
758-// WidgetGrabFocus focuses the widget if possible.
759-func WidgetGrabFocus(widget uintptr) {
760- if widget == 0 {
761- return
762- }
763- if !widgetIsValid(widget) {
764- log.Printf("[workspace] WidgetGrabFocus skipped invalid widget=%#x", widget)
765- return
766- }
767- // Reduced logging: focus grab logged only in consolidated focus operation
768- C.gtk_widget_grab_focus((*C.GtkWidget)(unsafe.Pointer(widget)))
769-}
770-
771-// WidgetSetFocusChild sets or clears the focus child of a widget.
772-// Pass 0 as child to clear the focus child (prevents GTK focus chain warnings during reparenting).
773-func WidgetSetFocusChild(widget uintptr, child uintptr) {
774- if widget == 0 {
775- return
776- }
777- if !widgetIsValid(widget) {
778- log.Printf("[workspace] WidgetSetFocusChild skipped invalid widget=%#x", widget)
779- return
780- }
781- var childPtr *C.GtkWidget
782- if child != 0 {
783- if !widgetIsValid(child) {
784- log.Printf("[workspace] WidgetSetFocusChild skipped invalid child=%#x", child)
785- return
786- }
787- childPtr = (*C.GtkWidget)(unsafe.Pointer(child))
788- }
789- C.gtk_widget_set_focus_child((*C.GtkWidget)(unsafe.Pointer(widget)), childPtr)
790- log.Printf("[workspace] WidgetSetFocusChild widget=%#x child=%#x", widget, child)
791-}
792-
793-// WidgetRef increments the reference count to keep the widget alive during reparenting.
794-func WidgetRef(widget uintptr) bool {
795- if widget == 0 {
796- return false
797- }
798- if !widgetIsValid(widget) {
799- log.Printf("[workspace] WidgetRef skipped invalid widget=%#x", widget)
800- return false
801- }
802- before := widgetRefCount(widget)
803- log.Printf("[workspace] WidgetRef widget=%#x ref_before=%d", widget, before)
804- C.g_object_ref(C.gpointer(unsafe.Pointer(widget)))
805- after := widgetRefCount(widget)
806- log.Printf("[workspace] WidgetRef widget=%#x ref_after=%d", widget, after)
807- return true
808-}
809-
810-// WidgetUnref releases a previously held widget reference.
811-func WidgetUnref(widget uintptr) {
812- if widget == 0 {
813- return
814- }
815- if !widgetIsValid(widget) {
816- log.Printf("[workspace] WidgetUnref skipped invalid widget=%#x", widget)
817- return
818- }
819- before := widgetRefCount(widget)
820- log.Printf("[workspace] WidgetUnref widget=%#x ref_before=%d", widget, before)
821- C.g_object_unref(C.gpointer(unsafe.Pointer(widget)))
822- if widgetIsValid(widget) {
823- after := widgetRefCount(widget)
824- log.Printf("[workspace] WidgetUnref widget=%#x ref_after=%d", widget, after)
825- } else {
826- log.Printf("[workspace] WidgetUnref widget=%#x now invalid", widget)
827- }
828-}
829-
830-// WidgetQueueAllocate queues an allocation on the widget.
831-func WidgetQueueAllocate(widget uintptr) {
832- if widget == 0 {
833- return
834- }
835- log.Printf("[workspace] WidgetQueueAllocate widget=%#x", widget)
836- C.gtk_widget_queue_allocate((*C.GtkWidget)(unsafe.Pointer(widget)))
837-}
838-
839-// WidgetQueueResize forces GTK to recalculate the widget's layout and size allocation.
840-// This is useful when a widget's size requirements have changed (e.g., after adding children).
841-func WidgetQueueResize(widget uintptr) {
842- if widget == 0 {
843- return
844- }
845- if !widgetIsValid(widget) {
846- log.Printf("[workspace] WidgetQueueResize skipped invalid widget=%#x", widget)
847- return
848- }
849- log.Printf("[workspace] WidgetQueueResize widget=%#x", widget)
850- C.gtk_widget_queue_resize((*C.GtkWidget)(unsafe.Pointer(widget)))
851-}
852-
853-// WidgetRealizeInContainer ensures a widget is properly realized within a container
854-func WidgetRealizeInContainer(widget uintptr) {
855- if widget == 0 {
856- return
857- }
858- // Reduced logging: widget realization logged only in consolidated focus operation
859- C.ensure_widget_realized((*C.GtkWidget)(unsafe.Pointer(widget)))
860-}
861-
862-// WidgetAddHoverHandler registers a hover callback for the given widget and
863-// returns a token that can be used to detach it later.
864-func WidgetAddHoverHandler(widget uintptr, fn func()) uintptr {
865- if widget == 0 || fn == nil || !widgetIsValid(widget) {
866- return 0
867- }
868- hoverMu.Lock()
869- token := nextHoverID
870- nextHoverID++
871- hoverCallbacks[token] = fn
872- hoverMu.Unlock()
873-
874- controller := C.widget_add_hover_controller((*C.GtkWidget)(unsafe.Pointer(widget)), C.uintptr_t(token))
875- if controller == nil {
876- hoverMu.Lock()
877- delete(hoverCallbacks, token)
878- hoverMu.Unlock()
879- return 0
880- }
881-
882- hoverMu.Lock()
883- hoverControllers[token] = uintptr(unsafe.Pointer(controller))
884- hoverMu.Unlock()
885- return token
886-}
887-
888-// WidgetRemoveHoverHandler removes a previously registered hover callback.
889-func WidgetRemoveHoverHandler(widget uintptr, token uintptr) {
890- if token == 0 {
891- return
892- }
893-
894- hoverMu.Lock()
895- controller := hoverControllers[token]
896- delete(hoverCallbacks, token)
897- delete(hoverControllers, token)
898- hoverMu.Unlock()
899-
900- if controller == 0 {
901- return
902- }
903-
904- if widget == 0 || !widgetIsValid(widget) {
905- return
906- }
907-
908- C.widget_remove_controller((*C.GtkWidget)(unsafe.Pointer(widget)), (*C.GtkEventController)(unsafe.Pointer(controller)))
909-}
910-
911-// WidgetAttachClickHandler attaches a click handler to any widget (for title bars)
912-func WidgetAttachClickHandler(widget uintptr, fn func()) uintptr {
913- if widget == 0 || fn == nil || !widgetIsValid(widget) {
914- return 0
915- }
916- titleBarMu.Lock()
917- token := nextTitleBarID
918- nextTitleBarID++
919- titleBarCallbacks[token] = fn
920- titleBarMu.Unlock()
921-
922- C.widget_attach_click_handler((*C.GtkWidget)(unsafe.Pointer(widget)), C.uintptr_t(token))
923-
924- return token
925-}
926-
927-// WidgetGetBounds returns the absolute bounds of the widget relative to the root window.
928-func WidgetGetBounds(widget uintptr) (WidgetBounds, bool) {
929- if widget == 0 || !widgetIsValid(widget) {
930- return WidgetBounds{}, false
931- }
932-
933- var x, y, width, height C.double
934- if C.widget_get_bounds((*C.GtkWidget)(unsafe.Pointer(widget)), (*C.double)(&x), (*C.double)(&y), (*C.double)(&width), (*C.double)(&height)) == 0 {
935- return WidgetBounds{}, false
936- }
937-
938- return WidgetBounds{
939- X: float64(x),
940- Y: float64(y),
941- Width: float64(width),
942- Height: float64(height),
943- }, true
944-}
945-
946-// Global storage for idle callbacks
947-var (
948- idleCallbacks = make(map[uintptr]func() bool)
949- nextIdleID uintptr = 1
950-)
951-
952-// IdleAdd schedules a function to be called when the main loop is idle.
953-func IdleAdd(fn func() bool) uintptr {
954- if fn == nil {
955- return 0
956- }
957- id := nextIdleID
958- nextIdleID++
959- idleCallbacks[id] = fn
960- if C.add_idle_callback(C.uintptr_t(id)) == 0 {
961- delete(idleCallbacks, id)
962- return 0
963- }
964- return id
965-}
966-
967-// IdleRemove cancels a previously scheduled idle callback.
968-func IdleRemove(handle uintptr) {
969- if handle == 0 {
970- return
971- }
972- C.remove_idle_callback(C.uintptr_t(handle))
973- delete(idleCallbacks, handle)
974-}
975-
976-// IterateMainLoop processes a single GTK main loop iteration and reports if work was handled.
977-func IterateMainLoop() bool {
978- return C.iterate_main_loop() == 1
979-}
980-
981-//export goIdleCallback
982-func goIdleCallback(handle uintptr) C.gboolean {
983- if fn, ok := idleCallbacks[handle]; ok {
984- delete(idleCallbacks, handle) // Clean up after calling
985- if fn() {
986- return C.gboolean(1) // G_SOURCE_CONTINUE
987- }
988- }
989- return C.gboolean(0) // G_SOURCE_REMOVE
990-}
991-
992-//export goHoverCallback
993-func goHoverCallback(handle C.uintptr_t) {
994- token := uintptr(handle)
995- hoverMu.Lock()
996- fn := hoverCallbacks[token]
997- hoverMu.Unlock()
998- if fn != nil {
999- fn()
1000- }
1001-}
1002-
1003-//export goTitleBarClickCallback
1004-func goTitleBarClickCallback(handle C.uintptr_t) {
1005- token := uintptr(handle)
1006- titleBarMu.Lock()
1007- fn := titleBarCallbacks[token]
1008- titleBarMu.Unlock()
1009- if fn != nil {
1010- fn()
1011- }
1012-}
1013-
1014-// GtkBox functions for stacked panes
1015-
1016-// NewBox creates a new GtkBox widget with the specified orientation and spacing.
1017-func NewBox(orientation Orientation, spacing int) uintptr {
1018- box := C.box_new(C.GtkOrientation(orientation), C.int(spacing))
1019- return uintptr(unsafe.Pointer(box))
1020-}
1021-
1022-// BoxAppend adds a child widget to the end of a GtkBox.
1023-func BoxAppend(box uintptr, child uintptr) {
1024- if box == 0 || child == 0 {
1025- return
1026- }
1027- log.Printf("[workspace] BoxAppend box=%#x child=%#x", box, child)
1028- C.box_append((*C.GtkWidget)(unsafe.Pointer(box)), (*C.GtkWidget)(unsafe.Pointer(child)))
1029-}
1030-
1031-// BoxPrepend adds a child widget to the beginning of a GtkBox.
1032-func BoxPrepend(box uintptr, child uintptr) {
1033- if box == 0 || child == 0 {
1034- return
1035- }
1036- log.Printf("[workspace] BoxPrepend box=%#x child=%#x", box, child)
1037- C.box_prepend((*C.GtkWidget)(unsafe.Pointer(box)), (*C.GtkWidget)(unsafe.Pointer(child)))
1038-}
1039-
1040-// BoxRemove removes a child widget from a GtkBox.
1041-func BoxRemove(box uintptr, child uintptr) {
1042- if box == 0 || child == 0 {
1043- return
1044- }
1045- log.Printf("[workspace] BoxRemove box=%#x child=%#x", box, child)
1046- C.box_remove((*C.GtkWidget)(unsafe.Pointer(box)), (*C.GtkWidget)(unsafe.Pointer(child)))
1047-}
1048-
1049-// BoxInsertChildAfter inserts a child widget after a sibling in a GtkBox.
1050-func BoxInsertChildAfter(box uintptr, child uintptr, sibling uintptr) {
1051- if box == 0 || child == 0 {
1052- return
1053- }
1054- log.Printf("[workspace] BoxInsertChildAfter box=%#x child=%#x sibling=%#x", box, child, sibling)
1055- C.box_insert_child_after((*C.GtkWidget)(unsafe.Pointer(box)), (*C.GtkWidget)(unsafe.Pointer(child)), (*C.GtkWidget)(unsafe.Pointer(sibling)))
1056-}
1057-
1058-// Widget visibility functions for stacked panes
1059-
1060-// WidgetSetVisible sets the visibility state of a widget.
1061-func WidgetSetVisible(widget uintptr, visible bool) {
1062- if widget == 0 {
1063- return
1064- }
1065- log.Printf("[workspace] WidgetSetVisible widget=%#x visible=%v", widget, visible)
1066- C.widget_set_visible((*C.GtkWidget)(unsafe.Pointer(widget)), goBool(visible))
1067-}
1068-
1069-// WidgetGetVisible returns the visibility state of a widget.
1070-func WidgetGetVisible(widget uintptr) bool {
1071- if widget == 0 {
1072- return false
1073- }
1074- return C.widget_get_visible((*C.GtkWidget)(unsafe.Pointer(widget))) != 0
1075-}
1076-
1077-// WidgetHide hides a widget.
1078-func WidgetHide(widget uintptr) {
1079- if widget == 0 {
1080- return
1081- }
1082- log.Printf("[workspace] WidgetHide widget=%#x", widget)
1083- C.widget_hide((*C.GtkWidget)(unsafe.Pointer(widget)))
1084-}
1085-
1086-// Label functions for title bars
1087-
1088-// NewLabel creates a new GtkLabel widget with the specified text.
1089-func NewLabel(text string) uintptr {
1090- cstr := C.CString(text)
1091- defer C.free(unsafe.Pointer(cstr))
1092- label := C.label_new(cstr)
1093- return uintptr(unsafe.Pointer(label))
1094-}
1095-
1096-// LabelSetText sets the text of a GtkLabel.
1097-func LabelSetText(label uintptr, text string) {
1098- if label == 0 {
1099- return
1100- }
1101- cstr := C.CString(text)
1102- defer C.free(unsafe.Pointer(cstr))
1103- C.label_set_text((*C.GtkWidget)(unsafe.Pointer(label)), cstr)
1104-}
1105-
1106-// LabelGetText returns the text of a GtkLabel.
1107-func LabelGetText(label uintptr) string {
1108- if label == 0 {
1109- return ""
1110- }
1111- cstr := C.label_get_text((*C.GtkWidget)(unsafe.Pointer(label)))
1112- if cstr == nil {
1113- return ""
1114- }
1115- return C.GoString(cstr)
1116-}
1117-
1118-// EllipsizeMode represents PangoEllipsizeMode values.
1119-type EllipsizeMode int
1120-
1121-const (
1122- EllipsizeNone EllipsizeMode = 0 // PANGO_ELLIPSIZE_NONE
1123- EllipsizeStart EllipsizeMode = 1 // PANGO_ELLIPSIZE_START
1124- EllipsizeMiddle EllipsizeMode = 2 // PANGO_ELLIPSIZE_MIDDLE
1125- EllipsizeEnd EllipsizeMode = 3 // PANGO_ELLIPSIZE_END
1126-)
1127-
1128-// LabelSetEllipsize sets the ellipsization mode for a GtkLabel.
1129-func LabelSetEllipsize(label uintptr, mode EllipsizeMode) {
1130- if label == 0 {
1131- return
1132- }
1133- C.label_set_ellipsize((*C.GtkWidget)(unsafe.Pointer(label)), C.int(mode))
1134-}
1135-
1136-// LabelSetMaxWidthChars sets the maximum width in characters for a GtkLabel.
1137-func LabelSetMaxWidthChars(label uintptr, nChars int) {
1138- if label == 0 {
1139- return
1140- }
1141- C.label_set_max_width_chars((*C.GtkWidget)(unsafe.Pointer(label)), C.int(nChars))
1142-}
1143-
1144-// WidgetWaitForDraw is deprecated - GTK4 handles widget operations automatically
1145-// This function is kept for compatibility but does nothing in production
1146-func WidgetWaitForDraw(widget uintptr) {
1147- // GTK4 documentation states: never force a redraw
1148- // Widget operations are handled automatically by the GTK4 main loop
1149- // This function is a no-op in production code
1150-}
1151-
1152-// WidgetQueueDraw safely queues a redraw operation (GTK4 recommended approach)
1153-func WidgetQueueDraw(widget uintptr) {
1154- if widget == 0 || !widgetIsValid(widget) {
1155- return
1156- }
1157- C.gtk_widget_queue_draw((*C.GtkWidget)(unsafe.Pointer(widget)))
1158-}
1159-
1160-// WidgetHasCSSClass checks if a widget has a specific CSS class
1161-func WidgetHasCSSClass(widget uintptr, class string) bool {
1162- if widget == 0 || class == "" || !widgetIsValid(widget) {
1163- return false
1164- }
1165- cstr := C.CString(class)
1166- defer C.free(unsafe.Pointer(cstr))
1167- result := C.gtk_widget_has_css_class((*C.GtkWidget)(unsafe.Pointer(widget)), cstr)
1168- return result != 0
1169-}
1170-
1171-// Widget margin functions for creating visual borders
1172-
1173-// WidgetSetMargin sets all margins (top, right, bottom, left) to the same value
1174-func WidgetSetMargin(widget uintptr, margin int) {
1175- if widget == 0 || !widgetIsValid(widget) {
1176- return
1177- }
1178- w := (*C.GtkWidget)(unsafe.Pointer(widget))
1179- C.gtk_widget_set_margin_top(w, C.int(margin))
1180- C.gtk_widget_set_margin_bottom(w, C.int(margin))
1181- C.gtk_widget_set_margin_start(w, C.int(margin))
1182- C.gtk_widget_set_margin_end(w, C.int(margin))
1183-}
1184-
1185-// WidgetSetMarginTop sets the top margin
1186-func WidgetSetMarginTop(widget uintptr, margin int) {
1187- if widget == 0 || !widgetIsValid(widget) {
1188- return
1189- }
1190- C.gtk_widget_set_margin_top((*C.GtkWidget)(unsafe.Pointer(widget)), C.int(margin))
1191-}
1192-
1193-// WidgetSetMarginBottom sets the bottom margin
1194-func WidgetSetMarginBottom(widget uintptr, margin int) {
1195- if widget == 0 || !widgetIsValid(widget) {
1196- return
1197- }
1198- C.gtk_widget_set_margin_bottom((*C.GtkWidget)(unsafe.Pointer(widget)), C.int(margin))
1199-}
1200-
1201-// WidgetSetMarginStart sets the start (left in LTR) margin
1202-func WidgetSetMarginStart(widget uintptr, margin int) {
1203- if widget == 0 || !widgetIsValid(widget) {
1204- return
1205- }
1206- C.gtk_widget_set_margin_start((*C.GtkWidget)(unsafe.Pointer(widget)), C.int(margin))
1207-}
1208-
1209-// WidgetSetMarginEnd sets the end (right in LTR) margin
1210-func WidgetSetMarginEnd(widget uintptr, margin int) {
1211- if widget == 0 || !widgetIsValid(widget) {
1212- return
1213- }
1214- C.gtk_widget_set_margin_end((*C.GtkWidget)(unsafe.Pointer(widget)), C.int(margin))
1215-}
1216-
1217-// GTK4 Focus Controller functions for focus state machine
1218-
1219-// WidgetAddFocusController registers focus callbacks for the given widget and
1220-// returns a token that can be used to detach it later.
1221-func WidgetAddFocusController(widget uintptr, onEnter, onLeave func()) uintptr {
1222- if widget == 0 || !widgetIsValid(widget) {
1223- return 0
1224- }
1225-
1226- focusMu.Lock()
1227- token := nextFocusID
1228- nextFocusID++
1229-
1230- callbacks := FocusCallbacks{
1231- OnEnter: onEnter,
1232- OnLeave: onLeave,
1233- }
1234- focusCallbacks[token] = callbacks
1235- focusMu.Unlock()
1236-
1237- controller := C.widget_add_focus_controller((*C.GtkWidget)(unsafe.Pointer(widget)), C.uintptr_t(token))
1238- if controller == nil {
1239- focusMu.Lock()
1240- delete(focusCallbacks, token)
1241- focusMu.Unlock()
1242- return 0
1243- }
1244-
1245- focusMu.Lock()
1246- focusControllers[token] = uintptr(unsafe.Pointer(controller))
1247- focusMu.Unlock()
1248- return token
1249-}
1250-
1251-// WidgetRemoveFocusController removes a previously registered focus controller.
1252-func WidgetRemoveFocusController(widget uintptr, token uintptr) {
1253- if token == 0 {
1254- return
1255- }
1256-
1257- focusMu.Lock()
1258- controller := focusControllers[token]
1259- delete(focusCallbacks, token)
1260- delete(focusControllers, token)
1261- focusMu.Unlock()
1262-
1263- if controller == 0 {
1264- return
1265- }
1266-
1267- if widget == 0 || !widgetIsValid(widget) {
1268- return
1269- }
1270-
1271- C.widget_remove_focus_controller((*C.GtkWidget)(unsafe.Pointer(widget)), (*C.GtkEventController)(unsafe.Pointer(controller)))
1272-}
1273-
1274-//export goFocusEnterCallback
1275-func goFocusEnterCallback(handle C.uintptr_t) {
1276- token := uintptr(handle)
1277- focusMu.Lock()
1278- callbacks := focusCallbacks[token]
1279- focusMu.Unlock()
1280-
1281- if callbacks.OnEnter != nil {
1282- callbacks.OnEnter()
1283- }
1284-}
1285-
1286-//export goFocusLeaveCallback
1287-func goFocusLeaveCallback(handle C.uintptr_t) {
1288- token := uintptr(handle)
1289- focusMu.Lock()
1290- callbacks := focusCallbacks[token]
1291- focusMu.Unlock()
1292-
1293- if callbacks.OnLeave != nil {
1294- callbacks.OnLeave()
1295- }
1296-}
1297-
1298-// WidgetGetAllocation gets the bounds (position and size) of a widget using modern GTK4 API
1299-func WidgetGetAllocation(widget uintptr) WidgetAllocation {
1300- var x, y, width, height C.int
1301-
1302- C.widget_get_allocation_modern(
1303- (*C.GtkWidget)(unsafe.Pointer(widget)),
1304- &x, &y, &width, &height,
1305- )
1306-
1307- return WidgetAllocation{
1308- X: int(x),
1309- Y: int(y),
1310- Width: int(width),
1311- Height: int(height),
1312- }
1313-}
1314-
1315-// GtkImage functions for favicon display
1316-
1317-// NewImage creates a new empty GtkImage widget.
1318-func NewImage() uintptr {
1319- image := C.image_new()
1320- return uintptr(unsafe.Pointer(image))
1321-}
1322-
1323-// ImageNewFromFile creates a new GtkImage widget from a file path.
1324-func ImageNewFromFile(filename string) uintptr {
1325- if filename == "" {
1326- return 0
1327- }
1328- cstr := C.CString(filename)
1329- defer C.free(unsafe.Pointer(cstr))
1330- image := C.image_new_from_file(cstr)
1331- return uintptr(unsafe.Pointer(image))
1332-}
1333-
1334-// ImageSetFromFile updates an existing GtkImage to display an image from a file.
1335-func ImageSetFromFile(image uintptr, filename string) {
1336- if image == 0 || filename == "" {
1337- return
1338- }
1339- cstr := C.CString(filename)
1340- defer C.free(unsafe.Pointer(cstr))
1341- C.image_set_from_file((*C.GtkWidget)(unsafe.Pointer(image)), cstr)
1342-}
1343-
1344-// ImageSetPixelSize sets the pixel size for the image (used for scaling icons).
1345-func ImageSetPixelSize(image uintptr, pixelSize int) {
1346- if image == 0 {
1347- return
1348- }
1349- C.image_set_pixel_size((*C.GtkWidget)(unsafe.Pointer(image)), C.int(pixelSize))
1350-}
D pkg/webkit/workspace_widgets_stub.go +0 -605
@@ -1,605 +0,0 @@
1-//go:build !webkit_cgo
2-
3-package webkit
4-
5-import "sync"
6-
7-// Orientation mirrors GtkOrientation in stub builds.
8-type Orientation int
9-
10-const (
11- OrientationHorizontal Orientation = 0
12- OrientationVertical Orientation = 1
13-)
14-
15-// WidgetBounds mirrors the CGO struct for stub builds.
16-type WidgetBounds struct {
17- X float64
18- Y float64
19- Width float64
20- Height float64
21-}
22-
23-// WidgetAllocation represents a widget's allocation (position and size) in stub builds
24-type WidgetAllocation struct {
25- X int
26- Y int
27- Width int
28- Height int
29-}
30-
31-type widgetStub struct {
32- startChild uintptr
33- endChild uintptr
34- bounds WidgetBounds
35- hasBounds bool
36- hover map[uintptr]func()
37- // GTK4 lifecycle simulation
38- parent uintptr
39- isDestroyed bool
40- refCount uint
41- widgetType string // Track widget type for IsPaned, etc.
42- // GTK focus management simulation
43- hasFocus bool
44- canFocus bool
45- focusedChild uintptr
46-}
47-
48-var (
49- widgetMu sync.Mutex
50- widgetState = map[uintptr]*widgetStub{}
51- nextWidgetID uintptr = 1
52- nextHoverToken uintptr = 1
53- // GTK focus management simulation
54- globalFocusedWidget uintptr
55- focusWarningEnabled bool = true
56- gtkTestInitOnce sync.Once
57-)
58-
59-func newWidgetHandle() uintptr {
60- widgetMu.Lock()
61- defer widgetMu.Unlock()
62- id := nextWidgetID
63- nextWidgetID++
64- widgetState[id] = &widgetStub{
65- refCount: 1, // GTK widgets start with ref count 1
66- canFocus: true, // Most widgets can receive focus
67- }
68- return id
69-}
70-
71-// NewTestWidget returns a unique widget handle for tests.
72-func NewTestWidget() uintptr { return newWidgetHandle() }
73-
74-// SetWidgetBoundsForTesting assigns bounds for the widget in stub builds.
75-func SetWidgetBoundsForTesting(widget uintptr, bounds WidgetBounds) {
76- widgetMu.Lock()
77- defer widgetMu.Unlock()
78- stub, ok := widgetState[widget]
79- if !ok {
80- stub = &widgetStub{}
81- widgetState[widget] = stub
82- }
83- stub.bounds = bounds
84- stub.hasBounds = true
85-}
86-
87-// ResetWidgetStubsForTesting clears widget stub state for deterministic tests.
88-func ResetWidgetStubsForTesting() {
89- widgetMu.Lock()
90- defer widgetMu.Unlock()
91- widgetState = map[uintptr]*widgetStub{}
92- nextWidgetID = 1
93- nextHoverToken = 1
94-}
95-
96-func NewPaned(orientation Orientation) uintptr {
97- widgetMu.Lock()
98- defer widgetMu.Unlock()
99- id := nextWidgetID
100- nextWidgetID++
101- widgetState[id] = &widgetStub{
102- refCount: 1,
103- canFocus: true,
104- widgetType: "paned", // Mark as paned widget
105- }
106- return id
107-}
108-
109-func PanedSetStartChild(paned uintptr, child uintptr) {
110- widgetMu.Lock()
111- defer widgetMu.Unlock()
112-
113- // GTK4 validation: paned must be a valid widget
114- if paned == 0 {
115- panic("GTK-CRITICAL simulation: gtk_widget_get_parent: assertion 'GTK_IS_WIDGET (widget)' failed - paned is NULL")
116- }
117-
118- stub, ok := widgetState[paned]
119- if !ok || stub.isDestroyed {
120- panic("GTK-CRITICAL simulation: gtk_widget_get_parent: assertion 'GTK_IS_WIDGET (widget)' failed - paned is invalid")
121- }
122-
123- // GTK4 validation: if child is not 0, it must be a valid widget
124- if child != 0 {
125- childStub, childExists := widgetState[child]
126- if !childExists || childStub.isDestroyed {
127- panic("GTK-CRITICAL simulation: gtk_widget_insert_before: assertion 'GTK_IS_WIDGET (widget)' failed - child is invalid")
128- }
129- }
130-
131- // GTK4 behavior: setting child to 0 automatically unparents the old child
132- if stub.startChild != 0 && child == 0 {
133- if oldChild, exists := widgetState[stub.startChild]; exists {
134- oldChild.parent = 0 // Automatically unparent
135- }
136- }
137- // Set new child and establish parent relationship
138- stub.startChild = child
139- if child != 0 {
140- if childStub, exists := widgetState[child]; exists {
141- // Simulate the exact GTK focus management issue from production
142- // When reparenting a widget with focus hierarchy to a new parent,
143- // GTK's focus management calls gtk_paned_set_focus_child with (nil)
144- if focusWarningEnabled && (childStub.hasFocus || childStub.focusedChild != 0) {
145- panic("GTK-WARNING simulation: Error finding last focus widget of GtkPaned, gtk_paned_set_focus_child was called on widget (nil) which is not child")
146- }
147- childStub.parent = paned
148- }
149- }
150-}
151-
152-func PanedSetEndChild(paned uintptr, child uintptr) {
153- widgetMu.Lock()
154- defer widgetMu.Unlock()
155-
156- // GTK4 validation: paned must be a valid widget
157- if paned == 0 {
158- panic("GTK-CRITICAL simulation: gtk_widget_get_parent: assertion 'GTK_IS_WIDGET (widget)' failed - paned is NULL")
159- }
160-
161- stub, ok := widgetState[paned]
162- if !ok || stub.isDestroyed {
163- panic("GTK-CRITICAL simulation: gtk_widget_get_parent: assertion 'GTK_IS_WIDGET (widget)' failed - paned is invalid")
164- }
165-
166- // GTK4 validation: if child is not 0, it must be a valid widget
167- if child != 0 {
168- childStub, childExists := widgetState[child]
169- if !childExists || childStub.isDestroyed {
170- panic("GTK-CRITICAL simulation: gtk_widget_insert_before: assertion 'GTK_IS_WIDGET (widget)' failed - child is invalid")
171- }
172- }
173-
174- // GTK4 behavior: setting child to 0 automatically unparents the old child
175- if stub.endChild != 0 && child == 0 {
176- if oldChild, exists := widgetState[stub.endChild]; exists {
177- oldChild.parent = 0 // Automatically unparent
178- }
179- }
180- // Set new child and establish parent relationship
181- stub.endChild = child
182- if child != 0 {
183- if childStub, exists := widgetState[child]; exists {
184- // Simulate the exact GTK focus management issue from production
185- // When reparenting a widget with focus hierarchy to a new parent,
186- // GTK's focus management calls gtk_paned_set_focus_child with (nil)
187- if focusWarningEnabled && (childStub.hasFocus || childStub.focusedChild != 0) {
188- panic("GTK-WARNING simulation: Error finding last focus widget of GtkPaned, gtk_paned_set_focus_child was called on widget (nil) which is not child")
189- }
190- childStub.parent = paned
191- }
192- }
193-}
194-
195-func PanedSetResizeStart(paned uintptr, resize bool) {}
196-
197-func PanedSetResizeEnd(paned uintptr, resize bool) {}
198-
199-// PanedSetPosition is a no-op in stub builds.
200-func PanedSetPosition(paned uintptr, pos int) {}
201-
202-// PanedGetPosition gets the current position of the divider in a GtkPaned (stub)
203-func PanedGetPosition(paned uintptr) int { return 0 }
204-
205-func WidgetUnparent(widget uintptr) {
206- widgetMu.Lock()
207- defer widgetMu.Unlock()
208- if stub, ok := widgetState[widget]; ok {
209- // GTK4 CRITICAL simulation: unparenting widget with no parent is illegal
210- if stub.parent == 0 {
211- // In real GTK4, this would cause: gtk_widget_get_parent: assertion 'GTK_IS_WIDGET (widget)' failed
212- // We'll simulate this by marking the widget as destroyed
213- stub.isDestroyed = true
214- panic("GTK-CRITICAL simulation: gtk_widget_unparent called on widget with no parent")
215- }
216- // Remove from parent's child list
217- if parentStub, exists := widgetState[stub.parent]; exists {
218- if parentStub.startChild == widget {
219- parentStub.startChild = 0
220- }
221- if parentStub.endChild == widget {
222- parentStub.endChild = 0
223- }
224- }
225- stub.parent = 0
226- }
227-}
228-
229-func WidgetSetHExpand(widget uintptr, expand bool) {}
230-
231-func WidgetSetVExpand(widget uintptr, expand bool) {}
232-
233-func WidgetGetParent(widget uintptr) uintptr {
234- widgetMu.Lock()
235- defer widgetMu.Unlock()
236- if stub, ok := widgetState[widget]; ok && !stub.isDestroyed {
237- return stub.parent
238- }
239- return 0
240-}
241-
242-func WidgetShow(widget uintptr) {}
243-
244-func WidgetGrabFocus(widget uintptr) {
245- widgetMu.Lock()
246- defer widgetMu.Unlock()
247-
248- if widget == 0 {
249- return
250- }
251-
252- stub, ok := widgetState[widget]
253- if !ok || stub.isDestroyed || !stub.canFocus {
254- return
255- }
256-
257- // Clear previous focus
258- if globalFocusedWidget != 0 {
259- if prevStub, exists := widgetState[globalFocusedWidget]; exists {
260- prevStub.hasFocus = false
261- }
262- }
263-
264- // Set new focus
265- stub.hasFocus = true
266- globalFocusedWidget = widget
267-
268- // Simulate focus in parent hierarchy - each parent should point to its direct focused child
269- currentChild := widget
270- parent := stub.parent
271- for parent != 0 {
272- if parentStub, exists := widgetState[parent]; exists {
273- parentStub.focusedChild = currentChild // Direct child that contains focus
274- currentChild = parent // Move up the hierarchy
275- parent = parentStub.parent
276- } else {
277- break
278- }
279- }
280-}
281-
282-// WidgetSetFocusChild sets or clears the focus child of a widget.
283-// Pass 0 as child to clear the focus child (prevents GTK focus chain warnings during reparenting).
284-func WidgetSetFocusChild(widget uintptr, child uintptr) {
285- widgetMu.Lock()
286- defer widgetMu.Unlock()
287-
288- if widget == 0 {
289- return
290- }
291-
292- stub, ok := widgetState[widget]
293- if !ok || stub.isDestroyed {
294- return
295- }
296-
297- stub.focusedChild = child
298-}
299-
300-func WidgetRef(widget uintptr) bool {
301- widgetMu.Lock()
302- defer widgetMu.Unlock()
303- _, ok := widgetState[widget]
304- return ok
305-}
306-
307-func WidgetUnref(widget uintptr) {}
308-
309-func WidgetQueueAllocate(widget uintptr) {}
310-
311-func WidgetRealizeInContainer(widget uintptr) {}
312-
313-func WidgetSetSizeRequest(widget uintptr, width, height int) {}
314-
315-func WidgetResetSizeRequest(widget uintptr) {}
316-
317-func WidgetQueueResize(widget uintptr) {}
318-
319-func WidgetHookDestroy(widget uintptr) {}
320-
321-func WidgetIsValid(widget uintptr) bool {
322- widgetMu.Lock()
323- defer widgetMu.Unlock()
324- if widget == 0 {
325- return false
326- }
327- if stub, ok := widgetState[widget]; ok {
328- return !stub.isDestroyed
329- }
330- return false
331-}
332-
333-// IsPaned checks if a widget is a GtkPaned container.
334-func IsPaned(widget uintptr) bool {
335- widgetMu.Lock()
336- defer widgetMu.Unlock()
337- if widget == 0 {
338- return false
339- }
340- if stub, ok := widgetState[widget]; ok && !stub.isDestroyed {
341- return stub.widgetType == "paned"
342- }
343- return false
344-}
345-
346-// IsBox checks if a widget is a GtkBox container.
347-func IsBox(widget uintptr) bool {
348- widgetMu.Lock()
349- defer widgetMu.Unlock()
350- if widget == 0 {
351- return false
352- }
353- if stub, ok := widgetState[widget]; ok && !stub.isDestroyed {
354- return stub.widgetType == "box"
355- }
356- return false
357-}
358-
359-func WidgetRefCount(widget uintptr) uint {
360- if WidgetIsValid(widget) {
361- return 1
362- }
363- return 0
364-}
365-
366-func WidgetAddHoverHandler(widget uintptr, fn func()) uintptr {
367- if widget == 0 || fn == nil {
368- return 0
369- }
370- widgetMu.Lock()
371- defer widgetMu.Unlock()
372- stub, ok := widgetState[widget]
373- if !ok {
374- stub = &widgetStub{}
375- widgetState[widget] = stub
376- }
377- if stub.hover == nil {
378- stub.hover = make(map[uintptr]func())
379- }
380- token := nextHoverToken
381- nextHoverToken++
382- stub.hover[token] = fn
383- return token
384-}
385-
386-func WidgetRemoveHoverHandler(widget uintptr, token uintptr) {
387- widgetMu.Lock()
388- defer widgetMu.Unlock()
389- if stub, ok := widgetState[widget]; ok && stub.hover != nil {
390- delete(stub.hover, token)
391- }
392-}
393-
394-func WidgetGetBounds(widget uintptr) (WidgetBounds, bool) {
395- widgetMu.Lock()
396- defer widgetMu.Unlock()
397- stub, ok := widgetState[widget]
398- if !ok || !stub.hasBounds {
399- return WidgetBounds{}, false
400- }
401- return stub.bounds, true
402-}
403-
404-// WidgetWaitForDraw is a no-op validation stub in this build.
405-// It does NOT provide any synchronization or guarantee that widget draw operations are complete.
406-// In the stub, it only validates the widget state and does not wait for any operations.
407-// In real GTK builds, this would wait for all pending draw operations to complete.
408-func WidgetWaitForDraw(widget uintptr) {
409- gtkTestInitOnce.Do(func() {})
410- widgetMu.Lock()
411- defer widgetMu.Unlock()
412-
413- // Validate widget is still valid after any recent operations
414- if widget != 0 {
415- stub, ok := widgetState[widget]
416- if !ok || stub.isDestroyed {
417- panic("GTK-CRITICAL simulation: gtk_test_widget_wait_for_draw: widget is invalid")
418- }
419- }
420-}
421-
422-// IdleAdd simulates GTK's idle callback system - DEPRECATED, use WidgetWaitForDraw instead
423-func IdleAdd(fn func() bool) uintptr {
424- if fn != nil {
425- fn()
426- }
427- return 0
428-}
429-
430-// IdleRemove cancels a simulated idle callback (no-op in stub builds).
431-func IdleRemove(handle uintptr) {
432- _ = handle
433-}
434-
435-func AddCSSProvider(css string) {}
436-
437-func WidgetAddCSSClass(widget uintptr, class string) {}
438-
439-func WidgetRemoveCSSClass(widget uintptr, class string) {}
440-
441-func WidgetHasCSSClass(widget uintptr, class string) bool {
442- // In stub mode, we don't track CSS classes
443- return false
444-}
445-
446-// Widget margin functions for creating visual borders (stub implementations)
447-
448-func WidgetSetMargin(widget uintptr, margin int) {}
449-
450-func WidgetSetMarginTop(widget uintptr, margin int) {}
451-
452-func WidgetSetMarginBottom(widget uintptr, margin int) {}
453-
454-func WidgetSetMarginStart(widget uintptr, margin int) {}
455-
456-func WidgetSetMarginEnd(widget uintptr, margin int) {}
457-
458-func WidgetQueueDraw(widget uintptr) {
459- // Stub implementation - no-op
460-}
461-
462-// Focus controller functions for GTK4 EventControllerFocus (stub implementations)
463-func WidgetAddFocusController(widget uintptr, onEnter, onLeave func()) uintptr {
464- // Stub implementation - return a unique token
465- token := nextHoverToken
466- nextHoverToken++
467- return token
468-}
469-
470-func WidgetRemoveFocusController(widget uintptr, token uintptr) {
471- // Stub implementation - no-op
472-}
473-
474-// SimulateFocusForTesting gives focus to a widget for test scenarios
475-func SimulateFocusForTesting(widget uintptr) {
476- WidgetGrabFocus(widget)
477-}
478-
479-// GtkBox functions for stacked panes (stub implementations)
480-
481-func NewBox(orientation Orientation, spacing int) uintptr {
482- widgetMu.Lock()
483- defer widgetMu.Unlock()
484- id := nextWidgetID
485- nextWidgetID++
486- widgetState[id] = &widgetStub{
487- refCount: 1,
488- canFocus: true,
489- widgetType: "box", // Mark as box widget
490- }
491- return id
492-}
493-
494-func BoxAppend(box uintptr, child uintptr) {
495- widgetMu.Lock()
496- defer widgetMu.Unlock()
497-
498- if box == 0 || child == 0 {
499- return
500- }
501-
502- boxStub, ok := widgetState[box]
503- if !ok || boxStub.isDestroyed {
504- panic("GTK-CRITICAL simulation: gtk_box_append: box is invalid")
505- }
506-
507- childStub, ok := widgetState[child]
508- if !ok || childStub.isDestroyed {
509- panic("GTK-CRITICAL simulation: gtk_box_append: child is invalid")
510- }
511-
512- if childStub.parent != 0 && childStub.parent != box {
513- panic("GTK-CRITICAL simulation: gtk_box_append: child already has a different parent")
514- }
515- childStub.parent = box
516-}
517-
518-func BoxPrepend(box uintptr, child uintptr) {
519- BoxAppend(box, child)
520-}
521-
522-func BoxRemove(box uintptr, child uintptr) {
523- widgetMu.Lock()
524- defer widgetMu.Unlock()
525-
526- if box == 0 || child == 0 {
527- return
528- }
529-
530- childStub, ok := widgetState[child]
531- if !ok || childStub.isDestroyed {
532- panic("GTK-CRITICAL simulation: gtk_box_remove: child is invalid")
533- }
534-
535- if childStub.parent != box {
536- panic("GTK-CRITICAL simulation: gtk_box_remove: child is not parented to the specified box")
537- }
538-
539- childStub.parent = 0
540-}
541-
542-func BoxInsertChildAfter(box uintptr, child uintptr, sibling uintptr) {
543- BoxAppend(box, child)
544-}
545-
546-// Widget visibility functions for stacked panes (stub implementations)
547-
548-func WidgetSetVisible(widget uintptr, visible bool) {}
549-
550-func WidgetGetVisible(widget uintptr) bool { return true }
551-
552-func WidgetHide(widget uintptr) {}
553-
554-// Label functions for title bars (stub implementations)
555-
556-func NewLabel(text string) uintptr {
557- return newWidgetHandle()
558-}
559-
560-func LabelSetText(label uintptr, text string) {}
561-
562-func LabelGetText(label uintptr) string { return "" }
563-
564-// EllipsizeMode represents PangoEllipsizeMode values in stub builds.
565-type EllipsizeMode int
566-
567-const (
568- EllipsizeNone EllipsizeMode = 0
569- EllipsizeStart EllipsizeMode = 1
570- EllipsizeMiddle EllipsizeMode = 2
571- EllipsizeEnd EllipsizeMode = 3
572-)
573-
574-func LabelSetEllipsize(label uintptr, mode EllipsizeMode) {}
575-
576-func LabelSetMaxWidthChars(label uintptr, nChars int) {}
577-
578-// SetTestContainer is a testing helper to set the container field for WebView in stub builds
579-func (w *WebView) SetTestContainer(container uintptr) {
580- w.container = container
581-}
582-
583-// WidgetGetAllocation returns a stub allocation for the widget
584-func WidgetGetAllocation(widget uintptr) WidgetAllocation {
585- widgetMu.Lock()
586- defer widgetMu.Unlock()
587-
588- // Return stub allocation based on widget bounds if available
589- if stub, ok := widgetState[widget]; ok && stub.hasBounds {
590- return WidgetAllocation{
591- X: int(stub.bounds.X),
592- Y: int(stub.bounds.Y),
593- Width: int(stub.bounds.Width),
594- Height: int(stub.bounds.Height),
595- }
596- }
597-
598- // Default stub allocation for testing
599- return WidgetAllocation{
600- X: 0,
601- Y: 0,
602- Width: 400,
603- Height: 300,
604- }
605-}
D pkg/webkit/zoom.go +0 -35
@@ -1,35 +0,0 @@
1-//go:build !webkit_cgo
2-
3-package webkit
4-
5-const (
6- zoomMin = 0.25
7- zoomMax = 5.0
8-)
9-
10-// SetZoom sets the current zoom factor for the WebView.
11-func (w *WebView) SetZoom(level float64) error {
12- if w == nil || w.destroyed {
13- return ErrNotImplemented
14- }
15- if level < zoomMin {
16- level = zoomMin
17- }
18- if level > zoomMax {
19- level = zoomMax
20- }
21- w.zoom = level
22- if w.useDomZoom {
23- w.SeedDomZoom(level)
24- }
25- w.dispatchZoomChanged(w.zoom)
26- return nil
27-}
28-
29-// GetZoom returns the current zoom factor.
30-func (w *WebView) GetZoom() (float64, error) {
31- if w == nil || w.destroyed {
32- return 1.0, ErrNotImplemented
33- }
34- return w.zoom, nil
35-}
D pkg/webkit/zoom_cgo.go +0 -43
@@ -1,43 +0,0 @@
1-//go:build webkit_cgo
2-
3-package webkit
4-
5-/*
6-#cgo pkg-config: webkitgtk-6.0 gtk4
7-#cgo CFLAGS: -I/usr/include/webkitgtk-6.0
8-#include <webkit/webkit.h>
9-*/
10-import "C"
11-
12-import "fmt"
13-
14-func (w *WebView) SetZoom(level float64) error {
15- if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
16- return ErrNotImplemented
17- }
18- level = clampDomZoom(level)
19- w.zoom = level
20- if w.useDomZoom {
21- w.SeedDomZoom(level)
22- script := fmt.Sprintf(`(function(){try{const lvl=%[1]f;window.__dumber_dom_zoom_level=lvl;if(window.__dumber_applyDomZoom){window.__dumber_applyDomZoom(lvl);}else{if(Math.abs(lvl-1.0)<1e-6){document.documentElement.style.zoom='';if(document.body){document.body.style.zoom='';}}else{const value=lvl.toString();document.documentElement.style.zoom=value;if(document.body){document.body.style.zoom=value;}}}}catch(e){console.error('[dumber] DOM zoom error',e);}})();`, level)
23- if err := w.InjectScript(script); err != nil {
24- C.webkit_web_view_set_zoom_level(w.native.wv, C.gdouble(level))
25- }
26- } else {
27- C.webkit_web_view_set_zoom_level(w.native.wv, C.gdouble(level))
28- }
29- w.dispatchZoomChanged(w.zoom)
30- return nil
31-}
32-
33-func (w *WebView) GetZoom() (float64, error) {
34- if w == nil || w.destroyed || w.native == nil || w.native.wv == nil {
35- return 1.0, ErrNotImplemented
36- }
37- if w.useDomZoom {
38- return w.zoom, nil
39- }
40- z := float64(C.webkit_web_view_get_zoom_level(w.native.wv))
41- w.zoom = z
42- return z, nil
43-}
D tests/integration/test_external_shortcuts.go +0 -17
@@ -1,17 +0,0 @@
1-package integration
2-
3-import (
4- "github.com/bnema/dumber/pkg/webkit"
5- "testing"
6-)
7-
8-// Integration: keyboard shortcuts work on external sites once implemented.
9-func Test_WebKit_ExternalShortcuts_Integration(t *testing.T) {
10- view, err := webkit.NewWebView(&webkit.Config{})
11- if err != nil {
12- t.Fatalf("NewWebView failed: %v", err)
13- }
14- if err := view.RegisterKeyboardShortcut("Ctrl+K", func() {}); err != nil {
15- t.Fatalf("RegisterKeyboardShortcut should succeed, got: %v", err)
16- }
17-}
D tests/integration/test_google_navigation.go +0 -17
@@ -1,17 +0,0 @@
1-package integration
2-
3-import (
4- "github.com/bnema/dumber/pkg/webkit"
5- "testing"
6-)
7-
8-// Integration: navigate to google.fr without error once implemented.
9-func Test_WebKit_GoogleNavigation_Integration(t *testing.T) {
10- view, err := webkit.NewWebView(&webkit.Config{})
11- if err != nil {
12- t.Fatalf("NewWebView failed: %v", err)
13- }
14- if err := view.LoadURL("https://www.google.fr"); err != nil {
15- t.Fatalf("LoadURL should succeed, got: %v", err)
16- }
17-}
D tests/integration/test_webkit_basic.go +0 -17
@@ -1,17 +0,0 @@
1-package integration
2-
3-import (
4- "github.com/bnema/dumber/pkg/webkit"
5- "testing"
6-)
7-
8-// Integration: basic WebKit window and navigation works.
9-func Test_WebKit_BasicWindow_Integration(t *testing.T) {
10- view, err := webkit.NewWebView(&webkit.Config{InitialURL: "about:blank"})
11- if err != nil {
12- t.Fatalf("NewWebView failed: %v", err)
13- }
14- if err := view.LoadURL("https://example.com"); err != nil {
15- t.Fatalf("LoadURL should succeed, got: %v", err)
16- }
17-}
D tests/integration/test_zoom_persistence.go +0 -24
@@ -1,24 +0,0 @@
1-package integration
2-
3-import (
4- "github.com/bnema/dumber/pkg/webkit"
5- "testing"
6-)
7-
8-// Integration: zoom persists across operations (with DB) once implemented.
9-func Test_WebKit_ZoomPersistence_Integration(t *testing.T) {
10- view, err := webkit.NewWebView(&webkit.Config{ZoomDefault: 1.0})
11- if err != nil {
12- t.Fatalf("NewWebView failed: %v", err)
13- }
14- if err := view.SetZoom(1.1); err != nil {
15- t.Fatalf("SetZoom should succeed, got: %v", err)
16- }
17- z, err := view.GetZoom()
18- if err != nil {
19- t.Fatalf("GetZoom should succeed, got: %v", err)
20- }
21- if z != 1.1 {
22- t.Fatalf("expected zoom 1.1, got %v", z)
23- }
24-}

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