detached d28477f

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

refactor: migrate to gotk4 library
commit
d28477f24d6aa955512d4df9087da1eb14791ce3
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
unsigned
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.
5 ## [Unreleased]5 ## [Unreleased]
6 6
7 ### Added7 ### Added
8+- **Native favicon support**: WebKit FaviconDatabase integration with automatic detection and caching for all panes
8 - ENV=dev support to isolate test builds from production config/data in .dev/dumber/ directory9 - ENV=dev support to isolate test builds from production config/data in .dev/dumber/ directory
9 - Content filtering whitelist config (Twitch enabled by default)10 - Content filtering whitelist config (Twitch enabled by default)
10 - `dumber config` command to open config file in $VISUAL/$EDITOR or print path with `--path`11 - `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
11 21
12 ### Fixed22 ### 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
13 - Enabled missing WebKitGTK6 features: WebRTC, MediaSource, LocalStorage, WebAudio, MediaStream, Clipboard34 - Enabled missing WebKitGTK6 features: WebRTC, MediaSource, LocalStorage, WebAudio, MediaStream, Clipboard
14 - Cosmetic filter duplicate injection in frames35 - Cosmetic filter duplicate injection in frames
15 - Config file duplicate keys (snake_case/camelCase) by using Viper's SafeWriteConfigAs36 - Config file duplicate keys (snake_case/camelCase) by using Viper's SafeWriteConfigAs
16 - Config not reading newly created default config file37 - Config not reading newly created default config file
17 - Config writing to disk on every load/reload38 - 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
18 47
19 ## [0.11.0] - 2025-10-0748 ## [0.11.0] - 2025-10-07
20 49
M Makefile +1 -0
@@ -123,6 +123,7 @@ clean: ## Clean build artifacts
123 rm -rf $(DIST_DIR)123 rm -rf $(DIST_DIR)
124 rm -f $(BINARY_NAME) # Remove any old binaries in root124 rm -f $(BINARY_NAME) # Remove any old binaries in root
125 rm -rf gui/dist gui/node_modules125 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
126 go clean -cache127 go clean -cache
127 go clean -testcache128 go clean -testcache
128 129
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
3 go 1.25.13 go 1.25.1
4 4
5 require (5 require (
6+ github.com/diamondburned/gotk4-webkitgtk/pkg v0.0.0-20240108031600-dee1973cf440
7+ github.com/diamondburned/gotk4/pkg v0.3.1
6 github.com/fsnotify/fsnotify v1.9.08 github.com/fsnotify/fsnotify v1.9.0
7 github.com/golang/mock v1.6.09 github.com/golang/mock v1.6.0
8 github.com/ncruces/go-sqlite3 v0.29.010 github.com/ncruces/go-sqlite3 v0.29.0
@@ -12,6 +14,7 @@ require (
12 )14 )
13 15
14 require (16 require (
17+ github.com/KarpelesLab/weak v0.1.1 // indirect
15 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect18 github.com/go-viper/mapstructure/v2 v2.4.0 // indirect
16 github.com/google/go-cmp v0.7.0 // indirect19 github.com/google/go-cmp v0.7.0 // indirect
17 github.com/inconshreveable/mousetrap v1.1.0 // indirect20 github.com/inconshreveable/mousetrap v1.1.0 // indirect
@@ -27,7 +30,11 @@ require (
27 github.com/subosito/gotenv v1.6.0 // indirect30 github.com/subosito/gotenv v1.6.0 // indirect
28 github.com/tetratelabs/wazero v1.9.0 // indirect31 github.com/tetratelabs/wazero v1.9.0 // indirect
29 go.yaml.in/yaml/v3 v3.0.4 // indirect32 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
30 golang.org/x/sys v0.36.0 // indirect35 golang.org/x/sys v0.36.0 // indirect
31 golang.org/x/text v0.29.0 // indirect36 golang.org/x/text v0.29.0 // indirect
32 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect37 gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect
33 )38 )
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 @@
110 $effect(() => {110 $effect(() => {
111 console.log('🔄 ToastContainer initialized, functions exposed');111 console.log('🔄 ToastContainer initialized, functions exposed');
112 112
113- // Monitor container periodically for debugging113+ // Monitor container periodically for debugging (disabled)
114- const monitorInterval = setInterval(() => {114+ // const monitorInterval = setInterval(() => {
115- const container = document.querySelector('.toast-container');115+ // const container = document.querySelector('.toast-container');
116- const browserRoot = document.querySelector('.browser-component-root');116+ // const browserRoot = document.querySelector('.browser-component-root');
117- console.log('[dumber] Periodic check - container:', !!container, 'browser-root:', !!browserRoot, 'toasts:', toasts.length);117+ // console.log('[dumber] Periodic check - container:', !!container, 'browser-root:', !!browserRoot, 'toasts:', toasts.length);
118- }, 5000);118+ // }, 5000);
119 119
120 return () => {120 return () => {
121- clearInterval(monitorInterval);121+ // clearInterval(monitorInterval);
122 console.log('🗑️ ToastContainer cleanup');122 console.log('🗑️ ToastContainer cleanup');
123 };123 };
124 });124 });
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 @@
1 /**1 /**
2- * Unified GUI Bundle Entry Point2+ * Unified GUI bundle entry point.
3 *3 *
4- * Single entry point for all WebKit GUI functionality.4+ * Delegates to the shared bootstrap so special pages can reuse the same setup
5- * Loads immediately and provides modular initialization system.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.
6 */10 */
7 11
8-// Import all GUI modules12+import { bootstrapGUI } from "./bootstrap";
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- }
426 13
427- console.log(14+bootstrapGUI();
428- `[gui-bootstrap] GUI bootstrap complete for webview ${currentWebViewId}`,
429- );
430- };
431- }
432-}
M gui/src/injected/main-world.ts +10 -384
@@ -14,7 +14,6 @@ declare global {
14 interface Window {14 interface Window {
15 __dumber: DumberAPI;15 __dumber: DumberAPI;
16 __dumber_page_bridge_installed?: boolean;16 __dumber_page_bridge_installed?: boolean;
17- __dumber_window_open_intercepted?: boolean;
18 __dumber_dom_zoom_seed?: number;17 __dumber_dom_zoom_seed?: number;
19 __dumber_dom_zoom_level?: number;18 __dumber_dom_zoom_level?: number;
20 __dumber_initial_theme?: string;19 __dumber_initial_theme?: string;
@@ -33,7 +32,7 @@ declare global {
33 ) => number | void;32 ) => number | void;
34 __dumber_showZoomToast?: (level: number) => void;33 __dumber_showZoomToast?: (level: number) => void;
35 __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void;34 __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void;
36- __dumber_webview_id?: string;35+ __dumber_webview_id?: string | number;
37 __dumber_is_active?: boolean;36 __dumber_is_active?: boolean;
38 __dumber_teardown?: () => void;37 __dumber_teardown?: () => void;
39 webkit?: {38 webkit?: {
@@ -46,65 +45,17 @@ declare global {
46 }45 }
47 }46 }
48 47
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-
59 interface Suggestion {48 interface Suggestion {
60 // Define proper suggestion types based on your needs49 // Define proper suggestion types based on your needs
61 [key: string]: unknown;50 [key: string]: unknown;
62 }51 }
63 52
64 interface DumberAPI {53 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- };
73 omnibox: {54 omnibox: {
74 suggestions: (suggestions: Suggestion[]) => void;55 suggestions: (suggestions: Suggestion[]) => void;
75 };56 };
76 }57 }
77 58
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-
108 (() => {59 (() => {
109 try {60 try {
110 // Prevent multiple installations61 // Prevent multiple installations
@@ -120,9 +71,14 @@ function detectWindowType(features?: string | null): string {
120 const initialZoom = 1.0; // __DOM_ZOOM_DEFAULT__ will be replaced by Go71 const initialZoom = 1.0; // __DOM_ZOOM_DEFAULT__ will be replaced by Go
121 window.__dumber_dom_zoom_seed = initialZoom;72 window.__dumber_dom_zoom_seed = initialZoom;
122 73
123- // Initialize WebView ID and active state (will be replaced by Go)74+ // Initialize WebView ID and active state (injected by Go at document-start)
124- window.__dumber_webview_id = "__WEBVIEW_ID__";75+ // Only set fallback values if not already injected
125- window.__dumber_is_active = "__WEBVIEW_ACTIVE__" as unknown as boolean;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+ }
126 82
127 // Request color palettes from Go backend83 // Request color palettes from Go backend
128 try {84 try {
@@ -273,59 +229,9 @@ function detectWindowType(features?: string | null): string {
273 }229 }
274 };230 };
275 231
276- // Initialize unified API object232+ // Initialize unified API object (toast functions are exposed by ToastContainer.svelte)
277 window.__dumber = window.__dumber || ({} as DumberAPI);233 window.__dumber = window.__dumber || ({} as DumberAPI);
278 234
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-
329 // DOM zoom functionality235 // DOM zoom functionality
330 if (typeof window.__dumber_dom_zoom_level !== "number") {236 if (typeof window.__dumber_dom_zoom_level !== "number") {
331 window.__dumber_dom_zoom_level = initialZoom;237 window.__dumber_dom_zoom_level = initialZoom;
@@ -894,284 +800,6 @@ function detectWindowType(features?: string | null): string {
894 // Setup OAuth callback detection800 // Setup OAuth callback detection
895 setupOAuthCallbackDetection();801 setupOAuthCallbackDetection();
896 802
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-
1175 const teardown = () => {803 const teardown = () => {
1176 if (teardownExecuted) {804 if (teardownExecuted) {
1177 return;805 return;
@@ -1193,7 +821,6 @@ function detectWindowType(features?: string | null): string {
1193 omniboxQueue.length = 0;821 omniboxQueue.length = 0;
1194 omniboxReady = false;822 omniboxReady = false;
1195 823
1196- window.__dumber_window_open_intercepted = false;
1197 window.__dumber_page_bridge_installed = false;824 window.__dumber_page_bridge_installed = false;
1198 window.__dumber_teardown = undefined;825 window.__dumber_teardown = undefined;
1199 window.__dumber_omnibox_suggestions = undefined;826 window.__dumber_omnibox_suggestions = undefined;
@@ -1205,7 +832,6 @@ function detectWindowType(features?: string | null): string {
1205 if (globalState) {832 if (globalState) {
1206 const mutableState = globalState as unknown as Record<string, unknown>;833 const mutableState = globalState as unknown as Record<string, unknown>;
1207 delete mutableState.omnibox;834 delete mutableState.omnibox;
1208- delete mutableState.toast;
1209 }835 }
1210 };836 };
1211 837
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> {
96 updateTheme(initialTheme === "dark");96 updateTheme(initialTheme === "dark");
97 }97 }
98 98
99- // Mount the Svelte toast container (Svelte 5 syntax)99+ // Mount the Svelte toast container - this exposes all toast functions to window
100 mount(ToastContainer, {100 mount(ToastContainer, {
101 target: rootElement as unknown as Element,101 target: rootElement as unknown as Element,
102 });102 });
103 103
104- // Signal isolated GUI readiness for page-world bridge104+ // Force immediate effect execution to ensure component functions are exposed
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
132 flushSync();105 flushSync();
133 106
134- // Toast functions are now exposed by the component itself
135- console.log("✅ Toast functions exposed via component instantiation");
136-
137 toastInitialized = true;107 toastInitialized = true;
138- console.log("✅ Toast system initialized with Svelte 5");108+ console.log("✅ Toast system initialized - functions exposed by ToastContainer");
139 } catch (error) {109 } catch (error) {
140 console.error("❌ Failed to initialize toast system:", error);110 console.error("❌ Failed to initialize toast system:", error);
141-111+ throw error;
142- // Fallback to basic toast system if Svelte fails
143- initializeFallbackToast();
144 }112 }
145 }113 }
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 @@
1 /**1 /**
2- * Workspace Controller Module2+ * Workspace UI Notifications Module
3 *3 *
4- * Provides the initial scaffolding for Zellij-inspired pane management.4+ * Pane mode is now handled entirely in the Go backend with visual border feedback.
5- * Currently tracks pane mode state, exposes configuration to the page,5+ * This module is kept for potential future UI enhancements.
6- * and wires keyboard service shortcuts based on configuration defaults.
7 */6 */
8 7
9-import { keyboardService } from "$lib/keyboard";8+console.log("[workspace] UI notification handlers initialized");
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 };
M gui/src/pages/Homepage.svelte +13 -16
@@ -549,10 +549,15 @@
549 return;549 return;
550 }550 }
551 551
552- if (mode === "dark") {552+ switch (mode) {
553- document.documentElement.classList.add("dark");553+ case "light":
554- } else {554+ document.documentElement.classList.add("light");
555- document.documentElement.classList.remove("dark");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;
556 }561 }
557 persistTheme(mode);562 persistTheme(mode);
558 };563 };
@@ -766,10 +771,8 @@
766 <meta name="description" content="Fast Wayland Browser - Your browsing patterns at a glance" />771 <meta name="description" content="Fast Wayland Browser - Your browsing patterns at a glance" />
767 772
768 {@html `<style>773 {@html `<style>
769- html { background: #0a0a0a; }774+ html { background: var(--dynamic-bg, #0a0a0a); }
770- body { background: #0a0a0a; color: #e5e5e5; }775+ body { background: var(--dynamic-bg, #0a0a0a); color: var(--dynamic-text, #e5e5e5); }
771- html.light { background: #ffffff; }
772- html.light body { background: #ffffff; color: #171717; }
773 </style>`}776 </style>`}
774 777
775 {@html `<script>778 {@html `<script>
@@ -806,13 +809,8 @@
806 {themeMode === "dark" ? "Switch to light mode" : "Switch to dark mode"}809 {themeMode === "dark" ? "Switch to light mode" : "Switch to dark mode"}
807 </span>810 </span>
808 {#if themeMode === "dark"}811 {#if themeMode === "dark"}
809- <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">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">
810- <path813+ <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
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- />
816 </svg>814 </svg>
817 {:else}815 {:else}
818 <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">816 <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false">
@@ -1193,7 +1191,6 @@
1193 <style>1191 <style>
1194 .homepage-shell {1192 .homepage-shell {
1195 min-height: 100vh;1193 min-height: 100vh;
1196- background-color: var(--dynamic-bg);
1197 color: var(--dynamic-text);1194 color: var(--dynamic-text);
1198 display: flex;1195 display: flex;
1199 flex-direction: column;1196 flex-direction: column;
M gui/src/pages/homepage.ts +3 -0
@@ -1,6 +1,7 @@
1 import { mount } from "svelte";1 import { mount } from "svelte";
2 import "../styles/tailwind.css";2 import "../styles/tailwind.css";
3 import Homepage from "./Homepage.svelte";3 import Homepage from "./Homepage.svelte";
4+import { bootstrapGUI } from "../injected/bootstrap";
4 5
5 // Proactively unregister any Service Workers and clear caches that may have been6 // Proactively unregister any Service Workers and clear caches that may have been
6 // installed by previous builds (e.g., SvelteKit), which can otherwise attempt to7 // installed by previous builds (e.g., SvelteKit), which can otherwise attempt to
@@ -25,6 +26,8 @@ async function cleanupServiceWorkersAndCaches() {
25 }26 }
26 }27 }
27 28
29+bootstrapGUI();
30+
28 // Mount the Homepage component to the DOM31 // Mount the Homepage component to the DOM
29 cleanupServiceWorkersAndCaches().finally(() => {32 cleanupServiceWorkersAndCaches().finally(() => {
30 console.log("[dumber] Mounting Homepage component to document.body");33 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";
5 5
6 export default defineConfig({6 export default defineConfig({
7 plugins: [7 plugins: [
8- svelte(),8+ svelte({
9+ emitCss: false, // Inline component CSS for shadow DOM compatibility
10+ }),
9 pageGenerator([11 pageGenerator([
10 {12 {
11 name: "homepage",13 name: "homepage",
12 title: "Dumber Browser",14 title: "Dumber Browser",
13 script: "homepage.min.js",15 script: "homepage.min.js",
14- css: "homepage.css",
15 filename: "index.html",16 filename: "index.html",
16 },17 },
17 ]),18 ]),
@@ -33,7 +34,7 @@ export default defineConfig({
33 target: ["es2020", "chrome91", "firefox90"],34 target: ["es2020", "chrome91", "firefox90"],
34 minify: true,35 minify: true,
35 sourcemap: false,36 sourcemap: false,
36- cssCodeSplit: true,37+ cssCodeSplit: false,
37 assetsInlineLimit: 4096,38 assetsInlineLimit: 4096,
38 },39 },
39 resolve: {40 resolve: {
M internal/app/api/scheme.go +88 -10
@@ -2,15 +2,20 @@ package api
2 2
3 import (3 import (
4 "embed"4 "embed"
5+ "fmt"
5 "log"6 "log"
6 "mime"7 "mime"
7 neturl "net/url"8 neturl "net/url"
9+ "os"
8 "path/filepath"10 "path/filepath"
9 "strings"11 "strings"
10 12
11 "github.com/bnema/dumber/internal/app/constants"13 "github.com/bnema/dumber/internal/app/constants"
12 "github.com/bnema/dumber/internal/config"14 "github.com/bnema/dumber/internal/config"
13 "github.com/bnema/dumber/internal/services"15 "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"
14 )19 )
15 20
16 // SchemeHandler handles custom dumb:// scheme resolution21 // SchemeHandler handles custom dumb:// scheme resolution
@@ -18,6 +23,7 @@ type SchemeHandler struct {
18 assets embed.FS23 assets embed.FS
19 parserService *services.ParserService24 parserService *services.ParserService
20 browserService *services.BrowserService25 browserService *services.BrowserService
26+ cfg *config.Config
21 }27 }
22 28
23 // NewSchemeHandler creates a new scheme handler29 // NewSchemeHandler creates a new scheme handler
@@ -33,25 +39,39 @@ func NewSchemeHandler(
33 }39 }
34 }40 }
35 41
36-// Handle processes dumb:// scheme requests42+// SetConfig sets the configuration for the scheme handler
37-func (s *SchemeHandler) Handle(uri string, cfg *config.Config) (string, []byte, bool) {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()
38 log.Printf("[scheme] request: %s", uri)50 log.Printf("[scheme] request: %s", uri)
39 51
40 // Known forms:52 // Known forms:
41 // - dumb://homepage or dumb:homepage → index.html53 // - dumb://homepage or dumb:homepage → index.html
42 // - dumb://app/index.html, dumb://app/<path> → serve from gui/<path>54 // - dumb://app/index.html, dumb://app/<path> → serve from gui/<path>
55+ // - dumb://favicon/<hash>.png → serve cached favicon
43 // - dumb://<anything> without path → index.html56 // - dumb://<anything> without path → index.html
44 u, err := neturl.Parse(uri)57 u, err := neturl.Parse(uri)
45 if err != nil || u.Scheme != "dumb" {58 if err != nil || u.Scheme != "dumb" {
46- return "", nil, false59+ 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
47 }67 }
48 68
49 // Static assets69 // Static assets
50- return s.handleAsset(u)70+ s.handleAsset(req, u)
51 }71 }
52 72
53 // handleAsset serves static assets from embedded filesystem73 // 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) {
55 // Resolve target path inside embed FS75 // Resolve target path inside embed FS
56 var rel string76 var rel string
57 if u.Opaque == constants.HomepagePath || (u.Host == constants.HomepagePath && (u.Path == "" || u.Path == "/")) || (u.Host == "" && (u.Path == "" || u.Path == "/")) {77 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) {
79 log.Printf("[scheme] asset: rel=%s (host=%s path=%s) → mapping to favicon.png", rel, u.Host, u.Path)99 log.Printf("[scheme] asset: rel=%s (host=%s path=%s) → mapping to favicon.png", rel, u.Host, u.Path)
80 data, rerr := s.assets.ReadFile(filepath.ToSlash(filepath.Join("assets", "gui", "favicon.png")))100 data, rerr := s.assets.ReadFile(filepath.ToSlash(filepath.Join("assets", "gui", "favicon.png")))
81 if rerr == nil {101 if rerr == nil {
82- return "image/png", data, true102+ s.finishRequest(req, "image/png", data, "favicon.png")
103+ return
83 }104 }
84 }105 }
85 106
@@ -89,16 +110,34 @@ func (s *SchemeHandler) handleAsset(u *neturl.URL) (string, []byte, bool) {
89 data, rerr := s.assets.ReadFile(filepath.ToSlash(filepath.Join("assets", "gui", rel)))110 data, rerr := s.assets.ReadFile(filepath.ToSlash(filepath.Join("assets", "gui", rel)))
90 if rerr != nil {111 if rerr != nil {
91 log.Printf("[scheme] not found: %s", rel)112 log.Printf("[scheme] not found: %s", rel)
92- return "", nil, false113+ req.FinishError(fmt.Errorf("asset not found: %s", rel))
114+ return
93 }115 }
94 116
95 // Determine mime type117 // Determine mime type
96 mt := s.getMimeType(rel)118 mt := s.getMimeType(rel)
97 119
98- // Add cache control for development (prevents future caching issues)120+ // Finish the request with the data
99- // Note: This doesn't affect the current cache, but prevents new caching
100 log.Printf("[scheme] serving %s with mime-type: %s", rel, mt)121 log.Printf("[scheme] serving %s with mime-type: %s", rel, mt)
101- return mt, data, true122+ 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)
102 }141 }
103 142
104 // getMimeType determines the MIME type for a given file path143 // getMimeType determines the MIME type for a given file path
@@ -152,3 +191,42 @@ func (s *SchemeHandler) getMimeType(filename string) string {
152 return "text/plain"191 return "text/plain"
153 }192 }
154 }193 }
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
3 import (3 import (
4 "database/sql"4 "database/sql"
5 "embed"5 "embed"
6- "fmt"
7 "log"6 "log"
8 "os"7 "os"
9 "os/signal"8 "os/signal"
@@ -18,7 +17,6 @@ import (
18 "github.com/bnema/dumber/internal/db"17 "github.com/bnema/dumber/internal/db"
19 "github.com/bnema/dumber/internal/filtering"18 "github.com/bnema/dumber/internal/filtering"
20 "github.com/bnema/dumber/internal/logging"19 "github.com/bnema/dumber/internal/logging"
21- "github.com/bnema/dumber/internal/migrations"
22 "github.com/bnema/dumber/internal/services"20 "github.com/bnema/dumber/internal/services"
23 "github.com/bnema/dumber/pkg/webkit"21 "github.com/bnema/dumber/pkg/webkit"
24 )22 )
@@ -119,21 +117,20 @@ func (app *BrowserApp) Initialize() error {
119 // Detect keyboard layout117 // Detect keyboard layout
120 environment.DetectAndSetKeyboardLocale()118 environment.DetectAndSetKeyboardLocale()
121 119
122- // Initialize database120+ // Initialize database (migrations run automatically in InitDB)
123 database, err := db.InitDB(app.config.Database.Path)121 database, err := db.InitDB(app.config.Database.Path)
124 if err != nil {122 if err != nil {
125 return err123 return err
126 }124 }
127 app.database = database125 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-
134 app.queries = db.New(database)126 app.queries = db.New(database)
135 log.Printf("Database opened at %s", app.config.Database.Path)127 log.Printf("Database opened at %s", app.config.Database.Path)
136 128
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+
137 // Initialize services134 // Initialize services
138 app.parserService = services.NewParserService(app.config, app.queries)135 app.parserService = services.NewParserService(app.config, app.queries)
139 app.browserService = services.NewBrowserService(app.config, app.queries)136 app.browserService = services.NewBrowserService(app.config, app.queries)
@@ -150,10 +147,11 @@ func (app *BrowserApp) Run() {
150 defer app.cleanup()147 defer app.cleanup()
151 defer logging.SetupPanicRecovery()148 defer logging.SetupPanicRecovery()
152 149
153- // Register custom scheme resolver150+ // Set config on scheme handler
154- webkit.SetURISchemeResolver(func(uri string) (string, []byte, bool) {151+ app.schemeHandler.SetConfig(app.config)
155- return app.schemeHandler.Handle(uri, app.config)152+
156- })153+ // Register custom scheme resolver for "dumb://" URIs (will be applied after WebView creation)
154+ webkit.RegisterURIScheme("dumb", app.schemeHandler.Handle)
157 155
158 // Create and setup WebView156 // Create and setup WebView
159 if err := app.createWebView(); err != nil {157 if err := app.createWebView(); err != nil {
@@ -161,6 +159,11 @@ func (app *BrowserApp) Run() {
161 return159 return
162 }160 }
163 161
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+
164 // Initialize content blocking167 // Initialize content blocking
165 if err := app.setupContentBlocking(); err != nil {168 if err := app.setupContentBlocking(); err != nil {
166 log.Printf("Warning: failed to setup content blocking: %v", err)169 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 (
10 "time"10 "time"
11 11
12 "github.com/bnema/dumber/pkg/webkit"12 "github.com/bnema/dumber/pkg/webkit"
13+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
13 )14 )
14 15
15 // FocusState represents the current state of the focus management system16 // FocusState represents the current state of the focus management system
@@ -158,7 +159,7 @@ func NewRequestDeduplicator(ttl time.Duration) *RequestDeduplicator {
158 }159 }
159 160
160 // IsDuplicate checks if a request is a duplicate within the TTL window161 // 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 {
162 rd.mu.Lock()163 rd.mu.Lock()
163 defer rd.mu.Unlock()164 defer rd.mu.Unlock()
164 165
@@ -173,12 +174,16 @@ func (rd *RequestDeduplicator) IsDuplicate(req FocusRequest) bool {
173 // Generate signature for this request174 // Generate signature for this request
174 sig := fmt.Sprintf("%p:%s", req.TargetNode, req.Source)175 sig := fmt.Sprintf("%p:%s", req.TargetNode, req.Source)
175 176
176- // Check if this signature exists177+ // Always record latest timestamp so future checks use fresh window
177- if _, exists := rd.recentSigs[sig]; exists {178+ // Prevent duplicates only when the target is already active; otherwise we
178- return true179+ // 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+ }
179 }185 }
180 186
181- // Record this signature
182 rd.recentSigs[sig] = now187 rd.recentSigs[sig] = now
183 return false188 return false
184 }189 }
@@ -366,8 +371,11 @@ func (fsm *FocusStateMachine) RequestFocus(node *paneNode, source FocusSource) e
366 fsm.mu.Unlock()371 fsm.mu.Unlock()
367 }372 }
368 373
369- // Check for duplicates374+ activePane := fsm.GetActivePane()
370- if fsm.deduplicator.IsDuplicate(request) {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) {
371 if fsm.metricsEnabled {379 if fsm.metricsEnabled {
372 fsm.mu.Lock()380 fsm.mu.Lock()
373 fsm.metrics.DuplicateRequests++381 fsm.metrics.DuplicateRequests++
@@ -556,17 +564,14 @@ func (fsm *FocusStateMachine) findTopLeftPane(leaves []*paneNode) *paneNode {
556 var bestScore float64 = 1e9564 var bestScore float64 = 1e9
557 565
558 for _, leaf := range leaves {566 for _, leaf := range leaves {
559- if leaf.container == 0 || !webkit.WidgetIsValid(leaf.container) {567+ if leaf.container == nil {
560 continue568 continue
561 }569 }
562 570
563- bounds, ok := webkit.WidgetGetBounds(leaf.container)571+ x, y, _, _ := webkit.WidgetGetAllocation(leaf.container)
564- if !ok {
565- continue
566- }
567 572
568 // Score based on distance from top-left corner (0,0)573 // Score based on distance from top-left corner (0,0)
569- score := bounds.X + bounds.Y574+ score := float64(x) + float64(y)
570 if score < bestScore {575 if score < bestScore {
571 bestScore = score576 bestScore = score
572 bestPane = leaf577 bestPane = leaf
@@ -591,8 +596,7 @@ func (fsm *FocusStateMachine) applyInitialFocus(node *paneNode) error {
591 596
592 // Apply initial visual border597 // Apply initial visual border
593 if fsm.wm != nil {598 if fsm.wm != nil {
594- ctx := fsm.wm.determineBorderContext(node)599+ fsm.wm.applyActivePaneBorder(node)
595- fsm.wm.applyActivePaneBorder(ctx)
596 }600 }
597 601
598 // Update state602 // Update state
@@ -615,6 +619,11 @@ func (fsm *FocusStateMachine) applyInitialFocus(node *paneNode) error {
615 fsm.wm.app.activePane = node.pane619 fsm.wm.app.activePane = node.pane
616 }620 }
617 621
622+ // Notify JS runtime about focus state
623+ if fsm.wm != nil {
624+ fsm.wm.DispatchPaneFocusEvent(node, true)
625+ }
626+
618 return nil627 return nil
619 }628 }
620 629
@@ -625,7 +634,7 @@ func (fsm *FocusStateMachine) applyGTKFocus(node *paneNode) error {
625 }634 }
626 635
627 viewWidget := node.pane.webView.Widget()636 viewWidget := node.pane.webView.Widget()
628- if viewWidget == 0 {637+ if viewWidget == nil {
629 return fmt.Errorf("webview has no valid widget")638 return fmt.Errorf("webview has no valid widget")
630 }639 }
631 640
@@ -705,8 +714,17 @@ func (fsm *FocusStateMachine) handleFocusRequest(request FocusRequest) {
705 log.Printf("[FSM] Processing focus request %s: %s -> %p",714 log.Printf("[FSM] Processing focus request %s: %s -> %p",
706 request.ID, request.Source, request.TargetNode)715 request.ID, request.Source, request.TargetNode)
707 716
708- // Execute focus change717+ // GTK operations must run on main thread - always use IdleAdd for simplicity
709- if err := fsm.executeFocusChange(request); err != nil {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 {
710 fsm.mu.Lock()728 fsm.mu.Lock()
711 fsm.metrics.FailedRequests++729 fsm.metrics.FailedRequests++
712 fsm.mu.Unlock()730 fsm.mu.Unlock()
@@ -758,8 +776,7 @@ func (fsm *FocusStateMachine) executeFocusChange(request FocusRequest) error {
758 fsm.wm.removeActivePaneBorder(oldPane)776 fsm.wm.removeActivePaneBorder(oldPane)
759 }777 }
760 // Add border to new pane778 // Add border to new pane
761- ctx := fsm.wm.determineBorderContext(newPane)779+ fsm.wm.applyActivePaneBorder(newPane)
762- fsm.wm.applyActivePaneBorder(ctx)
763 }780 }
764 781
765 // Update state782 // Update state
@@ -775,6 +792,16 @@ func (fsm *FocusStateMachine) executeFocusChange(request FocusRequest) error {
775 fsm.wm.app.activePane = newPane.pane792 fsm.wm.app.activePane = newPane.pane
776 }793 }
777 794
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+
778 // Start settling timer805 // Start settling timer
779 fsm.startSettlingTimer()806 fsm.startSettlingTimer()
780 807
@@ -918,27 +945,50 @@ func (fsm *FocusStateMachine) attachGTKController(node *paneNode) {
918 }945 }
919 946
920 widget := node.pane.webView.Widget()947 widget := node.pane.webView.Widget()
921- if widget == 0 || !webkit.WidgetIsValid(widget) {948+ if widget == nil {
922 return949 return
923 }950 }
924 951
925- // Create focus enter/leave callbacks for this specific node952+ // Create focus controller for GTK4
926- onEnter := func() {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+
927 log.Printf("[FSM] GTK focus enter: %p", node)968 log.Printf("[FSM] GTK focus enter: %p", node)
928 // Don't automatically change focus on GTK enter - let user interactions drive this969 // Don't automatically change focus on GTK enter - let user interactions drive this
929 // This prevents infinite loops with our own focus changes970 // 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
931 980
932- onLeave := func() {
933 log.Printf("[FSM] GTK focus leave: %p", node)981 log.Printf("[FSM] GTK focus leave: %p", node)
934 // Similarly, don't react to GTK leave events automatically982 // Similarly, don't react to GTK leave events automatically
935- }983+ })
936 984
937- // Add the focus controller and store the token for cleanup985+ // Add controller to widget
938- token := webkit.WidgetAddFocusController(widget, onEnter, onLeave)986+ webkit.WidgetAddController(widget, controller)
939- if token != 0 {987+
940- node.focusControllerToken = token988+ // Store controller pointer as token for later removal
941- log.Printf("[FSM] Attached GTK focus controller to pane %p with token %d", node, token)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)
942 }992 }
943 }993 }
944 994
@@ -955,12 +1005,8 @@ func (fsm *FocusStateMachine) detachGTKController(node *paneNode, token uintptr)
955 1005
956 node.focusControllerToken = 01006 node.focusControllerToken = 0
957 1007
958- widget := node.pane.webView.Widget()1008+ // Note: In GTK4, controllers are automatically removed when widget is destroyed
959- if widget == 0 || !webkit.WidgetIsValid(widget) {1009+ // We just need to clear our token reference
960- return
961- }
962-
963- webkit.WidgetRemoveFocusController(widget, token)
964 log.Printf("[FSM] Detached GTK focus controller from pane %p", node)1010 log.Printf("[FSM] Detached GTK focus controller from pane %p", node)
965 }1011 }
966 1012
M internal/app/browser/focus_state_machine_test.go +8 -8
@@ -221,7 +221,7 @@ func TestRequestDeduplicator_DetectsDuplicates(t *testing.T) {
221 }221 }
222 222
223 // First request should not be duplicate223 // First request should not be duplicate
224- if dedup.IsDuplicate(req1) {224+ if dedup.IsDuplicate(req1, false) {
225 t.Error("first request should not be duplicate")225 t.Error("first request should not be duplicate")
226 }226 }
227 227
@@ -232,7 +232,7 @@ func TestRequestDeduplicator_DetectsDuplicates(t *testing.T) {
232 Timestamp: time.Now(),232 Timestamp: time.Now(),
233 }233 }
234 234
235- if !dedup.IsDuplicate(req2) {235+ if !dedup.IsDuplicate(req2, true) {
236 t.Error("second identical request should be duplicate")236 t.Error("second identical request should be duplicate")
237 }237 }
238 }238 }
@@ -253,10 +253,10 @@ func TestRequestDeduplicator_DifferentSourcesNotDuplicate(t *testing.T) {
253 Source: SourceMouse,253 Source: SourceMouse,
254 }254 }
255 255
256- dedup.IsDuplicate(req1)256+ dedup.IsDuplicate(req1, false)
257 257
258 // Different source should not be duplicate258 // Different source should not be duplicate
259- if dedup.IsDuplicate(req2) {259+ if dedup.IsDuplicate(req2, false) {
260 t.Error("requests with different sources should not be duplicates")260 t.Error("requests with different sources should not be duplicates")
261 }261 }
262 }262 }
@@ -274,12 +274,12 @@ func TestRequestDeduplicator_ExpiresOldSignatures(t *testing.T) {
274 }274 }
275 275
276 // First request276 // First request
277- if dedup.IsDuplicate(req) {277+ if dedup.IsDuplicate(req, false) {
278 t.Error("first request should not be duplicate")278 t.Error("first request should not be duplicate")
279 }279 }
280 280
281 // Immediate duplicate281 // Immediate duplicate
282- if !dedup.IsDuplicate(req) {282+ if !dedup.IsDuplicate(req, true) {
283 t.Error("immediate duplicate should be detected")283 t.Error("immediate duplicate should be detected")
284 }284 }
285 285
@@ -287,7 +287,7 @@ func TestRequestDeduplicator_ExpiresOldSignatures(t *testing.T) {
287 time.Sleep(ttl + 10*time.Millisecond)287 time.Sleep(ttl + 10*time.Millisecond)
288 288
289 // Should not be duplicate after expiration289 // Should not be duplicate after expiration
290- if dedup.IsDuplicate(req) {290+ if dedup.IsDuplicate(req, false) {
291 t.Error("request after TTL should not be duplicate")291 t.Error("request after TTL should not be duplicate")
292 }292 }
293 }293 }
@@ -312,7 +312,7 @@ func TestRequestDeduplicator_ConcurrentAccess(t *testing.T) {
312 TargetNode: nodes[idx%len(nodes)],312 TargetNode: nodes[idx%len(nodes)],
313 Source: SourceKeyboard,313 Source: SourceKeyboard,
314 }314 }
315- _ = dedup.IsDuplicate(req)315+ _ = dedup.IsDuplicate(req, false)
316 time.Sleep(time.Microsecond)316 time.Sleep(time.Microsecond)
317 }317 }
318 }(i)318 }(i)
M internal/app/browser/shortcuts.go +3 -3
@@ -67,7 +67,7 @@ func (s *ShortcutHandler) RegisterShortcuts() {
67 // by WindowShortcutHandler at the window level to prevent duplicates67 // by WindowShortcutHandler at the window level to prevent duplicates
68 68
69 // Page refresh shortcuts69 // Page refresh shortcuts
70- _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+r", func() {70+ _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+r", 0, func() {
71 if !s.isActivePane() {71 if !s.isActivePane() {
72 return72 return
73 }73 }
@@ -75,7 +75,7 @@ func (s *ShortcutHandler) RegisterShortcuts() {
75 _ = s.webView.Reload()75 _ = s.webView.Reload()
76 })76 })
77 77
78- _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+shift+r", func() {78+ _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+shift+r", 0, func() {
79 if !s.isActivePane() {79 if !s.isActivePane() {
80 return80 return
81 }81 }
@@ -83,7 +83,7 @@ func (s *ShortcutHandler) RegisterShortcuts() {
83 _ = s.webView.ReloadBypassCache()83 _ = s.webView.ReloadBypassCache()
84 })84 })
85 85
86- _ = s.webView.RegisterKeyboardShortcut("F5", func() {86+ _ = s.webView.RegisterKeyboardShortcut("F5", 0, func() {
87 if !s.isActivePane() {87 if !s.isActivePane() {
88 return88 return
89 }89 }
M internal/app/browser/stacked_panes.go +160 -150
@@ -10,6 +10,7 @@ import (
10 10
11 "github.com/bnema/dumber/internal/cache"11 "github.com/bnema/dumber/internal/cache"
12 "github.com/bnema/dumber/pkg/webkit"12 "github.com/bnema/dumber/pkg/webkit"
13+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
13 )14 )
14 15
15 const (16 const (
@@ -19,16 +20,16 @@ const (
19 20
20 // StackedPaneManager handles all stacked pane operations21 // StackedPaneManager handles all stacked pane operations
21 type StackedPaneManager struct {22 type StackedPaneManager struct {
22- wm *WorkspaceManager23+ wm *WorkspaceManager
23- titleBarToPane map[uintptr]*paneNode24+ titleBarToPane map[uint64]*paneNode
24- nextTitleBarID uint6425+ nextTitleBarID uint64
25 }26 }
26 27
27 // NewStackedPaneManager creates a new stacked pane manager28 // NewStackedPaneManager creates a new stacked pane manager
28 func NewStackedPaneManager(wm *WorkspaceManager) *StackedPaneManager {29 func NewStackedPaneManager(wm *WorkspaceManager) *StackedPaneManager {
29 return &StackedPaneManager{30 return &StackedPaneManager{
30 wm: wm,31 wm: wm,
31- titleBarToPane: make(map[uintptr]*paneNode),32+ titleBarToPane: make(map[uint64]*paneNode),
32 }33 }
33 }34 }
34 35
@@ -84,12 +85,11 @@ func (spm *StackedPaneManager) prepareNewStackedPane() (*paneNode, error) {
84 }85 }
85 86
86 newContainer := newPane.webView.RootWidget()87 newContainer := newPane.webView.RootWidget()
87- if newContainer == 0 {88+ if newContainer == nil {
88 return nil, errors.New("new pane missing container")89 return nil, errors.New("new pane missing container")
89 }90 }
90 webkit.WidgetSetHExpand(newContainer, true)91 webkit.WidgetSetHExpand(newContainer, true)
91 webkit.WidgetSetVExpand(newContainer, true)92 webkit.WidgetSetVExpand(newContainer, true)
92- webkit.WidgetRealizeInContainer(newContainer)
93 93
94 // Create the new leaf node94 // Create the new leaf node
95 newLeaf := &paneNode{95 newLeaf := &paneNode{
@@ -105,7 +105,9 @@ func (spm *StackedPaneManager) prepareNewStackedPane() (*paneNode, error) {
105 105
106 // Keep new container hidden initially - will be shown after transition106 // Keep new container hidden initially - will be shown after transition
107 webkit.WidgetSetVisible(newContainer, false)107 webkit.WidgetSetVisible(newContainer, false)
108- webkit.WidgetSetVisible(newTitleBar, false)108+ if newTitleBar != nil {
109+ webkit.WidgetSetVisible(newTitleBar, false)
110+ }
109 111
110 return newLeaf, nil112 return newLeaf, nil
111 }113 }
@@ -141,8 +143,8 @@ func (spm *StackedPaneManager) addPaneToExistingStack(target, newLeaf *paneNode)
141 stackNode.stackedPanes[insertIndex] = newLeaf // Insert new pane143 stackNode.stackedPanes[insertIndex] = newLeaf // Insert new pane
142 144
143 // Unparent the new container before adding it to the stack (only if it has a parent)145 // 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) {146+ if newLeaf.container != nil {
145- if webkit.WidgetGetParent(newLeaf.container) != 0 {147+ if webkit.WidgetGetParent(newLeaf.container) != nil {
146 webkit.WidgetUnparent(newLeaf.container)148 webkit.WidgetUnparent(newLeaf.container)
147 }149 }
148 }150 }
@@ -151,32 +153,32 @@ func (spm *StackedPaneManager) addPaneToExistingStack(target, newLeaf *paneNode)
151 // Each pane has 2 widgets (titleBar, container), so position = insertIndex * 2153 // Each pane has 2 widgets (titleBar, container), so position = insertIndex * 2
152 154
153 // Find the widget to insert after (based on insertIndex)155 // Find the widget to insert after (based on insertIndex)
154- var insertAfterWidget uintptr = 0156+ var insertAfterWidget gtk.Widgetter
155 if insertIndex > 0 {157 if insertIndex > 0 {
156 // Insert after the previous pane's container158 // Insert after the previous pane's container
157 prevPane := stackNode.stackedPanes[insertIndex-1]159 prevPane := stackNode.stackedPanes[insertIndex-1]
158- if prevPane.container != 0 {160+ if prevPane.container != nil {
159 insertAfterWidget = prevPane.container161 insertAfterWidget = prevPane.container
160 }162 }
161 }163 }
162 164
163- if insertAfterWidget != 0 {165+ if insertAfterWidget != nil {
164 // Insert titleBar after the previous pane's container166 // Insert titleBar after the previous pane's container
165- if stackNode.stackWrapper != 0 && newLeaf.titleBar != 0 {167+ if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.titleBar != nil {
166- webkit.BoxInsertChildAfter(stackNode.stackWrapper, newLeaf.titleBar, insertAfterWidget)168+ box.InsertChildAfter(newLeaf.titleBar, insertAfterWidget)
167 }169 }
168 // Insert container after the newly inserted titleBar170 // Insert container after the newly inserted titleBar
169- if stackNode.stackWrapper != 0 && newLeaf.container != 0 && newLeaf.titleBar != 0 {171+ if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.container != nil && newLeaf.titleBar != nil {
170- webkit.BoxInsertChildAfter(stackNode.stackWrapper, newLeaf.container, newLeaf.titleBar)172+ box.InsertChildAfter(newLeaf.container, newLeaf.titleBar)
171 }173 }
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)
173 } else {175 } else {
174 // Insert at the beginning (insertIndex = 0)176 // Insert at the beginning (insertIndex = 0)
175- if stackNode.stackWrapper != 0 && newLeaf.container != 0 {177+ if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.container != nil {
176- webkit.BoxPrepend(stackNode.stackWrapper, newLeaf.container)178+ box.Prepend(newLeaf.container)
177 }179 }
178- if stackNode.stackWrapper != 0 && newLeaf.titleBar != 0 {180+ if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.titleBar != nil {
179- webkit.BoxPrepend(stackNode.stackWrapper, newLeaf.titleBar)181+ box.Prepend(newLeaf.titleBar)
180 }182 }
181 log.Printf("[workspace] prepended widgets at position 0")183 log.Printf("[workspace] prepended widgets at position 0")
182 }184 }
@@ -189,27 +191,27 @@ func (spm *StackedPaneManager) convertToStackedContainer(target, newLeaf *paneNo
189 log.Printf("[workspace] converting pane to stacked: %p", target)191 log.Printf("[workspace] converting pane to stacked: %p", target)
190 192
191 // Create the wrapper container - this is what will be used by splitNode193 // Create the wrapper container - this is what will be used by splitNode
192- stackWrapperContainer := webkit.NewBox(webkit.OrientationVertical, 0)194+ stackWrapperContainer := gtk.NewBox(gtk.OrientationVertical, 0)
193- if stackWrapperContainer == 0 {195+ if stackWrapperContainer == nil {
194 return nil, 0, errors.New("failed to create stack wrapper container")196 return nil, 0, errors.New("failed to create stack wrapper container")
195 }197 }
196- webkit.WidgetSetHExpand(stackWrapperContainer, true)198+ stackWrapperContainer.SetHExpand(true)
197- webkit.WidgetSetVExpand(stackWrapperContainer, true)199+ stackWrapperContainer.SetVExpand(true)
198- webkit.WidgetRealizeInContainer(stackWrapperContainer) // Ensures size request is set like regular panes200+ // Note: Don't call Realize() here - GTK4 will realize automatically when added to toplevel
199 201
200 // Add CSS class to the stack container for proper styling202 // Add CSS class to the stack container for proper styling
201- webkit.WidgetAddCSSClass(stackWrapperContainer, stackContainerClass)203+ stackWrapperContainer.AddCSSClass(stackContainerClass)
202 204
203 // Create the internal box for the actual stacked widgets (titles + webviews)205 // Create the internal box for the actual stacked widgets (titles + webviews)
204- stackInternalBox := webkit.NewBox(webkit.OrientationVertical, 0)206+ stackInternalBox := gtk.NewBox(gtk.OrientationVertical, 0)
205- if stackInternalBox == 0 {207+ if stackInternalBox == nil {
206 return nil, 0, errors.New("failed to create stack internal box")208 return nil, 0, errors.New("failed to create stack internal box")
207 }209 }
208- webkit.WidgetSetHExpand(stackInternalBox, true)210+ stackInternalBox.SetHExpand(true)
209- webkit.WidgetSetVExpand(stackInternalBox, true)211+ stackInternalBox.SetVExpand(true)
210 212
211 // The internal box goes inside the wrapper213 // The internal box goes inside the wrapper
212- webkit.BoxAppend(stackWrapperContainer, stackInternalBox)214+ stackWrapperContainer.Append(stackInternalBox)
213 215
214 // Get the existing container and parent info216 // Get the existing container and parent info
215 existingContainer := target.container217 existingContainer := target.container
@@ -219,15 +221,19 @@ func (spm *StackedPaneManager) convertToStackedContainer(target, newLeaf *paneNo
219 titleBar := spm.createTitleBar(target)221 titleBar := spm.createTitleBar(target)
220 222
221 // Keep existing container visible during transition to prevent rendering glitch223 // 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+ }
223 227
224 // Detach existing container from its current parent first228 // Detach existing container from its current parent first
225 spm.detachFromParent(target, parent)229 spm.detachFromParent(target, parent)
226 230
227 // Build the complete stack structure with hidden widgets231 // Build the complete stack structure with hidden widgets
228- webkit.BoxAppend(stackInternalBox, titleBar)232+ if titleBar != nil {
229- if existingContainer != 0 {233+ stackInternalBox.Append(titleBar)
230- webkit.BoxAppend(stackInternalBox, existingContainer)234+ }
235+ if existingContainer != nil {
236+ stackInternalBox.Append(existingContainer)
231 }237 }
232 238
233 // Immediately reattach the stack wrapper to minimize visibility gap239 // Immediately reattach the stack wrapper to minimize visibility gap
@@ -275,24 +281,24 @@ func (spm *StackedPaneManager) convertToStackedContainer(target, newLeaf *paneNo
275 281
276 // Unparent the new container before adding it to the stack282 // Unparent the new container before adding it to the stack
277 // This is critical: even freshly created WebViews might have internal parent refs283 // This is critical: even freshly created WebViews might have internal parent refs
278- if newLeaf.container != 0 && webkit.WidgetIsValid(newLeaf.container) {284+ if newLeaf.container != nil {
279- parent := webkit.WidgetGetParent(newLeaf.container)285+ parentWidget := webkit.WidgetGetParent(newLeaf.container)
280- if parent != 0 {286+ if parentWidget != nil {
281- log.Printf("[workspace] unparenting new pane container %#x from parent %#x before stack append", newLeaf.container, parent)287+ log.Printf("[workspace] unparenting new pane container %p from parent %p before stack append", newLeaf.container, parentWidget)
282 webkit.WidgetUnparent(newLeaf.container)288 webkit.WidgetUnparent(newLeaf.container)
283 // Verify unparent succeeded289 // Verify unparent succeeded
284- if finalParent := webkit.WidgetGetParent(newLeaf.container); finalParent != 0 {290+ if finalParent := webkit.WidgetGetParent(newLeaf.container); finalParent != nil {
285- log.Printf("[workspace] WARNING: container %#x still has parent %#x after unparent", newLeaf.container, finalParent)291+ log.Printf("[workspace] WARNING: container %p still has parent %p after unparent", newLeaf.container, finalParent)
286 }292 }
287 }293 }
288 }294 }
289 295
290 // Add the new widgets to the internal stack box296 // Add the new widgets to the internal stack box
291- if stackNode.stackWrapper != 0 && newLeaf.titleBar != 0 {297+ if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.titleBar != nil {
292- webkit.BoxAppend(stackNode.stackWrapper, newLeaf.titleBar)298+ box.Append(newLeaf.titleBar)
293 }299 }
294- if stackNode.stackWrapper != 0 && newLeaf.container != 0 {300+ if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.container != nil {
295- webkit.BoxAppend(stackNode.stackWrapper, newLeaf.container)301+ box.Append(newLeaf.container)
296 }302 }
297 303
298 return stackNode, insertIndex, nil304 return stackNode, insertIndex, nil
@@ -303,35 +309,35 @@ func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNo
303 if parent == nil {309 if parent == nil {
304 // Target is the root - remove it from window310 // Target is the root - remove it from window
305 if spm.wm.window != nil {311 if spm.wm.window != nil {
306- spm.wm.window.SetChild(0)312+ spm.wm.window.SetChild(nil)
307 }313 }
308 // Unparent if it has a GTK parent314 // Unparent if it has a GTK parent
309- if target.container != 0 && webkit.WidgetIsValid(target.container) {315+ if target.container != nil {
310- if webkit.WidgetGetParent(target.container) != 0 {316+ if webkit.WidgetGetParent(target.container) != nil {
311 webkit.WidgetUnparent(target.container)317 webkit.WidgetUnparent(target.container)
312 }318 }
313 }319 }
314- } else if parent.container != 0 && webkit.WidgetIsValid(parent.container) {320+ } else if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
315 // Target has a parent paned - remove it (automatically unparents in GTK4)321 // Target has a parent paned - remove it (automatically unparents in GTK4)
316 if parent.left == target {322 if parent.left == target {
317- webkit.PanedSetStartChild(parent.container, 0)323+ paned.SetStartChild(nil)
318 } else if parent.right == target {324 } else if parent.right == target {
319- webkit.PanedSetEndChild(parent.container, 0)325+ paned.SetEndChild(nil)
320 }326 }
321 }327 }
322 }328 }
323 329
324 // reattachToParent attaches a container to the parent330 // 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) {
326 if parent == nil {332 if parent == nil {
327 if spm.wm.window != nil {333 if spm.wm.window != nil {
328 spm.wm.window.SetChild(container)334 spm.wm.window.SetChild(container)
329 }335 }
330- } else if parent.container != 0 && webkit.WidgetIsValid(parent.container) {336+ } else if paned, ok := parent.container.(*gtk.Paned); ok && paned != nil {
331 if parent.left == target {337 if parent.left == target {
332- webkit.PanedSetStartChild(parent.container, container)338+ paned.SetStartChild(container)
333 } else if parent.right == target {339 } else if parent.right == target {
334- webkit.PanedSetEndChild(parent.container, container)340+ paned.SetEndChild(container)
335 }341 }
336 }342 }
337 }343 }
@@ -353,7 +359,7 @@ func (spm *StackedPaneManager) finalizeStackCreation(stackNode, newLeaf *paneNod
353 currentActiveIndex := stackNode.activeStackIndex359 currentActiveIndex := stackNode.activeStackIndex
354 if currentActiveIndex >= 0 && currentActiveIndex < len(stackNode.stackedPanes) && currentActiveIndex != insertIndex {360 if currentActiveIndex >= 0 && currentActiveIndex < len(stackNode.stackedPanes) && currentActiveIndex != insertIndex {
355 currentlyActivePane := stackNode.stackedPanes[currentActiveIndex]361 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 {
357 currentTitle := currentlyActivePane.pane.webView.GetTitle()363 currentTitle := currentlyActivePane.pane.webView.GetTitle()
358 if currentTitle != "" {364 if currentTitle != "" {
359 spm.updateTitleBarLabel(currentlyActivePane, currentTitle)365 spm.updateTitleBarLabel(currentlyActivePane, currentTitle)
@@ -406,19 +412,19 @@ func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) {
406 for i, pane := range stackNode.stackedPanes {412 for i, pane := range stackNode.stackedPanes {
407 if i == activeIndex {413 if i == activeIndex {
408 // Active pane: show container, NEVER show title bar414 // Active pane: show container, NEVER show title bar
409- if pane.container != 0 {415+ if pane.container != nil {
410 webkit.WidgetSetVisible(pane.container, true)416 webkit.WidgetSetVisible(pane.container, true)
411 }417 }
412- if pane.titleBar != 0 {418+ if pane.titleBar != nil {
413 webkit.WidgetSetVisible(pane.titleBar, false) // ABSOLUTE RULE: never visible for active pane419 webkit.WidgetSetVisible(pane.titleBar, false) // ABSOLUTE RULE: never visible for active pane
414 }420 }
415 log.Printf("[workspace] active pane %d: container=visible, titleBar=HIDDEN", i)421 log.Printf("[workspace] active pane %d: container=visible, titleBar=HIDDEN", i)
416 } else {422 } else {
417 // Inactive panes: hide container, show title bar423 // Inactive panes: hide container, show title bar
418- if pane.container != 0 {424+ if pane.container != nil {
419 webkit.WidgetSetVisible(pane.container, false)425 webkit.WidgetSetVisible(pane.container, false)
420 }426 }
421- if pane.titleBar != 0 {427+ if pane.titleBar != nil {
422 webkit.WidgetSetVisible(pane.titleBar, true)428 webkit.WidgetSetVisible(pane.titleBar, true)
423 429
424 // Refresh title bar to pick up any newly downloaded favicons430 // Refresh title bar to pick up any newly downloaded favicons
@@ -436,7 +442,7 @@ func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) {
436 442
437 // NavigateStack handles navigation within a stacked pane container443 // NavigateStack handles navigation within a stacked pane container
438 func (spm *StackedPaneManager) NavigateStack(direction string) bool {444 func (spm *StackedPaneManager) NavigateStack(direction string) bool {
439- if spm.wm.GetActiveNode() == nil {445+ if spm == nil || spm.wm == nil || spm.wm.GetActiveNode() == nil {
440 return false446 return false
441 }447 }
442 448
@@ -491,7 +497,7 @@ func (spm *StackedPaneManager) NavigateStack(direction string) bool {
491 // Update title bar for the pane transitioning from ACTIVE to INACTIVE497 // Update title bar for the pane transitioning from ACTIVE to INACTIVE
492 // This ensures the collapsed pane shows its current page title (Zellij-style layout)498 // This ensures the collapsed pane shows its current page title (Zellij-style layout)
493 currentActivePane := stackNode.stackedPanes[currentIndex]499 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 {
495 currentTitle := currentActivePane.pane.webView.GetTitle()501 currentTitle := currentActivePane.pane.webView.GetTitle()
496 if currentTitle != "" {502 if currentTitle != "" {
497 spm.updateTitleBarLabel(currentActivePane, currentTitle)503 spm.updateTitleBarLabel(currentActivePane, currentTitle)
@@ -513,7 +519,7 @@ func (spm *StackedPaneManager) NavigateStack(direction string) bool {
513 }519 }
514 520
515 // createTitleBar creates a title bar widget for a pane in a stack521 // 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 {
517 // Get the actual title and URL from the WebView523 // Get the actual title and URL from the WebView
518 var titleText, pageURL string524 var titleText, pageURL string
519 if pane.pane != nil && pane.pane.webView != nil {525 if pane.pane != nil && pane.pane.webView != nil {
@@ -527,15 +533,16 @@ func (spm *StackedPaneManager) createTitleBar(pane *paneNode) uintptr {
527 }533 }
528 534
529 titleBar := spm.createTitleBarWithTitle(titleText, pageURL)535 titleBar := spm.createTitleBarWithTitle(titleText, pageURL)
530- if titleBar != 0 {536+ if titleBar != nil {
531 // Store the mapping from titleBar ID to pane537 // Store the mapping from titleBar ID to pane
532 titleBarID := atomic.AddUint64(&spm.nextTitleBarID, 1)538 titleBarID := atomic.AddUint64(&spm.nextTitleBarID, 1)
533- spm.titleBarToPane[uintptr(titleBarID)] = pane539+ spm.titleBarToPane[titleBarID] = pane
534 540
535 // Attach click handler541 // Attach click handler
536- webkit.WidgetAttachClickHandler(titleBar, func() {542+ // TODO: Implement click handler with gtk.GestureClick
537- spm.handleTitleBarClick(uintptr(titleBarID))543+ // webkit.WidgetAttachClickHandler(titleBar, func() {
538- })544+ // spm.handleTitleBarClick(titleBarID)
545+ // })
539 }546 }
540 547
541 return titleBar548 return titleBar
@@ -554,7 +561,7 @@ func (spm *StackedPaneManager) UpdateTitleBar(webView *webkit.WebView, title str
554 }561 }
555 562
556 // Check if this pane is in a stack and has a title bar563 // 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 {
558 // CRITICAL: Only update title bar for INACTIVE panes565 // CRITICAL: Only update title bar for INACTIVE panes
559 // Active panes should never show their title bar566 // Active panes should never show their title bar
560 stackNode := node.parent567 stackNode := node.parent
@@ -572,17 +579,17 @@ func (spm *StackedPaneManager) UpdateTitleBar(webView *webkit.WebView, title str
572 if paneIndex != -1 && paneIndex != activeIndex {579 if paneIndex != -1 && paneIndex != activeIndex {
573 // This is an INACTIVE pane - safe to update title bar580 // This is an INACTIVE pane - safe to update title bar
574 spm.updateTitleBarLabel(node, title)581 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)
576 } else {583 } else {
577 // This is the ACTIVE pane - title bar should remain hidden584 // 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)
579 }586 }
580 }587 }
581 }588 }
582 589
583 // updateTitleBarLabel updates the label widget within a title bar by recreating it590 // updateTitleBarLabel updates the label widget within a title bar by recreating it
584 func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string) {591 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 {
586 return593 return
587 }594 }
588 595
@@ -594,22 +601,23 @@ func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string)
594 601
595 // Create new title bar with updated title and favicon602 // Create new title bar with updated title and favicon
596 newTitleBar := spm.createTitleBarWithTitle(title, pageURL)603 newTitleBar := spm.createTitleBarWithTitle(title, pageURL)
597- if newTitleBar == 0 {604+ if newTitleBar == nil {
598 log.Printf("[workspace] failed to create new title bar")605 log.Printf("[workspace] failed to create new title bar")
599 return606 return
600 }607 }
601 608
602 // Store the mapping from titleBar ID to pane and attach click handler609 // Store the mapping from titleBar ID to pane and attach click handler
603 titleBarID := atomic.AddUint64(&spm.nextTitleBarID, 1)610 titleBarID := atomic.AddUint64(&spm.nextTitleBarID, 1)
604- spm.titleBarToPane[uintptr(titleBarID)] = node611+ spm.titleBarToPane[titleBarID] = node
605 612
606 // Attach click handler613 // Attach click handler
607- webkit.WidgetAttachClickHandler(newTitleBar, func() {614+ // TODO: Implement with gtk.GestureClick
608- spm.handleTitleBarClick(uintptr(titleBarID))615+ // webkit.WidgetAttachClickHandler(newTitleBar, func() {
609- })616+ // spm.handleTitleBarClick(titleBarID)
617+ // })
610 618
611 // Replace the old title bar in the stack619 // 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 {
613 // Find the correct insertion position based on the pane's index in the stack621 // Find the correct insertion position based on the pane's index in the stack
614 paneIndex := -1622 paneIndex := -1
615 for i, stackedPane := range node.parent.stackedPanes {623 for i, stackedPane := range node.parent.stackedPanes {
@@ -620,7 +628,7 @@ func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string)
620 }628 }
621 629
622 // Remove old title bar630 // Remove old title bar
623- if node.titleBar != 0 && webkit.WidgetIsValid(node.titleBar) {631+ if node.titleBar != nil {
624 webkit.WidgetUnparent(node.titleBar)632 webkit.WidgetUnparent(node.titleBar)
625 }633 }
626 634
@@ -628,16 +636,16 @@ func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string)
628 spm.wm.setTitleBar(node, newTitleBar)636 spm.wm.setTitleBar(node, newTitleBar)
629 637
630 // Insert the new title bar at the correct position638 // Insert the new title bar at the correct position
631- if paneIndex == 0 {639+ if box, ok := node.parent.stackWrapper.(*gtk.Box); ok {
632- // First pane - insert at the beginning640+ if paneIndex == 0 {
633- if node.parent.stackWrapper != 0 {641+ // First pane - insert at the beginning
634- webkit.BoxPrepend(node.parent.stackWrapper, newTitleBar)642+ box.Prepend(newTitleBar)
635- }643+ } else {
636- } else {644+ // Insert after the previous pane's container
637- // Insert after the previous pane's container645+ prevPane := node.parent.stackedPanes[paneIndex-1]
638- prevPane := node.parent.stackedPanes[paneIndex-1]646+ if prevPane.container != nil {
639- if node.parent.stackWrapper != 0 && prevPane.container != 0 {647+ box.InsertChildAfter(newTitleBar, prevPane.container)
640- webkit.BoxInsertChildAfter(node.parent.stackWrapper, newTitleBar, prevPane.container)648+ }
641 }649 }
642 }650 }
643 651
@@ -646,18 +654,18 @@ func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string)
646 }654 }
647 655
648 // createTitleBarWithTitle creates a title bar with a specific title and optional favicon656 // createTitleBarWithTitle creates a title bar with a specific title and optional favicon
649-func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL string) uintptr {657+func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL string) gtk.Widgetter {
650- titleBox := webkit.NewBox(webkit.OrientationHorizontal, 8)658+ titleBox := gtk.NewBox(gtk.OrientationHorizontal, 8)
651- if titleBox == 0 {659+ if titleBox == nil {
652- return 0660+ return nil
653 }661 }
654 662
655- webkit.WidgetAddCSSClass(titleBox, "stacked-pane-title")663+ titleBox.AddCSSClass("stacked-pane-title")
656- webkit.WidgetSetHExpand(titleBox, true)664+ titleBox.SetHExpand(true)
657- webkit.WidgetSetVExpand(titleBox, false)665+ titleBox.SetVExpand(false)
658 666
659 // Add favicon placeholder for debugging (always visible)667 // Add favicon placeholder for debugging (always visible)
660- var faviconImg uintptr668+ var faviconImg gtk.Widgetter
661 var debugReason string669 var debugReason string
662 670
663 if pageURL == "" {671 if pageURL == "" {
@@ -691,9 +699,11 @@ func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL str
691 faviconPath := faviconCache.GetCachedPath(faviconURL)699 faviconPath := faviconCache.GetCachedPath(faviconURL)
692 if faviconPath != "" {700 if faviconPath != "" {
693 log.Printf("[favicon] Loading cached favicon from: %s", faviconPath)701 log.Printf("[favicon] Loading cached favicon from: %s", faviconPath)
694- faviconImg = webkit.ImageNewFromFile(faviconPath)702+ faviconImg = gtk.NewImageFromFile(faviconPath)
695- if faviconImg != 0 {703+ if faviconImg != nil {
696- webkit.ImageSetPixelSize(faviconImg, 16)704+ if img, ok := faviconImg.(*gtk.Image); ok {
705+ img.SetPixelSize(16)
706+ }
697 webkit.WidgetAddCSSClass(faviconImg, "stacked-pane-favicon")707 webkit.WidgetAddCSSClass(faviconImg, "stacked-pane-favicon")
698 log.Printf("[favicon] Successfully created favicon image widget")708 log.Printf("[favicon] Successfully created favicon image widget")
699 } else {709 } else {
@@ -711,32 +721,32 @@ func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL str
711 }721 }
712 722
713 // Add favicon or placeholder723 // Add favicon or placeholder
714- if faviconImg != 0 {724+ if faviconImg != nil {
715- webkit.BoxAppend(titleBox, faviconImg)725+ titleBox.Append(faviconImg)
716 webkit.WidgetShow(faviconImg)726 webkit.WidgetShow(faviconImg)
717 } else {727 } else {
718 // DEBUG: Add visible placeholder label to show code is running728 // DEBUG: Add visible placeholder label to show code is running
719- placeholderLabel := webkit.NewLabel(faviconPlaceholder)729+ placeholderLabel := gtk.NewLabel(faviconPlaceholder)
720- if placeholderLabel != 0 {730+ if placeholderLabel != nil {
721- webkit.WidgetAddCSSClass(placeholderLabel, "stacked-pane-favicon-placeholder")731+ placeholderLabel.AddCSSClass("stacked-pane-favicon-placeholder")
722- webkit.BoxAppend(titleBox, placeholderLabel)732+ titleBox.Append(placeholderLabel)
723- webkit.WidgetShow(placeholderLabel)733+ placeholderLabel.Show()
724 log.Printf("[favicon] Using placeholder for %s (reason: %s)", pageURL, debugReason)734 log.Printf("[favicon] Using placeholder for %s (reason: %s)", pageURL, debugReason)
725 }735 }
726 }736 }
727 737
728 // Create title label with the specified title738 // Create title label with the specified title
729- titleLabel := webkit.NewLabel(title)739+ titleLabel := gtk.NewLabel(title)
730- if titleLabel == 0 {740+ if titleLabel == nil {
731 return titleBox741 return titleBox
732 }742 }
733 743
734- webkit.WidgetAddCSSClass(titleLabel, "stacked-pane-title-text")744+ titleLabel.AddCSSClass("stacked-pane-title-text")
735- webkit.LabelSetEllipsize(titleLabel, webkit.EllipsizeEnd)745+ titleLabel.SetEllipsize(2) // pango.EllipsizeEnd = 2
736 746
737- webkit.BoxAppend(titleBox, titleLabel)747+ titleBox.Append(titleLabel)
738- webkit.WidgetShow(titleBox)748+ titleBox.Show()
739- webkit.WidgetShow(titleLabel)749+ titleLabel.Show()
740 750
741 return titleBox751 return titleBox
742 }752 }
@@ -775,15 +785,15 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
775 785
776 // Remove the pane widgets from the stack container before mutating the tree.786 // Remove the pane widgets from the stack container before mutating the tree.
777 stackBox := stackNode.stackWrapper787 stackBox := stackNode.stackWrapper
778- if stackBox == 0 {788+ if stackBox == nil {
779 stackBox = stackNode.container789 stackBox = stackNode.container
780 }790 }
781- if stackBox != 0 {791+ if box, ok := stackBox.(*gtk.Box); ok && box != nil {
782- if node.titleBar != 0 {792+ if node.titleBar != nil {
783- webkit.BoxRemove(stackBox, node.titleBar)793+ box.Remove(node.titleBar)
784 }794 }
785- if node.container != 0 {795+ if node.container != nil {
786- webkit.BoxRemove(stackBox, node.container)796+ box.Remove(node.container)
787 }797 }
788 }798 }
789 799
@@ -796,29 +806,29 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
796 parent := stackNode.parent806 parent := stackNode.parent
797 if parent == nil {807 if parent == nil {
798 // Stack was the root of the workspace — closing this pane exits the app.808 // Stack was the root of the workspace — closing this pane exits the app.
799- if stackNode.container != 0 && webkit.WidgetIsValid(stackNode.container) {809+ if stackNode.container != nil {
800- if webkit.WidgetGetParent(stackNode.container) != 0 {810+ if webkit.WidgetGetParent(stackNode.container) != nil {
801 webkit.WidgetUnparent(stackNode.container)811 webkit.WidgetUnparent(stackNode.container)
802 }812 }
803 }813 }
804 if spm.wm.window != nil {814 if spm.wm.window != nil {
805- spm.wm.window.SetChild(0)815+ spm.wm.window.SetChild(nil)
806 }816 }
807 _, err := spm.wm.cleanupAndExit(node)817 _, err := spm.wm.cleanupAndExit(node)
808 return err818 return err
809 }819 }
810 820
811 // Detach the empty stack container from its GtkPaned parent.821 // 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 {
813 if parent.left == stackNode {823 if parent.left == stackNode {
814- webkit.PanedSetStartChild(parent.container, 0)824+ paned.SetStartChild(nil)
815 } else if parent.right == stackNode {825 } else if parent.right == stackNode {
816- webkit.PanedSetEndChild(parent.container, 0)826+ paned.SetEndChild(nil)
817 }827 }
818- webkit.WidgetQueueAllocate(parent.container)828+ webkit.WidgetQueueAllocate(paned)
819 }829 }
820- if stackNode.container != 0 && webkit.WidgetIsValid(stackNode.container) {830+ if stackNode.container != nil {
821- if webkit.WidgetGetParent(stackNode.container) != 0 {831+ if webkit.WidgetGetParent(stackNode.container) != nil {
822 webkit.WidgetUnparent(stackNode.container)832 webkit.WidgetUnparent(stackNode.container)
823 }833 }
824 }834 }
@@ -840,8 +850,8 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
840 }850 }
841 851
842 stackNode.parent = nil852 stackNode.parent = nil
843- stackNode.container = 0853+ stackNode.container = nil
844- stackNode.stackWrapper = 0854+ stackNode.stackWrapper = nil
845 stackNode.stackedPanes = nil855 stackNode.stackedPanes = nil
846 856
847 return nil857 return nil
@@ -852,14 +862,14 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
852 parent := stackNode.parent862 parent := stackNode.parent
853 lastPaneContainer := lastPane.container863 lastPaneContainer := lastPane.container
854 864
855- if lastPane.titleBar != 0 && stackBox != 0 {865+ if box, ok := stackBox.(*gtk.Box); ok && box != nil && lastPane.titleBar != nil {
856- webkit.BoxRemove(stackBox, lastPane.titleBar)866+ box.Remove(lastPane.titleBar)
857 }867 }
858- if stackBox != 0 && lastPaneContainer != 0 {868+ if box, ok := stackBox.(*gtk.Box); ok && box != nil && lastPaneContainer != nil {
859- webkit.BoxRemove(stackBox, lastPaneContainer)869+ box.Remove(lastPaneContainer)
860 }870 }
861- if stackNode.container != 0 && webkit.WidgetIsValid(stackNode.container) {871+ if stackNode.container != nil {
862- if webkit.WidgetGetParent(stackNode.container) != 0 {872+ if webkit.WidgetGetParent(stackNode.container) != nil {
863 webkit.WidgetUnparent(stackNode.container)873 webkit.WidgetUnparent(stackNode.container)
864 }874 }
865 }875 }
@@ -867,7 +877,7 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
867 if parent == nil {877 if parent == nil {
868 spm.wm.root = lastPane878 spm.wm.root = lastPane
869 lastPane.parent = nil879 lastPane.parent = nil
870- if spm.wm.window != nil && lastPaneContainer != 0 {880+ if spm.wm.window != nil && lastPaneContainer != nil {
871 spm.wm.window.SetChild(lastPaneContainer)881 spm.wm.window.SetChild(lastPaneContainer)
872 webkit.WidgetQueueAllocate(lastPaneContainer)882 webkit.WidgetQueueAllocate(lastPaneContainer)
873 webkit.WidgetShow(lastPaneContainer)883 webkit.WidgetShow(lastPaneContainer)
@@ -879,36 +889,36 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
879 parent.right = lastPane889 parent.right = lastPane
880 }890 }
881 lastPane.parent = parent891 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 {
883 if parent.left == lastPane {893 if parent.left == lastPane {
884- webkit.PanedSetStartChild(parent.container, lastPaneContainer)894+ paned.SetStartChild(lastPaneContainer)
885 } else {895 } else {
886- webkit.PanedSetEndChild(parent.container, lastPaneContainer)896+ paned.SetEndChild(lastPaneContainer)
887 }897 }
888- webkit.WidgetQueueAllocate(parent.container)898+ webkit.WidgetQueueAllocate(paned)
889 }899 }
890- if lastPaneContainer != 0 {900+ if lastPaneContainer != nil {
891 webkit.WidgetSetVisible(lastPaneContainer, true)901 webkit.WidgetSetVisible(lastPaneContainer, true)
892 }902 }
893 }903 }
894 904
895 lastPane.isStacked = false905 lastPane.isStacked = false
896 lastPane.stackedPanes = nil906 lastPane.stackedPanes = nil
897- lastPane.titleBar = 0907+ lastPane.titleBar = nil
898 spm.wm.viewToNode[lastPane.pane.webView] = lastPane908 spm.wm.viewToNode[lastPane.pane.webView] = lastPane
899 909
900 generation := spm.wm.nextCleanupGeneration()910 generation := spm.wm.nextCleanupGeneration()
901 spm.wm.cleanupPane(node, generation)911 spm.wm.cleanupPane(node, generation)
902 912
903 spm.wm.SetActivePane(lastPane, SourceClose)913 spm.wm.SetActivePane(lastPane, SourceClose)
904- if lastPaneWidget := lastPane.pane.webView.Widget(); lastPaneWidget != 0 {914+ if lastPaneWidget := lastPane.pane.webView.Widget(); lastPaneWidget != nil {
905 webkit.WidgetShow(lastPaneWidget)915 webkit.WidgetShow(lastPaneWidget)
906 webkit.WidgetGrabFocus(lastPaneWidget)916 webkit.WidgetGrabFocus(lastPaneWidget)
907 }917 }
908 918
909 stackNode.parent = nil919 stackNode.parent = nil
910- stackNode.container = 0920+ stackNode.container = nil
911- stackNode.stackWrapper = 0921+ stackNode.stackWrapper = nil
912 stackNode.stackedPanes = nil922 stackNode.stackedPanes = nil
913 923
914 log.Printf("[workspace] converted stack back to regular pane")924 log.Printf("[workspace] converted stack back to regular pane")
@@ -939,7 +949,7 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error {
939 }949 }
940 950
941 // handleTitleBarClick handles clicks on title bars to switch the active pane951 // 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) {
943 pane, exists := spm.titleBarToPane[titleBarID]953 pane, exists := spm.titleBarToPane[titleBarID]
944 if !exists || pane == nil || pane.parent == nil || !pane.parent.isStacked {954 if !exists || pane == nil || pane.parent == nil || !pane.parent.isStacked {
945 return955 return
@@ -957,7 +967,7 @@ func (spm *StackedPaneManager) handleTitleBarClick(titleBarID uintptr) {
957 967
958 // Update title bar for the pane transitioning from ACTIVE to INACTIVE968 // Update title bar for the pane transitioning from ACTIVE to INACTIVE
959 currentActivePane := stackNode.stackedPanes[stackNode.activeStackIndex]969 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 {
961 currentTitle := currentActivePane.pane.webView.GetTitle()971 currentTitle := currentActivePane.pane.webView.GetTitle()
962 if currentTitle != "" {972 if currentTitle != "" {
963 spm.updateTitleBarLabel(currentActivePane, currentTitle)973 spm.updateTitleBarLabel(currentActivePane, currentTitle)
M internal/app/browser/webview.go +134 -138
@@ -2,69 +2,26 @@ package browser
2 2
3 import (3 import (
4 "context"4 "context"
5+ "encoding/json"
5 "fmt"6 "fmt"
6 "log"7 "log"
7 "os"8 "os"
8 "path/filepath"9 "path/filepath"
9- "strings"
10 "time"10 "time"
11 11
12 "github.com/bnema/dumber/internal/app/constants"12 "github.com/bnema/dumber/internal/app/constants"
13 "github.com/bnema/dumber/internal/app/control"13 "github.com/bnema/dumber/internal/app/control"
14- "github.com/bnema/dumber/internal/app/environment"
15 "github.com/bnema/dumber/internal/app/messaging"14 "github.com/bnema/dumber/internal/app/messaging"
16 "github.com/bnema/dumber/internal/config"15 "github.com/bnema/dumber/internal/config"
17 "github.com/bnema/dumber/internal/filtering"16 "github.com/bnema/dumber/internal/filtering"
18 "github.com/bnema/dumber/pkg/webkit"17 "github.com/bnema/dumber/pkg/webkit"
19 )18 )
20 19
21-// buildMemoryConfig converts config.WebkitMemoryConfig to webkit.MemoryConfig20+// Note: Memory management and color palettes are now handled at the application level
22-func buildMemoryConfig(cfg config.WebkitMemoryConfig) webkit.MemoryConfig {21+// The gotk4 webkit.Config only contains basic WebKit settings
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-}
66 22
67 func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) {23 func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) {
24+ // Ensure data directories exist for WebKit
68 dataDir, err := config.GetDataDir()25 dataDir, err := config.GetDataDir()
69 if err != nil {26 if err != nil {
70 return nil, err27 return nil, err
@@ -83,39 +40,70 @@ func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) {
83 return nil, err40 return nil, err
84 }41 }
85 42
43+ // Build WebKit configuration with data directories for persistence
86 cfg := &webkit.Config{44 cfg := &webkit.Config{
87- Assets: app.assets,45+ UserAgent: buildUserAgent(app.config),
88- InitialURL: "dumb://homepage",46+ EnableJavaScript: true,
89- ZoomDefault: app.config.DefaultZoom,47+ EnableWebGL: true,
90- EnableDeveloperExtras: true,48+ EnableMediaStream: true,
91- DataDir: webkitData,49+ HardwareAcceleration: true,
92- CacheDir: webkitCache,50+ DefaultFontSize: app.config.Appearance.DefaultFontSize,
93- APIToken: app.config.APISecurity.Token,51+ MinimumFontSize: 8,
94- DefaultSansFont: app.config.Appearance.SansFont,52+ DataDir: webkitData,
95- DefaultSerifFont: app.config.Appearance.SerifFont,53+ CacheDir: webkitCache,
96- DefaultMonospaceFont: app.config.Appearance.MonospaceFont,54+ AppearanceConfigJSON: app.buildAppearanceConfigJSON(),
97- DefaultFontSize: app.config.Appearance.DefaultFontSize,55+ CreateWindow: true, // Default to creating a window for standalone WebViews
98- Rendering: webkit.RenderingConfig{Mode: string(app.config.RenderingMode)},56+ }
99- UseDomZoom: app.config.UseDomZoom,57+
100- VideoAcceleration: webkit.VideoAccelerationConfig{58+ return cfg, nil
101- EnableVAAPI: app.config.VideoAcceleration.EnableVAAPI,59+}
102- AutoDetectGPU: app.config.VideoAcceleration.AutoDetectGPU,60+
103- VAAPIDriverName: app.config.VideoAcceleration.VAAPIDriverName,61+// buildUserAgent constructs the user agent string from config
104- EnableAllDrivers: app.config.VideoAcceleration.EnableAllDrivers,62+func buildUserAgent(cfg *config.Config) string {
105- LegacyVAAPI: app.config.VideoAcceleration.LegacyVAAPI,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,
106 },88 },
107- Memory: buildMemoryConfig(app.config.WebkitMemory),89+ "dark": map[string]string{
108- CodecPreferences: webkit.CodecPreferencesConfig{90+ "background": app.config.Appearance.DarkPalette.Background,
109- PreferredCodecs: strings.Split(app.config.CodecPreferences.PreferredCodecs, ","),91+ "surface": app.config.Appearance.DarkPalette.Surface,
110- BlockedCodecs: environment.BuildBlockedCodecsList(app.config.CodecPreferences),92+ "surface_variant": app.config.Appearance.DarkPalette.SurfaceVariant,
111- ForceAV1: app.config.CodecPreferences.ForceAV1,93+ "text": app.config.Appearance.DarkPalette.Text,
112- CustomUserAgent: app.config.CodecPreferences.CustomUserAgent,94+ "muted": app.config.Appearance.DarkPalette.Muted,
113- DisableTwitchCodecControl: app.config.CodecPreferences.DisableTwitchCodecControl,95+ "accent": app.config.Appearance.DarkPalette.Accent,
96+ "border": app.config.Appearance.DarkPalette.Border,
114 },97 },
115- Colors: buildColorPalettes(app.config.Appearance),
116 }98 }
117 99
118- return cfg, nil100+ 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)
119 }107 }
120 108
121 func (app *BrowserApp) buildPane(view *webkit.WebView) (*BrowserPane, error) {109 func (app *BrowserApp) buildPane(view *webkit.WebView) (*BrowserPane, error) {
@@ -167,7 +155,7 @@ func (app *BrowserApp) createPaneForView(view *webkit.WebView) (*BrowserPane, er
167 pane.initializeGUITracking()155 pane.initializeGUITracking()
168 156
169 // GUI manager will be loaded on-demand when needed157 // 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())
171 159
172 app.attachPaneHandlers(pane)160 app.attachPaneHandlers(pane)
173 return pane, nil161 return pane, nil
@@ -202,7 +190,7 @@ func (app *BrowserApp) attachPaneHandlers(pane *BrowserPane) {
202 })190 })
203 191
204 pane.webView.RegisterScriptMessageHandler(func(payload string) {192 pane.webView.RegisterScriptMessageHandler(func(payload string) {
205- if app.workspace != nil {193+ if app.workspace != nil && shouldFocusForScriptMessage(payload) {
206 app.workspace.focusByView(pane.webView)194 app.workspace.focusByView(pane.webView)
207 }195 }
208 pane.messageHandler.Handle(payload)196 pane.messageHandler.Handle(payload)
@@ -212,21 +200,32 @@ func (app *BrowserApp) attachPaneHandlers(pane *BrowserPane) {
212 pane.messageHandler.SetNavigationController(pane.navigationController)200 pane.messageHandler.SetNavigationController(pane.navigationController)
213 }201 }
214 202
215- pane.webView.RegisterPopupHandler(func(uri string) *webkit.WebView {203+ // Setup WebKit-native popup lifecycle using create/ready-to-show/close signals
216- if app.workspace != nil {204+ if app.workspace != nil {
217- return app.workspace.HandlePopup(pane.webView, uri)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())
218 }209 }
210+ }
211+}
219 212
220- // No workspace manager - just navigate in current pane213+// shouldFocusForScriptMessage filters script messages and only allows focus handoff
221- if uri != "" && pane.navigationController != nil {214+// for explicit user-driven actions (navigation, history commands, popup lifecycle).
222- go func() {215+// Background bootstrap messages (palette sync, workspace bridge chatter, etc.) are
223- if err := pane.navigationController.NavigateToURL(uri); err != nil {216+// ignored so they don't steal focus from the user's active pane.
224- log.Printf("[webview] failed to navigate to URL %s: %v", uri, err)217+func shouldFocusForScriptMessage(payload string) bool {
225- }218+ var msg messaging.Message
226- }()219+ if err := json.Unmarshal([]byte(payload), &msg); err != nil {
227- }220+ return true // keep legacy behaviour if parsing fails
228- return nil221+ }
229- })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+ }
230 }229 }
231 230
232 // createWebView creates and configures the WebView231 // createWebView creates and configures the WebView
@@ -238,9 +237,7 @@ func (app *BrowserApp) createWebView() error {
238 return err237 return err
239 }238 }
240 239
241- // Main window needs a top-level window240+ // Note: Window creation is now handled automatically by webkit.NewWebView
242- cfg.CreateWindow = true
243-
244 view, err := webkit.NewWebView(cfg)241 view, err := webkit.NewWebView(cfg)
245 242
246 if err != nil {243 if err != nil {
@@ -263,6 +260,19 @@ func (app *BrowserApp) createWebView() error {
263 260
264 if app.workspace == nil {261 if app.workspace == nil {
265 app.workspace = NewWorkspaceManager(app, pane)262 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+ }
266 }276 }
267 277
268 // Initialize window-level global shortcuts AFTER workspace is set up278 // Initialize window-level global shortcuts AFTER workspace is set up
@@ -294,8 +304,8 @@ func (app *BrowserApp) createWebView() error {
294 func (app *BrowserApp) setupWebViewIntegration() {304 func (app *BrowserApp) setupWebViewIntegration() {
295 app.browserService.AttachWebView(app.webView)305 app.browserService.AttachWebView(app.webView)
296 306
297- // GUI bundle is loaded via WebKit user scripts in enableUserContentManager307+ // GUI bundle is loaded via WebKit user scripts in SetupUserContentManager
298- // No need to load separately in browser service308+ // Appearance config is also injected at document-start via UserContentManager
299 309
300 // Use native window as title updater310 // Use native window as title updater
301 if win := app.webView.Window(); win != nil {311 if win := app.webView.Window(); win != nil {
@@ -308,7 +318,7 @@ func (app *BrowserApp) setupContentBlocking() error {
308 log.Printf("Initializing content blocking system...")318 log.Printf("Initializing content blocking system...")
309 319
310 // Enable WebKit debug logging if requested320 // Enable WebKit debug logging if requested
311- webkit.SetupWebKitDebugLogging(app.config)321+ webkit.SetupWebKitDebugLogging()
312 322
313 defer func() {323 defer func() {
314 if r := recover(); r != nil {324 if r := recover(); r != nil {
@@ -326,58 +336,44 @@ func (app *BrowserApp) setupContentBlocking() error {
326 // Store reference to filter manager336 // Store reference to filter manager
327 app.filterManager = filterManager337 app.filterManager = filterManager
328 338
329- // Set up callback to re-apply network filters when they become ready339+ // Set up callback to apply network filters when they become ready
330 // This callback will be called from the async filter loading process340 // This callback will be called from the async filter loading process
331 filterManager.SetFiltersReadyCallback(func() {341 filterManager.SetFiltersReadyCallback(func() {
332- // Add small delay to avoid race conditions with WebView initialization342+ log.Printf("[filtering] Filters ready, applying to WebView...")
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- })
344 343
345- // Start async filter loading early (before WebView content blocking initialization)344+ // Get the WebKit JSON rules from filter manager
346- // This allows filters to be ready when the WebView needs them345+ filterJSON, err := filterManager.GetNetworkFilters()
347- go func() {346+ if err != nil {
348- if err := filtering.InitializeFiltersAsync(filterManager); err != nil {347+ log.Printf("[filtering] Failed to get network filters: %v", err)
349- log.Printf("Warning: failed to initialize filters asynchronously: %v", err)348+ return
350 }349 }
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
359 350
360- if err := app.webView.InitializeContentBlocking(filterManager); err != nil {351+ if len(filterJSON) == 0 {
361- log.Printf("Warning: Failed to initialize content blocking: %v", err)352+ log.Printf("[filtering] No network filters to apply")
362- // Continue without content blocking rather than failing353+ return
363 }354 }
364 355
365- // Register navigation handler for domain-specific filtering and GUI injection356+ log.Printf("[filtering] Got %d bytes of WebKit JSON rules", len(filterJSON))
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
371 357
372- // Apply content filtering if available358+ // Apply filters to the WebView
373- if app.filterManager != nil {359+ // This must be done on the main thread since it touches GTK/WebKit
374- app.webView.OnNavigate(uri, app.filterManager, app.config.ContentFilteringWhitelist)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")
375 }366 }
367+ })
368+ }
369+ })
376 370
377- // Note: GUI bundle (controls and toast) is now injected as User Script371+ // Start async filter loading
378- // in WebKit's enableUserContentManager, so it persists across all navigations372+ // This allows filters to compile in the background while the browser starts
379- }()373+ go func() {
380- })374+ if err := filtering.InitializeFiltersAsync(filterManager); err != nil {
375+ log.Printf("Warning: failed to initialize filters asynchronously: %v", err)
376+ }
381 }()377 }()
382 378
383 log.Printf("Content blocking system initialization started")379 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
41 }41 }
42 42
43 func (h *WindowShortcutHandler) initialize() error {43 func (h *WindowShortcutHandler) initialize() error {
44- // Initialize GTK4 global shortcuts44+ // Create WindowShortcuts manager
45- h.shortcuts = h.window.InitializeGlobalShortcuts()45+ h.shortcuts = webkit.NewWindowShortcuts(h.window)
46 if h.shortcuts == nil {46 if h.shortcuts == nil {
47 return ErrFailedToInitialize47 return ErrFailedToInitialize
48 }48 }
@@ -60,6 +60,7 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
60 {"ctrl+f", h.handleFindToggle, "Find in page"},60 {"ctrl+f", h.handleFindToggle, "Find in page"},
61 {"ctrl+shift+c", h.handleCopyURL, "Copy URL"},61 {"ctrl+shift+c", h.handleCopyURL, "Copy URL"},
62 {"ctrl+shift+p", h.handlePrint, "Print page"},62 {"ctrl+shift+p", h.handlePrint, "Print page"},
63+ {"ctrl+t", h.handleNewTab, "New tab (no-op)"},
63 {"ctrl+w", h.handleClosePane, "Close current pane"},64 {"ctrl+w", h.handleClosePane, "Close current pane"},
64 {"F12", h.handleDevTools, "Developer tools"},65 {"F12", h.handleDevTools, "Developer tools"},
65 // Zoom shortcuts - global level for proper active pane targeting66 // Zoom shortcuts - global level for proper active pane targeting
@@ -75,11 +76,7 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error {
75 }76 }
76 77
77 for _, shortcut := range shortcuts {78 for _, shortcut := range shortcuts {
78- if err := h.shortcuts.RegisterGlobalShortcut(shortcut.key, shortcut.handler); err != nil {79+ h.shortcuts.RegisterShortcut(shortcut.key, shortcut.handler)
79- log.Printf("[window-shortcuts] Failed to register %s (%s): %v",
80- shortcut.key, shortcut.desc, err)
81- return err
82- }
83 log.Printf("[window-shortcuts] Registered global shortcut: %s (%s)",80 log.Printf("[window-shortcuts] Registered global shortcut: %s (%s)",
84 shortcut.key, shortcut.desc)81 shortcut.key, shortcut.desc)
85 }82 }
@@ -208,11 +205,7 @@ func (h *WindowShortcutHandler) handleZoom(action string, multiplier float64) {
208 log.Printf("[window-shortcuts] Zoom %s -> pane %p", action, activeWebView)205 log.Printf("[window-shortcuts] Zoom %s -> pane %p", action, activeWebView)
209 206
210 // Get current zoom level207 // Get current zoom level
211- currentZoom, err := activeWebView.GetZoom()208+ currentZoom := activeWebView.GetZoom()
212- if err != nil {
213- log.Printf("[window-shortcuts] Failed to get current zoom: %v", err)
214- return
215- }
216 209
217 // Calculate new zoom level210 // Calculate new zoom level
218 var newZoom float64211 var newZoom float64
@@ -234,6 +227,27 @@ func (h *WindowShortcutHandler) handleZoom(action string, multiplier float64) {
234 // Apply zoom to active pane227 // Apply zoom to active pane
235 if err := activeWebView.SetZoom(newZoom); err != nil {228 if err := activeWebView.SetZoom(newZoom); err != nil {
236 log.Printf("[window-shortcuts] Failed to set zoom: %v", err)229 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)
237 }251 }
238 }252 }
239 253
@@ -261,7 +275,7 @@ func (h *WindowShortcutHandler) getWebViewId(pane *BrowserPane) string {
261 if pane == nil || pane.webView == nil {275 if pane == nil || pane.webView == nil {
262 return "unknown"276 return "unknown"
263 }277 }
264- return pane.webView.ID()278+ return pane.webView.IDString()
265 }279 }
266 280
267 func (h *WindowShortcutHandler) handleUIToggle(lastToggle *time.Time, featureName, action string) {281 func (h *WindowShortcutHandler) handleUIToggle(lastToggle *time.Time, featureName, action string) {
@@ -320,10 +334,10 @@ func (h *WindowShortcutHandler) handleClosePane() {
320 for webView := range h.app.workspace.viewToNode {334 for webView := range h.app.workspace.viewToNode {
321 if webView != nil {335 if webView != nil {
322 isActive := webView.IsActive()336 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)
324 if isActive {338 if isActive {
325 activeWebView = webView339 activeWebView = webView
326- log.Printf("[window-shortcuts] Found active WebView: %s", webView.ID())340+ log.Printf("[window-shortcuts] Found active WebView: %d", webView.ID())
327 break341 break
328 }342 }
329 }343 }
@@ -342,7 +356,7 @@ func (h *WindowShortcutHandler) handleClosePane() {
342 // Use the proper close-popup message for popups356 // Use the proper close-popup message for popups
343 msg := messaging.Message{357 msg := messaging.Message{
344 Event: "close-popup",358 Event: "close-popup",
345- WebViewID: activeWebView.ID(),359+ WebViewID: activeWebView.IDString(),
346 Reason: "user-ctrl-w",360 Reason: "user-ctrl-w",
347 }361 }
348 h.app.workspace.OnWorkspaceMessage(activeWebView, msg)362 h.app.workspace.OnWorkspaceMessage(activeWebView, msg)
@@ -353,12 +367,17 @@ func (h *WindowShortcutHandler) handleClosePane() {
353 }367 }
354 }368 }
355 369
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+
356 // Cleanup releases resources377 // Cleanup releases resources
357 func (h *WindowShortcutHandler) Cleanup() {378 func (h *WindowShortcutHandler) Cleanup() {
358- if h.shortcuts != nil {379+ // WindowShortcuts doesn't require explicit cleanup
359- h.shortcuts.Cleanup()380+ h.shortcuts = nil
360- h.shortcuts = nil
361- }
362 }381 }
363 382
364 // Error definitions383 // Error definitions
M internal/app/browser/workspace_css.go +38 -24
@@ -3,6 +3,7 @@ package browser
3 3
4 import (4 import (
5 "fmt"5 "fmt"
6+ "log"
6 "strings"7 "strings"
7 8
8 "github.com/bnema/dumber/internal/config"9 "github.com/bnema/dumber/internal/config"
@@ -72,31 +73,50 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string {
72 73
73 activeBorderColor := getActivePaneBorderColor(styling, isDark)74 activeBorderColor := getActivePaneBorderColor(styling, isDark)
74 75
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+
75 css := fmt.Sprintf(`window {82 css := fmt.Sprintf(`window {
76 background-color: %s;83 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;
77 }97 }
78 98
79- /* Base pane styling - transparent outline for smooth transitions */99+ /* Base pane styling - subtle border for inactive panes */
80 .workspace-pane, .stacked-pane-container {100 .workspace-pane, .stacked-pane-container {
81- outline: 2px solid transparent;101+ border: 2px solid %s;
82- outline-offset: -2px;102+ border-radius: %dpx;
83- transition: outline-color %dms ease-in-out;103+ transition: border-color %dms ease-in-out;
104+ margin: 0;
84 }105 }
85 106
86- /* Active pane border styling using outline (no layout impact) */107+ /* Active pane border styling */
87 .workspace-pane-active {108 .workspace-pane-active {
88- outline-color: %s;109+ border-color: %s;
89 }110 }
90 111
91 /* Stacked panes styling */112 /* Stacked panes styling */
92 .stacked-pane-container {113 .stacked-pane-container {
93 background-color: %s;114 background-color: %s;
94- border-radius: %dpx;
95 }115 }
96 116
97- /* Active stacked pane container gets the outline color */117+ /* Stacked pane containers never get active border - focus is shown via WebKit */
98 .stacked-pane-container.workspace-pane-active {118 .stacked-pane-container.workspace-pane-active {
99- outline-color: %s;119+ border-color: %s;
100 }120 }
101 121
102 .stacked-pane-title {122 .stacked-pane-title {
@@ -129,11 +149,14 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string {
129 /* Collapsed panes are hidden - handled in code via widget visibility */149 /* Collapsed panes are hidden - handled in code via widget visibility */
130 }`,150 }`,
131 windowBackgroundColor, // window background151 windowBackgroundColor, // window background
132- styling.TransitionDuration, // base pane outline transition152+ paneModeColor, // window.pane-mode-active background (border color)
133- activeBorderColor, // workspace-pane-active outline-color153+ 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
134 windowBackgroundColor, // stacked-pane-container background158 windowBackgroundColor, // stacked-pane-container background
135- styling.BorderRadius, // stacked-pane-container border-radius159+ inactiveBorderColor, // stacked-pane-container.workspace-pane-active border color (keep subtle)
136- activeBorderColor, // stacked-pane-container.workspace-pane-active outline-color
137 getStackTitleBg(isDark), // stacked-pane-title background160 getStackTitleBg(isDark), // stacked-pane-title background
138 inactiveBorderColor, // stacked-pane-title border-bottom161 inactiveBorderColor, // stacked-pane-title border-bottom
139 styling.TransitionDuration, // stacked-pane-title transition162 styling.TransitionDuration, // stacked-pane-title transition
@@ -144,12 +167,6 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string {
144 return css167 return css
145 }168 }
146 169
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-
153 // Global CSS provider tracking to prevent duplication170 // Global CSS provider tracking to prevent duplication
154 var (171 var (
155 globalCSSInitialized bool172 globalCSSInitialized bool
@@ -171,12 +188,9 @@ func (wm *WorkspaceManager) ensureWorkspaceStyles() {
171 webkit.AddCSSProvider(workspaceCSS)188 webkit.AddCSSProvider(workspaceCSS)
172 globalCSSInitialized = true189 globalCSSInitialized = true
173 globalCSSContent = workspaceCSS190 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))])
174 }193 }
175 194
176 wm.cssInitialized = true195 wm.cssInitialized = true
177 }196 }
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 {
134 parts = append(parts, fmt.Sprintf("split orientation=%d", node.orientation))134 parts = append(parts, fmt.Sprintf("split orientation=%d", node.orientation))
135 }135 }
136 136
137- if node.container != 0 {137+ if node.container != nil {
138- parts = append(parts, fmt.Sprintf("widget=%#x", node.container))138+ parts = append(parts, fmt.Sprintf("widget=%p", node.container))
139 }139 }
140 140
141 if node.parent != nil {141 if node.parent != nil {
M internal/app/browser/workspace_geometry_validator.go +6 -6
@@ -50,7 +50,7 @@ func (gv *GeometryValidator) SetDebugMode(debug bool) {
50 func (gv *GeometryValidator) GetPaneGeometry(node *paneNode) PaneGeometry {50 func (gv *GeometryValidator) GetPaneGeometry(node *paneNode) PaneGeometry {
51 geometry := PaneGeometry{IsValid: false}51 geometry := PaneGeometry{IsValid: false}
52 52
53- if node == nil || node.container == 0 {53+ if node == nil || node.container == nil {
54 if gv.debugMode {54 if gv.debugMode {
55 log.Printf("[geometry] Invalid node or missing container")55 log.Printf("[geometry] Invalid node or missing container")
56 }56 }
@@ -58,12 +58,12 @@ func (gv *GeometryValidator) GetPaneGeometry(node *paneNode) PaneGeometry {
58 }58 }
59 59
60 // Get widget allocation directly - GTK operations are always on main thread60 // 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)
62 62
63- geometry.X = allocation.X63+ geometry.X = x
64- geometry.Y = allocation.Y64+ geometry.Y = y
65- geometry.Width = allocation.Width65+ geometry.Width = width
66- geometry.Height = allocation.Height66+ geometry.Height = height
67 geometry.IsValid = true67 geometry.IsValid = true
68 68
69 if gv.debugMode {69 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 (
12 12
13 "github.com/bnema/dumber/internal/app/messaging"13 "github.com/bnema/dumber/internal/app/messaging"
14 "github.com/bnema/dumber/pkg/webkit"14 "github.com/bnema/dumber/pkg/webkit"
15+ "github.com/diamondburned/gotk4/pkg/glib/v2"
16+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
15 )17 )
16 18
17 // WorkspaceManager coordinates Zellij-style pane operations.19 // WorkspaceManager coordinates Zellij-style pane operations.
@@ -30,9 +32,10 @@ type WorkspaceManager struct {
30 createPaneFn func(*webkit.WebView) (*BrowserPane, error)32 createPaneFn func(*webkit.WebView) (*BrowserPane, error)
31 33
32 // Coordination fields for preventing duplicate events34 // Coordination fields for preventing duplicate events
33- paneModeSource *webkit.WebView // Which webview initiated pane mode35+ paneModeSource *webkit.WebView // Which webview initiated pane mode
34- lastPaneModeEntry time.Time // When pane mode was last entered36+ paneModeActivePane *paneNode // Which pane node has pane mode active
35- paneMutex sync.Mutex // Protects pane mode state37+ paneModeContainer gtk.Widgetter // The container widget that has margin applied
38+ paneMutex sync.Mutex // Protects pane mode state
36 39
37 // Focus throttling fields to prevent infinite loops40 // Focus throttling fields to prevent infinite loops
38 lastFocusChange time.Time // When focus was last changed41 lastFocusChange time.Time // When focus was last changed
@@ -42,7 +45,8 @@ type WorkspaceManager struct {
42 lastStackOperation time.Time // When a stack operation was last performed45 lastStackOperation time.Time // When a stack operation was last performed
43 46
44 // NEW: Pane creation deduplicator47 // NEW: Pane creation deduplicator
45- paneDeduplicator *messaging.PaneRequestDeduplicator48+ // Popups waiting for ready-to-show lifecycle callback
49+ pendingPopups map[uint64]*pendingPopup
46 50
47 // Specialized managers for different pane operations51 // Specialized managers for different pane operations
48 stackedPaneManager *StackedPaneManager52 stackedPaneManager *StackedPaneManager
@@ -76,14 +80,14 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
76 debugLevel := getDebugLevel()80 debugLevel := getDebugLevel()
77 81
78 manager := &WorkspaceManager{82 manager := &WorkspaceManager{
79- app: app,83+ app: app,
80- window: rootPane.webView.Window(),84+ window: rootPane.webView.Window(),
81- viewToNode: make(map[*webkit.WebView]*paneNode),85+ viewToNode: make(map[*webkit.WebView]*paneNode),
82- lastSplitMsg: make(map[*webkit.WebView]time.Time),86+ lastSplitMsg: make(map[*webkit.WebView]time.Time),
83- lastExitMsg: make(map[*webkit.WebView]time.Time),87+ lastExitMsg: make(map[*webkit.WebView]time.Time),
84- paneDeduplicator: messaging.NewPaneRequestDeduplicator(),88+ pendingPopups: make(map[uint64]*pendingPopup),
85- focusDebounce: 150 * time.Millisecond,89+ focusDebounce: 150 * time.Millisecond,
86- debugLevel: debugLevel,90+ debugLevel: debugLevel,
87 }91 }
88 92
89 // Initialize validation components (opt-in based on debug level)93 // Initialize validation components (opt-in based on debug level)
@@ -114,10 +118,9 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
114 if err != nil {118 if err != nil {
115 return nil, err119 return nil, err
116 }120 }
117- // Pane WebViews are embedded inside the main application window, so they do not121+ // CRITICAL: Pane WebViews are embedded inside the workspace paned containers,
118- // need their own toplevel GtkWindow. Avoid creating per-pane windows to keep GTK122+ // so they do not need their own toplevel GtkWindow. Setting CreateWindow=false
119- // ownership simple (Epiphany follows the same pattern) and to prevent premature123+ // prevents GTK "widget already has parent" errors when adding to paned containers.
120- // swapchain destruction when panes are closed.
121 cfg.CreateWindow = false124 cfg.CreateWindow = false
122 return webkit.NewWebView(cfg)125 return webkit.NewWebView(cfg)
123 }126 }
@@ -166,6 +169,9 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
166 log.Printf("[workspace] Failed to initialize focus state machine: %v", err)169 log.Printf("[workspace] Failed to initialize focus state machine: %v", err)
167 }170 }
168 171
172+ // Ensure hover controllers are attached to all panes for mouse-driven focus changes
173+ manager.attachHoverHandlersToAllPanes()
174+
169 if manager.debugPaneClose {175 if manager.debugPaneClose {
170 manager.dumpTreeState("workspace_init")176 manager.dumpTreeState("workspace_init")
171 }177 }
@@ -173,213 +179,142 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag
173 return manager179 return manager
174 }180 }
175 181
176-// OnWorkspaceMessage implements messaging.WorkspaceObserver.182+// shouldDebounce checks if an operation should be debounced based on timing
177-func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messaging.Message) {183+func (wm *WorkspaceManager) shouldDebounce(source *webkit.WebView, threshold time.Duration) bool {
178- node := wm.viewToNode[source]184+ if last, ok := wm.lastSplitMsg[source]; ok {
179- if node == nil {185+ if elapsed := time.Since(last); elapsed < threshold {
180- log.Printf("[workspace] message from unknown webview: event=%s", msg.Event)186+ log.Printf("[workspace] operation debounced: %.0fms", elapsed.Seconds()*1000)
181- return187+ return true
188+ }
182 }189 }
190+ wm.lastSplitMsg[source] = time.Now()
191+ return false
192+}
183 193
184- switch event := strings.ToLower(msg.Event); event {194+// withSplitLock executes an operation with atomic splitting lock protection
185- case "pane-mode-entered":195+func (wm *WorkspaceManager) withSplitLock(operation string, fn func() error) error {
186- // Use mutex to prevent race conditions when multiple webviews try to enter pane mode196+ if !atomic.CompareAndSwapInt32(&wm.splitting, 0, 1) {
187- wm.paneMutex.Lock()197+ log.Printf("[workspace] %s ignored: operation in progress", operation)
188- defer wm.paneMutex.Unlock()198+ return fmt.Errorf("operation in progress")
199+ }
189 200
190- // Check if pane mode was already entered recently (within 200ms debounce window)201+ defer func() {
191- if time.Since(wm.lastPaneModeEntry) < 200*time.Millisecond {202+ atomic.StoreInt32(&wm.splitting, 0)
192- log.Printf("[workspace] pane-mode-entered rejected: debounce protection (%.0fms ago) source=%p",203+ if r := recover(); r != nil {
193- time.Since(wm.lastPaneModeEntry).Seconds()*1000, source)204+ log.Printf("[workspace] %s panicked: %v", operation, r)
194- return205+ panic(r)
195 }206 }
207+ }()
196 208
197- // Check if pane mode is already active from a different source209+ return fn()
198- if wm.paneModeActive && wm.paneModeSource != nil && wm.paneModeSource != source {210+}
199- log.Printf("[workspace] pane-mode-entered rejected: already active from different source (current=%p, new=%p)",
200- wm.paneModeSource, source)
201- return
202- }
203 211
204- log.Printf("[workspace] pane-mode-entered accepted: source=%p", source)212+// cleanupPopupStorage removes localStorage entries for a closed popup
205- wm.paneModeActive = true213+func (wm *WorkspaceManager) cleanupPopupStorage(parentNode, targetNode *paneNode, webviewID string) {
206- wm.paneModeSource = source214+ if parentNode.pane == nil || parentNode.pane.WebView() == nil || targetNode.requestID == "" {
207- wm.lastPaneModeEntry = time.Now()215+ return
208- wm.SetActivePane(node, SourceProgrammatic)216+ }
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- }
220 217
221- wm.paneMutex.Lock()218+ requestID := targetNode.requestID
222- wm.paneModeActive = false219+ parentWebViewID := parentNode.pane.WebView().ID()
223- wm.paneModeSource = nil220+
224- wm.paneMutex.Unlock()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]
225 242
226- wm.focusAfterPaneMode(node)243+ if msg.WebViewID != "" {
227- case "pane-close":244+ if explicitNode := wm.findNodeByWebViewID(msg.WebViewID); explicitNode != nil {
228- if !wm.paneModeActive {245+ if explicitNode.pane != nil && explicitNode.pane.webView != nil {
229- log.Printf("[workspace] close requested outside pane mode; ignoring")246+ node = explicitNode
230- break247+ source = explicitNode.pane.webView
248+ }
231 }249 }
250+ }
232 251
233- wm.paneMutex.Lock()252+ if node == nil {
234- wm.paneModeActive = false253+ log.Printf("[workspace] message from unknown webview: event=%s webviewId=%s", msg.Event, msg.WebViewID)
235- wm.paneModeSource = nil254+ return
236- wm.paneMutex.Unlock()255+ }
237 256
238- // Don't focus the node that's about to be closed - closeCurrentPane() will handle focus257+ switch event := strings.ToLower(msg.Event); event {
239- wm.closeCurrentPane()
240 case "pane-split":258 case "pane-split":
241 wm.SetActivePane(node, SourceProgrammatic)259 wm.SetActivePane(node, SourceProgrammatic)
242 direction := strings.ToLower(msg.Direction)260 direction := strings.ToLower(msg.Direction)
243 if direction == "" {261 if direction == "" {
244 direction = "right"262 direction = "right"
245 }263 }
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- }
252 264
253- // CRITICAL FIX: Use atomic operations for splitting flag to prevent race conditions265+ if wm.shouldDebounce(source, 200*time.Millisecond) {
254- if !atomic.CompareAndSwapInt32(&wm.splitting, 0, 1) {
255- log.Printf("[workspace] split ignored: operation already in progress")
256 return266 return
257 }267 }
258 268
259- // CRITICAL FIX: Always clear splitting flag on exit (panic recovery)269+ err := wm.withSplitLock("split", func() error {
260- defer func() {270+ newNode, err := wm.SplitPane(node, direction)
261- atomic.StoreInt32(&wm.splitting, 0)271+ if err != nil {
262- if r := recover(); r != nil {272+ if glib.MainContextDefault().IsOwner() {
263- log.Printf("[workspace] split operation panicked, flag cleared: %v", r)273+ glib.MainContextDefault().Iteration(false)
264- panic(r) // Re-panic to maintain error handling274+ }
275+ return err
265 }276 }
266- }()277+ wm.clonePaneState(node, newNode)
267-278+ return nil
268- wm.lastSplitMsg[source] = time.Now()279+ })
269 280
270- newNode, err := wm.SplitPane(node, direction)
271 if err != nil {281 if err != nil {
272 log.Printf("[workspace] split failed: %v", err)282 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
278 }283 }
279- wm.clonePaneState(node, newNode)284+
280 case "pane-stack":285 case "pane-stack":
281 wm.SetActivePane(node, SourceProgrammatic)286 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- }
288 287
289- // CRITICAL FIX: Use atomic operations for splitting flag288+ if wm.shouldDebounce(source, 200*time.Millisecond) {
290- if !atomic.CompareAndSwapInt32(&wm.splitting, 0, 1) {
291- log.Printf("[workspace] stack ignored: operation already in progress")
292 return289 return
293 }290 }
294 291
295- // CRITICAL FIX: Always clear splitting flag on exit (panic recovery)292+ err := wm.withSplitLock("stack", func() error {
296- defer func() {293+ newNode, err := wm.stackedPaneManager.StackPane(node)
297- atomic.StoreInt32(&wm.splitting, 0)294+ if err != nil {
298- if r := recover(); r != nil {295+ if glib.MainContextDefault().IsOwner() {
299- log.Printf("[workspace] stack operation panicked, flag cleared: %v", r)296+ glib.MainContextDefault().Iteration(false)
300- panic(r) // Re-panic to maintain error handling297+ }
298+ return err
301 }299 }
302- }()300+ wm.clonePaneState(node, newNode)
301+ return nil
302+ })
303 303
304- wm.lastSplitMsg[source] = time.Now()
305-
306- newNode, err := wm.stackedPaneManager.StackPane(node)
307 if err != nil {304 if err != nil {
308 log.Printf("[workspace] stack failed: %v", err)305 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)
329 }306 }
330 307
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- }
371 case "close-popup":308 case "close-popup":
372- log.Printf("[workspace] close-popup requested: webviewId=%s reason=%s", msg.WebViewID, msg.Reason)
373-
374 if msg.WebViewID == "" {309 if msg.WebViewID == "" {
375 log.Printf("[workspace] close-popup: empty webviewId, ignoring")310 log.Printf("[workspace] close-popup: empty webviewId, ignoring")
376- break311+ return
377 }312 }
378 313
379- // Find the popup pane by webview ID314+ // Find popup pane by webview ID
380 var targetNode *paneNode315 var targetNode *paneNode
381 for webView, node := range wm.viewToNode {316 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 {
383 targetNode = node318 targetNode = node
384 break319 break
385 }320 }
@@ -387,118 +322,57 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa
387 322
388 if targetNode == nil {323 if targetNode == nil {
389 log.Printf("[workspace] close-popup: webview not found: %s", msg.WebViewID)324 log.Printf("[workspace] close-popup: webview not found: %s", msg.WebViewID)
390- break325+ return
391 }326 }
392 327
393 if !targetNode.isPopup {328 if !targetNode.isPopup {
394 log.Printf("[workspace] close-popup: target is not a popup: %s", msg.WebViewID)329 log.Printf("[workspace] close-popup: target is not a popup: %s", msg.WebViewID)
395- break330+ return
396 }331 }
397 332
398- log.Printf("[workspace] Closing popup pane due to %s", msg.Reason)333+ log.Printf("[workspace] Closing popup due to %s", msg.Reason)
399 334
400- // Remove this popup from parent's activePopupChildren before closing335+ // Remove from parent's active popup list
401 if targetNode.parentPane != nil {336 if targetNode.parentPane != nil {
402 parentNode := targetNode.parentPane337 parentNode := targetNode.parentPane
403 for i, childID := range parentNode.activePopupChildren {338 for i, childID := range parentNode.activePopupChildren {
404 if childID == msg.WebViewID {339 if childID == msg.WebViewID {
405 parentNode.activePopupChildren = append(parentNode.activePopupChildren[:i], parentNode.activePopupChildren[i+1:]...)340 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))
407 break342 break
408 }343 }
409 }344 }
410 345
411- // Clean up ALL localStorage entries for this popup in parent WebView346+ wm.cleanupPopupStorage(parentNode, targetNode, msg.WebViewID)
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- }
450 }347 }
451 348
452- // Close the popup pane
453 if err := wm.ClosePane(targetNode); err != nil {349 if err := wm.ClosePane(targetNode); err != nil {
454- log.Printf("[workspace] Failed to close popup pane: %v", err)350+ log.Printf("[workspace] Failed to close popup: %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- }
494 }351 }
352+
495 default:353 default:
496 log.Printf("[workspace] unhandled workspace event: %s", msg.Event)354 log.Printf("[workspace] unhandled workspace event: %s", msg.Event)
497 }355 }
498 }356 }
499 357
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+
500 // GetActiveNode returns the currently active pane node371 // GetActiveNode returns the currently active pane node
501 func (wm *WorkspaceManager) GetActiveNode() *paneNode {372 func (wm *WorkspaceManager) GetActiveNode() *paneNode {
373+ if wm == nil || wm.focusStateMachine == nil {
374+ return nil
375+ }
502 return wm.focusStateMachine.GetActivePane()376 return wm.focusStateMachine.GetActivePane()
503 }377 }
504 378
@@ -526,11 +400,62 @@ func (wm *WorkspaceManager) GetNodeForWebView(webView *webkit.WebView) *paneNode
526 return wm.viewToNode[webView]400 return wm.viewToNode[webView]
527 }401 }
528 402
529-// RegisterNavigationHandler sets up navigation handling for a webview (simplified)403+// RegisterNavigationHandler sets up navigation handling for a webview
530 func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {404 func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) {
531 if webView == nil {405 if webView == nil {
532 return406 return
533 }407 }
534 408
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())
536 }461 }
M internal/app/browser/workspace_navigation.go +25 -76
@@ -9,6 +9,7 @@ import (
9 "time"9 "time"
10 10
11 "github.com/bnema/dumber/pkg/webkit"11 "github.com/bnema/dumber/pkg/webkit"
12+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
12 )13 )
13 14
14 const (15 const (
@@ -33,7 +34,7 @@ func (wm *WorkspaceManager) DispatchPaneFocusEvent(node *paneNode, active bool)
33 if err := node.pane.webView.DispatchCustomEvent("dumber:workspace-focus", detail); err != nil {34 if err := node.pane.webView.DispatchCustomEvent("dumber:workspace-focus", detail); err != nil {
34 log.Printf("[workspace] failed to dispatch focus event: %v", err)35 log.Printf("[workspace] failed to dispatch focus event: %v", err)
35 } else if wm.app.config != nil && wm.app.config.Debug.EnableWorkspaceDebug {36 } 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)
37 }38 }
38 }39 }
39 40
@@ -88,45 +89,7 @@ func (wm *WorkspaceManager) focusByView(view *webkit.WebView) {
88 }89 }
89 }90 }
90 91
91-// ensureHover sets up hover handlers for a pane node92+// Note: ensureHover and detachHover are now implemented in workspace_hover.go
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-}
130 93
131 // detachFocus removes the GTK focus controller attached to a pane, if any.94 // detachFocus removes the GTK focus controller attached to a pane, if any.
132 func (wm *WorkspaceManager) detachFocus(node *paneNode) {95 func (wm *WorkspaceManager) detachFocus(node *paneNode) {
@@ -240,7 +203,7 @@ func (wm *WorkspaceManager) navigateStack(direction string) bool {
240 // focusAdjacent uses geometry-based calculations to find and focus adjacent panes203 // focusAdjacent uses geometry-based calculations to find and focus adjacent panes
241 func (wm *WorkspaceManager) focusAdjacent(direction string) bool {204 func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
242 currentFocused := wm.GetActiveNode()205 currentFocused := wm.GetActiveNode()
243- if currentFocused == nil || !currentFocused.isLeaf || currentFocused.container == 0 {206+ if currentFocused == nil || !currentFocused.isLeaf || currentFocused.container == nil {
244 return false207 return false
245 }208 }
246 209
@@ -249,14 +212,9 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
249 return true212 return true
250 }213 }
251 214
252- currentBounds, ok := webkit.WidgetGetBounds(currentFocused.container)215+ curX, curY, curWidth, curHeight := webkit.WidgetGetAllocation(currentFocused.container)
253- if !ok {216+ cx := float64(curX) + float64(curWidth)/2.0
254- log.Printf("[workspace] unable to compute bounds for active pane")217+ cy := float64(curY) + float64(curHeight)/2.0
255- return false
256- }
257-
258- cx := currentBounds.X + currentBounds.Width/2.0
259- cy := currentBounds.Y + currentBounds.Height/2.0
260 218
261 leaves := wm.collectLeaves()219 leaves := wm.collectLeaves()
262 bestScore := math.MaxFloat64220 bestScore := math.MaxFloat64
@@ -264,16 +222,13 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
264 var debugCandidates []string222 var debugCandidates []string
265 223
266 for _, candidate := range leaves {224 for _, candidate := range leaves {
267- if candidate == nil || candidate == currentFocused || candidate.container == 0 {225+ if candidate == nil || candidate == currentFocused || candidate.container == nil {
268 continue226 continue
269 }227 }
270 228
271- bounds, ok := webkit.WidgetGetBounds(candidate.container)229+ x, y, width, height := webkit.WidgetGetAllocation(candidate.container)
272- if !ok {230+ tx := float64(x) + float64(width)/2.0
273- continue231+ ty := float64(y) + float64(height)/2.0
274- }
275- tx := bounds.X + bounds.Width/2.0
276- ty := bounds.Y + bounds.Height/2.0
277 232
278 dx := tx - cx233 dx := tx - cx
279 dy := ty - cy234 dy := ty - cy
@@ -303,7 +258,7 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
303 }258 }
304 259
305 if direction == "up" || direction == "down" {260 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))
307 }262 }
308 263
309 if score < bestScore {264 if score < bestScore {
@@ -318,47 +273,44 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool {
318 }273 }
319 274
320 if len(debugCandidates) > 0 {275 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, "; "))
322 }277 }
323 return false278 return false
324 }279 }
325 280
326 // structuralNeighbor finds neighbors based on the tree structure rather than geometry281 // structuralNeighbor finds neighbors based on the tree structure rather than geometry
327 func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string) *paneNode {282 func (wm *WorkspaceManager) structuralNeighbor(node *paneNode, direction string) *paneNode {
328- if node == nil || node.container == 0 {283+ if node == nil || node.container == nil {
329 return nil284 return nil
330 }285 }
331 286
332- refBounds, ok := webkit.WidgetGetBounds(node.container)287+ refX, refY, refWidth, refHeight := webkit.WidgetGetAllocation(node.container)
333- if !ok {288+ cx := float64(refX) + float64(refWidth)/2.0
334- return nil289+ cy := float64(refY) + float64(refHeight)/2.0
335- }
336- cx := refBounds.X + refBounds.Width/2.0
337- cy := refBounds.Y + refBounds.Height/2.0
338 axisVertical := direction == "up" || direction == "down"290 axisVertical := direction == "up" || direction == "down"
339 291
340 for parent := node.parent; parent != nil; parent = parent.parent {292 for parent := node.parent; parent != nil; parent = parent.parent {
341 switch direction {293 switch direction {
342 case "up":294 case "up":
343- if axisVertical && parent.orientation == webkit.OrientationVertical && parent.right == node {295+ if axisVertical && parent.orientation == gtk.OrientationVertical && parent.right == node {
344 if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {296 if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {
345 return leaf297 return leaf
346 }298 }
347 }299 }
348 case "down":300 case "down":
349- if axisVertical && parent.orientation == webkit.OrientationVertical && parent.left == node {301+ if axisVertical && parent.orientation == gtk.OrientationVertical && parent.left == node {
350 if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {302 if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {
351 return leaf303 return leaf
352 }304 }
353 }305 }
354 case "left":306 case "left":
355- if !axisVertical && parent.orientation == webkit.OrientationHorizontal && parent.right == node {307+ if !axisVertical && parent.orientation == gtk.OrientationHorizontal && parent.right == node {
356 if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {308 if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil {
357 return leaf309 return leaf
358 }310 }
359 }311 }
360 case "right":312 case "right":
361- if !axisVertical && parent.orientation == webkit.OrientationHorizontal && parent.left == node {313+ if !axisVertical && parent.orientation == gtk.OrientationHorizontal && parent.left == node {
362 if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {314 if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil {
363 return leaf315 return leaf
364 }316 }
@@ -375,16 +327,13 @@ func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float6
375 bestScore := math.MaxFloat64327 bestScore := math.MaxFloat64
376 var best *paneNode328 var best *paneNode
377 for _, leaf := range leaves {329 for _, leaf := range leaves {
378- if leaf == nil || leaf.container == 0 {330+ if leaf == nil || leaf.container == nil {
379 continue331 continue
380 }332 }
381 333
382- bounds, ok := webkit.WidgetGetBounds(leaf.container)334+ x, y, width, height := webkit.WidgetGetAllocation(leaf.container)
383- if !ok {335+ tx := float64(x) + float64(width)/2.0
384- continue336+ ty := float64(y) + float64(height)/2.0
385- }
386- tx := bounds.X + bounds.Width/2.0
387- ty := bounds.Y + bounds.Height/2.0
388 dx := tx - cx337 dx := tx - cx
389 dy := ty - cy338 dy := ty - cy
390 var score float64339 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
54 // Step 2: Execute directly if we're already on the GTK main thread54 // Step 2: Execute directly if we're already on the GTK main thread
55 if webkit.IsMainThread() {55 if webkit.IsMainThread() {
56 log.Printf("[workspace] Already on main thread, executing split directly")56 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)
58 if err != nil {58 if err != nil {
59 return nil, err59 return nil, err
60 }60 }
@@ -78,7 +78,7 @@ func (wm *WorkspaceManager) SplitPane(target *paneNode, direction string) (*pane
78 done := make(chan struct{})78 done := make(chan struct{})
79 79
80 _ = webkit.IdleAdd(func() bool {80 _ = webkit.IdleAdd(func() bool {
81- newNode, splitErr = wm.splitNode(target, direction)81+ newNode, splitErr = wm.splitNode(target, direction, nil)
82 if splitErr == nil {82 if splitErr == nil {
83 if wm.debugLevel >= DebugBasic {83 if wm.debugLevel >= DebugBasic {
84 if verr := wm.treeValidator.ValidateTree(wm.root, "after_split"); verr != nil {84 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 (
6 "log"6 "log"
7 7
8 "github.com/bnema/dumber/pkg/webkit"8 "github.com/bnema/dumber/pkg/webkit"
9+ "github.com/diamondburned/gotk4/pkg/gdk/v4"
10+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
9 )11 )
10 12
11 // closeContext manages the context for a pane close operation13 // closeContext manages the context for a pane close operation
@@ -112,23 +114,29 @@ func (wm *WorkspaceManager) createPane(view *webkit.WebView) (*BrowserPane, erro
112 // clonePaneState sets up a new pane with cloned state from another pane114 // clonePaneState sets up a new pane with cloned state from another pane
113 func (wm *WorkspaceManager) clonePaneState(_ *paneNode, target *paneNode) {115 func (wm *WorkspaceManager) clonePaneState(_ *paneNode, target *paneNode) {
114 if wm == nil || target == nil {116 if wm == nil || target == nil {
117+ log.Printf("[workspace] clonePaneState: wm or target is nil")
115 return118 return
116 }119 }
117 if target.pane == nil || target.pane.webView == nil {120 if target.pane == nil || target.pane.webView == nil {
121+ log.Printf("[workspace] clonePaneState: target pane or webView is nil")
118 return122 return
119 }123 }
120 124
125+ // Load about:blank and auto-open omnibox
121 const blankURL = "about:blank"126 const blankURL = "about:blank"
122 127
128+ log.Printf("[workspace] clonePaneState: loading %s in pane %p", blankURL, target.pane)
123 if err := target.pane.webView.LoadURL(blankURL); err != nil {129 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)
125 }133 }
126-
127- // Omnibox will auto-open on about:blank via client-side detection
128 }134 }
129 135
130-// safelyDetachControllersBeforeReparent removes GTK controllers that GTK will auto-clean up during reparenting.136+// safelyDetachControllersBeforeReparent marks nodes for controller reattachment after reparenting.
131-// It marks nodes for reattachment once the widget hierarchy settles after the split operation.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).
132 func (wm *WorkspaceManager) safelyDetachControllersBeforeReparent(node *paneNode) {140 func (wm *WorkspaceManager) safelyDetachControllersBeforeReparent(node *paneNode) {
133 if wm == nil || node == nil {141 if wm == nil || node == nil {
134 return142 return
@@ -139,15 +147,18 @@ func (wm *WorkspaceManager) safelyDetachControllersBeforeReparent(node *paneNode
139 return147 return
140 }148 }
141 149
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 {
143 target.pendingHoverReattach = true153 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)
145 }156 }
146 157
147- if wm.focusStateMachine != nil && target.focusControllerToken != 0 {158+ if target.focusControllerToken != 0 {
148- token := target.focusControllerToken
149 target.pendingFocusReattach = true159 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)
151 }162 }
152 }163 }
153 164
@@ -284,311 +295,15 @@ func (wm *WorkspaceManager) removeFromAppPanes(pane *BrowserPane) {
284 }295 }
285 }296 }
286 297
287-// insertPopupPane inserts a pre-created popup pane into the workspace298+// insertPopupPane has been REMOVED - all popup insertion now uses splitNode() with existing pane parameter.
288-func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPane, direction string) error {299+// This eliminates widget reparenting of existing WebViews, which was causing rendering corruption.
289- if target == nil {300+// See splitNode() for the unified implementation.
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-}
589 301
590 // splitNode splits a target pane into two panes in the specified direction302 // 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) {
592 if target == nil || !target.isLeaf || target.pane == nil {307 if target == nil || !target.isLeaf || target.pane == nil {
593 return nil, errors.New("split target must be a leaf pane")308 return nil, errors.New("split target must be a leaf pane")
594 }309 }
@@ -605,7 +320,7 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
605 320
606 // Determine what we're actually splitting321 // Determine what we're actually splitting
607 var splitTarget *paneNode322 var splitTarget *paneNode
608- var splitTargetContainer uintptr323+ var splitTargetContainer gtk.Widgetter
609 324
610 if stackContainer != nil {325 if stackContainer != nil {
611 // Case: splitting from inside a stack - we split around the entire stack326 // 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
619 log.Printf("[workspace] normal split from pane: target=%p", splitTarget)334 log.Printf("[workspace] normal split from pane: target=%p", splitTarget)
620 }335 }
621 336
622- newView, err := wm.createWebView()337+ // Use provided pane or create new one
623- if err != nil {338+ var newPane *BrowserPane
624- return nil, err
625- }
626 339
627- newPane, err := wm.createPane(newView)340+ if existingPane != nil {
628- if err != nil {341+ // Reuse existing pane (for popup insertion)
629- return nil, err342+ 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)
630 }356 }
631 357
632 if handler := newPane.MessageHandler(); handler != nil {358 if handler := newPane.MessageHandler(); handler != nil {
@@ -634,48 +360,47 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
634 }360 }
635 361
636 newContainer := newPane.webView.RootWidget()362 newContainer := newPane.webView.RootWidget()
637- if newContainer == 0 {363+ if newContainer == nil {
638 return nil, errors.New("new pane missing container")364 return nil, errors.New("new pane missing container")
639 }365 }
640 webkit.WidgetSetHExpand(newContainer, true)366 webkit.WidgetSetHExpand(newContainer, true)
641 webkit.WidgetSetVExpand(newContainer, true)367 webkit.WidgetSetVExpand(newContainer, true)
642- webkit.WidgetRealizeInContainer(newContainer)
643 368
644 // Use the determined split target container369 // Use the determined split target container
645- if splitTargetContainer == 0 {370+ if splitTargetContainer == nil {
646 return nil, errors.New("split target missing container")371 return nil, errors.New("split target missing container")
647 }372 }
648 373
649 // Only apply GTK operations if this is a simple widget (leaf pane)374 // Only apply GTK operations if this is a simple widget (leaf pane)
650 // For stacked containers, we need to be more careful375 // For stacked containers, we need to be more careful
651 if splitTarget.isLeaf {376 if splitTarget.isLeaf {
652- if webkit.WidgetIsValid(splitTargetContainer) {377+ if splitTargetContainer != nil {
653 webkit.WidgetSetHExpand(splitTargetContainer, true)378 webkit.WidgetSetHExpand(splitTargetContainer, true)
654 webkit.WidgetSetVExpand(splitTargetContainer, true)379 webkit.WidgetSetVExpand(splitTargetContainer, true)
655- webkit.WidgetRealizeInContainer(splitTargetContainer)
656 }380 }
657 } else if splitTarget.isStacked {381 } else if splitTarget.isStacked {
658 // Stack containers need the same setup as regular panes for proper splitting382 // Stack containers need the same setup as regular panes for proper splitting
659- if webkit.WidgetIsValid(splitTargetContainer) {383+ if splitTargetContainer != nil {
660 webkit.WidgetSetHExpand(splitTargetContainer, true)384 webkit.WidgetSetHExpand(splitTargetContainer, true)
661 webkit.WidgetSetVExpand(splitTargetContainer, true)385 webkit.WidgetSetVExpand(splitTargetContainer, true)
662- webkit.WidgetRealizeInContainer(splitTargetContainer)386+ log.Printf("[workspace] configured stack wrapper for split: %p", splitTargetContainer)
663- log.Printf("[workspace] configured stack wrapper for split: %#x", splitTargetContainer)
664 }387 }
665 }388 }
666 389
667 orientation, existingFirst := mapDirection(direction)390 orientation, existingFirst := mapDirection(direction)
668- log.Printf("[workspace] splitting direction=%s orientation=%v existingFirst=%v splitTarget.parent=%p splitTarget.isStacked=%v",391+ log.Printf("[workspace] splitting direction=%s orientation=%v existingFirst=%v splitTarget.parent=%p splitTarget.isStacked=%v isPopup=%v",
669- direction, orientation, existingFirst, splitTarget.parent, splitTarget.isStacked)392+ direction, orientation, existingFirst, splitTarget.parent, splitTarget.isStacked, existingPane != nil)
670 393
671- paned := webkit.NewPaned(orientation)394+ paned := gtk.NewPaned(orientation)
672- if paned == 0 {395+ if paned == nil {
673 return nil, errors.New("failed to create GtkPaned")396 return nil, errors.New("failed to create GtkPaned")
674 }397 }
675- webkit.WidgetSetHExpand(paned, true)398+ paned.SetHExpand(true)
676- webkit.WidgetSetVExpand(paned, true)399+ paned.SetVExpand(true)
677- webkit.PanedSetResizeStart(paned, true)400+
678- webkit.PanedSetResizeEnd(paned, true)401+ // Allow both children to resize; avoids shrinking existing pane to zero during root splits
402+ paned.SetResizeStartChild(true)
403+ paned.SetResizeEndChild(true)
679 404
680 newLeaf := &paneNode{405 newLeaf := &paneNode{
681 pane: newPane,406 pane: newPane,
@@ -693,37 +418,35 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
693 parent := split.parent418 parent := split.parent
694 419
695 // Clear CSS classes before reparenting to avoid GTK bloom filter corruption420 // 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 {
697 // Remove active border class if present (prevents GTK bloom filter corruption during unparent)424 // Remove active border class if present (prevents GTK bloom filter corruption during unparent)
698 if webkit.WidgetHasCSSClass(splitTargetContainer, activePaneClass) {425 if webkit.WidgetHasCSSClass(splitTargetContainer, activePaneClass) {
699 webkit.WidgetRemoveCSSClass(splitTargetContainer, activePaneClass)426 webkit.WidgetRemoveCSSClass(splitTargetContainer, activePaneClass)
427+ log.Printf("[workspace] removed active class from split target (will transfer to new pane)")
700 }428 }
701 }429 }
702 430
703 // Prevent GTK from auto-dropping controllers while the widget is temporarily unparented.431 // Prevent GTK from auto-dropping controllers while the widget is temporarily unparented.
704 wm.safelyDetachControllersBeforeReparent(splitTarget)432 wm.safelyDetachControllersBeforeReparent(splitTarget)
705 433
706- // Detach split target container from its current GTK parent before inserting into new paned.434+ // For non-root splits, unparent from parent paned
707- if parent == nil {435+ if parent != nil && parent.container != 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 {
715 // Split target has a parent paned - remove it (automatically unparents in GTK4)436 // 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)437+ log.Printf("[workspace] unparenting split target container=%p from parent paned=%p", splitTargetContainer, parent.container)
717- if webkit.WidgetIsValid(parent.container) {438+ if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil {
718 if parent.left == splitTarget {439 if parent.left == splitTarget {
719- webkit.PanedSetStartChild(parent.container, 0)440+ parentPaned.SetStartChild(nil)
720 } else if parent.right == splitTarget {441 } else if parent.right == splitTarget {
721- webkit.PanedSetEndChild(parent.container, 0)442+ parentPaned.SetEndChild(nil)
722 }443 }
723- // GTK4 automatically unparents when we set paned child to 0 - no manual unparent needed444+ // GTK4 automatically unparents when we set paned child to nil - no manual unparent needed
724 webkit.WidgetQueueAllocate(parent.container)445 webkit.WidgetQueueAllocate(parent.container)
725 }446 }
726 }447 }
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.
727 450
728 // Set up the tree structure first451 // Set up the tree structure first
729 if existingFirst {452 if existingFirst {
@@ -750,81 +473,66 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
750 473
751 // GTK4 handles widget operations automatically - no need to force redraw474 // GTK4 handles widget operations automatically - no need to force redraw
752 475
753- // Add both containers to the new paned476+ // Handle root split: attach paned to window FIRST, then let GTK handle reparenting
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
769 if parent == nil {477 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
770 if wm.window != nil {480 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
771 wm.window.SetChild(paned)485 wm.window.SetChild(paned)
772- webkit.WidgetQueueAllocate(paned)486+ log.Printf("[workspace] ROOT SPLIT: attached paned %p to window", paned)
773- log.Printf("[workspace] paned set as window child: paned=%#x", 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)
774 }498 }
775 } else {499 } 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 {
777 if parent.left == split {519 if parent.left == split {
778- webkit.PanedSetStartChild(parent.container, paned)520+ parentPaned.SetStartChild(paned)
779 } else if parent.right == split {521 } else if parent.right == split {
780- webkit.PanedSetEndChild(parent.container, paned)522+ parentPaned.SetEndChild(paned)
781 }523 }
782 webkit.WidgetQueueAllocate(parent.container)524 webkit.WidgetQueueAllocate(parent.container)
783- webkit.WidgetQueueAllocate(paned)525+ paned.QueueAllocate()
784- log.Printf("[workspace] paned inserted into parent=%#x", parent.container)526+ log.Printf("[workspace] paned inserted into parent=%p", parent.container)
785 }527 }
786 }528 }
787 529
788- webkit.WidgetShow(paned)530+ paned.Show()
789 531
790- // CRITICAL: Set initial 50/50 split position after showing paned532+ // Synchronize the divider once the paned has a meaningful allocation.
791- // GTK needs the widget to be realized before we can set position based on allocation533+ wm.syncPanedDivider(paned, orientation, split)
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)
811 534
812- // CRITICAL: Show the existing container and force GTK to recreate rendering surface after reparenting535+ // GTK4 automatically handles widget rendering after reparenting - no manual updates needed
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- }
828 536
829 wm.viewToNode[newPane.webView] = newLeaf537 wm.viewToNode[newPane.webView] = newLeaf
830 wm.ensureHover(newLeaf)538 wm.ensureHover(newLeaf)
@@ -838,7 +546,14 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
838 }546 }
839 }547 }
840 548
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+
842 if newPane.zoomController != nil {557 if newPane.zoomController != nil {
843 newPane.zoomController.ApplyInitialZoom()558 newPane.zoomController.ApplyInitialZoom()
844 }559 }
@@ -854,9 +569,27 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
854 if newLeaf == nil || !newLeaf.widgetValid {569 if newLeaf == nil || !newLeaf.widgetValid {
855 return false570 return false
856 }571 }
857- if newContainer != 0 && webkit.WidgetIsValid(newContainer) {572+ if newContainer != nil {
858 webkit.WidgetShow(newContainer)573 webkit.WidgetShow(newContainer)
859 }574 }
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+
860 // Attach GTK focus controller to new pane593 // Attach GTK focus controller to new pane
861 if wm.focusStateMachine != nil {594 if wm.focusStateMachine != nil {
862 wm.focusStateMachine.attachGTKController(newLeaf)595 wm.focusStateMachine.attachGTKController(newLeaf)
@@ -868,7 +601,7 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane
868 }601 }
869 if candidate.pendingHoverReattach {602 if candidate.pendingHoverReattach {
870 wm.ensureHover(candidate)603 wm.ensureHover(candidate)
871- if candidate.hoverToken != 0 {604+ if candidate.hoverToken != nil {
872 candidate.pendingHoverReattach = false605 candidate.pendingHoverReattach = false
873 }606 }
874 }607 }
@@ -931,25 +664,25 @@ func (wm *WorkspaceManager) swapContainers(grand *paneNode, sibling *paneNode) {
931 return664 return
932 }665 }
933 // Clear CSS classes before reparenting to avoid GTK bloom filter corruption666 // 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 {
935 if webkit.WidgetHasCSSClass(sibling.container, activePaneClass) {668 if webkit.WidgetHasCSSClass(sibling.container, activePaneClass) {
936 webkit.WidgetRemoveCSSClass(sibling.container, activePaneClass)669 webkit.WidgetRemoveCSSClass(sibling.container, activePaneClass)
937 }670 }
938 }671 }
939 672
940- if webkit.WidgetIsValid(grand.container) && webkit.WidgetIsValid(sibling.container) {673+ if grandPaned, ok := grand.container.(*gtk.Paned); ok && grandPaned != nil && sibling.container != nil {
941 // GTK4 PanedSetStartChild/EndChild auto-unparent from current parent674 // GTK4 PanedSetStartChild/EndChild auto-unparent from current parent
942 // Focus was already cleared before unparenting closed pane675 // Focus was already cleared before unparenting closed pane
943- if parent := webkit.WidgetGetParent(sibling.container); parent != 0 && parent != grand.container {676+ if parent := webkit.WidgetGetParent(sibling.container); parent != nil && parent != grand.container {
944- log.Printf("[workspace] widget %#x reparenting from %#x to %#x (GTK4 will auto-unparent)", sibling.container, parent, grand.container)677+ log.Printf("[workspace] widget %p reparenting from %p to %p (GTK4 will auto-unparent)", sibling.container, parent, grand.container)
945 }678 }
946 679
947 if grand.left == sibling {680 if grand.left == sibling {
948- webkit.PanedSetStartChild(grand.container, sibling.container)681+ grandPaned.SetStartChild(sibling.container)
949- log.Printf("[workspace] swapContainers: set widget %#x as start child of paned %#x", sibling.container, grand.container)682+ log.Printf("[workspace] swapContainers: set widget %p as start child of paned %p", sibling.container, grand.container)
950 } else {683 } else {
951- webkit.PanedSetEndChild(grand.container, sibling.container)684+ grandPaned.SetEndChild(sibling.container)
952- log.Printf("[workspace] swapContainers: set widget %#x as end child of paned %#x", sibling.container, grand.container)685+ log.Printf("[workspace] swapContainers: set widget %p as end child of paned %p", sibling.container, grand.container)
953 }686 }
954 webkit.WidgetQueueAllocate(grand.container)687 webkit.WidgetQueueAllocate(grand.container)
955 }688 }
@@ -985,7 +718,7 @@ func (wm *WorkspaceManager) cascadePromotion(singleChildPaned *paneNode) {
985 log.Printf("[workspace] Only child %p promoted to root", onlyChild)718 log.Printf("[workspace] Only child %p promoted to root", onlyChild)
986 719
987 // Attach to window720 // Attach to window
988- if onlyChild.container != 0 && webkit.WidgetIsValid(onlyChild.container) {721+ if onlyChild.container != nil {
989 wm.attachRoot(onlyChild)722 wm.attachRoot(onlyChild)
990 }723 }
991 724
@@ -1002,12 +735,12 @@ func (wm *WorkspaceManager) cascadePromotion(singleChildPaned *paneNode) {
1002 log.Printf("[workspace] Only child %p attached to great-grandparent %p", onlyChild, greatGrandparent)735 log.Printf("[workspace] Only child %p attached to great-grandparent %p", onlyChild, greatGrandparent)
1003 736
1004 // Reparent widget in GTK737 // 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 {
1006 // GTK4 PanedSet*Child automatically handles unparenting739 // GTK4 PanedSet*Child automatically handles unparenting
1007 if greatGrandparent.left == onlyChild {740 if greatGrandparent.left == onlyChild {
1008- webkit.PanedSetStartChild(greatGrandparent.container, onlyChild.container)741+ greatGrandPaned.SetStartChild(onlyChild.container)
1009 } else {742 } else {
1010- webkit.PanedSetEndChild(greatGrandparent.container, onlyChild.container)743+ greatGrandPaned.SetEndChild(onlyChild.container)
1011 }744 }
1012 745
1013 webkit.WidgetSetHExpand(onlyChild.container, true)746 webkit.WidgetSetHExpand(onlyChild.container, true)
@@ -1018,7 +751,7 @@ func (wm *WorkspaceManager) cascadePromotion(singleChildPaned *paneNode) {
1018 if onlyChild == nil || !onlyChild.widgetValid {751 if onlyChild == nil || !onlyChild.widgetValid {
1019 return false752 return false
1020 }753 }
1021- if onlyChild.container != 0 && webkit.WidgetIsValid(onlyChild.container) {754+ if onlyChild.container != nil {
1022 webkit.WidgetShow(onlyChild.container)755 webkit.WidgetShow(onlyChild.container)
1023 webkit.WidgetQueueResize(onlyChild.container)756 webkit.WidgetQueueResize(onlyChild.container)
1024 webkit.WidgetQueueDraw(onlyChild.container)757 webkit.WidgetQueueDraw(onlyChild.container)
@@ -1040,50 +773,33 @@ func (wm *WorkspaceManager) cascadePromotion(singleChildPaned *paneNode) {
1040 773
1041 // attachRoot attaches a node as the new window root774 // attachRoot attaches a node as the new window root
1042 func (wm *WorkspaceManager) attachRoot(root *paneNode) {775 func (wm *WorkspaceManager) attachRoot(root *paneNode) {
1043- if root == nil || root.container == 0 || wm.window == nil {776+ if root == nil || root.container == nil || wm.window == nil {
1044- return
1045- }
1046- if !webkit.WidgetIsValid(root.container) {
1047 return777 return
1048 }778 }
1049 779
1050- // Hold a transient reference so GTK does not destroy the widget while detached780+ // Note: In gotk4, reference counting is handled automatically by Go's GC
1051- releaseRef := webkit.WidgetRef(root.container)781+ // No need for manual Ref()/Unref() calls
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- }()
1061 782
1062 // Detach from previous container (paned, stack, etc.) before replacing the window child.783 // Detach from previous container (paned, stack, etc.) before replacing the window child.
1063- if parent := webkit.WidgetGetParent(root.container); parent != 0 {784+ // While GTK4 auto-unparents for paned operations, window.SetChild requires manual unparent
1064- log.Printf("[workspace] unparenting widget %#x from parent %#x before window attach", root.container, parent)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)
1065 webkit.WidgetUnparent(root.container)787 webkit.WidgetUnparent(root.container)
1066- if !webkit.WidgetIsValid(root.container) {788+ // Note: In gotk4, the Go object reference remains valid after unparent
1067- log.Printf("[workspace] WARNING: widget %#x became invalid during unparent; aborting attachRoot", root.container)789+ // Only the GTK parent relationship is cleared
1068- return
1069- }
1070 }790 }
1071 791
1072 // Clear any existing window child to avoid GTK warnings when swapping roots.792 // Clear any existing window child to avoid GTK warnings when swapping roots.
1073- wm.window.SetChild(0)793+ wm.window.SetChild(nil)
1074 794
1075- if webkit.WidgetIsValid(root.container) {795+ if root.container != nil {
1076 webkit.WidgetSetHExpand(root.container, true)796 webkit.WidgetSetHExpand(root.container, true)
1077 webkit.WidgetSetVExpand(root.container, true)797 webkit.WidgetSetVExpand(root.container, true)
1078 }798 }
1079 799
1080- if !webkit.WidgetIsValid(root.container) {800+ // GTK4 will automatically unparent root.container from its previous parent when we attach it here
1081- log.Printf("[workspace] WARNING: widget %#x invalid prior to window attach", root.container)
1082- return
1083- }
1084-
1085 wm.window.SetChild(root.container)801 wm.window.SetChild(root.container)
1086- if webkit.WidgetIsValid(root.container) {802+ if root.container != nil {
1087 webkit.WidgetQueueAllocate(root.container)803 webkit.WidgetQueueAllocate(root.container)
1088 webkit.WidgetShow(root.container)804 webkit.WidgetShow(root.container)
1089 webkit.WidgetQueueResize(root.container)805 webkit.WidgetQueueResize(root.container)
@@ -1094,7 +810,7 @@ func (wm *WorkspaceManager) attachRoot(root *paneNode) {
1094 if root == nil || !root.widgetValid {810 if root == nil || !root.widgetValid {
1095 return false811 return false
1096 }812 }
1097- if root.container != 0 && webkit.WidgetIsValid(root.container) {813+ if root.container != nil {
1098 webkit.WidgetQueueResize(root.container)814 webkit.WidgetQueueResize(root.container)
1099 webkit.WidgetQueueDraw(root.container)815 webkit.WidgetQueueDraw(root.container)
1100 }816 }
@@ -1102,10 +818,10 @@ func (wm *WorkspaceManager) attachRoot(root *paneNode) {
1102 }, root)818 }, root)
1103 819
1104 // Verify attachment succeeded820 // Verify attachment succeeded
1105- if finalParent := webkit.WidgetGetParent(root.container); finalParent == 0 {821+ if finalParent := webkit.WidgetGetParent(root.container); finalParent == nil {
1106- log.Printf("[workspace] WARNING: widget %#x has no parent after SetChild", root.container)822+ log.Printf("[workspace] WARNING: widget %p has no parent after SetChild", root.container)
1107 } else {823 } 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)
1109 }825 }
1110 }826 }
1111 827
@@ -1138,9 +854,9 @@ func (wm *WorkspaceManager) cleanupPane(node *paneNode, generation uint) {
1138 node.pane = nil854 node.pane = nil
1139 }855 }
1140 856
1141- if node.container != 0 {857+ if node.container != nil {
1142 // Container widget will be destroyed by GTK when unparented858 // Container widget will be destroyed by GTK when unparented
1143- node.container = 0859+ node.container = nil
1144 }860 }
1145 861
1146 node.pendingIdleHandles = nil862 node.pendingIdleHandles = nil
@@ -1157,9 +873,10 @@ func (wm *WorkspaceManager) decommissionParent(parent *paneNode, generation uint
1157 873
1158 // For branch nodes (paneds), destroy the widget874 // For branch nodes (paneds), destroy the widget
1159 // Children have already been unparented during promotion/close, no need to clear them875 // Children have already been unparented during promotion/close, no need to clear them
1160- if !parent.isLeaf && parent.container != 0 && webkit.WidgetIsValid(parent.container) {876+ if !parent.isLeaf && parent.container != nil {
1161- webkit.WidgetHookDestroy(parent.container)877+ // In gotk4, we don't need to manually destroy - GTK handles this when unparented
1162- log.Printf("[workspace] destroyed paned widget: %#x", parent.container)878+ // Just mark it for cleanup
879+ log.Printf("[workspace] marked paned widget for cleanup: %p", parent.container)
1163 }880 }
1164 881
1165 wm.cleanupPane(parent, generation)882 wm.cleanupPane(parent, generation)
@@ -1191,6 +908,8 @@ func (wm *WorkspaceManager) cleanupAndExit(node *paneNode) (*paneNode, error) {
1191 log.Printf("[workspace] closing final pane; exiting browser")908 log.Printf("[workspace] closing final pane; exiting browser")
1192 wm.cleanupPane(node, wm.nextCleanupGeneration())909 wm.cleanupPane(node, wm.nextCleanupGeneration())
1193 wm.detachHover(node)910 wm.detachHover(node)
911+
912+ // Gracefully quit the GTK main loop
1194 webkit.QuitMainLoop()913 webkit.QuitMainLoop()
1195 return nil, nil914 return nil, nil
1196 }915 }
@@ -1223,7 +942,8 @@ func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) {
1223 wm.detachFocus(node)942 wm.detachFocus(node)
1224 943
1225 // STEP 3: Handle trivial exit cases944 // 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 {
1227 return wm.cleanupAndExit(node)947 return wm.cleanupAndExit(node)
1228 }948 }
1229 if node == wm.root {949 if node == wm.root {
@@ -1237,36 +957,52 @@ func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) {
1237 957
1238 // Clear grandparent's focus child BEFORE unparenting closed pane958 // Clear grandparent's focus child BEFORE unparenting closed pane
1239 // GTK propagates focus events upward, so we must clear grandparent focus first959 // GTK propagates focus events upward, so we must clear grandparent focus first
1240- if grandparent != nil && grandparent.container != 0 && webkit.WidgetIsValid(grandparent.container) {960+ if grandparent != nil && grandparent.container != nil {
1241- webkit.WidgetSetFocusChild(grandparent.container, 0)961+ webkit.WidgetSetFocusChild(grandparent.container, nil)
1242 }962 }
1243 963
1244 // Unparent the closed pane's container from the paned BEFORE promoting sibling964 // 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 {
1246 if containerParent := webkit.WidgetGetParent(node.container); containerParent == parent.container {966 if containerParent := webkit.WidgetGetParent(node.container); containerParent == parent.container {
1247- if parent.left == node {967+ if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil {
1248- webkit.PanedSetStartChild(parent.container, 0)968+ if parent.left == node {
1249- } else if parent.right == node {969+ parentPaned.SetStartChild(nil)
1250- webkit.PanedSetEndChild(parent.container, 0)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)
1251 }986 }
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)
1253 }988 }
1254 }989 }
1255 990
1256- // STEP 5: Promote sibling in-place (tree structure)991+ // STEP 6: Promote sibling in-place (tree structure)
1257 wm.promoteSibling(grandparent, parent, sibling)992 wm.promoteSibling(grandparent, parent, sibling)
1258 993
1259- // STEP 6: GTK handles all reparenting automatically994+ // STEP 7: GTK widget reparenting
1260- // PanedSetStartChild/EndChild and window.SetChild auto-unparent widgets995+ // 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
1261 wm.swapContainers(grandparent, sibling)997 wm.swapContainers(grandparent, sibling)
1262 998
1263 // Reconnect the promoted sibling's rendering pipeline after GTK reparenting.999 // Reconnect the promoted sibling's rendering pipeline after GTK reparenting.
1264- if sibling != nil && sibling.container != 0 {1000+ if sibling != nil && sibling.container != nil {
1265 wm.scheduleIdleGuarded(func() bool {1001 wm.scheduleIdleGuarded(func() bool {
1266 if sibling == nil || !sibling.widgetValid {1002 if sibling == nil || !sibling.widgetValid {
1267 return false1003 return false
1268 }1004 }
1269- if sibling.container != 0 && webkit.WidgetIsValid(sibling.container) {1005+ if sibling.container != nil {
1270 webkit.WidgetShow(sibling.container)1006 webkit.WidgetShow(sibling.container)
1271 webkit.WidgetQueueResize(sibling.container)1007 webkit.WidgetQueueResize(sibling.container)
1272 webkit.WidgetQueueDraw(sibling.container)1008 webkit.WidgetQueueDraw(sibling.container)
@@ -1275,14 +1011,14 @@ func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) {
1275 }, sibling)1011 }, sibling)
1276 }1012 }
1277 1013
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)
1279 if grandparent != nil && ((grandparent.left == nil) != (grandparent.right == nil)) {1015 if grandparent != nil && ((grandparent.left == nil) != (grandparent.right == nil)) {
1280 // Grandparent has exactly one child - it should be promoted too1016 // Grandparent has exactly one child - it should be promoted too
1281 log.Printf("[workspace] Grandparent %p now has only one child, cascading promotion", grandparent)1017 log.Printf("[workspace] Grandparent %p now has only one child, cascading promotion", grandparent)
1282 wm.cascadePromotion(grandparent)1018 wm.cascadePromotion(grandparent)
1283 }1019 }
1284 1020
1285- // STEP 8: Cleanup & focus1021+ // STEP 9: Cleanup & focus
1286 wm.cleanupPane(node, ctx.Generation())1022 wm.cleanupPane(node, ctx.Generation())
1287 wm.decommissionParent(parent, ctx.Generation())1023 wm.decommissionParent(parent, ctx.Generation())
1288 if sibling != nil {1024 if sibling != nil {
@@ -1335,17 +1071,17 @@ func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, e
1335 }1071 }
1336 1072
1337 // Update GTK widgets1073 // 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 {
1339 if stack.parent.left == remaining {1075 if stack.parent.left == remaining {
1340- webkit.PanedSetStartChild(stack.parent.container, remaining.container)1076+ parentPaned.SetStartChild(remaining.container)
1341 } else {1077 } else {
1342- webkit.PanedSetEndChild(stack.parent.container, remaining.container)1078+ parentPaned.SetEndChild(remaining.container)
1343 }1079 }
1344 }1080 }
1345 } else {1081 } else {
1346 // Stack was root1082 // Stack was root
1347 wm.root = remaining1083 wm.root = remaining
1348- if webkit.WidgetIsValid(remaining.container) {1084+ if remaining.container != nil {
1349 wm.window.SetChild(remaining.container)1085 wm.window.SetChild(remaining.container)
1350 }1086 }
1351 }1087 }
@@ -1362,14 +1098,14 @@ func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, e
1362 }1098 }
1363 1099
1364 // Remove widget from GTK box1100 // Remove widget from GTK box
1365- if webkit.WidgetIsValid(stack.container) && webkit.WidgetIsValid(node.container) {1101+ if stackBox, ok := stack.container.(*gtk.Box); ok && stackBox != nil && node.container != nil {
1366- webkit.BoxRemove(stack.container, node.container)1102+ stackBox.Remove(node.container)
1367 }1103 }
1368 1104
1369 // Show new active pane1105 // Show new active pane
1370 if stack.activeStackIndex >= 0 {1106 if stack.activeStackIndex >= 0 {
1371 active := stack.stackedPanes[stack.activeStackIndex]1107 active := stack.stackedPanes[stack.activeStackIndex]
1372- if webkit.WidgetIsValid(active.container) {1108+ if active.container != nil {
1373 webkit.WidgetShow(active.container)1109 webkit.WidgetShow(active.container)
1374 }1110 }
1375 }1111 }
@@ -1379,3 +1115,77 @@ func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, e
1379 1115
1380 return stack, nil1116 return stack, nil
1381 }1117 }
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 @@
2 package browser2 package browser
3 3
4 import (4 import (
5- "fmt"
6 "log"5 "log"
7 "strings"6 "strings"
8 "time"7 "time"
9 8
10- "github.com/bnema/dumber/internal/app/messaging"9+ "github.com/bnema/dumber/internal/config"
11 "github.com/bnema/dumber/pkg/webkit"10 "github.com/bnema/dumber/pkg/webkit"
11+ webkitgtk "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
12+ "github.com/diamondburned/gotk4/pkg/glib/v2"
12 )13 )
13 14
14 const (15 const (
@@ -17,262 +18,9 @@ const (
17 defaultPopupMinHeight = 600 // Minimum height for OAuth popups18 defaultPopupMinHeight = 600 // Minimum height for OAuth popups
18 )19 )
19 20
20-// HandlePopup handles popup window creation requests from WebViews21+// HandlePopup is DEPRECATED and removed.
21-func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *webkit.WebView {22+// Popup lifecycle now uses WebKit's native create/ready-to-show/close signals.
22- log.Printf("[workspace] HandlePopup called for URL: %s", url)23+// See setupPopupHandling(), handlePopupReadyToShow(), and handlePopupClose() instead.
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-}
276 24
277 // registerOAuthAutoClose sets up OAuth auto-close functionality for popups25 // registerOAuthAutoClose sets up OAuth auto-close functionality for popups
278 // Note: OAuth detection is now handled by the main-world.js injection script26 // 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
281 log.Printf("[workspace] OAuth detection will be handled by main-world.js injection script")29 log.Printf("[workspace] OAuth detection will be handled by main-world.js injection script")
282 }30 }
283 31
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-
572 // insertIndependentPane inserts a new independent pane next to the source32 // insertIndependentPane inserts a new independent pane next to the source
573 func (wm *WorkspaceManager) insertIndependentPane(sourceNode *paneNode, webView *webkit.WebView, url string) error {33 func (wm *WorkspaceManager) insertIndependentPane(sourceNode *paneNode, webView *webkit.WebView, url string) error {
574 newPane, err := wm.createPane(webView)34 newPane, err := wm.createPane(webView)
@@ -579,7 +39,8 @@ func (wm *WorkspaceManager) insertIndependentPane(sourceNode *paneNode, webView
579 if direction == "" {39 if direction == "" {
580 direction = "right"40 direction = "right"
581 }41 }
582- if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil { // reuse insertion primitive42+ // Use splitNode with existing pane instead of insertPopupPane
43+ if _, err := wm.splitNode(sourceNode, direction, newPane); err != nil {
583 return err44 return err
584 }45 }
585 node := wm.viewToNode[webView]46 node := wm.viewToNode[webView]
@@ -606,12 +67,13 @@ func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView
606 if direction == "" {67 if direction == "" {
607 direction = "right"68 direction = "right"
608 }69 }
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 {
610 log.Printf("[workspace] failed to insert related popup pane: %v", err)72 log.Printf("[workspace] failed to insert related popup pane: %v", err)
611 return73 return
612 }74 }
613 node := wm.viewToNode[related]75 node := wm.viewToNode[related]
614- popupWebViewID := related.ID()76+ popupWebViewID := related.IDString()
615 77
616 if node != nil {78 if node != nil {
617 node.windowType = webkit.WindowTypePopup79 node.windowType = webkit.WindowTypePopup
@@ -715,17 +177,17 @@ func (wm *WorkspaceManager) applyPopupSizeConstraints(view *webkit.WebView, widt
715 }177 }
716 178
717 // Apply to container179 // Apply to container
718- if container := view.RootWidget(); container != 0 && webkit.WidgetIsValid(container) {180+ if container := view.RootWidget(); container != nil {
719 webkit.WidgetSetSizeRequest(container, minWidth, minHeight)181 webkit.WidgetSetSizeRequest(container, minWidth, minHeight)
720 webkit.WidgetQueueResize(container)182 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)
722 }184 }
723 185
724 // Apply to WebView widget186 // Apply to WebView widget
725- if webViewWidget := view.Widget(); webViewWidget != 0 && webkit.WidgetIsValid(webViewWidget) {187+ if webViewWidget := view.Widget(); webViewWidget != nil {
726 webkit.WidgetSetSizeRequest(webViewWidget, minWidth, minHeight)188 webkit.WidgetSetSizeRequest(webViewWidget, minWidth, minHeight)
727 webkit.WidgetQueueResize(webViewWidget)189 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)
729 }191 }
730 }192 }
731 193
@@ -796,9 +258,10 @@ func (wm *WorkspaceManager) handleDetectedWindowType(sourceNode *paneNode, webVi
796 log.Printf("[workspace] failed to build webkit config for tab: %v", err)258 log.Printf("[workspace] failed to build webkit config for tab: %v", err)
797 return259 return
798 }260 }
261+ // Tabs are embedded in workspace paned containers - no separate window needed
799 webkitCfg.CreateWindow = false262 webkitCfg.CreateWindow = false
800 263
801- // Create independent WebView like handleIntentAsTab does264+ // Create independent WebView matching our standard tab insertion path
802 independentView, err := webkit.NewWebView(webkitCfg)265 independentView, err := webkit.NewWebView(webkitCfg)
803 if err != nil {266 if err != nil {
804 log.Printf("[workspace] failed to create independent tab WebView: %v", err)267 log.Printf("[workspace] failed to create independent tab WebView: %v", err)
@@ -819,3 +282,316 @@ func (wm *WorkspaceManager) handleDetectedWindowType(sourceNode *paneNode, webVi
819 wm.configureRelatedPopup(sourceNode, webView, url, features)282 wm.configureRelatedPopup(sourceNode, webView, url, features)
820 }283 }
821 }284 }
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 (
5 "os"5 "os"
6 6
7 "github.com/bnema/dumber/pkg/webkit"7 "github.com/bnema/dumber/pkg/webkit"
8+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
8 )9 )
9 10
10 // DebugLevel controls validation and safety checks11 // DebugLevel controls validation and safety checks
@@ -49,31 +50,35 @@ type paneNode struct {
49 left *paneNode50 left *paneNode
50 right *paneNode51 right *paneNode
51 52
52- // Widget management - direct GTK pointers (all ops on main thread)53+ // Widget management - gotk4 widgets (all ops on main thread)
53- container uintptr // Main container: GtkPaned (branch), GtkBox (stack), or WebView container (leaf)54+ container gtk.Widgetter // Main container: *gtk.Paned (branch), *gtk.Box (stack), or WebView widget (leaf)
54- orientation webkit.Orientation55+ orientation gtk.Orientation
55 isLeaf bool56 isLeaf bool
56 isPopup bool // Deprecated: use windowType instead57 isPopup bool // Deprecated: use windowType instead
57 58
58 // Window type tracking59 // Window type tracking
59- windowType webkit.WindowType // Tab or Popup60+ windowType webkit.WindowType // Tab or Popup
60- windowFeatures *webkit.WindowFeatures // Features if popup61+ windowFeatures *webkit.WindowFeatures // Features if popup
61- isRelated bool // Shares context62+ isRelated bool // Shares context
62- parentPane *paneNode // Parent for related views63+ parentPane *paneNode // Parent for related views
63- activePopupChildren []string // WebView IDs of active popup children (for tracking related popups)64+ activePopupChildren []string // WebView IDs of active popup children (for tracking related popups)
64- autoClose bool // Auto-close on OAuth success65+ autoClose bool // Auto-close on OAuth success
65- requestID string // Request ID for popup (used for localStorage cleanup)66+ requestID string // Request ID for popup (used for localStorage cleanup)
66- hoverToken uintptr67+ popupID uint64 // Unique popup ID (for bare WebView lifecycle tracking)
67- focusControllerToken uintptr68+ hoverToken *gtk.EventControllerMotion // Hover event controller for mouse-based focus
68- pendingHoverReattach bool69+ focusControllerToken uintptr // Focus controller token (stored as native pointer)
69- pendingFocusReattach bool70+ 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)
70 75
71 // Stacked panes support - terminal branch nodes76 // Stacked panes support - terminal branch nodes
72- isStacked bool // Whether this node contains stacked panes77+ isStacked bool // Whether this node contains stacked panes
73- stackedPanes []*paneNode // List of stacked panes (if isStacked)78+ stackedPanes []*paneNode // List of stacked panes (if isStacked)
74- activeStackIndex int // Index of currently visible pane in stack79+ activeStackIndex int // Index of currently visible pane in stack
75- titleBar uintptr // GtkBox for title bar (when collapsed)80+ titleBar gtk.Widgetter // *gtk.Box for title bar (when collapsed)
76- stackWrapper uintptr // Internal GtkBox containing the actual stacked widgets (titles + webviews)81+ stackWrapper gtk.Widgetter // Internal *gtk.Box containing the actual stacked widgets (titles + webviews)
77 82
78 // Cleanup tracking83 // Cleanup tracking
79 widgetValid bool // Guard flagged before GTK destruction84 widgetValid bool // Guard flagged before GTK destruction
@@ -90,5 +95,15 @@ const (
90 stackContainerClass = "stacked-pane-container"95 stackContainerClass = "stacked-pane-container"
91 )96 )
92 97
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+
93 // Focus calculation epsilon for geometric comparisons108 // Focus calculation epsilon for geometric comparisons
94 const focusEpsilon = 1e-3109 const focusEpsilon = 1e-3
M internal/app/browser/workspace_utils.go +89 -106
@@ -6,13 +6,10 @@ import (
6 "log"6 "log"
7 7
8 "github.com/bnema/dumber/pkg/webkit"8 "github.com/bnema/dumber/pkg/webkit"
9+ "github.com/diamondburned/gotk4/pkg/glib/v2"
10+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
9 )11 )
10 12
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-
16 // UpdateTitleBar updates the title bar label for a WebView in stacked panes13 // UpdateTitleBar updates the title bar label for a WebView in stacked panes
17 func (wm *WorkspaceManager) UpdateTitleBar(webView *webkit.WebView, title string) {14 func (wm *WorkspaceManager) UpdateTitleBar(webView *webkit.WebView, title string) {
18 // Delegate to StackedPaneManager which has the correct logic15 // Delegate to StackedPaneManager which has the correct logic
@@ -36,171 +33,157 @@ func (wm *WorkspaceManager) ensureGUIInPane(pane *BrowserPane) {
36 }33 }
37 34
38 // mapDirection maps a direction string to GTK orientation and positioning35 // 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) {
40 switch direction {37 switch direction {
41 case "left":38 case "left":
42- return webkit.OrientationHorizontal, false39+ return gtk.OrientationHorizontal, false
43 case "up":40 case "up":
44- return webkit.OrientationVertical, false41+ return gtk.OrientationVertical, false
45 case "down":42 case "down":
46- return webkit.OrientationVertical, true43+ return gtk.OrientationVertical, true
47 default:44 default:
48- return webkit.OrientationHorizontal, true45+ return gtk.OrientationHorizontal, true
49 }46 }
50 }47 }
51 48
52 // Widget management utilities49 // Widget management utilities
53 50
54 // initializePaneWidgets sets up widgets for a paneNode51 // initializePaneWidgets sets up widgets for a paneNode
55-func (wm *WorkspaceManager) initializePaneWidgets(node *paneNode, containerPtr uintptr) {52+func (wm *WorkspaceManager) initializePaneWidgets(node *paneNode, widget gtk.Widgetter) {
56- node.container = containerPtr53+ node.container = widget
57 54
58 // Add base CSS class to the container55 // Add base CSS class to the container
59- if node.container != 0 && webkit.WidgetIsValid(node.container) {56+ if node.container != nil {
60 webkit.WidgetAddCSSClass(node.container, basePaneClass)57 webkit.WidgetAddCSSClass(node.container, basePaneClass)
61 }58 }
62 59
63- // Initialize other widget fields as zero (will be set when needed)60+ // Initialize other widget fields as nil (will be set when needed)
64- node.titleBar = 061+ node.titleBar = nil
65- node.stackWrapper = 062+ node.stackWrapper = nil
66 63
67 // Initialize cleanup tracking fields64 // Initialize cleanup tracking fields
68 node.widgetValid = true65 node.widgetValid = true
69 node.cleanupGeneration = 066 node.cleanupGeneration = 0
70 }67 }
71 68
72-// setContainer sets the container widget pointer69+// setContainer sets the container widget
73-func (wm *WorkspaceManager) setContainer(node *paneNode, ptr uintptr, typeInfo string) {70+func (wm *WorkspaceManager) setContainer(node *paneNode, widget gtk.Widgetter, typeInfo string) {
74 _ = typeInfo // Type info kept for API compatibility but not used71 _ = typeInfo // Type info kept for API compatibility but not used
75- node.container = ptr72+ node.container = widget
76 73
77 // Mark widget validity based on container presence so idle guards run for stacks too74 // Mark widget validity based on container presence so idle guards run for stacks too
78- if ptr != 0 {75+ if widget != nil {
79 node.widgetValid = true76 node.widgetValid = true
80 } else {77 } else {
81 node.widgetValid = false78 node.widgetValid = false
82 }79 }
83 }80 }
84 81
85-// setTitleBar sets the titleBar widget pointer82+// setTitleBar sets the titleBar widget
86-func (wm *WorkspaceManager) setTitleBar(node *paneNode, ptr uintptr) {83+func (wm *WorkspaceManager) setTitleBar(node *paneNode, widget gtk.Widgetter) {
87- node.titleBar = ptr84+ node.titleBar = widget
88 }85 }
89 86
90-// setStackWrapper sets the stackWrapper widget pointer87+// setStackWrapper sets the stackWrapper widget
91-func (wm *WorkspaceManager) setStackWrapper(node *paneNode, ptr uintptr) {88+func (wm *WorkspaceManager) setStackWrapper(node *paneNode, widget gtk.Widgetter) {
92- node.stackWrapper = ptr89+ node.stackWrapper = widget
93 }90 }
94 91
95 // Centralized Active Pane Border Management System92 // Centralized Active Pane Border Management System
96 // Handles all pane types: regular, popup, stacked, split panes93 // Handles all pane types: regular, popup, stacked, split panes
97 94
98-// PaneBorderContext holds the context for applying borders to any pane type95+// borderTargets returns the widgets that should reflect the pane border state.
99-type PaneBorderContext struct {96+func (wm *WorkspaceManager) borderTargets(node *paneNode) []gtk.Widgetter {
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 {
108 if node == nil {97 if node == nil {
109 return nil98 return nil
110 }99 }
111 100
112- ctx := &PaneBorderContext{}101+ targets := make([]gtk.Widgetter, 0, 4)
113-102+ seen := make(map[*gtk.Widget]struct{})
114- // Step 1: Get WebView widget for margin (same for all pane types)103+ add := func(widget gtk.Widgetter) {
115- if node.pane != nil && node.pane.webView != nil {104+ if widget == nil {
116- ctx.webViewWidget = node.pane.webView.RootWidget()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)
117 }114 }
118 115
119- // Step 2: Determine the border container based on pane type116+ // Primary container for this node.
120- switch {117+ add(node.container)
121- case node.windowType == webkit.WindowTypePopup:118+ add(node.stackWrapper)
122- // Popup panes: Border on popup container
123- ctx.borderContainer = node.container
124- ctx.cssClasses = []string{activePaneClass}
125- ctx.paneType = "popup"
126 119
127- case node.parent != nil && node.parent.isStacked:120+ // Highlight stacks via their outer container as well.
128- // Pane in stack: Border on stack wrapper container121+ if node.parent != nil && node.parent.isStacked {
129- ctx.borderContainer = node.parent.stackWrapper122+ add(node.parent.container)
130- ctx.cssClasses = []string{activePaneClass}123+ add(node.parent.stackWrapper)
131- ctx.paneType = "stacked"124+ }
132 125
133- default:126+ // Fallback to the WebView root widget to cover any edge cases where the
134- // Regular pane: Border on pane container127+ // container reference is temporarily nil.
135- ctx.borderContainer = node.container128+ if node.pane != nil && node.pane.webView != nil {
136- ctx.cssClasses = []string{activePaneClass}129+ add(node.pane.webView.RootWidget())
137- ctx.paneType = "regular"
138 }130 }
139 131
140- return ctx132+ if len(targets) == 0 {
133+ return nil
134+ }
135+ return targets
141 }136 }
142 137
143-// applyActivePaneBorder applies the active pane border using the border context138+// applyActivePaneBorder marks the target widgets as active for CSS styling.
144-func (wm *WorkspaceManager) applyActivePaneBorder(ctx *PaneBorderContext) {139+func (wm *WorkspaceManager) applyActivePaneBorder(node *paneNode) {
145- if ctx == nil {140+ if wm == nil || node == nil {
146- log.Printf("[workspace] applyActivePaneBorder: nil context")
147 return141 return
148 }142 }
149 143
150- // Skip applying border if there's only one pane144+ // Only show active border if there are 2+ panes
151- if !wm.hasMultiplePanes() {145+ leaves := wm.collectLeaves()
152- log.Printf("[workspace] Skipping active border: only one pane exists")146+ if len(leaves) < 2 {
153 return147 return
154 }148 }
155 149
156- // Apply margin to WebView widget (creates space for border)150+ targets := wm.borderTargets(node)
157- if ctx.webViewWidget != 0 && webkit.WidgetIsValid(ctx.webViewWidget) {151+ for _, widget := range targets {
158- webkit.WidgetSetMargin(ctx.webViewWidget, 2)152+ if widget == nil {
159- }153+ continue
160-154+ }
161- // Apply CSS classes to border container155+ // Only add CSS class if it doesn't already exist to prevent bloom filter errors
162- if ctx.borderContainer != 0 && webkit.WidgetIsValid(ctx.borderContainer) {156+ if !webkit.WidgetHasCSSClass(widget, activePaneClass) {
163- for _, class := range ctx.cssClasses {157+ webkit.WidgetAddCSSClass(widget, activePaneClass)
164- webkit.WidgetAddCSSClass(ctx.borderContainer, class)158+ log.Printf("[workspace] Applied active border to pane %p (widget=%p)", node, widget)
165 }159 }
166 }160 }
167-
168- log.Printf("[workspace] Applied active border: type=%s webView=%#x container=%#x",
169- ctx.paneType, ctx.webViewWidget, ctx.borderContainer)
170 }161 }
171 162
172-// removeActivePaneBorder removes the active pane border from a node163+// removeActivePaneBorder clears the active styling from a pane.
173 func (wm *WorkspaceManager) removeActivePaneBorder(node *paneNode) {164 func (wm *WorkspaceManager) removeActivePaneBorder(node *paneNode) {
174- if node == nil {165+ if wm == nil || node == nil {
175- return
176- }
177-
178- ctx := wm.determineBorderContext(node)
179- if ctx == nil {
180 return166 return
181 }167 }
182 168
183- // Remove margin from WebView widget169+ targets := wm.borderTargets(node)
184- if ctx.webViewWidget != 0 && webkit.WidgetIsValid(ctx.webViewWidget) {170+ for _, widget := range targets {
185- webkit.WidgetSetMargin(ctx.webViewWidget, 0)171+ if widget == nil {
186- }172+ continue
187-173+ }
188- // Remove CSS classes from border container174+ // Only remove CSS class if it exists to prevent bloom filter errors
189- if ctx.borderContainer != 0 && webkit.WidgetIsValid(ctx.borderContainer) {175+ if webkit.WidgetHasCSSClass(widget, activePaneClass) {
190- for _, class := range ctx.cssClasses {176+ webkit.WidgetRemoveCSSClass(widget, activePaneClass)
191- webkit.WidgetRemoveCSSClass(ctx.borderContainer, class)177+ log.Printf("[workspace] Removed active border from pane %p (widget=%p)", node, widget)
192 }178 }
193 }179 }
194-
195- log.Printf("[workspace] Removed active border: type=%s webView=%#x container=%#x",
196- ctx.paneType, ctx.webViewWidget, ctx.borderContainer)
197 }180 }
198 181
199 // Widget cleanup utilities182 // Widget cleanup utilities
200 183
201 // cleanupWidget safely invalidates and cleans up a widget184 // cleanupWidget safely invalidates and cleans up a widget
202-func (wm *WorkspaceManager) cleanupWidget(ptr uintptr) {185+func (wm *WorkspaceManager) cleanupWidget(widget gtk.Widgetter) {
203- if ptr == 0 {186+ if widget == nil {
204 return187 return
205 }188 }
206 189
@@ -216,7 +199,7 @@ func (wm *WorkspaceManager) scheduleIdleGuarded(fn func() bool, nodes ...*paneNo
216 }199 }
217 guards := uniquePaneNodes(nodes...)200 guards := uniquePaneNodes(nodes...)
218 var handle uintptr201 var handle uintptr
219- handle = webkit.IdleAdd(func() bool {202+ handle = uintptr(glib.IdleAdd(func() bool {
220 defer wm.releaseIdleHandle(handle)203 defer wm.releaseIdleHandle(handle)
221 for _, node := range guards {204 for _, node := range guards {
222 if node == nil {205 if node == nil {
@@ -227,7 +210,7 @@ func (wm *WorkspaceManager) scheduleIdleGuarded(fn func() bool, nodes ...*paneNo
227 }210 }
228 }211 }
229 return fn()212 return fn()
230- })213+ }))
231 if handle != 0 {214 if handle != 0 {
232 wm.registerIdleHandle(handle, guards...)215 wm.registerIdleHandle(handle, guards...)
233 }216 }
@@ -301,7 +284,7 @@ func (wm *WorkspaceManager) cancelIdleHandle(handle uintptr) {
301 return284 return
302 }285 }
303 wm.releaseIdleHandle(handle)286 wm.releaseIdleHandle(handle)
304- webkit.IdleRemove(handle)287+ glib.SourceRemove(glib.SourceHandle(handle))
305 }288 }
306 289
307 func (wm *WorkspaceManager) cancelIdleHandles(node *paneNode) {290 func (wm *WorkspaceManager) cancelIdleHandles(node *paneNode) {
@@ -328,19 +311,19 @@ func formatPaneInfo(node *paneNode) string {
328 311
329 var info string312 var info string
330 if node.isLeaf {313 if node.isLeaf {
331- info = fmt.Sprintf("leaf(container=%#x", node.container)314+ info = fmt.Sprintf("leaf(container=%p", node.container)
332 if node.pane != nil && node.pane.webView != nil {315 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())
334 }317 }
335 if node.windowType == webkit.WindowTypePopup {318 if node.windowType == webkit.WindowTypePopup {
336 info += " popup"319 info += " popup"
337 }320 }
338 info += ")"321 info += ")"
339 } else if node.isStacked {322 } 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)",
341 len(node.stackedPanes), node.activeStackIndex, node.container)324 len(node.stackedPanes), node.activeStackIndex, node.container)
342 } else {325 } else {
343- info = fmt.Sprintf("branch(container=%#x orientation=%v)",326+ info = fmt.Sprintf("branch(container=%p orientation=%v)",
344 node.container, node.orientation)327 node.container, node.orientation)
345 }328 }
346 329
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 {
53 Payload json.RawMessage `json:"payload"`53 Payload json.RawMessage `json:"payload"`
54 }54 }
55 55
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-
76 // NewHandler creates a new message handler56 // NewHandler creates a new message handler
77 func NewHandler(parserService *services.ParserService, browserService *services.BrowserService) *Handler {57 func NewHandler(parserService *services.ParserService, browserService *services.BrowserService) *Handler {
78 return &Handler{58 return &Handler{
@@ -123,8 +103,6 @@ func (h *Handler) Handle(payload string) {
123 h.handleHistoryDelete(msg)103 h.handleHistoryDelete(msg)
124 case "workspace":104 case "workspace":
125 h.handleWorkspace(msg)105 h.handleWorkspace(msg)
126- case "handle-window-open":
127- h.handleWindowOpen(msg)
128 case "close-popup":106 case "close-popup":
129 h.handleClosePopup(msg)107 h.handleClosePopup(msg)
130 case "console-message":108 case "console-message":
@@ -194,7 +172,7 @@ func (h *Handler) handleWorkspace(msg Message) {
194 log.Printf("[workspace] Ignoring workspace event %q: webview not attached", msg.Event)172 log.Printf("[workspace] Ignoring workspace event %q: webview not attached", msg.Event)
195 return173 return
196 }174 }
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)
198 h.workspaceObserver.OnWorkspaceMessage(h.webView, msg)176 h.workspaceObserver.OnWorkspaceMessage(h.webView, msg)
199 }177 }
200 178
@@ -530,33 +508,6 @@ func (h *Handler) handleHistoryDelete(msg Message) {
530 _ = h.webView.InjectScript("window.__dumber_history_deleted && window.__dumber_history_deleted(" + string(b) + ")")508 _ = h.webView.InjectScript("window.__dumber_history_deleted && window.__dumber_history_deleted(" + string(b) + ")")
531 }509 }
532 510
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-
560 // handleConsoleMessage processes console-message from JavaScript511 // handleConsoleMessage processes console-message from JavaScript
561 func (h *Handler) handleConsoleMessage(msg Message) {512 func (h *Handler) handleConsoleMessage(msg Message) {
562 // Check if console capture is enabled513 // Check if console capture is enabled
@@ -595,7 +546,7 @@ func (h *Handler) handleWebViewIDRequest(msg Message) {
595 }546 }
596 547
597 webViewID := h.webView.ID()548 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)
599 550
600 // Send the webview ID back to JavaScript via custom event551 // Send the webview ID back to JavaScript via custom event
601 if err := h.webView.DispatchCustomEvent("dumber:webview-id", map[string]any{552 if err := h.webView.DispatchCustomEvent("dumber:webview-id", map[string]any{
M internal/cache/favicon.go +27 -7
@@ -14,6 +14,7 @@ import (
14 "os"14 "os"
15 "path/filepath"15 "path/filepath"
16 "strings"16 "strings"
17+ "sync"
17 "time"18 "time"
18 19
19 "github.com/bnema/dumber/internal/config"20 "github.com/bnema/dumber/internal/config"
@@ -22,7 +23,8 @@ import (
22 23
23 // FaviconCache manages favicon caching to local files24 // FaviconCache manages favicon caching to local files
24 type FaviconCache struct {25 type FaviconCache struct {
25- cacheDir string26+ cacheDir string
27+ downloadMutex sync.Mutex // Prevents concurrent downloads of the same favicon
26 }28 }
27 29
28 // NewFaviconCache creates a new favicon cache30 // NewFaviconCache creates a new favicon cache
@@ -123,7 +125,13 @@ func (fc *FaviconCache) downloadFavicon(faviconURL string) error {
123 filename := fc.getFilename(faviconURL)125 filename := fc.getFilename(faviconURL)
124 cachedPath := filepath.Join(fc.cacheDir, filename)126 cachedPath := filepath.Join(fc.cacheDir, filename)
125 127
126- // Don't download if already exists and is recent128+ // 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
127 if info, err := os.Stat(cachedPath); err == nil {135 if info, err := os.Stat(cachedPath); err == nil {
128 if time.Since(info.ModTime()) < 7*24*time.Hour {136 if time.Since(info.ModTime()) < 7*24*time.Hour {
129 return nil137 return nil
@@ -175,8 +183,11 @@ func (fc *FaviconCache) downloadFavicon(faviconURL string) error {
175 }183 }
176 }()184 }()
177 defer func() {185 defer func() {
178- if err := os.Remove(tempDownloadFile); err != nil {186+ // Clean up temp download file if it still exists
179- log.Printf("[favicon] failed to remove temp download file: %v", err)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+ }
180 }191 }
181 }()192 }()
182 193
@@ -235,9 +246,14 @@ func (fc *FaviconCache) savePNG(img image.Image, outputPath string) error {
235 if err != nil {246 if err != nil {
236 return fmt.Errorf("failed to create temp file: %w", err)247 return fmt.Errorf("failed to create temp file: %w", err)
237 }248 }
249+
250+ // Track if we've already closed the file
251+ closed := false
238 defer func() {252 defer func() {
239- if err := file.Close(); err != nil {253+ if !closed {
240- log.Printf("[favicon] failed to close file: %v", err)254+ if err := file.Close(); err != nil {
255+ log.Printf("[favicon] failed to close file: %v", err)
256+ }
241 }257 }
242 }()258 }()
243 259
@@ -248,7 +264,11 @@ func (fc *FaviconCache) savePNG(img image.Image, outputPath string) error {
248 return fmt.Errorf("failed to encode PNG: %w", err)264 return fmt.Errorf("failed to encode PNG: %w", err)
249 }265 }
250 266
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
252 272
253 // Atomic move273 // Atomic move
254 if err := os.Rename(tempFile, outputPath); err != nil {274 if err := os.Rename(tempFile, outputPath); err != nil {
M internal/cli/root.go +4 -9
@@ -11,7 +11,6 @@ import (
11 "github.com/bnema/dumber/internal/cache"11 "github.com/bnema/dumber/internal/cache"
12 "github.com/bnema/dumber/internal/config"12 "github.com/bnema/dumber/internal/config"
13 "github.com/bnema/dumber/internal/db"13 "github.com/bnema/dumber/internal/db"
14- "github.com/bnema/dumber/internal/migrations"
15 "github.com/bnema/dumber/internal/services"14 "github.com/bnema/dumber/internal/services"
16 15
17 _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver for database/sql16 _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver for database/sql
@@ -193,16 +192,12 @@ func NewRootCmd(version, commit, buildDate string) *cobra.Command {
193 return rootCmd192 return rootCmd
194 }193 }
195 194
196-// initializeDatabase runs embedded migrations and ensures database is up to date195+// initializeDatabase ensures configured shortcuts are inserted
197-func initializeDatabase(db *sql.DB, cfg *config.Config) error {196+// Migrations are run automatically in db.InitDB()
198- // Run embedded migrations - this will create all tables and apply any new migrations197+func initializeDatabase(database *sql.DB, cfg *config.Config) error {
199- if err := migrations.RunEmbeddedMigrations(db); err != nil {
200- return fmt.Errorf("failed to run database migrations: %w", err)
201- }
202-
203 // Insert configured shortcuts (these are additive, won't override existing)198 // Insert configured shortcuts (these are additive, won't override existing)
204 for shortcut, shortcutCfg := range cfg.SearchShortcuts {199 for shortcut, shortcutCfg := range cfg.SearchShortcuts {
205- _, err := db.Exec(200+ _, err := database.Exec(
206 "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)",201 "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)",
207 shortcut, shortcutCfg.URL, shortcutCfg.Description,202 shortcut, shortcutCfg.URL, shortcutCfg.Description,
208 )203 )
M internal/config/config.go +33 -4
@@ -282,18 +282,42 @@ type TabKeyConfig struct {
282 PreviousTab string `mapstructure:"previous_tab" yaml:"previous_tab" json:"previous_tab"`282 PreviousTab string `mapstructure:"previous_tab" yaml:"previous_tab" json:"previous_tab"`
283 }283 }
284 284
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+
285 // PopupBehaviorConfig defines handling for popup windows.299 // PopupBehaviorConfig defines handling for popup windows.
286 type PopupBehaviorConfig struct {300 type PopupBehaviorConfig struct {
287- Placement string `mapstructure:"placement" yaml:"placement" json:"placement"`301+ // Behavior determines how popups are opened (split/stacked/tabbed/windowed)
288- OpenInNewPane bool `mapstructure:"open_in_new_pane" yaml:"open_in_new_pane" json:"open_in_new_pane"`302+ Behavior PopupBehavior `mapstructure:"behavior" yaml:"behavior" json:"behavior"`
289- FollowPaneContext bool `mapstructure:"follow_pane_context" yaml:"follow_pane_context" json:"follow_pane_context"`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"`
290 313
291- // New fields for distinguishing window types and behaviors
292 // BlankTargetBehavior determines how to handle window.open(url, "_blank") intents314 // BlankTargetBehavior determines how to handle window.open(url, "_blank") intents
293 // Accepted values: "pane" (default) or "tab" (future support)315 // Accepted values: "pane" (default) or "tab" (future support)
294 BlankTargetBehavior string `mapstructure:"blank_target_behavior" yaml:"blank_target_behavior" json:"blank_target_behavior"`316 BlankTargetBehavior string `mapstructure:"blank_target_behavior" yaml:"blank_target_behavior" json:"blank_target_behavior"`
317+
295 // EnableSmartDetection uses WebKitWindowProperties to detect popup vs tab intents318 // EnableSmartDetection uses WebKitWindowProperties to detect popup vs tab intents
296 EnableSmartDetection bool `mapstructure:"enable_smart_detection" yaml:"enable_smart_detection" json:"enable_smart_detection"`319 EnableSmartDetection bool `mapstructure:"enable_smart_detection" yaml:"enable_smart_detection" json:"enable_smart_detection"`
320+
297 // OAuthAutoClose enables auto-closing OAuth popups after successful auth redirects321 // OAuthAutoClose enables auto-closing OAuth popups after successful auth redirects
298 OAuthAutoClose bool `mapstructure:"oauth_auto_close" yaml:"oauth_auto_close" json:"oauth_auto_close"`322 OAuthAutoClose bool `mapstructure:"oauth_auto_close" yaml:"oauth_auto_close" json:"oauth_auto_close"`
299 }323 }
@@ -304,6 +328,9 @@ type WorkspaceStylingConfig struct {
304 BorderWidth int `mapstructure:"border_width" yaml:"border_width" json:"border_width"`328 BorderWidth int `mapstructure:"border_width" yaml:"border_width" json:"border_width"`
305 // BorderColor for focused panes (CSS color value or theme variable)329 // BorderColor for focused panes (CSS color value or theme variable)
306 BorderColor string `mapstructure:"border_color" yaml:"border_color" json:"border_color"`330 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"`
307 // TransitionDuration in milliseconds for border animations334 // TransitionDuration in milliseconds for border animations
308 TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" json:"transition_duration"`335 TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" json:"transition_duration"`
309 // BorderRadius in pixels for pane border corners336 // BorderRadius in pixels for pane border corners
@@ -733,6 +760,7 @@ func (m *Manager) setDefaults() {
733 m.viper.SetDefault("workspace.tabs.close_tab", defaults.Workspace.Tabs.CloseTab)760 m.viper.SetDefault("workspace.tabs.close_tab", defaults.Workspace.Tabs.CloseTab)
734 m.viper.SetDefault("workspace.tabs.next_tab", defaults.Workspace.Tabs.NextTab)761 m.viper.SetDefault("workspace.tabs.next_tab", defaults.Workspace.Tabs.NextTab)
735 m.viper.SetDefault("workspace.tabs.previous_tab", defaults.Workspace.Tabs.PreviousTab)762 m.viper.SetDefault("workspace.tabs.previous_tab", defaults.Workspace.Tabs.PreviousTab)
763+ m.viper.SetDefault("workspace.popups.behavior", string(defaults.Workspace.Popups.Behavior))
736 m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)764 m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement)
737 m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)765 m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane)
738 m.viper.SetDefault("workspace.popups.follow_pane_context", defaults.Workspace.Popups.FollowPaneContext)766 m.viper.SetDefault("workspace.popups.follow_pane_context", defaults.Workspace.Popups.FollowPaneContext)
@@ -742,6 +770,7 @@ func (m *Manager) setDefaults() {
742 m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose)770 m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose)
743 m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)771 m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth)
744 m.viper.SetDefault("workspace.styling.border_color", defaults.Workspace.Styling.BorderColor)772 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)
745 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)774 m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration)
746 m.viper.SetDefault("workspace.styling.border_radius", defaults.Workspace.Styling.BorderRadius)775 m.viper.SetDefault("workspace.styling.border_radius", defaults.Workspace.Styling.BorderRadius)
747 }776 }
M internal/config/defaults.go +11 -8
@@ -32,10 +32,11 @@ const (
32 defaultPopupPlacement = "right"32 defaultPopupPlacement = "right"
33 33
34 // Workspace styling defaults34 // Workspace styling defaults
35- defaultBorderWidth = 235+ defaultBorderWidth = 2
36- defaultBorderColor = "@theme_selected_bg_color"36+ defaultBorderColor = "@theme_selected_bg_color"
37- defaultTransitionDuration = 12037+ defaultPaneModeBorderColor = "#FFA500" // Orange for pane mode indicator
38- defaultBorderRadius = 038+ defaultTransitionDuration = 120
39+ defaultBorderRadius = 0
39 )40 )
40 41
41 // getDefaultLogDir returns the default log directory, falls back to empty string on error42 // getDefaultLogDir returns the default log directory, falls back to empty string on error
@@ -191,6 +192,7 @@ func DefaultConfig() *Config {
191 PreviousTab: "cmdorctrl+shift+tab",192 PreviousTab: "cmdorctrl+shift+tab",
192 },193 },
193 Popups: PopupBehaviorConfig{194 Popups: PopupBehaviorConfig{
195+ Behavior: PopupBehaviorSplit, // Default: open popups in split panes
194 Placement: defaultPopupPlacement,196 Placement: defaultPopupPlacement,
195 OpenInNewPane: true,197 OpenInNewPane: true,
196 FollowPaneContext: true,198 FollowPaneContext: true,
@@ -199,10 +201,11 @@ func DefaultConfig() *Config {
199 OAuthAutoClose: true, // Auto-close OAuth popups on success201 OAuthAutoClose: true, // Auto-close OAuth popups on success
200 },202 },
201 Styling: WorkspaceStylingConfig{203 Styling: WorkspaceStylingConfig{
202- BorderWidth: defaultBorderWidth,204+ BorderWidth: defaultBorderWidth,
203- BorderColor: defaultBorderColor,205+ BorderColor: defaultBorderColor,
204- TransitionDuration: defaultTransitionDuration,206+ PaneModeBorderColor: defaultPaneModeBorderColor,
205- BorderRadius: defaultBorderRadius,207+ TransitionDuration: defaultTransitionDuration,
208+ BorderRadius: defaultBorderRadius,
206 },209 },
207 },210 },
208 ContentFilteringWhitelist: []string{211 ContentFilteringWhitelist: []string{
M internal/db/certificate_validations.sql.go +24 -2
@@ -31,8 +31,8 @@ func (q *Queries) DeleteExpiredCertificateValidations(ctx context.Context) error
31 }31 }
32 32
33 const GetCertificateValidation = `-- name: GetCertificateValidation :one33 const GetCertificateValidation = `-- name: GetCertificateValidation :one
34-SELECT id, hostname, certificate_hash, user_decision, created_at, expires_at FROM certificate_validations 34+SELECT id, hostname, certificate_hash, user_decision, created_at, expires_at FROM certificate_validations
35-WHERE hostname = ? AND certificate_hash = ? 35+WHERE hostname = ? AND certificate_hash = ?
36 AND (expires_at IS NULL OR expires_at > datetime('now'))36 AND (expires_at IS NULL OR expires_at > datetime('now'))
37 LIMIT 137 LIMIT 1
38 `38 `
@@ -51,6 +51,28 @@ func (q *Queries) GetCertificateValidation(ctx context.Context, hostname string,
51 return i, err51 return i, err
52 }52 }
53 53
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+
54 const StoreCertificateValidation = `-- name: StoreCertificateValidation :exec76 const StoreCertificateValidation = `-- name: StoreCertificateValidation :exec
55 INSERT OR REPLACE INTO certificate_validations (hostname, certificate_hash, user_decision, expires_at)77 INSERT OR REPLACE INTO certificate_validations (hostname, certificate_hash, user_decision, expires_at)
56 VALUES (?, ?, ?, ?)78 VALUES (?, ?, ?, ?)
M internal/db/init.go +12 -43
@@ -8,6 +8,7 @@ import (
8 "path/filepath"8 "path/filepath"
9 9
10 "github.com/bnema/dumber/internal/config"10 "github.com/bnema/dumber/internal/config"
11+ "github.com/bnema/dumber/internal/migrations"
11 _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver12 _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver
12 _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite13 _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite
13 )14 )
@@ -37,12 +38,20 @@ func InitDB(dbPath string) (*sql.DB, error) {
37 return nil, fmt.Errorf("failed to connect to database: %w", err)38 return nil, fmt.Errorf("failed to connect to database: %w", err)
38 }39 }
39 40
40- // Initialize database schema41+ // Run embedded migrations - single source of truth for schema initialization
41- if err := initializeSchema(database); err != nil {42+ if err := migrations.RunEmbeddedMigrations(database); err != nil {
42 if err := database.Close(); err != nil {43 if err := database.Close(); err != nil {
43 log.Printf("Warning: failed to close database: %v", err)44 log.Printf("Warning: failed to close database: %v", err)
44 }45 }
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)
46 }55 }
47 56
48 return database, nil57 return database, nil
@@ -66,46 +75,6 @@ func InitDBWithConfig(dbPath string, cfg *config.Config) (*sql.DB, error) {
66 return db, nil75 return db, nil
67 }76 }
68 77
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-
109 // initializeShortcuts inserts configured shortcuts into the database78 // initializeShortcuts inserts configured shortcuts into the database
110 func initializeShortcuts(db *sql.DB, cfg *config.Config) error {79 func initializeShortcuts(db *sql.DB, cfg *config.Config) error {
111 if cfg == nil || cfg.SearchShortcuts == nil {80 if cfg == nil || cfg.SearchShortcuts == nil {
M internal/db/querier.go +1 -0
@@ -19,6 +19,7 @@ type Querier interface {
19 // Delete zoom level setting for a domain19 // Delete zoom level setting for a domain
20 DeleteZoomLevel(ctx context.Context, domain string) error20 DeleteZoomLevel(ctx context.Context, domain string) error
21 GetCertificateValidation(ctx context.Context, hostname string, certificateHash string) (CertificateValidation, error)21 GetCertificateValidation(ctx context.Context, hostname string, certificateHash string) (CertificateValidation, error)
22+ GetCertificateValidationByHostname(ctx context.Context, hostname string) (CertificateValidation, error)
22 GetHistory(ctx context.Context, limit int64) ([]History, error)23 GetHistory(ctx context.Context, limit int64) ([]History, error)
23 GetHistoryEntry(ctx context.Context, url string) (History, error)24 GetHistoryEntry(ctx context.Context, url string) (History, error)
24 GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error)25 GetHistoryWithOffset(ctx context.Context, limit int64, offset int64) ([]History, error)
M internal/db/queries/certificate_validations.sql +9 -2
@@ -1,9 +1,16 @@
1 -- name: GetCertificateValidation :one1 -- name: GetCertificateValidation :one
2-SELECT * FROM certificate_validations 2+SELECT * FROM certificate_validations
3-WHERE hostname = ? AND certificate_hash = ? 3+WHERE hostname = ? AND certificate_hash = ?
4 AND (expires_at IS NULL OR expires_at > datetime('now'))4 AND (expires_at IS NULL OR expires_at > datetime('now'))
5 LIMIT 1;5 LIMIT 1;
6 6
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+
7 -- name: StoreCertificateValidation :exec14 -- name: StoreCertificateValidation :exec
8 INSERT OR REPLACE INTO certificate_validations (hostname, certificate_hash, user_decision, expires_at)15 INSERT OR REPLACE INTO certificate_validations (hostname, certificate_hash, user_decision, expires_at)
9 VALUES (?, ?, ?, ?);16 VALUES (?, ?, ?, ?);
M internal/migrations/migrations.go +46 -0
@@ -78,6 +78,10 @@ func RunEmbeddedMigrations(db *sql.DB) error {
78 return fmt.Errorf("failed to create migrations table: %w", err)78 return fmt.Errorf("failed to create migrations table: %w", err)
79 }79 }
80 80
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+
81 migrations, err := GetMigrations()85 migrations, err := GetMigrations()
82 if err != nil {86 if err != nil {
83 return fmt.Errorf("failed to load migrations: %w", err)87 return fmt.Errorf("failed to load migrations: %w", err)
@@ -188,3 +192,45 @@ func GetAppliedMigrations(db *sql.DB) ([]int, error) {
188 192
189 return versions, nil193 return versions, nil
190 }194 }
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) {
93 s.webView = view93 s.webView = view
94 94
95 // Register legacy favicon change handler (binary data - fallback)95 // 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()
96 view.RegisterFaviconChangedHandler(func(data []byte) {98 view.RegisterFaviconChangedHandler(func(data []byte) {
97 if currentURL := view.GetCurrentURL(); currentURL != "" {99 if currentURL := view.GetCurrentURL(); currentURL != "" {
98 log.Printf("[favicon] Favicon detected for %s, data size: %d bytes", currentURL, len(data))100 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) {
100 }102 }
101 })103 })
102 104
103- // Register favicon URI change handler (WebKit native)105+ // Note: RegisterFaviconURIChangedHandler is now only called in buildPane()
104- view.RegisterFaviconURIChangedHandler(func(pageURI, faviconURI string) {106+ // to avoid duplicate handler registration which causes multiple downloads
105- s.handleFaviconURIChanged(pageURI, faviconURI)107+ // of the same favicon and file handle race conditions
106- })
107 }108 }
108 109
109 // LoadGUIBundle loads the unified GUI bundle from assets110 // LoadGUIBundle loads the unified GUI bundle from assets
@@ -124,28 +125,6 @@ func (s *BrowserService) GetGUIBundle() string {
124 return s.guiBundle125 return s.guiBundle
125 }126 }
126 127
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-
149 // InjectToastSystem injects the GUI bundle and initializes the toast system128 // InjectToastSystem injects the GUI bundle and initializes the toast system
150 func (s *BrowserService) InjectToastSystem(ctx context.Context, theme string) error {129 func (s *BrowserService) InjectToastSystem(ctx context.Context, theme string) error {
151 _ = ctx130 _ = ctx
@@ -628,7 +607,7 @@ func (s *BrowserService) handleFaviconChanged(pageURL string, pngData []byte) {
628 607
629 // Store a reference to the cached file in the database608 // Store a reference to the cached file in the database
630 // We use a consistent path format for the favicon URL609 // 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)))
632 faviconNullString := sql.NullString{String: faviconCacheURL, Valid: true}611 faviconNullString := sql.NullString{String: faviconCacheURL, Valid: true}
633 if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {612 if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil {
634 // Silently fail - favicon is not critical613 // 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.
2 package webkit1 package webkit
3 2
4 import "errors"3 import "errors"
5 4
6-// ErrNotImplemented is returned by stub implementations pending WebKit2GTK migration.5+var (
7-var ErrNotImplemented = errors.New("webkit: not implemented")6+ // ErrNotImplemented indicates a feature is not yet implemented
7+ ErrNotImplemented = errors.New("webkit: not implemented")
8 8
9-// ErrWebViewNotInitialized is returned when WebView operations are attempted on uninitialized WebView9+ // ErrWebViewNotInitialized indicates the WebView was not properly initialized
10-var ErrWebViewNotInitialized = errors.New("webkit: webview not initialized")10+ ErrWebViewNotInitialized = errors.New("webkit: webview not initialized")
11 11
12-// ErrContentManagerNotFound is returned when WebKit user content manager cannot be retrieved12+ // ErrWebViewDestroyed indicates the WebView has been destroyed
13-var ErrContentManagerNotFound = errors.New("webkit: user content manager not found")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-
3 package webkit1 package webkit
4 2
5 import (3 import (
4+ "encoding/json"
6 "fmt"5 "fmt"
7 "log"6 "log"
8 "strconv"7 "strconv"
8+ "sync"
9 "sync/atomic"9 "sync/atomic"
10- "unsafe"10+
11+ webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6"
12+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
11 )13 )
12 14
13-// Package-level ID counter for stub WebViews15+var (
14-var stubViewIDCounter uint6416+ viewIDCounter uint64
17+ viewRegistry = make(map[uint64]*WebView)
18+ viewMu sync.RWMutex
19+)
15 20
16-// nextStubViewID generates a unique ID for stub WebViews21+// WebView wraps a WebKitGTK WebView
17-func nextStubViewID() uintptr {22+type WebView struct {
18- return uintptr(atomic.AddUint64(&stubViewIDCounter, 1))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
19 }48 }
20 49
21-// WebView represents a browser view powered by WebKit2GTK.50+// NewWebView creates a new WebView with the given configuration
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.
51 func NewWebView(cfg *Config) (*WebView, error) {51 func NewWebView(cfg *Config) (*WebView, error) {
52 if cfg == nil {52 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
54 }77 }
55- log.Printf("[webkit] NewWebView (non-CGO stub) — UI will not be displayed. Build with -tags=webkit_cgo for native window.")78+
56- wv := &WebView{config: cfg, useDomZoom: cfg.UseDomZoom}79+ // Verify the WebView is using the persistent session
57- if cfg.ZoomDefault <= 0 {80+ viewSession := wkView.NetworkSession()
58- wv.zoom = 1.081+ 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)
59 } else {148 } else {
60- wv.zoom = cfg.ZoomDefault149+ log.Printf("[webkit] Created WebView ID %d without window (for workspace embedding)", id)
61 }150 }
62- wv.domZoomSeed = wv.zoom151+
63- wv.id = nextStubViewID() // Assign unique ID152+ // Register in global registry
64- // Construction succeeds; create a logical window placeholder.153+ viewMu.Lock()
65- wv.window = &Window{Title: "Dumber Browser"}154+ viewRegistry[id] = wv
66- wv.container = newWidgetHandle()155+ viewMu.Unlock()
156+
67 return wv, nil157 return wv, nil
68 }158 }
69 159
70-// NewWebViewWithRelated creates a new WebView related to an existing one (non-CGO stub)160+// applyConfig applies the configuration to the WebView settings
71-func NewWebViewWithRelated(cfg *Config, relatedView *WebView) (*WebView, error) {161+func (w *WebView) applyConfig() error {
72- // In non-CGO build, just create a regular WebView162+ settings := w.view.Settings()
73- return NewWebView(cfg)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()
74 }315 }
75 316
76-// LoadURL navigates the webview to the specified URL.317+// GoBack navigates back in history
77-func (w *WebView) LoadURL(rawURL string) error {318+func (w *WebView) GoBack() error {
78- if w == nil || w.destroyed {319+ w.mu.RLock()
79- return ErrNotImplemented320+ defer w.mu.RUnlock()
321+
322+ if w.destroyed {
323+ return ErrWebViewDestroyed
80 }324 }
81- if rawURL == "" {325+
82- return fmt.Errorf("url cannot be empty")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
83 }350 }
84- // Minimal validation; actual navigation handled by CGO bridge later.351+
85- w.url = rawURL352+ w.view.Reload()
86- log.Printf("[webkit] LoadURL (non-CGO stub): %s", rawURL)
87 return nil353 return nil
88 }354 }
89 355
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
91 func (w *WebView) Show() error {370 func (w *WebView) Show() error {
92- if w == nil || w.destroyed {371+ w.mu.RLock()
93- return ErrNotImplemented372+ 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()
94 }384 }
95- w.visible = true385+
96- log.Printf("[webkit] Show (non-CGO stub): no native window will appear")
97 return nil386 return nil
98 }387 }
99 388
100-// Hide hides the WebView window.389+// Hide makes the WebView invisible
101 func (w *WebView) Hide() error {390 func (w *WebView) Hide() error {
102- if w == nil || w.destroyed {391+ w.mu.RLock()
103- return ErrNotImplemented392+ defer w.mu.RUnlock()
393+
394+ if w.destroyed {
395+ return ErrWebViewDestroyed
104 }396 }
105- w.visible = false397+
398+ w.view.SetVisible(false)
106 return nil399 return nil
107 }400 }
108 401
109-// Destroy releases native resources.402+// Destroy destroys the WebView and releases resources
110 func (w *WebView) Destroy() error {403 func (w *WebView) Destroy() error {
111- if w == nil {404+ w.mu.Lock()
112- return ErrNotImplemented405+ defer w.mu.Unlock()
406+
407+ if w.destroyed {
408+ return nil
113 }409 }
410+
114 w.destroyed = true411 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
115 return nil419 return nil
116 }420 }
117 421
118-// IsDestroyed returns whether this WebView has been destroyed422+// IsDestroyed returns true if the WebView has been destroyed
119 func (w *WebView) IsDestroyed() bool {423 func (w *WebView) IsDestroyed() bool {
120- return w != nil && w.destroyed424+ 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
121 }456 }
122 457
123-// Window returns the associated native window wrapper (non-nil).458+// Event handler registration methods
124-func (w *WebView) Window() *Window { return w.window }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+}
125 466
126-// GetCurrentURL returns the last requested URL (non-CGO build approximation).467+// RegisterTitleChangedHandler registers a handler for title changes
127-func (w *WebView) GetCurrentURL() string { return w.url }468+func (w *WebView) RegisterTitleChangedHandler(handler func(string)) {
469+ w.mu.Lock()
470+ defer w.mu.Unlock()
471+ w.onTitleChanged = handler
472+}
128 473
129-// GetTitle returns a stub page title.474+// RegisterURIChangedHandler registers a handler for URI changes
130-func (w *WebView) GetTitle() string { return "New Tab" }475+func (w *WebView) RegisterURIChangedHandler(handler func(string)) {
476+ w.mu.Lock()
477+ defer w.mu.Unlock()
478+ w.onURIChanged = handler
479+}
131 480
132-// GoBack is not supported in the non-CGO stub.481+// RegisterFaviconChangedHandler registers a handler for favicon changes
133-func (w *WebView) GoBack() error { return ErrNotImplemented }482+func (w *WebView) RegisterFaviconChangedHandler(handler func([]byte)) {
483+ w.mu.Lock()
484+ defer w.mu.Unlock()
485+ w.onFaviconChanged = handler
486+}
134 487
135-// GoForward is not supported in the non-CGO stub.488+// RegisterFaviconURIChangedHandler registers a handler for favicon URI changes
136-func (w *WebView) GoForward() error { return ErrNotImplemented }489+func (w *WebView) RegisterFaviconURIChangedHandler(handler func(pageURI, faviconURI string)) {
490+ w.mu.Lock()
491+ defer w.mu.Unlock()
492+ w.onFaviconURIChanged = handler
493+}
137 494
138-// Reload is not supported in the non-CGO stub.495+// RegisterZoomChangedHandler registers a handler for zoom changes
139-func (w *WebView) Reload() error { return ErrNotImplemented }496+func (w *WebView) RegisterZoomChangedHandler(handler func(float64)) {
497+ w.mu.Lock()
498+ defer w.mu.Unlock()
499+ w.onZoomChanged = handler
500+}
140 501
141-// ReloadBypassCache is not supported in the non-CGO stub.502+// RegisterCloseHandler registers a handler for close requests
142-func (w *WebView) ReloadBypassCache() error { return ErrNotImplemented }503+func (w *WebView) RegisterCloseHandler(handler func()) {
504+ w.mu.Lock()
505+ defer w.mu.Unlock()
506+ w.onClose = handler
507+}
143 508
144-// ShowDevTools is a no-op in the non-CGO build.509+// RegisterPopupCreateHandler registers a handler for WebKit's create signal
145-func (w *WebView) ShowDevTools() error { return nil }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+}
146 518
147-// CloseDevTools is a no-op in the non-CGO build.519+// RegisterReadyToShowHandler registers a handler for WebKit's ready-to-show signal
148-func (w *WebView) CloseDevTools() error { return nil }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+}
149 527
150-// ShowPrintDialog is a no-op in the non-CGO build.528+// RegisterNavigationPolicyHandler registers a handler for navigation policy decisions
151-func (w *WebView) ShowPrintDialog() error { return nil }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+}
152 534
153-// RegisterScriptMessageHandler registers a callback invoked when the content script posts a message.535+// OnWindowTypeDetected registers a handler for window type detection
154-func (w *WebView) RegisterScriptMessageHandler(cb func(payload string)) { w.msgHandler = cb }536+func (w *WebView) OnWindowTypeDetected(handler func(WindowType, *WindowFeatures)) {
537+ w.mu.Lock()
538+ defer w.mu.Unlock()
539+ w.onWindowTypeDetected = handler
540+}
155 541
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+}
157 546
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+}
159 551
160-func (w *WebView) RegisterNavigationPolicyHandler(cb func(url string, isUserGesture bool) bool) {552+// Widget returns the WebView widget (for compatibility with old code expecting uintptr)
161- w.navigationPolicyHandler = cb553+// Deprecated: Use AsWidget() instead
554+func (w *WebView) Widget() gtk.Widgetter {
555+ return w.AsWidget()
162 }556 }
163 557
164-func (w *WebView) dispatchScriptMessage(payload string) { //nolint:unused // Called from CGO WebKit callbacks558+// RootWidget returns the root container widget for this WebView
165- if w != nil && w.msgHandler != nil {559+// This is the GtkBox container that holds the WebView and can be reparented
166- w.msgHandler(payload)560+func (w *WebView) RootWidget() gtk.Widgetter {
561+ if w.container != nil {
562+ return w.container
167 }563 }
564+ // Fallback to WebView if container is not available (shouldn't happen)
565+ return w.AsWidget()
168 }566 }
169 567
170-func (w *WebView) dispatchPopupRequest(uri string) *WebView { //nolint:unused // Called from CGO WebKit callbacks568+// SetZoom sets the zoom level of the WebView
171- if w != nil && w.popupHandler != nil {569+func (w *WebView) SetZoom(zoom float64) error {
172- return w.popupHandler(uri)570+ w.mu.RLock()
571+ defer w.mu.RUnlock()
572+
573+ if w.destroyed {
574+ return ErrWebViewDestroyed
173 }575 }
174- return nil
175-}
176 576
177-// GetNativePointer returns nil for non-CGO build (stub implementation)577+ w.view.SetZoomLevel(zoom)
178-func (w *WebView) GetNativePointer() unsafe.Pointer { //nolint:unused // Needed for interface compatibility
179 return nil578 return nil
180 }579 }
181 580
182-// RegisterTitleChangedHandler registers a callback invoked when the page title changes.581+// GetZoom returns the current zoom level
183-func (w *WebView) RegisterTitleChangedHandler(cb func(title string)) { w.titleHandler = cb }582+func (w *WebView) GetZoom() float64 {
583+ w.mu.RLock()
584+ defer w.mu.RUnlock()
184 585
185-func (w *WebView) dispatchTitleChanged(title string) { //nolint:unused // Called from CGO WebKit callbacks586+ if w.destroyed {
186- if w != nil && w.titleHandler != nil {587+ return 1.0
187- w.titleHandler(title)
188 }588 }
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
189 }603 }
190 604
191-// RegisterURIChangedHandler registers a callback invoked when the current page URI changes.605+// InjectScript executes JavaScript in the WebView
192-func (w *WebView) RegisterURIChangedHandler(cb func(uri string)) { w.uriHandler = cb }606+func (w *WebView) InjectScript(script string) error {
607+ w.mu.RLock()
608+ defer w.mu.RUnlock()
193 609
194-func (w *WebView) dispatchURIChanged(uri string) { //nolint:unused // Called from CGO WebKit callbacks610+ if w.destroyed {
195- if w != nil && w.uriHandler != nil {611+ return ErrWebViewDestroyed
196- w.uriHandler(uri)
197 }612 }
613+
614+ // Execute JavaScript using our CGO wrapper
615+ EvaluateJavascript(w.view, script)
616+ return nil
198 }617 }
199 618
200-// RegisterFaviconChangedHandler registers a callback invoked when the page favicon changes.619+// DispatchCustomEvent dispatches a custom event via JavaScript
201-func (w *WebView) RegisterFaviconChangedHandler(cb func(data []byte)) { w.faviconHandler = cb }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+ }
202 626
203-// RegisterFaviconURIChangedHandler registers a callback invoked when WebKit detects a favicon URI.627+ log.Printf("[webkit] Dispatching event '%s' to WebView ID %d with data: %s", eventName, w.id, string(jsonData))
204-func (w *WebView) RegisterFaviconURIChangedHandler(cb func(pageURI, faviconURI string)) {628+
205- w.faviconURIHandler = cb629+ // 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)
206 }643 }
207 644
208-func (w *WebView) dispatchFaviconChanged(data []byte) { //nolint:unused // Called from CGO WebKit callbacks645+// ShowDevTools opens the WebKit inspector/developer tools
209- if w != nil && w.faviconHandler != nil {646+func (w *WebView) ShowDevTools() error {
210- w.faviconHandler(data)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()
211 }657 }
658+ return nil
212 }659 }
213 660
214-// RegisterZoomChangedHandler registers a callback invoked when zoom level changes.661+// ShowPrintDialog shows the print dialog for the current page
215-func (w *WebView) RegisterZoomChangedHandler(cb func(level float64)) { w.zoomHandler = cb }662+func (w *WebView) ShowPrintDialog() error {
663+ w.mu.RLock()
664+ defer w.mu.RUnlock()
216 665
217-func (w *WebView) dispatchZoomChanged(level float64) {666+ if w.destroyed {
218- if w != nil && w.zoomHandler != nil {667+ return ErrWebViewDestroyed
219- w.zoomHandler(level)
220 }668 }
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
221 }682 }
222 683
223-// Widget returns an empty handle in stub builds.684+// SetWindowFeatures sets window features for popup windows
224-func (w *WebView) Widget() uintptr { return 0 }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+}
225 689
226-// RootWidget returns the container widget handle in CGO builds. Stub returns 0.690+// IsActive returns whether this WebView is currently active/focused
227-func (w *WebView) RootWidget() uintptr { return w.container }691+func (w *WebView) IsActive() bool {
692+ w.mu.RLock()
693+ defer w.mu.RUnlock()
228 694
229-// DestroyWindow is a no-op in stub builds.695+ if w.destroyed {
230-func (w *WebView) DestroyWindow() {}696+ return false
697+ }
231 698
232-// RunOnMainThread executes fn immediately in non-CGO builds.699+ widget := w.view
233-func (w *WebView) RunOnMainThread(fn func()) {700+ if widget != nil {
234- if fn != nil {701+ return widget.IsFocus()
235- fn()
236 }702 }
703+ return false
237 }704 }
238 705
239-// PrepareForReparenting is a no-op in stub builds.706+// Window returns the parent Window of this WebView
240-func (w *WebView) PrepareForReparenting() {}707+func (w *WebView) Window() *Window {
708+ w.mu.RLock()
709+ defer w.mu.RUnlock()
241 710
242-// RefreshAfterReparenting is a no-op in stub builds.711+ if w.destroyed {
243-func (w *WebView) RefreshAfterReparenting() {}712+ return nil
713+ }
244 714
245-// UsesDomZoom reports whether DOM-based zoom is enabled in this WebView.715+ return w.window
246-func (w *WebView) UsesDomZoom() bool {
247- return w != nil && w.useDomZoom
248 }716 }
249 717
250-// SeedDomZoom stores the desired DOM zoom level for the next navigation (stub implementation).718+// UpdateContentFilters updates the content filtering rules
251-func (w *WebView) SeedDomZoom(level float64) {719+func (w *WebView) UpdateContentFilters(rules string) error {
252- if w == nil || !w.useDomZoom {720+ w.mu.RLock()
253- return721+ defer w.mu.RUnlock()
254- }722+
255- if level < 0.25 {723+ if w.destroyed {
256- level = 0.25724+ return ErrWebViewDestroyed
257- } else if level > 5.0 {
258- level = 5.0
259 }725 }
260- w.domZoomSeed = level
261-}
262 726
263-// CreateRelatedView returns a new WebView (stub creates independent)727+ // Note: Content filtering is handled via InitializeContentBlocking()
264-func (w *WebView) CreateRelatedView() *WebView {728+ // This method is kept for backward compatibility but is deprecated
265- nw, _ := NewWebView(w.config)729+ log.Printf("[webkit] UpdateContentFilters called but is deprecated - use InitializeContentBlocking instead")
266- return nw730+ return nil
267 }731 }
268 732
269-// OnWindowTypeDetected registers a callback (stored but never invoked in stub)733+// InitializeContentBlocking initializes content blocking with filter lists
270-func (w *WebView) OnWindowTypeDetected(callback func(WindowType, *WindowFeatures)) {734+func (w *WebView) InitializeContentBlocking(filterJSON []byte) error {
271- if w != nil {735+ w.mu.RLock()
272- w.windowTypeCallback = callback736+ defer w.mu.RUnlock()
737+
738+ if w.destroyed {
739+ return ErrWebViewDestroyed
273 }740 }
274-}
275 741
276-// InitializeContentBlocking initializes WebKit content blocking with filter manager (stub)742+ if len(filterJSON) == 0 {
277-func (w *WebView) InitializeContentBlocking(filterManager interface{}) error {743+ return fmt.Errorf("no filter rules provided")
278- return ErrNotImplemented744+ }
745+
746+ // Apply filters using the content blocking API
747+ return ApplyFiltersToWebView(w, filterJSON)
279 }748 }
280 749
281-// OnNavigate sets up domain-specific cosmetic filtering on navigation (stub)750+// OnNavigate registers a navigation handler
282-func (w *WebView) OnNavigate(url string, filterManager interface{}, whitelist []string) {751+func (w *WebView) OnNavigate(handler func(url string)) {
283- // No-op in stub752+ w.mu.Lock()
753+ defer w.mu.Unlock()
754+
755+ // This wraps the URI changed handler
756+ w.onURIChanged = handler
284 }757 }
285 758
286-// UpdateContentFilters updates the content filters dynamically (stub)759+// RegisterWorkspaceNavigationHandler registers a handler for workspace pane navigation
287-func (w *WebView) UpdateContentFilters(filterManager interface{}) error {760+// The handler receives the direction ("up", "down", "left", "right") and returns true if handled
288- return ErrNotImplemented761+func (w *WebView) RegisterWorkspaceNavigationHandler(handler func(direction string) bool) {
762+ w.mu.Lock()
763+ defer w.mu.Unlock()
764+ w.onWorkspaceNavigation = handler
289 }765 }
290 766
291-// SetWindowFeatures sets the window features for this WebView (stub)767+// RegisterPaneModeHandler registers a handler for pane mode shortcuts
292-func (w *WebView) SetWindowFeatures(features *WindowFeatures) {768+// The handler receives the action ("enter", "close", "split-right", etc.) and returns true if handled
293- w.windowFeatures = features769+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
294 }774 }
295 775
296-// SetActive sets whether this WebView is currently active/focused (stub)776+// setupFaviconHandlers connects to the FaviconDatabase signals
297-func (w *WebView) SetActive(active bool) {777+func (w *WebView) setupFaviconHandlers() {
298- // No-op in stub778+ // 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)
299 }820 }
300 821
301-// IsActive returns whether this WebView is currently active/focused (stub)822+// GtkWebView returns the underlying gotk4 WebView for advanced operations
302-func (w *WebView) IsActive() bool {823+// This is used when creating related views (popups) that need to share the same session
303- return false824+func (w *WebView) GtkWebView() *webkit.WebView {
825+ w.mu.RLock()
826+ defer w.mu.RUnlock()
827+ return w.view
304 }828 }
305 829
306-// ID returns the unique identifier for this WebView as a string (stub)830+// WrapBareWebView creates a minimal WebView wrapper around a bare gotk4 WebView
307-func (w *WebView) ID() string {831+// This is used during popup creation to return a WebView to WebKit before initialization
308- if w == nil {832+// Full initialization (container, scripts, event handlers) should be done later
309- return ""833+func WrapBareWebView(bareView *webkit.WebView) *WebView {
834+ if bareView == nil {
835+ return nil
310 }836 }
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
312 }857 }
313 858
314-// PrefersDarkTheme returns true if GTK is configured to prefer dark theme (stub)859+// InitializeFromBare completes initialization of a bare WebView wrapper
315-func PrefersDarkTheme() bool {860+// This should be called in ready-to-show after WebKit has configured the view
316- return false // Fallback to light theme when not using CGO861+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
317 }914 }
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 large diff (2846 lines changed): collapsed
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-
3 package webkit1 package webkit
4 2
5-import "sync"3+import (
4+ "github.com/diamondburned/gotk4/pkg/gtk/v4"
5+)
6 6
7-// Window represents a top-level application window hosting a WebView.7+// Window wraps a GTK Window for the browser
8-// Stub for future GTK application window management.
9 type Window struct {8 type Window struct {
10- Title string9+ win *gtk.Window
11- child uintptr
12- mu sync.Mutex
13 }10 }
14 11
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
16 func NewWindow(title string) (*Window, error) {13 func NewWindow(title string) (*Window, error) {
17- _ = title14+ InitMainThread()
18- return &Window{Title: title}, ErrNotImplemented15+
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
19 }25 }
20 26
21-// SetTitle updates the window title (no-op in non-CGO build).27+// SetTitle updates the window title
22 func (w *Window) SetTitle(title string) {28 func (w *Window) SetTitle(title string) {
23- w.Title = title29+ if w == nil || w.win == nil {
30+ return
31+ }
32+ w.win.SetTitle(title)
24 }33 }
25 34
26-// SetChild is a stubbed window child setter for non-CGO builds.35+// SetChild sets the child widget of the window
27-// Simulates GTK4 behavior where setting child to 0 unparents the current child.36+func (w *Window) SetChild(child gtk.Widgetter) {
28-func (w *Window) SetChild(child uintptr) {37+ if w == nil || w.win == nil {
29- if w == nil {
30 return38 return
31 }39 }
40+ w.win.SetChild(child)
41+}
32 42
33- w.mu.Lock()43+// Show makes the window visible
34- defer w.mu.Unlock()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+}
35 51
36- // GTK4 behavior: Setting child to 0 removes and potentially invalidates the current child52+// Present brings the window to the front
37- if child == 0 && w.child != 0 {53+func (w *Window) Present() {
38- // GTK4 automatically unparents the current child but keeps the widget alive54+ if w == nil || w.win == nil {
39- widgetMu.Lock()55+ return
40- if stub, ok := widgetState[w.child]; ok {
41- stub.parent = 0
42- }
43- widgetMu.Unlock()
44 }56 }
57+ w.win.Present()
58+}
45 59
46- // GTK4 validation: if child is not 0, it must be a valid widget60+// Close destroys the window
47- if child != 0 {61+func (w *Window) Close() {
48- widgetMu.Lock()62+ if w == nil || w.win == nil {
49- childStub, childExists := widgetState[child]63+ return
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()
56 }64 }
65+ w.win.Close()
66+}
57 67
58- w.child = child68+// 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
59 }74 }
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