Merge pull request #3 from bnema/refacto/gotk4-migration
refactor: migrate to gotk4 library
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 | 5 | ## [Unreleased] | |
| 6 | 6 | ||
| 7 | 7 | ### Added | |
| 8 | + | - **Native favicon support**: WebKit FaviconDatabase integration with automatic detection and caching for all panes | |
| 8 | 9 | - ENV=dev support to isolate test builds from production config/data in .dev/dumber/ directory | |
| 9 | 10 | - Content filtering whitelist config (Twitch enabled by default) | |
| 10 | 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 | 22 | ### 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 | 34 | - Enabled missing WebKitGTK6 features: WebRTC, MediaSource, LocalStorage, WebAudio, MediaStream, Clipboard | |
| 14 | 35 | - Cosmetic filter duplicate injection in frames | |
| 15 | 36 | - Config file duplicate keys (snake_case/camelCase) by using Viper's SafeWriteConfigAs | |
| 16 | 37 | - Config not reading newly created default config file | |
| 17 | 38 | - 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 | 48 | ## [0.11.0] - 2025-10-07 | |
| 20 | 49 | ||
M Makefile +1 -0
| @@ -123,6 +123,7 @@ clean: ## Clean build artifacts | |||
| 123 | 123 | rm -rf $(DIST_DIR) | |
| 124 | 124 | rm -f $(BINARY_NAME) # Remove any old binaries in root | |
| 125 | 125 | 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 | 127 | go clean -cache | |
| 127 | 128 | 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 | 3 | go 1.25.1 | |
| 4 | 4 | ||
| 5 | 5 | require ( | |
| 6 | + | github.com/diamondburned/gotk4-webkitgtk/pkg v0.0.0-20240108031600-dee1973cf440 | |
| 7 | + | github.com/diamondburned/gotk4/pkg v0.3.1 | |
| 6 | 8 | github.com/fsnotify/fsnotify v1.9.0 | |
| 7 | 9 | github.com/golang/mock v1.6.0 | |
| 8 | 10 | github.com/ncruces/go-sqlite3 v0.29.0 | |
| @@ -12,6 +14,7 @@ require ( | |||
| 12 | 14 | ) | |
| 13 | 15 | ||
| 14 | 16 | require ( | |
| 17 | + | github.com/KarpelesLab/weak v0.1.1 // indirect | |
| 15 | 18 | github.com/go-viper/mapstructure/v2 v2.4.0 // indirect | |
| 16 | 19 | github.com/google/go-cmp v0.7.0 // indirect | |
| 17 | 20 | github.com/inconshreveable/mousetrap v1.1.0 // indirect | |
| @@ -27,7 +30,11 @@ require ( | |||
| 27 | 30 | github.com/subosito/gotenv v1.6.0 // indirect | |
| 28 | 31 | github.com/tetratelabs/wazero v1.9.0 // indirect | |
| 29 | 32 | 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 | 35 | golang.org/x/sys v0.36.0 // indirect | |
| 31 | 36 | golang.org/x/text v0.29.0 // indirect | |
| 32 | 37 | 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 | 110 | $effect(() => { | |
| 111 | 111 | console.log('🔄 ToastContainer initialized, functions exposed'); | |
| 112 | 112 | ||
| 113 | - | // Monitor container periodically for debugging | |
| 114 | - | const monitorInterval = setInterval(() => { | |
| 115 | - | const container = document.querySelector('.toast-container'); | |
| 116 | - | const browserRoot = document.querySelector('.browser-component-root'); | |
| 117 | - | console.log('[dumber] Periodic check - container:', !!container, 'browser-root:', !!browserRoot, 'toasts:', toasts.length); | |
| 118 | - | }, 5000); | |
| 113 | + | // Monitor container periodically for debugging (disabled) | |
| 114 | + | // const monitorInterval = setInterval(() => { | |
| 115 | + | // const container = document.querySelector('.toast-container'); | |
| 116 | + | // const browserRoot = document.querySelector('.browser-component-root'); | |
| 117 | + | // console.log('[dumber] Periodic check - container:', !!container, 'browser-root:', !!browserRoot, 'toasts:', toasts.length); | |
| 118 | + | // }, 5000); | |
| 119 | 119 | ||
| 120 | 120 | return () => { | |
| 121 | - | clearInterval(monitorInterval); | |
| 121 | + | // clearInterval(monitorInterval); | |
| 122 | 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 Point | |
| 2 | + | * Unified GUI bundle entry point. | |
| 3 | 3 | * | |
| 4 | - | * Single entry point for all WebKit GUI functionality. | |
| 5 | - | * Loads immediately and provides modular initialization system. | |
| 4 | + | * Delegates to the shared bootstrap so special pages can reuse the same setup | |
| 5 | + | * without depending on this injected build. | |
| 6 | + | * | |
| 7 | + | * Always calls bootstrapGUI() - deduplication is handled inside bootstrap.ts | |
| 8 | + | * via window.__dumber_gui_ready_for check. This ensures GUI loads in all | |
| 9 | + | * contexts including new split panes and about:blank pages. | |
| 6 | 10 | */ | |
| 7 | 11 | ||
| 8 | - | // Import all GUI modules | |
| 9 | - | import { initializeToast, type ToastConfig } from "./modules/toast"; | |
| 10 | - | import { initializeOmnibox, type OmniboxInitConfig } from "./modules/omnibox"; | |
| 11 | - | import { | |
| 12 | - | initializeWorkspace, | |
| 13 | - | type WorkspaceConfigPayload, | |
| 14 | - | type WorkspaceRuntime, | |
| 15 | - | } from "./modules/workspace"; | |
| 16 | - | import { keyboardService, type KeyboardService } from "$lib/keyboard"; | |
| 17 | - | import type { Suggestion } from "../components/omnibox/types"; | |
| 18 | - | // Note: color-scheme module is loaded separately at document-start by WebKit | |
| 19 | - | ||
| 20 | - | // Global interface for the unified GUI system | |
| 21 | - | interface DumberGUI { | |
| 22 | - | initializeToast: (config?: ToastConfig) => Promise<void>; | |
| 23 | - | initializeOmnibox: (config?: OmniboxInitConfig) => Promise<void>; | |
| 24 | - | keyboard: KeyboardService; | |
| 25 | - | isReady: boolean; | |
| 26 | - | } | |
| 27 | - | ||
| 28 | - | // (legacy suggestions normalization removed; omnibox now fetches suggestions directly via API) | |
| 29 | - | ||
| 30 | - | // DOM readiness utility - handles all document ready states | |
| 31 | - | function whenDOMReady(callback: () => void) { | |
| 32 | - | if (document.readyState === "loading") { | |
| 33 | - | document.addEventListener("DOMContentLoaded", callback, { once: true }); | |
| 34 | - | } else if (document.readyState === "interactive" && !document.body) { | |
| 35 | - | // Interactive but no body yet, wait a bit more | |
| 36 | - | setTimeout(() => whenDOMReady(callback), 10); | |
| 37 | - | } else { | |
| 38 | - | // DOM ready and body exists | |
| 39 | - | callback(); | |
| 40 | - | } | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | // Install a page-world bridge so Go can call window.__dumber_omnibox_suggestions() | |
| 44 | - | // Page-world bridge is injected by WebKit at document-start; no-op here to avoid duplicates. | |
| 45 | - | ||
| 46 | - | // Create global namespace | |
| 47 | - | declare global { | |
| 48 | - | interface Window { | |
| 49 | - | __dumber_gui?: DumberGUI; | |
| 50 | - | __dumber_gui_ready?: boolean; | |
| 51 | - | __dumber_showToast?: ( | |
| 52 | - | message: string, | |
| 53 | - | duration?: number, | |
| 54 | - | type?: "info" | "success" | "error", | |
| 55 | - | ) => number | void; | |
| 56 | - | __dumber_showZoomToast?: (level: number) => void; | |
| 57 | - | __dumber_keyboard?: KeyboardService; | |
| 58 | - | __dumber_workspace_config?: WorkspaceConfigPayload; | |
| 59 | - | __dumber_workspace?: WorkspaceRuntime; | |
| 60 | - | __dumber_omnibox?: { | |
| 61 | - | setSuggestions: (suggestions: Suggestion[]) => void; | |
| 62 | - | toggle: () => void; | |
| 63 | - | open: (mode?: string, query?: string) => void; | |
| 64 | - | close: () => void; | |
| 65 | - | findQuery: (query: string) => void; | |
| 66 | - | setActive: (active: boolean) => void; | |
| 67 | - | }; | |
| 68 | - | __dumber_gui_bootstrap?: () => void; | |
| 69 | - | // Legacy compatibility functions for Go bridge | |
| 70 | - | __dumber_toggle?: () => void; | |
| 71 | - | __dumber_find_open?: (query?: string) => void; | |
| 72 | - | __dumber_find_close?: () => void; | |
| 73 | - | __dumber_find_query?: (query: string) => void; | |
| 74 | - | } | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | // Prevent multiple initialization with enhanced logging | |
| 78 | - | if (!window.__dumber_gui_ready) { | |
| 79 | - | // Skip initialization in iframes - GUI only needed in main frame | |
| 80 | - | if (window.self !== window.top) { | |
| 81 | - | console.log("🚫 Dumber GUI skipped in iframe"); | |
| 82 | - | } else { | |
| 83 | - | window.__dumber_gui_ready = true; | |
| 84 | - | ||
| 85 | - | let workspaceHasFocus = true; // Assume focus initially, will be corrected by focus events | |
| 86 | - | let currentWebViewId = window.__dumber_webview_id || "unknown"; | |
| 87 | - | let hasReceivedFocusEvent = false; // Track if we've received any workspace focus events | |
| 88 | - | ||
| 89 | - | // Request webview ID from Go if it's unknown (needed for stub builds) | |
| 90 | - | if ( | |
| 91 | - | currentWebViewId === "unknown" || | |
| 92 | - | currentWebViewId === "__WEBVIEW_ID__" | |
| 93 | - | ) { | |
| 94 | - | console.log("[workspace] WebView ID unknown, requesting from Go backend"); | |
| 95 | - | if (window.webkit?.messageHandlers?.dumber) { | |
| 96 | - | window.webkit.messageHandlers.dumber.postMessage( | |
| 97 | - | JSON.stringify({ | |
| 98 | - | type: "request-webview-id", | |
| 99 | - | payload: { timestamp: Date.now() }, | |
| 100 | - | }), | |
| 101 | - | ); | |
| 102 | - | } | |
| 103 | - | } | |
| 104 | - | ||
| 105 | - | // Listen for webview ID from Go backend | |
| 106 | - | document.addEventListener("dumber:webview-id", (event: Event) => { | |
| 107 | - | const detail = (event as CustomEvent).detail || {}; | |
| 108 | - | if (detail.webviewId) { | |
| 109 | - | currentWebViewId = detail.webviewId; | |
| 110 | - | window.__dumber_webview_id = detail.webviewId; | |
| 111 | - | console.log( | |
| 112 | - | "[workspace] Received webview ID from Go:", | |
| 113 | - | detail.webviewId, | |
| 114 | - | ); | |
| 115 | - | } | |
| 116 | - | }); | |
| 117 | - | ||
| 118 | - | document.addEventListener("dumber:workspace-focus", (event: Event) => { | |
| 119 | - | const detail = (event as CustomEvent).detail || {}; | |
| 120 | - | console.log( | |
| 121 | - | "[workspace focus event]", | |
| 122 | - | detail, | |
| 123 | - | "prev focus=", | |
| 124 | - | workspaceHasFocus, | |
| 125 | - | ); | |
| 126 | - | ||
| 127 | - | hasReceivedFocusEvent = true; | |
| 128 | - | ||
| 129 | - | // Update focus state | |
| 130 | - | if (typeof detail?.active === "boolean") { | |
| 131 | - | workspaceHasFocus = detail.active; | |
| 132 | - | } else { | |
| 133 | - | workspaceHasFocus = detail !== false; | |
| 134 | - | } | |
| 135 | - | ||
| 136 | - | console.log( | |
| 137 | - | "[workspace focus updated] focus=", | |
| 138 | - | workspaceHasFocus, | |
| 139 | - | "webviewId=", | |
| 140 | - | currentWebViewId, | |
| 141 | - | ); | |
| 142 | - | ||
| 143 | - | // Handle GUI visibility based on focus | |
| 144 | - | if (window.__dumber_omnibox) { | |
| 145 | - | if (workspaceHasFocus) { | |
| 146 | - | window.__dumber_omnibox.setActive(true); | |
| 147 | - | } else { | |
| 148 | - | window.__dumber_omnibox.setActive(false); | |
| 149 | - | window.__dumber_omnibox.close(); | |
| 150 | - | } | |
| 151 | - | } | |
| 152 | - | }); | |
| 153 | - | ||
| 154 | - | // Initialize Svelte GUI systems immediately | |
| 155 | - | whenDOMReady(async () => { | |
| 156 | - | try { | |
| 157 | - | // Page-world bridge is injected by WebKit; avoid duplicate injection here | |
| 158 | - | // window.open interceptor is now in main-world.ts (page-world context) | |
| 159 | - | ||
| 160 | - | // Initialize toast system first | |
| 161 | - | await initializeToast(); | |
| 162 | - | ||
| 163 | - | // Verify toast functions are available | |
| 164 | - | if (typeof window.__dumber_showToast !== "function") { | |
| 165 | - | throw new Error("Toast functions not exposed after initialization"); | |
| 166 | - | } | |
| 167 | - | ||
| 168 | - | // Initialize omnibox system | |
| 169 | - | await initializeOmnibox(); | |
| 170 | - | ||
| 171 | - | // Initialize workspace controls (pane/tab scaffolding) | |
| 172 | - | initializeWorkspace(window.__dumber_workspace_config); | |
| 173 | - | ||
| 174 | - | console.log("✅ All GUI systems initialized successfully"); | |
| 175 | - | } catch (e) { | |
| 176 | - | console.error("❌ Failed to initialize Svelte toast system:", e); | |
| 177 | - | ||
| 178 | - | // Enhanced fallback with better error recovery | |
| 179 | - | window.__dumber_showToast = ( | |
| 180 | - | message: string, | |
| 181 | - | duration: number = 2500, | |
| 182 | - | ) => { | |
| 183 | - | // Remove existing toasts to prevent overlap | |
| 184 | - | document | |
| 185 | - | .querySelectorAll(".dumber-fallback-toast") | |
| 186 | - | .forEach((el) => el.remove()); | |
| 187 | - | ||
| 188 | - | const toast = document.createElement("div"); | |
| 189 | - | toast.className = "dumber-fallback-toast"; | |
| 190 | - | toast.textContent = message; | |
| 191 | - | toast.style.cssText = ` | |
| 192 | - | position: fixed; | |
| 193 | - | bottom: 20px; | |
| 194 | - | right: 20px; | |
| 195 | - | background: rgba(0,0,0,0.9); | |
| 196 | - | color: white; | |
| 197 | - | padding: 12px 16px; | |
| 198 | - | border-radius: 6px; | |
| 199 | - | z-index: 2147483647; | |
| 200 | - | font-family: system-ui, -apple-system, sans-serif; | |
| 201 | - | font-size: 14px; | |
| 202 | - | opacity: 0; | |
| 203 | - | transform: translateX(100%); | |
| 204 | - | transition: all 0.3s ease; | |
| 205 | - | max-width: 300px; | |
| 206 | - | word-wrap: break-word; | |
| 207 | - | `; | |
| 208 | - | ||
| 209 | - | if (document.body) { | |
| 210 | - | document.body.appendChild(toast); | |
| 211 | - | ||
| 212 | - | // Animate in | |
| 213 | - | requestAnimationFrame(() => { | |
| 214 | - | toast.style.opacity = "1"; | |
| 215 | - | toast.style.transform = "translateX(0)"; | |
| 216 | - | }); | |
| 217 | - | ||
| 218 | - | // Animate out and remove | |
| 219 | - | setTimeout(() => { | |
| 220 | - | toast.style.opacity = "0"; | |
| 221 | - | toast.style.transform = "translateX(100%)"; | |
| 222 | - | setTimeout(() => toast.remove(), 300); | |
| 223 | - | }, duration); | |
| 224 | - | } | |
| 225 | - | }; | |
| 226 | - | ||
| 227 | - | window.__dumber_showZoomToast = (level: number) => { | |
| 228 | - | const percentage = Math.round(level * 100); | |
| 229 | - | window.__dumber_showToast!(`Zoom: ${percentage}%`, 1500); | |
| 230 | - | }; | |
| 231 | - | ||
| 232 | - | // Expose other required functions | |
| 233 | - | window.__dumber_dismissToast = () => { | |
| 234 | - | document | |
| 235 | - | .querySelectorAll(".dumber-fallback-toast") | |
| 236 | - | .forEach((el) => el.remove()); | |
| 237 | - | }; | |
| 238 | - | ||
| 239 | - | window.__dumber_clearToasts = () => { | |
| 240 | - | document | |
| 241 | - | .querySelectorAll(".dumber-fallback-toast") | |
| 242 | - | .forEach((el) => el.remove()); | |
| 243 | - | }; | |
| 244 | - | } | |
| 245 | - | }); | |
| 246 | - | ||
| 247 | - | // Expose keyboard service globally for Go bridge | |
| 248 | - | window.__dumber_keyboard = keyboardService; | |
| 249 | - | ||
| 250 | - | // Initialize global keyboard and mouse event listeners for the keyboard service | |
| 251 | - | whenDOMReady(() => { | |
| 252 | - | // Global keyboard event listener (capture phase for high priority) | |
| 253 | - | document.addEventListener( | |
| 254 | - | "keydown", | |
| 255 | - | (event) => { | |
| 256 | - | keyboardService.handleKeyboardEvent(event); | |
| 257 | - | }, | |
| 258 | - | true, | |
| 259 | - | ); | |
| 260 | - | ||
| 261 | - | // Global mouse event listener for navigation buttons | |
| 262 | - | document.addEventListener( | |
| 263 | - | "mousedown", | |
| 264 | - | (event) => { | |
| 265 | - | keyboardService.handleMouseEvent(event); | |
| 266 | - | }, | |
| 267 | - | true, | |
| 268 | - | ); | |
| 269 | - | ||
| 270 | - | console.log("✅ KeyboardService initialized with global listeners"); | |
| 271 | - | ||
| 272 | - | // Listen to bridge keyboard events from page-world and forward to KeyboardService | |
| 273 | - | document.addEventListener("dumber:key", (e: Event) => { | |
| 274 | - | const detail = (e as CustomEvent).detail || {}; | |
| 275 | - | if (detail && typeof detail.shortcut === "string") { | |
| 276 | - | keyboardService.handleNativeShortcut(detail.shortcut); | |
| 277 | - | } | |
| 278 | - | }); | |
| 279 | - | ||
| 280 | - | document.addEventListener("dumber:ui:shortcut", (e: Event) => { | |
| 281 | - | const detail = (e as CustomEvent).detail || {}; | |
| 282 | - | const action = detail?.action; | |
| 283 | - | const eventWebViewId = detail?.webviewId; | |
| 284 | - | const source = detail?.source || "unknown"; | |
| 285 | - | ||
| 286 | - | if (typeof action !== "string") { | |
| 287 | - | return; | |
| 288 | - | } | |
| 289 | - | ||
| 290 | - | // Enhanced focus checking with webview ID validation | |
| 291 | - | const isForThisWebView = | |
| 292 | - | !eventWebViewId || eventWebViewId === currentWebViewId; | |
| 293 | - | // Only apply strict focus checking to omnibox actions, be permissive for others | |
| 294 | - | const isOmniboxAction = action.startsWith("omnibox-"); | |
| 295 | - | const shouldHandle = | |
| 296 | - | isForThisWebView && | |
| 297 | - | (isOmniboxAction | |
| 298 | - | ? hasReceivedFocusEvent | |
| 299 | - | ? workspaceHasFocus | |
| 300 | - | : true | |
| 301 | - | : true); | |
| 302 | - | ||
| 303 | - | if (!shouldHandle) { | |
| 304 | - | console.log("[dumber shortcuts] ignored action", { | |
| 305 | - | action, | |
| 306 | - | workspaceHasFocus, | |
| 307 | - | isForThisWebView, | |
| 308 | - | eventWebViewId, | |
| 309 | - | currentWebViewId, | |
| 310 | - | source, | |
| 311 | - | detail, | |
| 312 | - | hasReceivedFocusEvent, | |
| 313 | - | }); | |
| 314 | - | return; | |
| 315 | - | } | |
| 316 | - | ||
| 317 | - | console.log("[dumber shortcuts] handling action", { | |
| 318 | - | action, | |
| 319 | - | source, | |
| 320 | - | webviewId: currentWebViewId, | |
| 321 | - | }); | |
| 322 | - | ||
| 323 | - | const omnibox = window.__dumber_omnibox; | |
| 324 | - | ||
| 325 | - | switch (action) { | |
| 326 | - | case "omnibox-nav-toggle": | |
| 327 | - | omnibox?.open?.("omnibox", detail?.query); | |
| 328 | - | break; | |
| 329 | - | case "omnibox-open": | |
| 330 | - | omnibox?.open?.("omnibox", detail?.query); | |
| 331 | - | break; | |
| 332 | - | case "omnibox-find-toggle": | |
| 333 | - | omnibox?.open?.("find", detail?.query); | |
| 334 | - | break; | |
| 335 | - | case "omnibox-close": | |
| 336 | - | omnibox?.close?.(); | |
| 337 | - | break; | |
| 338 | - | default: | |
| 339 | - | console.warn("[dumber] Unknown UI shortcut action:", action); | |
| 340 | - | } | |
| 341 | - | }); | |
| 342 | - | ||
| 343 | - | // Suggestions are now fetched via API directly by the omnibox component; no page-bridge handlers needed | |
| 344 | - | }); | |
| 345 | - | ||
| 346 | - | // Legacy compatibility functions for existing Go code | |
| 347 | - | window.__dumber_toggle = () => { | |
| 348 | - | try { | |
| 349 | - | console.log("🎯 __dumber_toggle called"); | |
| 350 | - | console.log("🔧 Omnibox API available:", !!window.__dumber_omnibox); | |
| 351 | - | if (window.__dumber_omnibox?.toggle) { | |
| 352 | - | console.log("✅ Using Svelte omnibox toggle"); | |
| 353 | - | window.__dumber_omnibox.toggle(); | |
| 354 | - | return; | |
| 355 | - | } | |
| 356 | - | ||
| 357 | - | throw new Error("Omnibox toggle requested but API is unavailable"); | |
| 358 | - | } catch (error) { | |
| 359 | - | console.error("❌ Error in __dumber_toggle:", error); | |
| 360 | - | } | |
| 361 | - | }; | |
| 362 | - | ||
| 363 | - | window.__dumber_find_open = (query?: string) => { | |
| 364 | - | try { | |
| 365 | - | if (window.__dumber_omnibox?.open) { | |
| 366 | - | window.__dumber_omnibox.open("find", query); | |
| 367 | - | return; | |
| 368 | - | } | |
| 369 | - | ||
| 370 | - | throw new Error("Omnibox find requested but API is unavailable"); | |
| 371 | - | } catch (error) { | |
| 372 | - | console.error("❌ Error in __dumber_find_open:", error); | |
| 373 | - | } | |
| 374 | - | }; | |
| 375 | - | ||
| 376 | - | window.__dumber_find_close = () => { | |
| 377 | - | if (window.__dumber_omnibox?.close) { | |
| 378 | - | window.__dumber_omnibox.close(); | |
| 379 | - | return; | |
| 380 | - | } | |
| 381 | - | ||
| 382 | - | const error = new Error("Omnibox close requested but API is unavailable"); | |
| 383 | - | console.error("❌ Error in __dumber_find_close:", error); | |
| 384 | - | }; | |
| 385 | - | ||
| 386 | - | // Find query function that Go uses to set find query | |
| 387 | - | window.__dumber_find_query = (query: string) => { | |
| 388 | - | if (window.__dumber_omnibox?.findQuery) { | |
| 389 | - | window.__dumber_omnibox.findQuery(query); | |
| 390 | - | } | |
| 391 | - | }; | |
| 392 | - | ||
| 393 | - | // Create the global GUI object | |
| 394 | - | window.__dumber_gui = { | |
| 395 | - | initializeToast: async (config?: ToastConfig) => { | |
| 396 | - | return await initializeToast(config); | |
| 397 | - | }, | |
| 398 | - | ||
| 399 | - | initializeOmnibox: async (config?: OmniboxInitConfig) => { | |
| 400 | - | return await initializeOmnibox(config); | |
| 401 | - | }, | |
| 402 | - | ||
| 403 | - | keyboard: keyboardService, | |
| 404 | - | ||
| 405 | - | isReady: true, | |
| 406 | - | }; | |
| 407 | - | ||
| 408 | - | // Bootstrap function for lazy GUI initialization by workspace manager | |
| 409 | - | window.__dumber_gui_bootstrap = function () { | |
| 410 | - | console.log("[gui-bootstrap] Initializing GUI for workspace"); | |
| 411 | - | ||
| 412 | - | // Pane ID will be updated via focus events | |
| 413 | - | ||
| 414 | - | // Ensure omnibox is available for this pane | |
| 415 | - | if (!window.__dumber_omnibox) { | |
| 416 | - | console.log( | |
| 417 | - | `[gui-bootstrap] Omnibox not yet available for webview ${currentWebViewId}, will be loaded when needed`, | |
| 418 | - | ); | |
| 419 | - | } else { | |
| 420 | - | console.log( | |
| 421 | - | `[gui-bootstrap] Omnibox already available for webview ${currentWebViewId}`, | |
| 422 | - | ); | |
| 423 | - | // Ensure omnibox is aware of the current pane | |
| 424 | - | window.__dumber_omnibox.setActive(workspaceHasFocus); | |
| 425 | - | } | |
| 12 | + | import { bootstrapGUI } from "./bootstrap"; | |
| 426 | 13 | ||
| 427 | - | console.log( | |
| 428 | - | `[gui-bootstrap] GUI bootstrap complete for webview ${currentWebViewId}`, | |
| 429 | - | ); | |
| 430 | - | }; | |
| 431 | - | } | |
| 432 | - | } | |
| 14 | + | bootstrapGUI(); | |
M gui/src/injected/main-world.ts +10 -384
| @@ -14,7 +14,6 @@ declare global { | |||
| 14 | 14 | interface Window { | |
| 15 | 15 | __dumber: DumberAPI; | |
| 16 | 16 | __dumber_page_bridge_installed?: boolean; | |
| 17 | - | __dumber_window_open_intercepted?: boolean; | |
| 18 | 17 | __dumber_dom_zoom_seed?: number; | |
| 19 | 18 | __dumber_dom_zoom_level?: number; | |
| 20 | 19 | __dumber_initial_theme?: string; | |
| @@ -33,7 +32,7 @@ declare global { | |||
| 33 | 32 | ) => number | void; | |
| 34 | 33 | __dumber_showZoomToast?: (level: number) => void; | |
| 35 | 34 | __dumber_omnibox_suggestions?: (suggestions: Suggestion[]) => void; | |
| 36 | - | __dumber_webview_id?: string; | |
| 35 | + | __dumber_webview_id?: string | number; | |
| 37 | 36 | __dumber_is_active?: boolean; | |
| 38 | 37 | __dumber_teardown?: () => void; | |
| 39 | 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 | 48 | interface Suggestion { | |
| 60 | 49 | // Define proper suggestion types based on your needs | |
| 61 | 50 | [key: string]: unknown; | |
| 62 | 51 | } | |
| 63 | 52 | ||
| 64 | 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 | 54 | omnibox: { | |
| 74 | 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 | 60 | try { | |
| 110 | 61 | // Prevent multiple installations | |
| @@ -120,9 +71,14 @@ function detectWindowType(features?: string | null): string { | |||
| 120 | 71 | const initialZoom = 1.0; // __DOM_ZOOM_DEFAULT__ will be replaced by Go | |
| 121 | 72 | window.__dumber_dom_zoom_seed = initialZoom; | |
| 122 | 73 | ||
| 123 | - | // Initialize WebView ID and active state (will be replaced by Go) | |
| 124 | - | window.__dumber_webview_id = "__WEBVIEW_ID__"; | |
| 125 | - | window.__dumber_is_active = "__WEBVIEW_ACTIVE__" as unknown as boolean; | |
| 74 | + | // Initialize WebView ID and active state (injected by Go at document-start) | |
| 75 | + | // Only set fallback values if not already injected | |
| 76 | + | if (typeof window.__dumber_webview_id === 'undefined') { | |
| 77 | + | window.__dumber_webview_id = "__WEBVIEW_ID__"; | |
| 78 | + | } | |
| 79 | + | if (typeof window.__dumber_is_active === 'undefined') { | |
| 80 | + | window.__dumber_is_active = "__WEBVIEW_ACTIVE__" as unknown as boolean; | |
| 81 | + | } | |
| 126 | 82 | ||
| 127 | 83 | // Request color palettes from Go backend | |
| 128 | 84 | try { | |
| @@ -273,59 +229,9 @@ function detectWindowType(features?: string | null): string { | |||
| 273 | 229 | } | |
| 274 | 230 | }; | |
| 275 | 231 | ||
| 276 | - | // Initialize unified API object | |
| 232 | + | // Initialize unified API object (toast functions are exposed by ToastContainer.svelte) | |
| 277 | 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 | 235 | // DOM zoom functionality | |
| 330 | 236 | if (typeof window.__dumber_dom_zoom_level !== "number") { | |
| 331 | 237 | window.__dumber_dom_zoom_level = initialZoom; | |
| @@ -894,284 +800,6 @@ function detectWindowType(features?: string | null): string { | |||
| 894 | 800 | // Setup OAuth callback detection | |
| 895 | 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 | 803 | const teardown = () => { | |
| 1176 | 804 | if (teardownExecuted) { | |
| 1177 | 805 | return; | |
| @@ -1193,7 +821,6 @@ function detectWindowType(features?: string | null): string { | |||
| 1193 | 821 | omniboxQueue.length = 0; | |
| 1194 | 822 | omniboxReady = false; | |
| 1195 | 823 | ||
| 1196 | - | window.__dumber_window_open_intercepted = false; | |
| 1197 | 824 | window.__dumber_page_bridge_installed = false; | |
| 1198 | 825 | window.__dumber_teardown = undefined; | |
| 1199 | 826 | window.__dumber_omnibox_suggestions = undefined; | |
| @@ -1205,7 +832,6 @@ function detectWindowType(features?: string | null): string { | |||
| 1205 | 832 | if (globalState) { | |
| 1206 | 833 | const mutableState = globalState as unknown as Record<string, unknown>; | |
| 1207 | 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 | 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 | 100 | mount(ToastContainer, { | |
| 101 | 101 | target: rootElement as unknown as Element, | |
| 102 | 102 | }); | |
| 103 | 103 | ||
| 104 | - | // Signal isolated GUI readiness for page-world bridge | |
| 105 | - | try { | |
| 106 | - | document.documentElement.setAttribute("data-dumber-gui", "ready"); | |
| 107 | - | document.dispatchEvent(new CustomEvent("dumber:gui-ready")); | |
| 108 | - | } catch { | |
| 109 | - | // Ignore errors in GUI readiness signaling | |
| 110 | - | } | |
| 111 | - | ||
| 112 | - | // Listen for page-world bridge events and forward to internal toast functions | |
| 113 | - | const handleShowToast = (e: Event) => { | |
| 114 | - | const detail = (e as CustomEvent).detail || {}; | |
| 115 | - | if (typeof window.__dumber_showToast === "function") { | |
| 116 | - | window.__dumber_showToast(detail.message, detail.duration, detail.type); | |
| 117 | - | } | |
| 118 | - | }; | |
| 119 | - | document.addEventListener("dumber:showToast", handleShowToast); | |
| 120 | - | document.addEventListener("dumber:toast:show", handleShowToast); | |
| 121 | - | ||
| 122 | - | const handleZoomToast = (e: Event) => { | |
| 123 | - | const detail = (e as CustomEvent).detail || {}; | |
| 124 | - | if (typeof window.__dumber_showZoomToast === "function") { | |
| 125 | - | window.__dumber_showZoomToast(detail.level); | |
| 126 | - | } | |
| 127 | - | }; | |
| 128 | - | document.addEventListener("dumber:showZoomToast", handleZoomToast); | |
| 129 | - | document.addEventListener("dumber:toast:zoom", handleZoomToast); | |
| 130 | - | ||
| 131 | - | // Force immediate effect execution to ensure onMount callbacks run | |
| 104 | + | // Force immediate effect execution to ensure component functions are exposed | |
| 132 | 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 | 107 | toastInitialized = true; | |
| 138 | - | console.log("✅ Toast system initialized with Svelte 5"); | |
| 108 | + | console.log("✅ Toast system initialized - functions exposed by ToastContainer"); | |
| 139 | 109 | } catch (error) { | |
| 140 | 110 | console.error("❌ Failed to initialize toast system:", error); | |
| 141 | - | ||
| 142 | - | // Fallback to basic toast system if Svelte fails | |
| 143 | - | initializeFallbackToast(); | |
| 111 | + | throw error; | |
| 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 Module | |
| 2 | + | * Workspace UI Notifications Module | |
| 3 | 3 | * | |
| 4 | - | * Provides the initial scaffolding for Zellij-inspired pane management. | |
| 5 | - | * Currently tracks pane mode state, exposes configuration to the page, | |
| 6 | - | * and wires keyboard service shortcuts based on configuration defaults. | |
| 4 | + | * Pane mode is now handled entirely in the Go backend with visual border feedback. | |
| 5 | + | * This module is kept for potential future UI enhancements. | |
| 7 | 6 | */ | |
| 8 | 7 | ||
| 9 | - | import { keyboardService } from "$lib/keyboard"; | |
| 10 | - | ||
| 11 | - | const ACTIVATION_COMPONENT_ID = "workspace-pane-activation"; | |
| 12 | - | const ACTION_COMPONENT_ID = "workspace-pane-actions"; | |
| 13 | - | ||
| 14 | - | export interface WorkspaceConfigPayload { | |
| 15 | - | enable_zellij_controls?: boolean; | |
| 16 | - | pane_mode?: { | |
| 17 | - | activation_shortcut?: string; | |
| 18 | - | timeout_ms?: number; | |
| 19 | - | action_bindings?: Record<string, string>; | |
| 20 | - | }; | |
| 21 | - | tabs?: { | |
| 22 | - | new_tab?: string; | |
| 23 | - | close_tab?: string; | |
| 24 | - | next_tab?: string; | |
| 25 | - | previous_tab?: string; | |
| 26 | - | }; | |
| 27 | - | popups?: { | |
| 28 | - | placement?: string; | |
| 29 | - | open_in_new_pane?: boolean; | |
| 30 | - | follow_pane_context?: boolean; | |
| 31 | - | }; | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | export interface WorkspacePaneModeConfig { | |
| 35 | - | activationShortcut: string; | |
| 36 | - | timeoutMs: number; | |
| 37 | - | actionBindings: Record<string, string>; | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | export interface WorkspaceTabsConfig { | |
| 41 | - | newTab: string; | |
| 42 | - | closeTab: string; | |
| 43 | - | nextTab: string; | |
| 44 | - | previousTab: string; | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | export interface WorkspacePopupConfig { | |
| 48 | - | placement: string; | |
| 49 | - | openInNewPane: boolean; | |
| 50 | - | followPaneContext: boolean; | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | export interface WorkspaceConfigNormalized { | |
| 54 | - | enableZellijControls: boolean; | |
| 55 | - | paneMode: WorkspacePaneModeConfig; | |
| 56 | - | tabs: WorkspaceTabsConfig; | |
| 57 | - | popups: WorkspacePopupConfig; | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | export interface WorkspaceRuntime { | |
| 61 | - | enterPaneMode: () => void; | |
| 62 | - | exitPaneMode: (reason?: string) => void; | |
| 63 | - | isPaneModeActive: () => boolean; | |
| 64 | - | getConfig: () => WorkspaceConfigNormalized; | |
| 65 | - | getInstanceId: () => string; | |
| 66 | - | isActiveInstance: () => boolean; | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | const DEFAULT_CONFIG: WorkspaceConfigNormalized = { | |
| 70 | - | enableZellijControls: true, | |
| 71 | - | paneMode: { | |
| 72 | - | activationShortcut: "cmdorctrl+p", | |
| 73 | - | timeoutMs: 3000, | |
| 74 | - | actionBindings: { | |
| 75 | - | arrowright: "split-right", | |
| 76 | - | arrowleft: "split-left", | |
| 77 | - | arrowup: "split-up", | |
| 78 | - | arrowdown: "split-down", | |
| 79 | - | r: "split-right", | |
| 80 | - | l: "split-left", | |
| 81 | - | u: "split-up", | |
| 82 | - | d: "split-down", | |
| 83 | - | s: "stack-pane", | |
| 84 | - | x: "close-pane", | |
| 85 | - | enter: "confirm", | |
| 86 | - | escape: "cancel", | |
| 87 | - | }, | |
| 88 | - | }, | |
| 89 | - | tabs: { | |
| 90 | - | newTab: "cmdorctrl+t", | |
| 91 | - | closeTab: "cmdorctrl+w", | |
| 92 | - | nextTab: "cmdorctrl+tab", | |
| 93 | - | previousTab: "cmdorctrl+shift+tab", | |
| 94 | - | }, | |
| 95 | - | popups: { | |
| 96 | - | placement: "right", | |
| 97 | - | openInNewPane: true, | |
| 98 | - | followPaneContext: true, | |
| 99 | - | }, | |
| 100 | - | }; | |
| 101 | - | ||
| 102 | - | function normalizeWorkspaceConfig( | |
| 103 | - | payload?: WorkspaceConfigPayload | null, | |
| 104 | - | ): WorkspaceConfigNormalized { | |
| 105 | - | const paneModePayload = payload?.pane_mode ?? {}; | |
| 106 | - | const tabsPayload = payload?.tabs ?? {}; | |
| 107 | - | const popupsPayload = payload?.popups ?? {}; | |
| 108 | - | ||
| 109 | - | return { | |
| 110 | - | enableZellijControls: | |
| 111 | - | payload?.enable_zellij_controls ?? DEFAULT_CONFIG.enableZellijControls, | |
| 112 | - | paneMode: { | |
| 113 | - | activationShortcut: | |
| 114 | - | paneModePayload.activation_shortcut ?? | |
| 115 | - | DEFAULT_CONFIG.paneMode.activationShortcut, | |
| 116 | - | timeoutMs: | |
| 117 | - | paneModePayload.timeout_ms ?? DEFAULT_CONFIG.paneMode.timeoutMs, | |
| 118 | - | actionBindings: { | |
| 119 | - | ...DEFAULT_CONFIG.paneMode.actionBindings, | |
| 120 | - | ...(paneModePayload.action_bindings ?? {}), | |
| 121 | - | }, | |
| 122 | - | }, | |
| 123 | - | tabs: { | |
| 124 | - | newTab: tabsPayload.new_tab ?? DEFAULT_CONFIG.tabs.newTab, | |
| 125 | - | closeTab: tabsPayload.close_tab ?? DEFAULT_CONFIG.tabs.closeTab, | |
| 126 | - | nextTab: tabsPayload.next_tab ?? DEFAULT_CONFIG.tabs.nextTab, | |
| 127 | - | previousTab: tabsPayload.previous_tab ?? DEFAULT_CONFIG.tabs.previousTab, | |
| 128 | - | }, | |
| 129 | - | popups: { | |
| 130 | - | placement: popupsPayload.placement ?? DEFAULT_CONFIG.popups.placement, | |
| 131 | - | openInNewPane: | |
| 132 | - | popupsPayload.open_in_new_pane ?? DEFAULT_CONFIG.popups.openInNewPane, | |
| 133 | - | followPaneContext: | |
| 134 | - | popupsPayload.follow_pane_context ?? | |
| 135 | - | DEFAULT_CONFIG.popups.followPaneContext, | |
| 136 | - | }, | |
| 137 | - | }; | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | class WorkspaceController implements WorkspaceRuntime { | |
| 141 | - | private config: WorkspaceConfigNormalized = DEFAULT_CONFIG; | |
| 142 | - | private paneModeActive = false; | |
| 143 | - | private paneModeTimer: ReturnType<typeof setTimeout> | null = null; | |
| 144 | - | private instanceId: string; | |
| 145 | - | private lastActiveCheckTime = 0; | |
| 146 | - | ||
| 147 | - | constructor() { | |
| 148 | - | // Generate unique instance ID for this webview | |
| 149 | - | this.instanceId = `workspace-${Date.now()}-${Math.random().toString(36).substring(2)}`; | |
| 150 | - | console.log( | |
| 151 | - | `[workspace] Created WorkspaceController instance: ${this.instanceId}`, | |
| 152 | - | ); | |
| 153 | - | } | |
| 154 | - | ||
| 155 | - | getInstanceId(): string { | |
| 156 | - | return this.instanceId; | |
| 157 | - | } | |
| 158 | - | ||
| 159 | - | isActiveInstance(): boolean { | |
| 160 | - | // Cache active check for 50ms to avoid excessive checks | |
| 161 | - | const now = Date.now(); | |
| 162 | - | if (now - this.lastActiveCheckTime < 50) { | |
| 163 | - | return true; // Assume active within debounce window | |
| 164 | - | } | |
| 165 | - | this.lastActiveCheckTime = now; | |
| 166 | - | ||
| 167 | - | // Check if this webview has focus by attempting to query document.hasFocus() | |
| 168 | - | // In a multi-pane environment, only the focused webview should handle pane mode | |
| 169 | - | try { | |
| 170 | - | const hasFocus = document.hasFocus(); | |
| 171 | - | const isVisible = document.visibilityState === "visible"; | |
| 172 | - | const isActive = hasFocus && isVisible; | |
| 173 | - | ||
| 174 | - | if (!isActive) { | |
| 175 | - | console.log( | |
| 176 | - | `[workspace] Instance ${this.instanceId} not active: focus=${hasFocus} visible=${isVisible}`, | |
| 177 | - | ); | |
| 178 | - | } | |
| 179 | - | ||
| 180 | - | return isActive; | |
| 181 | - | } catch (error) { | |
| 182 | - | console.warn( | |
| 183 | - | "[workspace] Failed to check active state, assuming active:", | |
| 184 | - | error, | |
| 185 | - | ); | |
| 186 | - | return true; // Fallback to active if check fails | |
| 187 | - | } | |
| 188 | - | } | |
| 189 | - | ||
| 190 | - | configure(payload?: WorkspaceConfigPayload | null): void { | |
| 191 | - | const normalized = normalizeWorkspaceConfig(payload); | |
| 192 | - | this.unregisterShortcuts(); | |
| 193 | - | this.config = normalized; | |
| 194 | - | ||
| 195 | - | if (!normalized.enableZellijControls) { | |
| 196 | - | console.log("[workspace] Zellij controls disabled via config"); | |
| 197 | - | window.__dumber_workspace = this.exportRuntime(); | |
| 198 | - | return; | |
| 199 | - | } | |
| 200 | - | ||
| 201 | - | this.registerShortcuts(); | |
| 202 | - | window.__dumber_workspace = this.exportRuntime(); | |
| 203 | - | console.log( | |
| 204 | - | "[workspace] Zellij controls enabled with config:", | |
| 205 | - | this.config, | |
| 206 | - | ); | |
| 207 | - | } | |
| 208 | - | ||
| 209 | - | private bridge(message: Record<string, unknown>): void { | |
| 210 | - | const bridge = window.webkit?.messageHandlers?.dumber; | |
| 211 | - | if (!bridge || typeof bridge.postMessage !== "function") { | |
| 212 | - | return; | |
| 213 | - | } | |
| 214 | - | ||
| 215 | - | try { | |
| 216 | - | bridge.postMessage( | |
| 217 | - | JSON.stringify({ | |
| 218 | - | type: "workspace", | |
| 219 | - | ...message, | |
| 220 | - | }), | |
| 221 | - | ); | |
| 222 | - | } catch (error) { | |
| 223 | - | console.error("[workspace] Failed to post workspace message", error); | |
| 224 | - | } | |
| 225 | - | } | |
| 226 | - | ||
| 227 | - | enterPaneMode(): void { | |
| 228 | - | if (!this.config.enableZellijControls) return; | |
| 229 | - | ||
| 230 | - | // Only allow the active instance to enter pane mode | |
| 231 | - | if (!this.isActiveInstance()) { | |
| 232 | - | console.log( | |
| 233 | - | `[workspace] Instance ${this.instanceId} ignoring pane mode - not active instance`, | |
| 234 | - | ); | |
| 235 | - | return; | |
| 236 | - | } | |
| 237 | - | ||
| 238 | - | console.log(`[workspace] Instance ${this.instanceId} entering pane mode`); | |
| 239 | - | this.paneModeActive = true; | |
| 240 | - | this.restartPaneModeTimer(); | |
| 241 | - | this.emitWorkspaceEvent("pane-mode-entered", { | |
| 242 | - | action: "enter", | |
| 243 | - | config: this.config, | |
| 244 | - | instanceId: this.instanceId, | |
| 245 | - | }); | |
| 246 | - | this.bridge({ event: "pane-mode-entered", instanceId: this.instanceId }); | |
| 247 | - | this.showToast("Pane mode: awaiting direction"); | |
| 248 | - | } | |
| 249 | - | ||
| 250 | - | exitPaneMode(reason: string = "exit"): void { | |
| 251 | - | if (!this.paneModeActive) return; | |
| 252 | - | ||
| 253 | - | this.paneModeActive = false; | |
| 254 | - | if (this.paneModeTimer) { | |
| 255 | - | clearTimeout(this.paneModeTimer); | |
| 256 | - | this.paneModeTimer = null; | |
| 257 | - | } | |
| 258 | - | this.bridge({ event: "pane-mode-exited", reason }); | |
| 259 | - | } | |
| 260 | - | ||
| 261 | - | isPaneModeActive(): boolean { | |
| 262 | - | return this.paneModeActive; | |
| 263 | - | } | |
| 264 | - | ||
| 265 | - | getConfig(): WorkspaceConfigNormalized { | |
| 266 | - | return this.config; | |
| 267 | - | } | |
| 268 | - | ||
| 269 | - | private registerShortcuts(): void { | |
| 270 | - | keyboardService.registerShortcuts(ACTIVATION_COMPONENT_ID, [ | |
| 271 | - | { | |
| 272 | - | key: this.config.paneMode.activationShortcut, | |
| 273 | - | handler: () => this.enterPaneMode(), | |
| 274 | - | description: "Enter pane mode", | |
| 275 | - | preventDefault: true, | |
| 276 | - | stopPropagation: true, | |
| 277 | - | whenFocused: false, | |
| 278 | - | }, | |
| 279 | - | ]); | |
| 280 | - | ||
| 281 | - | const paneShortcuts = Object.entries( | |
| 282 | - | this.config.paneMode.actionBindings, | |
| 283 | - | ).map(([key, action]) => ({ | |
| 284 | - | key, | |
| 285 | - | handler: () => this.handlePaneAction(action, key), | |
| 286 | - | description: `Pane action: ${action}`, | |
| 287 | - | preventDefault: true, | |
| 288 | - | stopPropagation: true, | |
| 289 | - | whenFocused: false, | |
| 290 | - | })); | |
| 291 | - | ||
| 292 | - | keyboardService.registerShortcuts(ACTION_COMPONENT_ID, paneShortcuts); | |
| 293 | - | } | |
| 294 | - | ||
| 295 | - | private unregisterShortcuts(): void { | |
| 296 | - | keyboardService.unregisterShortcuts(ACTIVATION_COMPONENT_ID); | |
| 297 | - | keyboardService.unregisterShortcuts(ACTION_COMPONENT_ID); | |
| 298 | - | } | |
| 299 | - | ||
| 300 | - | private handlePaneAction(action: string, key?: string): void { | |
| 301 | - | if (!this.paneModeActive) { | |
| 302 | - | return; | |
| 303 | - | } | |
| 304 | - | ||
| 305 | - | switch (action) { | |
| 306 | - | case "split-right": | |
| 307 | - | this.emitWorkspaceEvent("pane-split", { | |
| 308 | - | direction: "right", | |
| 309 | - | config: this.config, | |
| 310 | - | }); | |
| 311 | - | this.bridge({ event: "pane-split", direction: "right", action: "split-right" }); | |
| 312 | - | this.showToast("Split pane to the right"); | |
| 313 | - | this.exitPaneMode("split-right"); | |
| 314 | - | break; | |
| 315 | - | case "split-left": | |
| 316 | - | this.emitWorkspaceEvent("pane-split", { | |
| 317 | - | direction: "left", | |
| 318 | - | config: this.config, | |
| 319 | - | }); | |
| 320 | - | this.bridge({ event: "pane-split", direction: "left", action: "split-left" }); | |
| 321 | - | this.showToast("Split pane to the left"); | |
| 322 | - | this.exitPaneMode("split-left"); | |
| 323 | - | break; | |
| 324 | - | case "split-up": | |
| 325 | - | this.emitWorkspaceEvent("pane-split", { | |
| 326 | - | direction: "up", | |
| 327 | - | config: this.config, | |
| 328 | - | }); | |
| 329 | - | this.bridge({ event: "pane-split", direction: "up", action: "split-up" }); | |
| 330 | - | this.showToast("Split pane upwards"); | |
| 331 | - | this.exitPaneMode("split-up"); | |
| 332 | - | break; | |
| 333 | - | case "split-down": | |
| 334 | - | this.emitWorkspaceEvent("pane-split", { | |
| 335 | - | direction: "down", | |
| 336 | - | config: this.config, | |
| 337 | - | }); | |
| 338 | - | this.bridge({ event: "pane-split", direction: "down", action: "split-down" }); | |
| 339 | - | this.showToast("Split pane downwards"); | |
| 340 | - | this.exitPaneMode("split-down"); | |
| 341 | - | break; | |
| 342 | - | case "stack-pane": | |
| 343 | - | this.bridge({ event: "pane-stack", action: "stack-pane" }); | |
| 344 | - | this.showToast("Stacked pane"); | |
| 345 | - | this.exitPaneMode("stack-pane"); | |
| 346 | - | break; | |
| 347 | - | case "close-pane": | |
| 348 | - | this.emitWorkspaceEvent("pane-closed", { key, config: this.config }); | |
| 349 | - | this.bridge({ event: "pane-close", action }); | |
| 350 | - | this.showToast("Closed pane"); | |
| 351 | - | this.exitPaneMode("close-pane"); | |
| 352 | - | break; | |
| 353 | - | case "confirm": | |
| 354 | - | this.bridge({ event: "pane-confirmed" }); | |
| 355 | - | this.showToast("Pane mode confirmed"); | |
| 356 | - | this.exitPaneMode("confirm"); | |
| 357 | - | break; | |
| 358 | - | case "cancel": | |
| 359 | - | this.bridge({ event: "pane-cancelled" }); | |
| 360 | - | this.showToast("Pane mode cancelled"); | |
| 361 | - | this.exitPaneMode("cancel"); | |
| 362 | - | break; | |
| 363 | - | default: | |
| 364 | - | console.warn("[workspace] Unhandled pane action:", action); | |
| 365 | - | } | |
| 366 | - | } | |
| 367 | - | ||
| 368 | - | private restartPaneModeTimer(): void { | |
| 369 | - | if (this.paneModeTimer) { | |
| 370 | - | clearTimeout(this.paneModeTimer); | |
| 371 | - | } | |
| 372 | - | ||
| 373 | - | if (this.config.paneMode.timeoutMs > 0) { | |
| 374 | - | this.paneModeTimer = setTimeout(() => { | |
| 375 | - | this.showToast("Pane mode timed out"); | |
| 376 | - | this.exitPaneMode("timeout"); | |
| 377 | - | }, this.config.paneMode.timeoutMs); | |
| 378 | - | } | |
| 379 | - | } | |
| 380 | - | ||
| 381 | - | private showToast(message: string): void { | |
| 382 | - | if (typeof window.__dumber_showToast === "function") { | |
| 383 | - | window.__dumber_showToast(message, 2000, "info"); | |
| 384 | - | } else { | |
| 385 | - | console.log("[workspace toast]", message); | |
| 386 | - | } | |
| 387 | - | } | |
| 388 | - | ||
| 389 | - | private emitWorkspaceEvent( | |
| 390 | - | event: string, | |
| 391 | - | detail: Record<string, unknown>, | |
| 392 | - | ): void { | |
| 393 | - | document.dispatchEvent( | |
| 394 | - | new CustomEvent(`dumber:workspace-${event}`, { detail }), | |
| 395 | - | ); | |
| 396 | - | } | |
| 397 | - | ||
| 398 | - | private exportRuntime(): WorkspaceRuntime { | |
| 399 | - | return { | |
| 400 | - | enterPaneMode: () => this.enterPaneMode(), | |
| 401 | - | exitPaneMode: (reason?: string) => | |
| 402 | - | this.exitPaneMode(reason ?? "external"), | |
| 403 | - | isPaneModeActive: () => this.isPaneModeActive(), | |
| 404 | - | getConfig: () => this.getConfig(), | |
| 405 | - | getInstanceId: () => this.getInstanceId(), | |
| 406 | - | isActiveInstance: () => this.isActiveInstance(), | |
| 407 | - | }; | |
| 408 | - | } | |
| 409 | - | } | |
| 410 | - | ||
| 411 | - | const workspaceController = new WorkspaceController(); | |
| 412 | - | let listenersAttached = false; | |
| 413 | - | ||
| 414 | - | export function initializeWorkspace( | |
| 415 | - | payload?: WorkspaceConfigPayload | null, | |
| 416 | - | ): void { | |
| 417 | - | if (!listenersAttached) { | |
| 418 | - | document.addEventListener("dumber:workspace-config", (event: Event) => { | |
| 419 | - | const detail = (event as CustomEvent<WorkspaceConfigPayload>).detail; | |
| 420 | - | workspaceController.configure(detail); | |
| 421 | - | }); | |
| 422 | - | listenersAttached = true; | |
| 423 | - | } | |
| 424 | - | ||
| 425 | - | const bootstrapPayload = payload ?? window.__dumber_workspace_config ?? null; | |
| 426 | - | workspaceController.configure(bootstrapPayload); | |
| 427 | - | } | |
| 428 | - | ||
| 429 | - | export { workspaceController }; | |
| 8 | + | console.log("[workspace] UI notification handlers initialized"); | |
M gui/src/pages/Homepage.svelte +13 -16
| @@ -549,10 +549,15 @@ | |||
| 549 | 549 | return; | |
| 550 | 550 | } | |
| 551 | 551 | ||
| 552 | - | if (mode === "dark") { | |
| 553 | - | document.documentElement.classList.add("dark"); | |
| 554 | - | } else { | |
| 555 | - | document.documentElement.classList.remove("dark"); | |
| 552 | + | switch (mode) { | |
| 553 | + | case "light": | |
| 554 | + | document.documentElement.classList.add("light"); | |
| 555 | + | document.documentElement.classList.remove("dark"); | |
| 556 | + | break; | |
| 557 | + | case "dark": | |
| 558 | + | document.documentElement.classList.remove("light"); | |
| 559 | + | document.documentElement.classList.add("dark"); | |
| 560 | + | break; | |
| 556 | 561 | } | |
| 557 | 562 | persistTheme(mode); | |
| 558 | 563 | }; | |
| @@ -766,10 +771,8 @@ | |||
| 766 | 771 | <meta name="description" content="Fast Wayland Browser - Your browsing patterns at a glance" /> | |
| 767 | 772 | ||
| 768 | 773 | {@html `<style> | |
| 769 | - | html { background: #0a0a0a; } | |
| 770 | - | body { background: #0a0a0a; color: #e5e5e5; } | |
| 771 | - | html.light { background: #ffffff; } | |
| 772 | - | html.light body { background: #ffffff; color: #171717; } | |
| 774 | + | html { background: var(--dynamic-bg, #0a0a0a); } | |
| 775 | + | body { background: var(--dynamic-bg, #0a0a0a); color: var(--dynamic-text, #e5e5e5); } | |
| 773 | 776 | </style>`} | |
| 774 | 777 | ||
| 775 | 778 | {@html `<script> | |
| @@ -806,13 +809,8 @@ | |||
| 806 | 809 | {themeMode === "dark" ? "Switch to light mode" : "Switch to dark mode"} | |
| 807 | 810 | </span> | |
| 808 | 811 | {#if themeMode === "dark"} | |
| 809 | - | <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"> | |
| 810 | - | <path | |
| 811 | - | fill="currentColor" | |
| 812 | - | fill-rule="evenodd" | |
| 813 | - | clip-rule="evenodd" | |
| 814 | - | d="M17.293 13.293A8 8 0 0 1 10.707 2.997a8.001 8.001 0 1 0 6.586 10.296z" | |
| 815 | - | /> | |
| 812 | + | <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| 813 | + | <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" /> | |
| 816 | 814 | </svg> | |
| 817 | 815 | {:else} | |
| 818 | 816 | <svg viewBox="0 0 24 24" aria-hidden="true" focusable="false"> | |
| @@ -1193,7 +1191,6 @@ | |||
| 1193 | 1191 | <style> | |
| 1194 | 1192 | .homepage-shell { | |
| 1195 | 1193 | min-height: 100vh; | |
| 1196 | - | background-color: var(--dynamic-bg); | |
| 1197 | 1194 | color: var(--dynamic-text); | |
| 1198 | 1195 | display: flex; | |
| 1199 | 1196 | flex-direction: column; | |
M gui/src/pages/homepage.ts +3 -0
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | import { mount } from "svelte"; | |
| 2 | 2 | import "../styles/tailwind.css"; | |
| 3 | 3 | import Homepage from "./Homepage.svelte"; | |
| 4 | + | import { bootstrapGUI } from "../injected/bootstrap"; | |
| 4 | 5 | ||
| 5 | 6 | // Proactively unregister any Service Workers and clear caches that may have been | |
| 6 | 7 | // 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 | 31 | // Mount the Homepage component to the DOM | |
| 29 | 32 | cleanupServiceWorkersAndCaches().finally(() => { | |
| 30 | 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 | 6 | export default defineConfig({ | |
| 7 | 7 | plugins: [ | |
| 8 | - | svelte(), | |
| 8 | + | svelte({ | |
| 9 | + | emitCss: false, // Inline component CSS for shadow DOM compatibility | |
| 10 | + | }), | |
| 9 | 11 | pageGenerator([ | |
| 10 | 12 | { | |
| 11 | 13 | name: "homepage", | |
| 12 | 14 | title: "Dumber Browser", | |
| 13 | 15 | script: "homepage.min.js", | |
| 14 | - | css: "homepage.css", | |
| 15 | 16 | filename: "index.html", | |
| 16 | 17 | }, | |
| 17 | 18 | ]), | |
| @@ -33,7 +34,7 @@ export default defineConfig({ | |||
| 33 | 34 | target: ["es2020", "chrome91", "firefox90"], | |
| 34 | 35 | minify: true, | |
| 35 | 36 | sourcemap: false, | |
| 36 | - | cssCodeSplit: true, | |
| 37 | + | cssCodeSplit: false, | |
| 37 | 38 | assetsInlineLimit: 4096, | |
| 38 | 39 | }, | |
| 39 | 40 | resolve: { | |
M internal/app/api/scheme.go +88 -10
| @@ -2,15 +2,20 @@ package api | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "embed" | |
| 5 | + | "fmt" | |
| 5 | 6 | "log" | |
| 6 | 7 | "mime" | |
| 7 | 8 | neturl "net/url" | |
| 9 | + | "os" | |
| 8 | 10 | "path/filepath" | |
| 9 | 11 | "strings" | |
| 10 | 12 | ||
| 11 | 13 | "github.com/bnema/dumber/internal/app/constants" | |
| 12 | 14 | "github.com/bnema/dumber/internal/config" | |
| 13 | 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 | 21 | // SchemeHandler handles custom dumb:// scheme resolution | |
| @@ -18,6 +23,7 @@ type SchemeHandler struct { | |||
| 18 | 23 | assets embed.FS | |
| 19 | 24 | parserService *services.ParserService | |
| 20 | 25 | browserService *services.BrowserService | |
| 26 | + | cfg *config.Config | |
| 21 | 27 | } | |
| 22 | 28 | ||
| 23 | 29 | // NewSchemeHandler creates a new scheme handler | |
| @@ -33,25 +39,39 @@ func NewSchemeHandler( | |||
| 33 | 39 | } | |
| 34 | 40 | } | |
| 35 | 41 | ||
| 36 | - | // Handle processes dumb:// scheme requests | |
| 37 | - | func (s *SchemeHandler) Handle(uri string, cfg *config.Config) (string, []byte, bool) { | |
| 42 | + | // SetConfig sets the configuration for the scheme handler | |
| 43 | + | func (s *SchemeHandler) SetConfig(cfg *config.Config) { | |
| 44 | + | s.cfg = cfg | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | // Handle processes dumb:// scheme requests using the new URISchemeRequest API | |
| 48 | + | func (s *SchemeHandler) Handle(req *webkit.URISchemeRequest) { | |
| 49 | + | uri := req.URI() | |
| 38 | 50 | log.Printf("[scheme] request: %s", uri) | |
| 39 | 51 | ||
| 40 | 52 | // Known forms: | |
| 41 | 53 | // - dumb://homepage or dumb:homepage → index.html | |
| 42 | 54 | // - dumb://app/index.html, dumb://app/<path> → serve from gui/<path> | |
| 55 | + | // - dumb://favicon/<hash>.png → serve cached favicon | |
| 43 | 56 | // - dumb://<anything> without path → index.html | |
| 44 | 57 | u, err := neturl.Parse(uri) | |
| 45 | 58 | if err != nil || u.Scheme != "dumb" { | |
| 46 | - | return "", nil, false | |
| 59 | + | req.FinishError(fmt.Errorf("invalid URI: %s", uri)) | |
| 60 | + | return | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | // Check if this is a favicon request | |
| 64 | + | if u.Host == "favicon" || (u.Host == "" && strings.HasPrefix(u.Path, "/favicon/")) { | |
| 65 | + | s.handleFavicon(req, u) | |
| 66 | + | return | |
| 47 | 67 | } | |
| 48 | 68 | ||
| 49 | 69 | // Static assets | |
| 50 | - | return s.handleAsset(u) | |
| 70 | + | s.handleAsset(req, u) | |
| 51 | 71 | } | |
| 52 | 72 | ||
| 53 | 73 | // 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 | 75 | // Resolve target path inside embed FS | |
| 56 | 76 | var rel string | |
| 57 | 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 | 99 | log.Printf("[scheme] asset: rel=%s (host=%s path=%s) → mapping to favicon.png", rel, u.Host, u.Path) | |
| 80 | 100 | data, rerr := s.assets.ReadFile(filepath.ToSlash(filepath.Join("assets", "gui", "favicon.png"))) | |
| 81 | 101 | if rerr == nil { | |
| 82 | - | return "image/png", data, true | |
| 102 | + | 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 | 110 | data, rerr := s.assets.ReadFile(filepath.ToSlash(filepath.Join("assets", "gui", rel))) | |
| 90 | 111 | if rerr != nil { | |
| 91 | 112 | log.Printf("[scheme] not found: %s", rel) | |
| 92 | - | return "", nil, false | |
| 113 | + | req.FinishError(fmt.Errorf("asset not found: %s", rel)) | |
| 114 | + | return | |
| 93 | 115 | } | |
| 94 | 116 | ||
| 95 | 117 | // Determine mime type | |
| 96 | 118 | mt := s.getMimeType(rel) | |
| 97 | 119 | ||
| 98 | - | // Add cache control for development (prevents future caching issues) | |
| 99 | - | // Note: This doesn't affect the current cache, but prevents new caching | |
| 120 | + | // Finish the request with the data | |
| 100 | 121 | log.Printf("[scheme] serving %s with mime-type: %s", rel, mt) | |
| 101 | - | return mt, data, true | |
| 122 | + | s.finishRequest(req, mt, data, rel) | |
| 123 | + | } | |
| 124 | + | ||
| 125 | + | // finishRequest completes a URI scheme request with the provided data | |
| 126 | + | func (s *SchemeHandler) finishRequest(req *webkit.URISchemeRequest, mimeType string, data []byte, filename string) { | |
| 127 | + | // Convert byte slice to GLib Bytes | |
| 128 | + | gbytes := glib.NewBytes(data) | |
| 129 | + | ||
| 130 | + | // Create an input stream from the bytes | |
| 131 | + | stream := gio.NewMemoryInputStreamFromBytes(gbytes) | |
| 132 | + | if stream == nil { | |
| 133 | + | req.FinishError(fmt.Errorf("failed to create input stream for: %s", filename)) | |
| 134 | + | log.Printf("[scheme] failed to create stream for: %s", filename) | |
| 135 | + | return | |
| 136 | + | } | |
| 137 | + | ||
| 138 | + | // Finish the request with the stream | |
| 139 | + | contentLength := int64(len(data)) | |
| 140 | + | req.Finish(stream, contentLength, mimeType) | |
| 102 | 141 | } | |
| 103 | 142 | ||
| 104 | 143 | // getMimeType determines the MIME type for a given file path | |
| @@ -152,3 +191,42 @@ func (s *SchemeHandler) getMimeType(filename string) string { | |||
| 152 | 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 | 3 | import ( | |
| 4 | 4 | "database/sql" | |
| 5 | 5 | "embed" | |
| 6 | - | "fmt" | |
| 7 | 6 | "log" | |
| 8 | 7 | "os" | |
| 9 | 8 | "os/signal" | |
| @@ -18,7 +17,6 @@ import ( | |||
| 18 | 17 | "github.com/bnema/dumber/internal/db" | |
| 19 | 18 | "github.com/bnema/dumber/internal/filtering" | |
| 20 | 19 | "github.com/bnema/dumber/internal/logging" | |
| 21 | - | "github.com/bnema/dumber/internal/migrations" | |
| 22 | 20 | "github.com/bnema/dumber/internal/services" | |
| 23 | 21 | "github.com/bnema/dumber/pkg/webkit" | |
| 24 | 22 | ) | |
| @@ -119,21 +117,20 @@ func (app *BrowserApp) Initialize() error { | |||
| 119 | 117 | // Detect keyboard layout | |
| 120 | 118 | environment.DetectAndSetKeyboardLocale() | |
| 121 | 119 | ||
| 122 | - | // Initialize database | |
| 120 | + | // Initialize database (migrations run automatically in InitDB) | |
| 123 | 121 | database, err := db.InitDB(app.config.Database.Path) | |
| 124 | 122 | if err != nil { | |
| 125 | 123 | return err | |
| 126 | 124 | } | |
| 127 | 125 | 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 | 126 | app.queries = db.New(database) | |
| 135 | 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 | 134 | // Initialize services | |
| 138 | 135 | app.parserService = services.NewParserService(app.config, app.queries) | |
| 139 | 136 | app.browserService = services.NewBrowserService(app.config, app.queries) | |
| @@ -150,10 +147,11 @@ func (app *BrowserApp) Run() { | |||
| 150 | 147 | defer app.cleanup() | |
| 151 | 148 | defer logging.SetupPanicRecovery() | |
| 152 | 149 | ||
| 153 | - | // Register custom scheme resolver | |
| 154 | - | webkit.SetURISchemeResolver(func(uri string) (string, []byte, bool) { | |
| 155 | - | return app.schemeHandler.Handle(uri, app.config) | |
| 156 | - | }) | |
| 150 | + | // Set config on scheme handler | |
| 151 | + | app.schemeHandler.SetConfig(app.config) | |
| 152 | + | ||
| 153 | + | // Register custom scheme resolver for "dumb://" URIs (will be applied after WebView creation) | |
| 154 | + | webkit.RegisterURIScheme("dumb", app.schemeHandler.Handle) | |
| 157 | 155 | ||
| 158 | 156 | // Create and setup WebView | |
| 159 | 157 | if err := app.createWebView(); err != nil { | |
| @@ -161,6 +159,11 @@ func (app *BrowserApp) Run() { | |||
| 161 | 159 | 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 | 167 | // Initialize content blocking | |
| 165 | 168 | if err := app.setupContentBlocking(); err != nil { | |
| 166 | 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 | 10 | "time" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/dumber/pkg/webkit" | |
| 13 | + | "github.com/diamondburned/gotk4/pkg/gtk/v4" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 16 | // 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 | 161 | // 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 | 163 | rd.mu.Lock() | |
| 163 | 164 | defer rd.mu.Unlock() | |
| 164 | 165 | ||
| @@ -173,12 +174,16 @@ func (rd *RequestDeduplicator) IsDuplicate(req FocusRequest) bool { | |||
| 173 | 174 | // Generate signature for this request | |
| 174 | 175 | sig := fmt.Sprintf("%p:%s", req.TargetNode, req.Source) | |
| 175 | 176 | ||
| 176 | - | // Check if this signature exists | |
| 177 | - | if _, exists := rd.recentSigs[sig]; exists { | |
| 178 | - | return true | |
| 177 | + | // Always record latest timestamp so future checks use fresh window | |
| 178 | + | // Prevent duplicates only when the target is already active; otherwise we | |
| 179 | + | // want to process the request to move focus back to that pane. | |
| 180 | + | if timestamp, exists := rd.recentSigs[sig]; exists { | |
| 181 | + | if isActiveTarget && now.Sub(timestamp) <= rd.ttl { | |
| 182 | + | rd.recentSigs[sig] = now | |
| 183 | + | return true | |
| 184 | + | } | |
| 179 | 185 | } | |
| 180 | 186 | ||
| 181 | - | // Record this signature | |
| 182 | 187 | rd.recentSigs[sig] = now | |
| 183 | 188 | return false | |
| 184 | 189 | } | |
| @@ -366,8 +371,11 @@ func (fsm *FocusStateMachine) RequestFocus(node *paneNode, source FocusSource) e | |||
| 366 | 371 | fsm.mu.Unlock() | |
| 367 | 372 | } | |
| 368 | 373 | ||
| 369 | - | // Check for duplicates | |
| 370 | - | if fsm.deduplicator.IsDuplicate(request) { | |
| 374 | + | activePane := fsm.GetActivePane() | |
| 375 | + | ||
| 376 | + | // Check for duplicates (only dedupe when the request targets the currently | |
| 377 | + | // active pane to avoid suppressing necessary focus transitions). | |
| 378 | + | if fsm.deduplicator.IsDuplicate(request, activePane == node) { | |
| 371 | 379 | if fsm.metricsEnabled { | |
| 372 | 380 | fsm.mu.Lock() | |
| 373 | 381 | fsm.metrics.DuplicateRequests++ | |
| @@ -556,17 +564,14 @@ func (fsm *FocusStateMachine) findTopLeftPane(leaves []*paneNode) *paneNode { | |||
| 556 | 564 | var bestScore float64 = 1e9 | |
| 557 | 565 | ||
| 558 | 566 | for _, leaf := range leaves { | |
| 559 | - | if leaf.container == 0 || !webkit.WidgetIsValid(leaf.container) { | |
| 567 | + | if leaf.container == nil { | |
| 560 | 568 | continue | |
| 561 | 569 | } | |
| 562 | 570 | ||
| 563 | - | bounds, ok := webkit.WidgetGetBounds(leaf.container) | |
| 564 | - | if !ok { | |
| 565 | - | continue | |
| 566 | - | } | |
| 571 | + | x, y, _, _ := webkit.WidgetGetAllocation(leaf.container) | |
| 567 | 572 | ||
| 568 | 573 | // Score based on distance from top-left corner (0,0) | |
| 569 | - | score := bounds.X + bounds.Y | |
| 574 | + | score := float64(x) + float64(y) | |
| 570 | 575 | if score < bestScore { | |
| 571 | 576 | bestScore = score | |
| 572 | 577 | bestPane = leaf | |
| @@ -591,8 +596,7 @@ func (fsm *FocusStateMachine) applyInitialFocus(node *paneNode) error { | |||
| 591 | 596 | ||
| 592 | 597 | // Apply initial visual border | |
| 593 | 598 | if fsm.wm != nil { | |
| 594 | - | ctx := fsm.wm.determineBorderContext(node) | |
| 595 | - | fsm.wm.applyActivePaneBorder(ctx) | |
| 599 | + | fsm.wm.applyActivePaneBorder(node) | |
| 596 | 600 | } | |
| 597 | 601 | ||
| 598 | 602 | // Update state | |
| @@ -615,6 +619,11 @@ func (fsm *FocusStateMachine) applyInitialFocus(node *paneNode) error { | |||
| 615 | 619 | 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 | 627 | return nil | |
| 619 | 628 | } | |
| 620 | 629 | ||
| @@ -625,7 +634,7 @@ func (fsm *FocusStateMachine) applyGTKFocus(node *paneNode) error { | |||
| 625 | 634 | } | |
| 626 | 635 | ||
| 627 | 636 | viewWidget := node.pane.webView.Widget() | |
| 628 | - | if viewWidget == 0 { | |
| 637 | + | if viewWidget == nil { | |
| 629 | 638 | return fmt.Errorf("webview has no valid widget") | |
| 630 | 639 | } | |
| 631 | 640 | ||
| @@ -705,8 +714,17 @@ func (fsm *FocusStateMachine) handleFocusRequest(request FocusRequest) { | |||
| 705 | 714 | log.Printf("[FSM] Processing focus request %s: %s -> %p", | |
| 706 | 715 | request.ID, request.Source, request.TargetNode) | |
| 707 | 716 | ||
| 708 | - | // Execute focus change | |
| 709 | - | if err := fsm.executeFocusChange(request); err != nil { | |
| 717 | + | // GTK operations must run on main thread - always use IdleAdd for simplicity | |
| 718 | + | var err error | |
| 719 | + | done := make(chan struct{}) | |
| 720 | + | _ = webkit.IdleAdd(func() bool { | |
| 721 | + | err = fsm.executeFocusChange(request) | |
| 722 | + | close(done) | |
| 723 | + | return false | |
| 724 | + | }) | |
| 725 | + | <-done | |
| 726 | + | ||
| 727 | + | if err != nil { | |
| 710 | 728 | fsm.mu.Lock() | |
| 711 | 729 | fsm.metrics.FailedRequests++ | |
| 712 | 730 | fsm.mu.Unlock() | |
| @@ -758,8 +776,7 @@ func (fsm *FocusStateMachine) executeFocusChange(request FocusRequest) error { | |||
| 758 | 776 | fsm.wm.removeActivePaneBorder(oldPane) | |
| 759 | 777 | } | |
| 760 | 778 | // Add border to new pane | |
| 761 | - | ctx := fsm.wm.determineBorderContext(newPane) | |
| 762 | - | fsm.wm.applyActivePaneBorder(ctx) | |
| 779 | + | fsm.wm.applyActivePaneBorder(newPane) | |
| 763 | 780 | } | |
| 764 | 781 | ||
| 765 | 782 | // Update state | |
| @@ -775,6 +792,16 @@ func (fsm *FocusStateMachine) executeFocusChange(request FocusRequest) error { | |||
| 775 | 792 | 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 | 805 | // Start settling timer | |
| 779 | 806 | fsm.startSettlingTimer() | |
| 780 | 807 | ||
| @@ -918,27 +945,50 @@ func (fsm *FocusStateMachine) attachGTKController(node *paneNode) { | |||
| 918 | 945 | } | |
| 919 | 946 | ||
| 920 | 947 | widget := node.pane.webView.Widget() | |
| 921 | - | if widget == 0 || !webkit.WidgetIsValid(widget) { | |
| 948 | + | if widget == nil { | |
| 922 | 949 | return | |
| 923 | 950 | } | |
| 924 | 951 | ||
| 925 | - | // Create focus enter/leave callbacks for this specific node | |
| 926 | - | onEnter := func() { | |
| 952 | + | // Create focus controller for GTK4 | |
| 953 | + | controller := gtk.NewEventControllerFocus() | |
| 954 | + | ||
| 955 | + | // Set propagation phase to TARGET only to avoid duplicate events from parent widgets | |
| 956 | + | // GTK_PHASE_TARGET means we only get events for this specific widget, not bubbled events | |
| 957 | + | controller.SetPropagationPhase(gtk.PhaseTarget) | |
| 958 | + | ||
| 959 | + | // Connect focus enter/leave callbacks with timestamp-based deduplication | |
| 960 | + | controller.ConnectEnter(func() { | |
| 961 | + | // Deduplicate: WebKitGTK nested widgets can fire multiple events in same millisecond | |
| 962 | + | now := time.Now().UnixMilli() | |
| 963 | + | if node.lastFocusEnterTime == now { | |
| 964 | + | return // Duplicate event in same millisecond | |
| 965 | + | } | |
| 966 | + | node.lastFocusEnterTime = now | |
| 967 | + | ||
| 927 | 968 | log.Printf("[FSM] GTK focus enter: %p", node) | |
| 928 | 969 | // Don't automatically change focus on GTK enter - let user interactions drive this | |
| 929 | 970 | // 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 | 981 | log.Printf("[FSM] GTK focus leave: %p", node) | |
| 934 | 982 | // Similarly, don't react to GTK leave events automatically | |
| 935 | - | } | |
| 983 | + | }) | |
| 936 | 984 | ||
| 937 | - | // Add the focus controller and store the token for cleanup | |
| 938 | - | token := webkit.WidgetAddFocusController(widget, onEnter, onLeave) | |
| 939 | - | if token != 0 { | |
| 940 | - | node.focusControllerToken = token | |
| 941 | - | log.Printf("[FSM] Attached GTK focus controller to pane %p with token %d", node, token) | |
| 985 | + | // Add controller to widget | |
| 986 | + | webkit.WidgetAddController(widget, controller) | |
| 987 | + | ||
| 988 | + | // Store controller pointer as token for later removal | |
| 989 | + | node.focusControllerToken = uintptr(controller.Native()) | |
| 990 | + | if node.focusControllerToken != 0 { | |
| 991 | + | log.Printf("[FSM] Attached GTK focus controller to pane %p with token %d", node, node.focusControllerToken) | |
| 942 | 992 | } | |
| 943 | 993 | } | |
| 944 | 994 | ||
| @@ -955,12 +1005,8 @@ func (fsm *FocusStateMachine) detachGTKController(node *paneNode, token uintptr) | |||
| 955 | 1005 | ||
| 956 | 1006 | node.focusControllerToken = 0 | |
| 957 | 1007 | ||
| 958 | - | widget := node.pane.webView.Widget() | |
| 959 | - | if widget == 0 || !webkit.WidgetIsValid(widget) { | |
| 960 | - | return | |
| 961 | - | } | |
| 962 | - | ||
| 963 | - | webkit.WidgetRemoveFocusController(widget, token) | |
| 1008 | + | // Note: In GTK4, controllers are automatically removed when widget is destroyed | |
| 1009 | + | // We just need to clear our token reference | |
| 964 | 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 | 223 | // First request should not be duplicate | |
| 224 | - | if dedup.IsDuplicate(req1) { | |
| 224 | + | if dedup.IsDuplicate(req1, false) { | |
| 225 | 225 | t.Error("first request should not be duplicate") | |
| 226 | 226 | } | |
| 227 | 227 | ||
| @@ -232,7 +232,7 @@ func TestRequestDeduplicator_DetectsDuplicates(t *testing.T) { | |||
| 232 | 232 | Timestamp: time.Now(), | |
| 233 | 233 | } | |
| 234 | 234 | ||
| 235 | - | if !dedup.IsDuplicate(req2) { | |
| 235 | + | if !dedup.IsDuplicate(req2, true) { | |
| 236 | 236 | t.Error("second identical request should be duplicate") | |
| 237 | 237 | } | |
| 238 | 238 | } | |
| @@ -253,10 +253,10 @@ func TestRequestDeduplicator_DifferentSourcesNotDuplicate(t *testing.T) { | |||
| 253 | 253 | Source: SourceMouse, | |
| 254 | 254 | } | |
| 255 | 255 | ||
| 256 | - | dedup.IsDuplicate(req1) | |
| 256 | + | dedup.IsDuplicate(req1, false) | |
| 257 | 257 | ||
| 258 | 258 | // Different source should not be duplicate | |
| 259 | - | if dedup.IsDuplicate(req2) { | |
| 259 | + | if dedup.IsDuplicate(req2, false) { | |
| 260 | 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 | 276 | // First request | |
| 277 | - | if dedup.IsDuplicate(req) { | |
| 277 | + | if dedup.IsDuplicate(req, false) { | |
| 278 | 278 | t.Error("first request should not be duplicate") | |
| 279 | 279 | } | |
| 280 | 280 | ||
| 281 | 281 | // Immediate duplicate | |
| 282 | - | if !dedup.IsDuplicate(req) { | |
| 282 | + | if !dedup.IsDuplicate(req, true) { | |
| 283 | 283 | t.Error("immediate duplicate should be detected") | |
| 284 | 284 | } | |
| 285 | 285 | ||
| @@ -287,7 +287,7 @@ func TestRequestDeduplicator_ExpiresOldSignatures(t *testing.T) { | |||
| 287 | 287 | time.Sleep(ttl + 10*time.Millisecond) | |
| 288 | 288 | ||
| 289 | 289 | // Should not be duplicate after expiration | |
| 290 | - | if dedup.IsDuplicate(req) { | |
| 290 | + | if dedup.IsDuplicate(req, false) { | |
| 291 | 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 | 312 | TargetNode: nodes[idx%len(nodes)], | |
| 313 | 313 | Source: SourceKeyboard, | |
| 314 | 314 | } | |
| 315 | - | _ = dedup.IsDuplicate(req) | |
| 315 | + | _ = dedup.IsDuplicate(req, false) | |
| 316 | 316 | time.Sleep(time.Microsecond) | |
| 317 | 317 | } | |
| 318 | 318 | }(i) | |
M internal/app/browser/shortcuts.go +3 -3
| @@ -67,7 +67,7 @@ func (s *ShortcutHandler) RegisterShortcuts() { | |||
| 67 | 67 | // by WindowShortcutHandler at the window level to prevent duplicates | |
| 68 | 68 | ||
| 69 | 69 | // Page refresh shortcuts | |
| 70 | - | _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+r", func() { | |
| 70 | + | _ = s.webView.RegisterKeyboardShortcut("cmdorctrl+r", 0, func() { | |
| 71 | 71 | if !s.isActivePane() { | |
| 72 | 72 | return | |
| 73 | 73 | } | |
| @@ -75,7 +75,7 @@ func (s *ShortcutHandler) RegisterShortcuts() { | |||
| 75 | 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 | 79 | if !s.isActivePane() { | |
| 80 | 80 | return | |
| 81 | 81 | } | |
| @@ -83,7 +83,7 @@ func (s *ShortcutHandler) RegisterShortcuts() { | |||
| 83 | 83 | _ = s.webView.ReloadBypassCache() | |
| 84 | 84 | }) | |
| 85 | 85 | ||
| 86 | - | _ = s.webView.RegisterKeyboardShortcut("F5", func() { | |
| 86 | + | _ = s.webView.RegisterKeyboardShortcut("F5", 0, func() { | |
| 87 | 87 | if !s.isActivePane() { | |
| 88 | 88 | return | |
| 89 | 89 | } | |
M internal/app/browser/stacked_panes.go +160 -150
| @@ -10,6 +10,7 @@ import ( | |||
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/dumber/internal/cache" | |
| 12 | 12 | "github.com/bnema/dumber/pkg/webkit" | |
| 13 | + | "github.com/diamondburned/gotk4/pkg/gtk/v4" | |
| 13 | 14 | ) | |
| 14 | 15 | ||
| 15 | 16 | const ( | |
| @@ -19,16 +20,16 @@ const ( | |||
| 19 | 20 | ||
| 20 | 21 | // StackedPaneManager handles all stacked pane operations | |
| 21 | 22 | type StackedPaneManager struct { | |
| 22 | - | wm *WorkspaceManager | |
| 23 | - | titleBarToPane map[uintptr]*paneNode | |
| 24 | - | nextTitleBarID uint64 | |
| 23 | + | wm *WorkspaceManager | |
| 24 | + | titleBarToPane map[uint64]*paneNode | |
| 25 | + | nextTitleBarID uint64 | |
| 25 | 26 | } | |
| 26 | 27 | ||
| 27 | 28 | // NewStackedPaneManager creates a new stacked pane manager | |
| 28 | 29 | func NewStackedPaneManager(wm *WorkspaceManager) *StackedPaneManager { | |
| 29 | 30 | return &StackedPaneManager{ | |
| 30 | 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 | 87 | newContainer := newPane.webView.RootWidget() | |
| 87 | - | if newContainer == 0 { | |
| 88 | + | if newContainer == nil { | |
| 88 | 89 | return nil, errors.New("new pane missing container") | |
| 89 | 90 | } | |
| 90 | 91 | webkit.WidgetSetHExpand(newContainer, true) | |
| 91 | 92 | webkit.WidgetSetVExpand(newContainer, true) | |
| 92 | - | webkit.WidgetRealizeInContainer(newContainer) | |
| 93 | 93 | ||
| 94 | 94 | // Create the new leaf node | |
| 95 | 95 | newLeaf := &paneNode{ | |
| @@ -105,7 +105,9 @@ func (spm *StackedPaneManager) prepareNewStackedPane() (*paneNode, error) { | |||
| 105 | 105 | ||
| 106 | 106 | // Keep new container hidden initially - will be shown after transition | |
| 107 | 107 | webkit.WidgetSetVisible(newContainer, false) | |
| 108 | - | webkit.WidgetSetVisible(newTitleBar, false) | |
| 108 | + | if newTitleBar != nil { | |
| 109 | + | webkit.WidgetSetVisible(newTitleBar, false) | |
| 110 | + | } | |
| 109 | 111 | ||
| 110 | 112 | return newLeaf, nil | |
| 111 | 113 | } | |
| @@ -141,8 +143,8 @@ func (spm *StackedPaneManager) addPaneToExistingStack(target, newLeaf *paneNode) | |||
| 141 | 143 | stackNode.stackedPanes[insertIndex] = newLeaf // Insert new pane | |
| 142 | 144 | ||
| 143 | 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) { | |
| 145 | - | if webkit.WidgetGetParent(newLeaf.container) != 0 { | |
| 146 | + | if newLeaf.container != nil { | |
| 147 | + | if webkit.WidgetGetParent(newLeaf.container) != nil { | |
| 146 | 148 | webkit.WidgetUnparent(newLeaf.container) | |
| 147 | 149 | } | |
| 148 | 150 | } | |
| @@ -151,32 +153,32 @@ func (spm *StackedPaneManager) addPaneToExistingStack(target, newLeaf *paneNode) | |||
| 151 | 153 | // Each pane has 2 widgets (titleBar, container), so position = insertIndex * 2 | |
| 152 | 154 | ||
| 153 | 155 | // Find the widget to insert after (based on insertIndex) | |
| 154 | - | var insertAfterWidget uintptr = 0 | |
| 156 | + | var insertAfterWidget gtk.Widgetter | |
| 155 | 157 | if insertIndex > 0 { | |
| 156 | 158 | // Insert after the previous pane's container | |
| 157 | 159 | prevPane := stackNode.stackedPanes[insertIndex-1] | |
| 158 | - | if prevPane.container != 0 { | |
| 160 | + | if prevPane.container != nil { | |
| 159 | 161 | insertAfterWidget = prevPane.container | |
| 160 | 162 | } | |
| 161 | 163 | } | |
| 162 | 164 | ||
| 163 | - | if insertAfterWidget != 0 { | |
| 165 | + | if insertAfterWidget != nil { | |
| 164 | 166 | // Insert titleBar after the previous pane's container | |
| 165 | - | if stackNode.stackWrapper != 0 && newLeaf.titleBar != 0 { | |
| 166 | - | webkit.BoxInsertChildAfter(stackNode.stackWrapper, newLeaf.titleBar, insertAfterWidget) | |
| 167 | + | if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.titleBar != nil { | |
| 168 | + | box.InsertChildAfter(newLeaf.titleBar, insertAfterWidget) | |
| 167 | 169 | } | |
| 168 | 170 | // Insert container after the newly inserted titleBar | |
| 169 | - | if stackNode.stackWrapper != 0 && newLeaf.container != 0 && newLeaf.titleBar != 0 { | |
| 170 | - | webkit.BoxInsertChildAfter(stackNode.stackWrapper, newLeaf.container, newLeaf.titleBar) | |
| 171 | + | if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.container != nil && newLeaf.titleBar != nil { | |
| 172 | + | box.InsertChildAfter(newLeaf.container, newLeaf.titleBar) | |
| 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 | 175 | } else { | |
| 174 | 176 | // Insert at the beginning (insertIndex = 0) | |
| 175 | - | if stackNode.stackWrapper != 0 && newLeaf.container != 0 { | |
| 176 | - | webkit.BoxPrepend(stackNode.stackWrapper, newLeaf.container) | |
| 177 | + | if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.container != nil { | |
| 178 | + | box.Prepend(newLeaf.container) | |
| 177 | 179 | } | |
| 178 | - | if stackNode.stackWrapper != 0 && newLeaf.titleBar != 0 { | |
| 179 | - | webkit.BoxPrepend(stackNode.stackWrapper, newLeaf.titleBar) | |
| 180 | + | if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.titleBar != nil { | |
| 181 | + | box.Prepend(newLeaf.titleBar) | |
| 180 | 182 | } | |
| 181 | 183 | log.Printf("[workspace] prepended widgets at position 0") | |
| 182 | 184 | } | |
| @@ -189,27 +191,27 @@ func (spm *StackedPaneManager) convertToStackedContainer(target, newLeaf *paneNo | |||
| 189 | 191 | log.Printf("[workspace] converting pane to stacked: %p", target) | |
| 190 | 192 | ||
| 191 | 193 | // Create the wrapper container - this is what will be used by splitNode | |
| 192 | - | stackWrapperContainer := webkit.NewBox(webkit.OrientationVertical, 0) | |
| 193 | - | if stackWrapperContainer == 0 { | |
| 194 | + | stackWrapperContainer := gtk.NewBox(gtk.OrientationVertical, 0) | |
| 195 | + | if stackWrapperContainer == nil { | |
| 194 | 196 | return nil, 0, errors.New("failed to create stack wrapper container") | |
| 195 | 197 | } | |
| 196 | - | webkit.WidgetSetHExpand(stackWrapperContainer, true) | |
| 197 | - | webkit.WidgetSetVExpand(stackWrapperContainer, true) | |
| 198 | - | webkit.WidgetRealizeInContainer(stackWrapperContainer) // Ensures size request is set like regular panes | |
| 198 | + | stackWrapperContainer.SetHExpand(true) | |
| 199 | + | stackWrapperContainer.SetVExpand(true) | |
| 200 | + | // Note: Don't call Realize() here - GTK4 will realize automatically when added to toplevel | |
| 199 | 201 | ||
| 200 | 202 | // Add CSS class to the stack container for proper styling | |
| 201 | - | webkit.WidgetAddCSSClass(stackWrapperContainer, stackContainerClass) | |
| 203 | + | stackWrapperContainer.AddCSSClass(stackContainerClass) | |
| 202 | 204 | ||
| 203 | 205 | // Create the internal box for the actual stacked widgets (titles + webviews) | |
| 204 | - | stackInternalBox := webkit.NewBox(webkit.OrientationVertical, 0) | |
| 205 | - | if stackInternalBox == 0 { | |
| 206 | + | stackInternalBox := gtk.NewBox(gtk.OrientationVertical, 0) | |
| 207 | + | if stackInternalBox == nil { | |
| 206 | 208 | return nil, 0, errors.New("failed to create stack internal box") | |
| 207 | 209 | } | |
| 208 | - | webkit.WidgetSetHExpand(stackInternalBox, true) | |
| 209 | - | webkit.WidgetSetVExpand(stackInternalBox, true) | |
| 210 | + | stackInternalBox.SetHExpand(true) | |
| 211 | + | stackInternalBox.SetVExpand(true) | |
| 210 | 212 | ||
| 211 | 213 | // The internal box goes inside the wrapper | |
| 212 | - | webkit.BoxAppend(stackWrapperContainer, stackInternalBox) | |
| 214 | + | stackWrapperContainer.Append(stackInternalBox) | |
| 213 | 215 | ||
| 214 | 216 | // Get the existing container and parent info | |
| 215 | 217 | existingContainer := target.container | |
| @@ -219,15 +221,19 @@ func (spm *StackedPaneManager) convertToStackedContainer(target, newLeaf *paneNo | |||
| 219 | 221 | titleBar := spm.createTitleBar(target) | |
| 220 | 222 | ||
| 221 | 223 | // 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 | 228 | // Detach existing container from its current parent first | |
| 225 | 229 | spm.detachFromParent(target, parent) | |
| 226 | 230 | ||
| 227 | 231 | // Build the complete stack structure with hidden widgets | |
| 228 | - | webkit.BoxAppend(stackInternalBox, titleBar) | |
| 229 | - | if existingContainer != 0 { | |
| 230 | - | webkit.BoxAppend(stackInternalBox, existingContainer) | |
| 232 | + | if titleBar != nil { | |
| 233 | + | stackInternalBox.Append(titleBar) | |
| 234 | + | } | |
| 235 | + | if existingContainer != nil { | |
| 236 | + | stackInternalBox.Append(existingContainer) | |
| 231 | 237 | } | |
| 232 | 238 | ||
| 233 | 239 | // Immediately reattach the stack wrapper to minimize visibility gap | |
| @@ -275,24 +281,24 @@ func (spm *StackedPaneManager) convertToStackedContainer(target, newLeaf *paneNo | |||
| 275 | 281 | ||
| 276 | 282 | // Unparent the new container before adding it to the stack | |
| 277 | 283 | // This is critical: even freshly created WebViews might have internal parent refs | |
| 278 | - | if newLeaf.container != 0 && webkit.WidgetIsValid(newLeaf.container) { | |
| 279 | - | parent := webkit.WidgetGetParent(newLeaf.container) | |
| 280 | - | if parent != 0 { | |
| 281 | - | log.Printf("[workspace] unparenting new pane container %#x from parent %#x before stack append", newLeaf.container, parent) | |
| 284 | + | if newLeaf.container != nil { | |
| 285 | + | parentWidget := webkit.WidgetGetParent(newLeaf.container) | |
| 286 | + | if parentWidget != nil { | |
| 287 | + | log.Printf("[workspace] unparenting new pane container %p from parent %p before stack append", newLeaf.container, parentWidget) | |
| 282 | 288 | webkit.WidgetUnparent(newLeaf.container) | |
| 283 | 289 | // Verify unparent succeeded | |
| 284 | - | if finalParent := webkit.WidgetGetParent(newLeaf.container); finalParent != 0 { | |
| 285 | - | log.Printf("[workspace] WARNING: container %#x still has parent %#x after unparent", newLeaf.container, finalParent) | |
| 290 | + | if finalParent := webkit.WidgetGetParent(newLeaf.container); finalParent != nil { | |
| 291 | + | log.Printf("[workspace] WARNING: container %p still has parent %p after unparent", newLeaf.container, finalParent) | |
| 286 | 292 | } | |
| 287 | 293 | } | |
| 288 | 294 | } | |
| 289 | 295 | ||
| 290 | 296 | // Add the new widgets to the internal stack box | |
| 291 | - | if stackNode.stackWrapper != 0 && newLeaf.titleBar != 0 { | |
| 292 | - | webkit.BoxAppend(stackNode.stackWrapper, newLeaf.titleBar) | |
| 297 | + | if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.titleBar != nil { | |
| 298 | + | box.Append(newLeaf.titleBar) | |
| 293 | 299 | } | |
| 294 | - | if stackNode.stackWrapper != 0 && newLeaf.container != 0 { | |
| 295 | - | webkit.BoxAppend(stackNode.stackWrapper, newLeaf.container) | |
| 300 | + | if box, ok := stackNode.stackWrapper.(*gtk.Box); ok && newLeaf.container != nil { | |
| 301 | + | box.Append(newLeaf.container) | |
| 296 | 302 | } | |
| 297 | 303 | ||
| 298 | 304 | return stackNode, insertIndex, nil | |
| @@ -303,35 +309,35 @@ func (spm *StackedPaneManager) detachFromParent(target *paneNode, parent *paneNo | |||
| 303 | 309 | if parent == nil { | |
| 304 | 310 | // Target is the root - remove it from window | |
| 305 | 311 | if spm.wm.window != nil { | |
| 306 | - | spm.wm.window.SetChild(0) | |
| 312 | + | spm.wm.window.SetChild(nil) | |
| 307 | 313 | } | |
| 308 | 314 | // Unparent if it has a GTK parent | |
| 309 | - | if target.container != 0 && webkit.WidgetIsValid(target.container) { | |
| 310 | - | if webkit.WidgetGetParent(target.container) != 0 { | |
| 315 | + | if target.container != nil { | |
| 316 | + | if webkit.WidgetGetParent(target.container) != nil { | |
| 311 | 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 | 321 | // Target has a parent paned - remove it (automatically unparents in GTK4) | |
| 316 | 322 | if parent.left == target { | |
| 317 | - | webkit.PanedSetStartChild(parent.container, 0) | |
| 323 | + | paned.SetStartChild(nil) | |
| 318 | 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 | 330 | // 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 | 332 | if parent == nil { | |
| 327 | 333 | if spm.wm.window != nil { | |
| 328 | 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 | 337 | if parent.left == target { | |
| 332 | - | webkit.PanedSetStartChild(parent.container, container) | |
| 338 | + | paned.SetStartChild(container) | |
| 333 | 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 | 359 | currentActiveIndex := stackNode.activeStackIndex | |
| 354 | 360 | if currentActiveIndex >= 0 && currentActiveIndex < len(stackNode.stackedPanes) && currentActiveIndex != insertIndex { | |
| 355 | 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 | 363 | currentTitle := currentlyActivePane.pane.webView.GetTitle() | |
| 358 | 364 | if currentTitle != "" { | |
| 359 | 365 | spm.updateTitleBarLabel(currentlyActivePane, currentTitle) | |
| @@ -406,19 +412,19 @@ func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) { | |||
| 406 | 412 | for i, pane := range stackNode.stackedPanes { | |
| 407 | 413 | if i == activeIndex { | |
| 408 | 414 | // Active pane: show container, NEVER show title bar | |
| 409 | - | if pane.container != 0 { | |
| 415 | + | if pane.container != nil { | |
| 410 | 416 | webkit.WidgetSetVisible(pane.container, true) | |
| 411 | 417 | } | |
| 412 | - | if pane.titleBar != 0 { | |
| 418 | + | if pane.titleBar != nil { | |
| 413 | 419 | webkit.WidgetSetVisible(pane.titleBar, false) // ABSOLUTE RULE: never visible for active pane | |
| 414 | 420 | } | |
| 415 | 421 | log.Printf("[workspace] active pane %d: container=visible, titleBar=HIDDEN", i) | |
| 416 | 422 | } else { | |
| 417 | 423 | // Inactive panes: hide container, show title bar | |
| 418 | - | if pane.container != 0 { | |
| 424 | + | if pane.container != nil { | |
| 419 | 425 | webkit.WidgetSetVisible(pane.container, false) | |
| 420 | 426 | } | |
| 421 | - | if pane.titleBar != 0 { | |
| 427 | + | if pane.titleBar != nil { | |
| 422 | 428 | webkit.WidgetSetVisible(pane.titleBar, true) | |
| 423 | 429 | ||
| 424 | 430 | // Refresh title bar to pick up any newly downloaded favicons | |
| @@ -436,7 +442,7 @@ func (spm *StackedPaneManager) UpdateStackVisibility(stackNode *paneNode) { | |||
| 436 | 442 | ||
| 437 | 443 | // NavigateStack handles navigation within a stacked pane container | |
| 438 | 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 | 446 | return false | |
| 441 | 447 | } | |
| 442 | 448 | ||
| @@ -491,7 +497,7 @@ func (spm *StackedPaneManager) NavigateStack(direction string) bool { | |||
| 491 | 497 | // Update title bar for the pane transitioning from ACTIVE to INACTIVE | |
| 492 | 498 | // This ensures the collapsed pane shows its current page title (Zellij-style layout) | |
| 493 | 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 | 501 | currentTitle := currentActivePane.pane.webView.GetTitle() | |
| 496 | 502 | if currentTitle != "" { | |
| 497 | 503 | spm.updateTitleBarLabel(currentActivePane, currentTitle) | |
| @@ -513,7 +519,7 @@ func (spm *StackedPaneManager) NavigateStack(direction string) bool { | |||
| 513 | 519 | } | |
| 514 | 520 | ||
| 515 | 521 | // 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 | 523 | // Get the actual title and URL from the WebView | |
| 518 | 524 | var titleText, pageURL string | |
| 519 | 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 | 535 | titleBar := spm.createTitleBarWithTitle(titleText, pageURL) | |
| 530 | - | if titleBar != 0 { | |
| 536 | + | if titleBar != nil { | |
| 531 | 537 | // Store the mapping from titleBar ID to pane | |
| 532 | 538 | titleBarID := atomic.AddUint64(&spm.nextTitleBarID, 1) | |
| 533 | - | spm.titleBarToPane[uintptr(titleBarID)] = pane | |
| 539 | + | spm.titleBarToPane[titleBarID] = pane | |
| 534 | 540 | ||
| 535 | 541 | // Attach click handler | |
| 536 | - | webkit.WidgetAttachClickHandler(titleBar, func() { | |
| 537 | - | spm.handleTitleBarClick(uintptr(titleBarID)) | |
| 538 | - | }) | |
| 542 | + | // TODO: Implement click handler with gtk.GestureClick | |
| 543 | + | // webkit.WidgetAttachClickHandler(titleBar, func() { | |
| 544 | + | // spm.handleTitleBarClick(titleBarID) | |
| 545 | + | // }) | |
| 539 | 546 | } | |
| 540 | 547 | ||
| 541 | 548 | return titleBar | |
| @@ -554,7 +561,7 @@ func (spm *StackedPaneManager) UpdateTitleBar(webView *webkit.WebView, title str | |||
| 554 | 561 | } | |
| 555 | 562 | ||
| 556 | 563 | // 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 | 565 | // CRITICAL: Only update title bar for INACTIVE panes | |
| 559 | 566 | // Active panes should never show their title bar | |
| 560 | 567 | stackNode := node.parent | |
| @@ -572,17 +579,17 @@ func (spm *StackedPaneManager) UpdateTitleBar(webView *webkit.WebView, title str | |||
| 572 | 579 | if paneIndex != -1 && paneIndex != activeIndex { | |
| 573 | 580 | // This is an INACTIVE pane - safe to update title bar | |
| 574 | 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 | 583 | } else { | |
| 577 | 584 | // 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 | 590 | // updateTitleBarLabel updates the label widget within a title bar by recreating it | |
| 584 | 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 | 593 | return | |
| 587 | 594 | } | |
| 588 | 595 | ||
| @@ -594,22 +601,23 @@ func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string) | |||
| 594 | 601 | ||
| 595 | 602 | // Create new title bar with updated title and favicon | |
| 596 | 603 | newTitleBar := spm.createTitleBarWithTitle(title, pageURL) | |
| 597 | - | if newTitleBar == 0 { | |
| 604 | + | if newTitleBar == nil { | |
| 598 | 605 | log.Printf("[workspace] failed to create new title bar") | |
| 599 | 606 | return | |
| 600 | 607 | } | |
| 601 | 608 | ||
| 602 | 609 | // Store the mapping from titleBar ID to pane and attach click handler | |
| 603 | 610 | titleBarID := atomic.AddUint64(&spm.nextTitleBarID, 1) | |
| 604 | - | spm.titleBarToPane[uintptr(titleBarID)] = node | |
| 611 | + | spm.titleBarToPane[titleBarID] = node | |
| 605 | 612 | ||
| 606 | 613 | // Attach click handler | |
| 607 | - | webkit.WidgetAttachClickHandler(newTitleBar, func() { | |
| 608 | - | spm.handleTitleBarClick(uintptr(titleBarID)) | |
| 609 | - | }) | |
| 614 | + | // TODO: Implement with gtk.GestureClick | |
| 615 | + | // webkit.WidgetAttachClickHandler(newTitleBar, func() { | |
| 616 | + | // spm.handleTitleBarClick(titleBarID) | |
| 617 | + | // }) | |
| 610 | 618 | ||
| 611 | 619 | // 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 | 621 | // Find the correct insertion position based on the pane's index in the stack | |
| 614 | 622 | paneIndex := -1 | |
| 615 | 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 | 630 | // Remove old title bar | |
| 623 | - | if node.titleBar != 0 && webkit.WidgetIsValid(node.titleBar) { | |
| 631 | + | if node.titleBar != nil { | |
| 624 | 632 | webkit.WidgetUnparent(node.titleBar) | |
| 625 | 633 | } | |
| 626 | 634 | ||
| @@ -628,16 +636,16 @@ func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string) | |||
| 628 | 636 | spm.wm.setTitleBar(node, newTitleBar) | |
| 629 | 637 | ||
| 630 | 638 | // Insert the new title bar at the correct position | |
| 631 | - | if paneIndex == 0 { | |
| 632 | - | // First pane - insert at the beginning | |
| 633 | - | if node.parent.stackWrapper != 0 { | |
| 634 | - | webkit.BoxPrepend(node.parent.stackWrapper, newTitleBar) | |
| 635 | - | } | |
| 636 | - | } else { | |
| 637 | - | // Insert after the previous pane's container | |
| 638 | - | prevPane := node.parent.stackedPanes[paneIndex-1] | |
| 639 | - | if node.parent.stackWrapper != 0 && prevPane.container != 0 { | |
| 640 | - | webkit.BoxInsertChildAfter(node.parent.stackWrapper, newTitleBar, prevPane.container) | |
| 639 | + | if box, ok := node.parent.stackWrapper.(*gtk.Box); ok { | |
| 640 | + | if paneIndex == 0 { | |
| 641 | + | // First pane - insert at the beginning | |
| 642 | + | box.Prepend(newTitleBar) | |
| 643 | + | } else { | |
| 644 | + | // Insert after the previous pane's container | |
| 645 | + | prevPane := node.parent.stackedPanes[paneIndex-1] | |
| 646 | + | if prevPane.container != nil { | |
| 647 | + | box.InsertChildAfter(newTitleBar, prevPane.container) | |
| 648 | + | } | |
| 641 | 649 | } | |
| 642 | 650 | } | |
| 643 | 651 | ||
| @@ -646,18 +654,18 @@ func (spm *StackedPaneManager) updateTitleBarLabel(node *paneNode, title string) | |||
| 646 | 654 | } | |
| 647 | 655 | ||
| 648 | 656 | // createTitleBarWithTitle creates a title bar with a specific title and optional favicon | |
| 649 | - | func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL string) uintptr { | |
| 650 | - | titleBox := webkit.NewBox(webkit.OrientationHorizontal, 8) | |
| 651 | - | if titleBox == 0 { | |
| 652 | - | return 0 | |
| 657 | + | func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL string) gtk.Widgetter { | |
| 658 | + | titleBox := gtk.NewBox(gtk.OrientationHorizontal, 8) | |
| 659 | + | if titleBox == nil { | |
| 660 | + | return nil | |
| 653 | 661 | } | |
| 654 | 662 | ||
| 655 | - | webkit.WidgetAddCSSClass(titleBox, "stacked-pane-title") | |
| 656 | - | webkit.WidgetSetHExpand(titleBox, true) | |
| 657 | - | webkit.WidgetSetVExpand(titleBox, false) | |
| 663 | + | titleBox.AddCSSClass("stacked-pane-title") | |
| 664 | + | titleBox.SetHExpand(true) | |
| 665 | + | titleBox.SetVExpand(false) | |
| 658 | 666 | ||
| 659 | 667 | // Add favicon placeholder for debugging (always visible) | |
| 660 | - | var faviconImg uintptr | |
| 668 | + | var faviconImg gtk.Widgetter | |
| 661 | 669 | var debugReason string | |
| 662 | 670 | ||
| 663 | 671 | if pageURL == "" { | |
| @@ -691,9 +699,11 @@ func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL str | |||
| 691 | 699 | faviconPath := faviconCache.GetCachedPath(faviconURL) | |
| 692 | 700 | if faviconPath != "" { | |
| 693 | 701 | log.Printf("[favicon] Loading cached favicon from: %s", faviconPath) | |
| 694 | - | faviconImg = webkit.ImageNewFromFile(faviconPath) | |
| 695 | - | if faviconImg != 0 { | |
| 696 | - | webkit.ImageSetPixelSize(faviconImg, 16) | |
| 702 | + | faviconImg = gtk.NewImageFromFile(faviconPath) | |
| 703 | + | if faviconImg != nil { | |
| 704 | + | if img, ok := faviconImg.(*gtk.Image); ok { | |
| 705 | + | img.SetPixelSize(16) | |
| 706 | + | } | |
| 697 | 707 | webkit.WidgetAddCSSClass(faviconImg, "stacked-pane-favicon") | |
| 698 | 708 | log.Printf("[favicon] Successfully created favicon image widget") | |
| 699 | 709 | } else { | |
| @@ -711,32 +721,32 @@ func (spm *StackedPaneManager) createTitleBarWithTitle(title string, pageURL str | |||
| 711 | 721 | } | |
| 712 | 722 | ||
| 713 | 723 | // Add favicon or placeholder | |
| 714 | - | if faviconImg != 0 { | |
| 715 | - | webkit.BoxAppend(titleBox, faviconImg) | |
| 724 | + | if faviconImg != nil { | |
| 725 | + | titleBox.Append(faviconImg) | |
| 716 | 726 | webkit.WidgetShow(faviconImg) | |
| 717 | 727 | } else { | |
| 718 | 728 | // DEBUG: Add visible placeholder label to show code is running | |
| 719 | - | placeholderLabel := webkit.NewLabel(faviconPlaceholder) | |
| 720 | - | if placeholderLabel != 0 { | |
| 721 | - | webkit.WidgetAddCSSClass(placeholderLabel, "stacked-pane-favicon-placeholder") | |
| 722 | - | webkit.BoxAppend(titleBox, placeholderLabel) | |
| 723 | - | webkit.WidgetShow(placeholderLabel) | |
| 729 | + | placeholderLabel := gtk.NewLabel(faviconPlaceholder) | |
| 730 | + | if placeholderLabel != nil { | |
| 731 | + | placeholderLabel.AddCSSClass("stacked-pane-favicon-placeholder") | |
| 732 | + | titleBox.Append(placeholderLabel) | |
| 733 | + | placeholderLabel.Show() | |
| 724 | 734 | log.Printf("[favicon] Using placeholder for %s (reason: %s)", pageURL, debugReason) | |
| 725 | 735 | } | |
| 726 | 736 | } | |
| 727 | 737 | ||
| 728 | 738 | // Create title label with the specified title | |
| 729 | - | titleLabel := webkit.NewLabel(title) | |
| 730 | - | if titleLabel == 0 { | |
| 739 | + | titleLabel := gtk.NewLabel(title) | |
| 740 | + | if titleLabel == nil { | |
| 731 | 741 | return titleBox | |
| 732 | 742 | } | |
| 733 | 743 | ||
| 734 | - | webkit.WidgetAddCSSClass(titleLabel, "stacked-pane-title-text") | |
| 735 | - | webkit.LabelSetEllipsize(titleLabel, webkit.EllipsizeEnd) | |
| 744 | + | titleLabel.AddCSSClass("stacked-pane-title-text") | |
| 745 | + | titleLabel.SetEllipsize(2) // pango.EllipsizeEnd = 2 | |
| 736 | 746 | ||
| 737 | - | webkit.BoxAppend(titleBox, titleLabel) | |
| 738 | - | webkit.WidgetShow(titleBox) | |
| 739 | - | webkit.WidgetShow(titleLabel) | |
| 747 | + | titleBox.Append(titleLabel) | |
| 748 | + | titleBox.Show() | |
| 749 | + | titleLabel.Show() | |
| 740 | 750 | ||
| 741 | 751 | return titleBox | |
| 742 | 752 | } | |
| @@ -775,15 +785,15 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error { | |||
| 775 | 785 | ||
| 776 | 786 | // Remove the pane widgets from the stack container before mutating the tree. | |
| 777 | 787 | stackBox := stackNode.stackWrapper | |
| 778 | - | if stackBox == 0 { | |
| 788 | + | if stackBox == nil { | |
| 779 | 789 | stackBox = stackNode.container | |
| 780 | 790 | } | |
| 781 | - | if stackBox != 0 { | |
| 782 | - | if node.titleBar != 0 { | |
| 783 | - | webkit.BoxRemove(stackBox, node.titleBar) | |
| 791 | + | if box, ok := stackBox.(*gtk.Box); ok && box != nil { | |
| 792 | + | if node.titleBar != nil { | |
| 793 | + | box.Remove(node.titleBar) | |
| 784 | 794 | } | |
| 785 | - | if node.container != 0 { | |
| 786 | - | webkit.BoxRemove(stackBox, node.container) | |
| 795 | + | if node.container != nil { | |
| 796 | + | box.Remove(node.container) | |
| 787 | 797 | } | |
| 788 | 798 | } | |
| 789 | 799 | ||
| @@ -796,29 +806,29 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error { | |||
| 796 | 806 | parent := stackNode.parent | |
| 797 | 807 | if parent == nil { | |
| 798 | 808 | // Stack was the root of the workspace — closing this pane exits the app. | |
| 799 | - | if stackNode.container != 0 && webkit.WidgetIsValid(stackNode.container) { | |
| 800 | - | if webkit.WidgetGetParent(stackNode.container) != 0 { | |
| 809 | + | if stackNode.container != nil { | |
| 810 | + | if webkit.WidgetGetParent(stackNode.container) != nil { | |
| 801 | 811 | webkit.WidgetUnparent(stackNode.container) | |
| 802 | 812 | } | |
| 803 | 813 | } | |
| 804 | 814 | if spm.wm.window != nil { | |
| 805 | - | spm.wm.window.SetChild(0) | |
| 815 | + | spm.wm.window.SetChild(nil) | |
| 806 | 816 | } | |
| 807 | 817 | _, err := spm.wm.cleanupAndExit(node) | |
| 808 | 818 | return err | |
| 809 | 819 | } | |
| 810 | 820 | ||
| 811 | 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 | 823 | if parent.left == stackNode { | |
| 814 | - | webkit.PanedSetStartChild(parent.container, 0) | |
| 824 | + | paned.SetStartChild(nil) | |
| 815 | 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) { | |
| 821 | - | if webkit.WidgetGetParent(stackNode.container) != 0 { | |
| 830 | + | if stackNode.container != nil { | |
| 831 | + | if webkit.WidgetGetParent(stackNode.container) != nil { | |
| 822 | 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 | 852 | stackNode.parent = nil | |
| 843 | - | stackNode.container = 0 | |
| 844 | - | stackNode.stackWrapper = 0 | |
| 853 | + | stackNode.container = nil | |
| 854 | + | stackNode.stackWrapper = nil | |
| 845 | 855 | stackNode.stackedPanes = nil | |
| 846 | 856 | ||
| 847 | 857 | return nil | |
| @@ -852,14 +862,14 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error { | |||
| 852 | 862 | parent := stackNode.parent | |
| 853 | 863 | lastPaneContainer := lastPane.container | |
| 854 | 864 | ||
| 855 | - | if lastPane.titleBar != 0 && stackBox != 0 { | |
| 856 | - | webkit.BoxRemove(stackBox, lastPane.titleBar) | |
| 865 | + | if box, ok := stackBox.(*gtk.Box); ok && box != nil && lastPane.titleBar != nil { | |
| 866 | + | box.Remove(lastPane.titleBar) | |
| 857 | 867 | } | |
| 858 | - | if stackBox != 0 && lastPaneContainer != 0 { | |
| 859 | - | webkit.BoxRemove(stackBox, lastPaneContainer) | |
| 868 | + | if box, ok := stackBox.(*gtk.Box); ok && box != nil && lastPaneContainer != nil { | |
| 869 | + | box.Remove(lastPaneContainer) | |
| 860 | 870 | } | |
| 861 | - | if stackNode.container != 0 && webkit.WidgetIsValid(stackNode.container) { | |
| 862 | - | if webkit.WidgetGetParent(stackNode.container) != 0 { | |
| 871 | + | if stackNode.container != nil { | |
| 872 | + | if webkit.WidgetGetParent(stackNode.container) != nil { | |
| 863 | 873 | webkit.WidgetUnparent(stackNode.container) | |
| 864 | 874 | } | |
| 865 | 875 | } | |
| @@ -867,7 +877,7 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error { | |||
| 867 | 877 | if parent == nil { | |
| 868 | 878 | spm.wm.root = lastPane | |
| 869 | 879 | lastPane.parent = nil | |
| 870 | - | if spm.wm.window != nil && lastPaneContainer != 0 { | |
| 880 | + | if spm.wm.window != nil && lastPaneContainer != nil { | |
| 871 | 881 | spm.wm.window.SetChild(lastPaneContainer) | |
| 872 | 882 | webkit.WidgetQueueAllocate(lastPaneContainer) | |
| 873 | 883 | webkit.WidgetShow(lastPaneContainer) | |
| @@ -879,36 +889,36 @@ func (spm *StackedPaneManager) CloseStackedPane(node *paneNode) error { | |||
| 879 | 889 | parent.right = lastPane | |
| 880 | 890 | } | |
| 881 | 891 | 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 | 893 | if parent.left == lastPane { | |
| 884 | - | webkit.PanedSetStartChild(parent.container, lastPaneContainer) | |
| 894 | + | paned.SetStartChild(lastPaneContainer) | |
| 885 | 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 | 901 | webkit.WidgetSetVisible(lastPaneContainer, true) | |
| 892 | 902 | } | |
| 893 | 903 | } | |
| 894 | 904 | ||
| 895 | 905 | lastPane.isStacked = false | |
| 896 | 906 | lastPane.stackedPanes = nil | |
| 897 | - | lastPane.titleBar = 0 | |
| 907 | + | lastPane.titleBar = nil | |
| 898 | 908 | spm.wm.viewToNode[lastPane.pane.webView] = lastPane | |
| 899 | 909 | ||
| 900 | 910 | generation := spm.wm.nextCleanupGeneration() | |
| 901 | 911 | spm.wm.cleanupPane(node, generation) | |
| 902 | 912 | ||
| 903 | 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 | 915 | webkit.WidgetShow(lastPaneWidget) | |
| 906 | 916 | webkit.WidgetGrabFocus(lastPaneWidget) | |
| 907 | 917 | } | |
| 908 | 918 | ||
| 909 | 919 | stackNode.parent = nil | |
| 910 | - | stackNode.container = 0 | |
| 911 | - | stackNode.stackWrapper = 0 | |
| 920 | + | stackNode.container = nil | |
| 921 | + | stackNode.stackWrapper = nil | |
| 912 | 922 | stackNode.stackedPanes = nil | |
| 913 | 923 | ||
| 914 | 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 | 951 | // 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 | 953 | pane, exists := spm.titleBarToPane[titleBarID] | |
| 944 | 954 | if !exists || pane == nil || pane.parent == nil || !pane.parent.isStacked { | |
| 945 | 955 | return | |
| @@ -957,7 +967,7 @@ func (spm *StackedPaneManager) handleTitleBarClick(titleBarID uintptr) { | |||
| 957 | 967 | ||
| 958 | 968 | // Update title bar for the pane transitioning from ACTIVE to INACTIVE | |
| 959 | 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 | 971 | currentTitle := currentActivePane.pane.webView.GetTitle() | |
| 962 | 972 | if currentTitle != "" { | |
| 963 | 973 | spm.updateTitleBarLabel(currentActivePane, currentTitle) | |
M internal/app/browser/webview.go +134 -138
| @@ -2,69 +2,26 @@ package browser | |||
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | 4 | "context" | |
| 5 | + | "encoding/json" | |
| 5 | 6 | "fmt" | |
| 6 | 7 | "log" | |
| 7 | 8 | "os" | |
| 8 | 9 | "path/filepath" | |
| 9 | - | "strings" | |
| 10 | 10 | "time" | |
| 11 | 11 | ||
| 12 | 12 | "github.com/bnema/dumber/internal/app/constants" | |
| 13 | 13 | "github.com/bnema/dumber/internal/app/control" | |
| 14 | - | "github.com/bnema/dumber/internal/app/environment" | |
| 15 | 14 | "github.com/bnema/dumber/internal/app/messaging" | |
| 16 | 15 | "github.com/bnema/dumber/internal/config" | |
| 17 | 16 | "github.com/bnema/dumber/internal/filtering" | |
| 18 | 17 | "github.com/bnema/dumber/pkg/webkit" | |
| 19 | 18 | ) | |
| 20 | 19 | ||
| 21 | - | // buildMemoryConfig converts config.WebkitMemoryConfig to webkit.MemoryConfig | |
| 22 | - | func buildMemoryConfig(cfg config.WebkitMemoryConfig) webkit.MemoryConfig { | |
| 23 | - | mc := webkit.MemoryConfig{ | |
| 24 | - | MemoryLimitMB: cfg.MemoryLimitMB, | |
| 25 | - | ConservativeThreshold: cfg.ConservativeThreshold, | |
| 26 | - | StrictThreshold: cfg.StrictThreshold, | |
| 27 | - | KillThreshold: cfg.KillThreshold, | |
| 28 | - | PollIntervalSeconds: cfg.PollIntervalSeconds, | |
| 29 | - | EnableGCInterval: cfg.EnableGCInterval, | |
| 30 | - | ProcessRecycleThreshold: cfg.ProcessRecycleThreshold, | |
| 31 | - | EnablePageCache: cfg.EnablePageCache, | |
| 32 | - | EnableMemoryMonitoring: cfg.EnableMemoryMonitoring, | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | // Convert string cache model to webkit constant | |
| 36 | - | switch strings.ToLower(cfg.CacheModel) { | |
| 37 | - | case "document_viewer", "documentviewer", "doc": | |
| 38 | - | mc.CacheModel = webkit.CacheModelDocumentViewer | |
| 39 | - | case "primary_web_browser", "primary", "primarywebbrowser": | |
| 40 | - | mc.CacheModel = webkit.CacheModelPrimaryWebBrowser | |
| 41 | - | default: | |
| 42 | - | mc.CacheModel = webkit.CacheModelWebBrowser | |
| 43 | - | } | |
| 44 | - | ||
| 45 | - | return mc | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | func buildColorPalettes(cfg config.AppearanceConfig) webkit.ColorPalettes { | |
| 49 | - | toPalette := func(src config.ColorPalette) webkit.ColorPalette { | |
| 50 | - | return webkit.ColorPalette{ | |
| 51 | - | Background: src.Background, | |
| 52 | - | Surface: src.Surface, | |
| 53 | - | SurfaceVariant: src.SurfaceVariant, | |
| 54 | - | Text: src.Text, | |
| 55 | - | Muted: src.Muted, | |
| 56 | - | Accent: src.Accent, | |
| 57 | - | Border: src.Border, | |
| 58 | - | } | |
| 59 | - | } | |
| 60 | - | ||
| 61 | - | return webkit.ColorPalettes{ | |
| 62 | - | Light: toPalette(cfg.LightPalette), | |
| 63 | - | Dark: toPalette(cfg.DarkPalette), | |
| 64 | - | } | |
| 65 | - | } | |
| 20 | + | // Note: Memory management and color palettes are now handled at the application level | |
| 21 | + | // The gotk4 webkit.Config only contains basic WebKit settings | |
| 66 | 22 | ||
| 67 | 23 | func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) { | |
| 24 | + | // Ensure data directories exist for WebKit | |
| 68 | 25 | dataDir, err := config.GetDataDir() | |
| 69 | 26 | if err != nil { | |
| 70 | 27 | return nil, err | |
| @@ -83,39 +40,70 @@ func (app *BrowserApp) buildWebkitConfig() (*webkit.Config, error) { | |||
| 83 | 40 | return nil, err | |
| 84 | 41 | } | |
| 85 | 42 | ||
| 43 | + | // Build WebKit configuration with data directories for persistence | |
| 86 | 44 | cfg := &webkit.Config{ | |
| 87 | - | Assets: app.assets, | |
| 88 | - | InitialURL: "dumb://homepage", | |
| 89 | - | ZoomDefault: app.config.DefaultZoom, | |
| 90 | - | EnableDeveloperExtras: true, | |
| 91 | - | DataDir: webkitData, | |
| 92 | - | CacheDir: webkitCache, | |
| 93 | - | APIToken: app.config.APISecurity.Token, | |
| 94 | - | DefaultSansFont: app.config.Appearance.SansFont, | |
| 95 | - | DefaultSerifFont: app.config.Appearance.SerifFont, | |
| 96 | - | DefaultMonospaceFont: app.config.Appearance.MonospaceFont, | |
| 97 | - | DefaultFontSize: app.config.Appearance.DefaultFontSize, | |
| 98 | - | Rendering: webkit.RenderingConfig{Mode: string(app.config.RenderingMode)}, | |
| 99 | - | UseDomZoom: app.config.UseDomZoom, | |
| 100 | - | VideoAcceleration: webkit.VideoAccelerationConfig{ | |
| 101 | - | EnableVAAPI: app.config.VideoAcceleration.EnableVAAPI, | |
| 102 | - | AutoDetectGPU: app.config.VideoAcceleration.AutoDetectGPU, | |
| 103 | - | VAAPIDriverName: app.config.VideoAcceleration.VAAPIDriverName, | |
| 104 | - | EnableAllDrivers: app.config.VideoAcceleration.EnableAllDrivers, | |
| 105 | - | LegacyVAAPI: app.config.VideoAcceleration.LegacyVAAPI, | |
| 45 | + | UserAgent: buildUserAgent(app.config), | |
| 46 | + | EnableJavaScript: true, | |
| 47 | + | EnableWebGL: true, | |
| 48 | + | EnableMediaStream: true, | |
| 49 | + | HardwareAcceleration: true, | |
| 50 | + | DefaultFontSize: app.config.Appearance.DefaultFontSize, | |
| 51 | + | MinimumFontSize: 8, | |
| 52 | + | DataDir: webkitData, | |
| 53 | + | CacheDir: webkitCache, | |
| 54 | + | AppearanceConfigJSON: app.buildAppearanceConfigJSON(), | |
| 55 | + | CreateWindow: true, // Default to creating a window for standalone WebViews | |
| 56 | + | } | |
| 57 | + | ||
| 58 | + | return cfg, nil | |
| 59 | + | } | |
| 60 | + | ||
| 61 | + | // buildUserAgent constructs the user agent string from config | |
| 62 | + | func buildUserAgent(cfg *config.Config) string { | |
| 63 | + | if cfg.CodecPreferences.CustomUserAgent != "" { | |
| 64 | + | return cfg.CodecPreferences.CustomUserAgent | |
| 65 | + | } | |
| 66 | + | // Use default WebKit user agent | |
| 67 | + | return "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36" | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // buildAppearanceConfigJSON builds the palette configuration as JSON | |
| 71 | + | // The GUI expects window.__dumber_palette with just the palette data | |
| 72 | + | func (app *BrowserApp) buildAppearanceConfigJSON() string { | |
| 73 | + | if app.config == nil { | |
| 74 | + | return "" | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // Build palette config in the format main-world.ts expects | |
| 78 | + | // It looks for window.__dumber_palette = { "light": {...}, "dark": {...} } | |
| 79 | + | paletteConfig := map[string]interface{}{ | |
| 80 | + | "light": map[string]string{ | |
| 81 | + | "background": app.config.Appearance.LightPalette.Background, | |
| 82 | + | "surface": app.config.Appearance.LightPalette.Surface, | |
| 83 | + | "surface_variant": app.config.Appearance.LightPalette.SurfaceVariant, | |
| 84 | + | "text": app.config.Appearance.LightPalette.Text, | |
| 85 | + | "muted": app.config.Appearance.LightPalette.Muted, | |
| 86 | + | "accent": app.config.Appearance.LightPalette.Accent, | |
| 87 | + | "border": app.config.Appearance.LightPalette.Border, | |
| 106 | 88 | }, | |
| 107 | - | Memory: buildMemoryConfig(app.config.WebkitMemory), | |
| 108 | - | CodecPreferences: webkit.CodecPreferencesConfig{ | |
| 109 | - | PreferredCodecs: strings.Split(app.config.CodecPreferences.PreferredCodecs, ","), | |
| 110 | - | BlockedCodecs: environment.BuildBlockedCodecsList(app.config.CodecPreferences), | |
| 111 | - | ForceAV1: app.config.CodecPreferences.ForceAV1, | |
| 112 | - | CustomUserAgent: app.config.CodecPreferences.CustomUserAgent, | |
| 113 | - | DisableTwitchCodecControl: app.config.CodecPreferences.DisableTwitchCodecControl, | |
| 89 | + | "dark": map[string]string{ | |
| 90 | + | "background": app.config.Appearance.DarkPalette.Background, | |
| 91 | + | "surface": app.config.Appearance.DarkPalette.Surface, | |
| 92 | + | "surface_variant": app.config.Appearance.DarkPalette.SurfaceVariant, | |
| 93 | + | "text": app.config.Appearance.DarkPalette.Text, | |
| 94 | + | "muted": app.config.Appearance.DarkPalette.Muted, | |
| 95 | + | "accent": app.config.Appearance.DarkPalette.Accent, | |
| 96 | + | "border": app.config.Appearance.DarkPalette.Border, | |
| 114 | 97 | }, | |
| 115 | - | Colors: buildColorPalettes(app.config.Appearance), | |
| 116 | 98 | } | |
| 117 | 99 | ||
| 118 | - | return cfg, nil | |
| 100 | + | payload, err := json.Marshal(paletteConfig) | |
| 101 | + | if err != nil { | |
| 102 | + | log.Printf("[webview] Failed to marshal palette config: %v", err) | |
| 103 | + | return "" | |
| 104 | + | } | |
| 105 | + | ||
| 106 | + | return string(payload) | |
| 119 | 107 | } | |
| 120 | 108 | ||
| 121 | 109 | func (app *BrowserApp) buildPane(view *webkit.WebView) (*BrowserPane, error) { | |
| @@ -167,7 +155,7 @@ func (app *BrowserApp) createPaneForView(view *webkit.WebView) (*BrowserPane, er | |||
| 167 | 155 | pane.initializeGUITracking() | |
| 168 | 156 | ||
| 169 | 157 | // 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 | 160 | app.attachPaneHandlers(pane) | |
| 173 | 161 | return pane, nil | |
| @@ -202,7 +190,7 @@ func (app *BrowserApp) attachPaneHandlers(pane *BrowserPane) { | |||
| 202 | 190 | }) | |
| 203 | 191 | ||
| 204 | 192 | pane.webView.RegisterScriptMessageHandler(func(payload string) { | |
| 205 | - | if app.workspace != nil { | |
| 193 | + | if app.workspace != nil && shouldFocusForScriptMessage(payload) { | |
| 206 | 194 | app.workspace.focusByView(pane.webView) | |
| 207 | 195 | } | |
| 208 | 196 | pane.messageHandler.Handle(payload) | |
| @@ -212,21 +200,32 @@ func (app *BrowserApp) attachPaneHandlers(pane *BrowserPane) { | |||
| 212 | 200 | pane.messageHandler.SetNavigationController(pane.navigationController) | |
| 213 | 201 | } | |
| 214 | 202 | ||
| 215 | - | pane.webView.RegisterPopupHandler(func(uri string) *webkit.WebView { | |
| 216 | - | if app.workspace != nil { | |
| 217 | - | return app.workspace.HandlePopup(pane.webView, uri) | |
| 203 | + | // Setup WebKit-native popup lifecycle using create/ready-to-show/close signals | |
| 204 | + | if app.workspace != nil { | |
| 205 | + | node := app.workspace.GetNodeForWebView(pane.webView) | |
| 206 | + | if node != nil { | |
| 207 | + | app.workspace.setupPopupHandling(pane.webView, node) | |
| 208 | + | log.Printf("[webview] Setup native popup handling for WebView ID: %d", pane.webView.ID()) | |
| 218 | 209 | } | |
| 210 | + | } | |
| 211 | + | } | |
| 219 | 212 | ||
| 220 | - | // No workspace manager - just navigate in current pane | |
| 221 | - | if uri != "" && pane.navigationController != nil { | |
| 222 | - | go func() { | |
| 223 | - | if err := pane.navigationController.NavigateToURL(uri); err != nil { | |
| 224 | - | log.Printf("[webview] failed to navigate to URL %s: %v", uri, err) | |
| 225 | - | } | |
| 226 | - | }() | |
| 227 | - | } | |
| 228 | - | return nil | |
| 229 | - | }) | |
| 213 | + | // shouldFocusForScriptMessage filters script messages and only allows focus handoff | |
| 214 | + | // for explicit user-driven actions (navigation, history commands, popup lifecycle). | |
| 215 | + | // Background bootstrap messages (palette sync, workspace bridge chatter, etc.) are | |
| 216 | + | // ignored so they don't steal focus from the user's active pane. | |
| 217 | + | func shouldFocusForScriptMessage(payload string) bool { | |
| 218 | + | var msg messaging.Message | |
| 219 | + | if err := json.Unmarshal([]byte(payload), &msg); err != nil { | |
| 220 | + | return true // keep legacy behaviour if parsing fails | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | switch msg.Type { | |
| 224 | + | case "navigate", "query", "wails", "history_recent", "history_stats", "history_search", "history_delete", "close-popup": | |
| 225 | + | return true | |
| 226 | + | default: | |
| 227 | + | return false | |
| 228 | + | } | |
| 230 | 229 | } | |
| 231 | 230 | ||
| 232 | 231 | // createWebView creates and configures the WebView | |
| @@ -238,9 +237,7 @@ func (app *BrowserApp) createWebView() error { | |||
| 238 | 237 | return err | |
| 239 | 238 | } | |
| 240 | 239 | ||
| 241 | - | // Main window needs a top-level window | |
| 242 | - | cfg.CreateWindow = true | |
| 243 | - | ||
| 240 | + | // Note: Window creation is now handled automatically by webkit.NewWebView | |
| 244 | 241 | view, err := webkit.NewWebView(cfg) | |
| 245 | 242 | ||
| 246 | 243 | if err != nil { | |
| @@ -263,6 +260,19 @@ func (app *BrowserApp) createWebView() error { | |||
| 263 | 260 | ||
| 264 | 261 | if app.workspace == nil { | |
| 265 | 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 | 278 | // Initialize window-level global shortcuts AFTER workspace is set up | |
| @@ -294,8 +304,8 @@ func (app *BrowserApp) createWebView() error { | |||
| 294 | 304 | func (app *BrowserApp) setupWebViewIntegration() { | |
| 295 | 305 | app.browserService.AttachWebView(app.webView) | |
| 296 | 306 | ||
| 297 | - | // GUI bundle is loaded via WebKit user scripts in enableUserContentManager | |
| 298 | - | // No need to load separately in browser service | |
| 307 | + | // GUI bundle is loaded via WebKit user scripts in SetupUserContentManager | |
| 308 | + | // Appearance config is also injected at document-start via UserContentManager | |
| 299 | 309 | ||
| 300 | 310 | // Use native window as title updater | |
| 301 | 311 | if win := app.webView.Window(); win != nil { | |
| @@ -308,7 +318,7 @@ func (app *BrowserApp) setupContentBlocking() error { | |||
| 308 | 318 | log.Printf("Initializing content blocking system...") | |
| 309 | 319 | ||
| 310 | 320 | // Enable WebKit debug logging if requested | |
| 311 | - | webkit.SetupWebKitDebugLogging(app.config) | |
| 321 | + | webkit.SetupWebKitDebugLogging() | |
| 312 | 322 | ||
| 313 | 323 | defer func() { | |
| 314 | 324 | if r := recover(); r != nil { | |
| @@ -326,58 +336,44 @@ func (app *BrowserApp) setupContentBlocking() error { | |||
| 326 | 336 | // Store reference to filter manager | |
| 327 | 337 | app.filterManager = filterManager | |
| 328 | 338 | ||
| 329 | - | // Set up callback to re-apply network filters when they become ready | |
| 339 | + | // Set up callback to apply network filters when they become ready | |
| 330 | 340 | // This callback will be called from the async filter loading process | |
| 331 | 341 | filterManager.SetFiltersReadyCallback(func() { | |
| 332 | - | // Add small delay to avoid race conditions with WebView initialization | |
| 333 | - | go func() { | |
| 334 | - | time.Sleep(300 * time.Millisecond) | |
| 335 | - | if app.webView != nil { | |
| 336 | - | if err := app.webView.UpdateContentFilters(filterManager); err != nil { | |
| 337 | - | log.Printf("Failed to apply filters after loading: %v", err) | |
| 338 | - | } else { | |
| 339 | - | log.Printf("Successfully applied filters after async loading") | |
| 340 | - | } | |
| 341 | - | } | |
| 342 | - | }() | |
| 343 | - | }) | |
| 342 | + | log.Printf("[filtering] Filters ready, applying to WebView...") | |
| 344 | 343 | ||
| 345 | - | // Start async filter loading early (before WebView content blocking initialization) | |
| 346 | - | // This allows filters to be ready when the WebView needs them | |
| 347 | - | go func() { | |
| 348 | - | if err := filtering.InitializeFiltersAsync(filterManager); err != nil { | |
| 349 | - | log.Printf("Warning: failed to initialize filters asynchronously: %v", err) | |
| 344 | + | // Get the WebKit JSON rules from filter manager | |
| 345 | + | filterJSON, err := filterManager.GetNetworkFilters() | |
| 346 | + | if err != nil { | |
| 347 | + | log.Printf("[filtering] Failed to get network filters: %v", err) | |
| 348 | + | return | |
| 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 { | |
| 361 | - | log.Printf("Warning: Failed to initialize content blocking: %v", err) | |
| 362 | - | // Continue without content blocking rather than failing | |
| 351 | + | if len(filterJSON) == 0 { | |
| 352 | + | log.Printf("[filtering] No network filters to apply") | |
| 353 | + | return | |
| 363 | 354 | } | |
| 364 | 355 | ||
| 365 | - | // Register navigation handler for domain-specific filtering and GUI injection | |
| 366 | - | // Only register after content blocking is initialized | |
| 367 | - | app.webView.RegisterURIChangedHandler(func(uri string) { | |
| 368 | - | // Add small delay to avoid conflicts with page load | |
| 369 | - | go func() { | |
| 370 | - | time.Sleep(200 * time.Millisecond) // Slightly longer delay for stability | |
| 356 | + | log.Printf("[filtering] Got %d bytes of WebKit JSON rules", len(filterJSON)) | |
| 371 | 357 | ||
| 372 | - | // Apply content filtering if available | |
| 373 | - | if app.filterManager != nil { | |
| 374 | - | app.webView.OnNavigate(uri, app.filterManager, app.config.ContentFilteringWhitelist) | |
| 358 | + | // Apply filters to the WebView | |
| 359 | + | // This must be done on the main thread since it touches GTK/WebKit | |
| 360 | + | if app.webView != nil { | |
| 361 | + | app.webView.RunOnMainThread(func() { | |
| 362 | + | if err := app.webView.InitializeContentBlocking(filterJSON); err != nil { | |
| 363 | + | log.Printf("[filtering] Failed to apply content filters: %v", err) | |
| 364 | + | } else { | |
| 365 | + | log.Printf("[filtering] ✅ Content blocking enabled successfully") | |
| 375 | 366 | } | |
| 367 | + | }) | |
| 368 | + | } | |
| 369 | + | }) | |
| 376 | 370 | ||
| 377 | - | // Note: GUI bundle (controls and toast) is now injected as User Script | |
| 378 | - | // in WebKit's enableUserContentManager, so it persists across all navigations | |
| 379 | - | }() | |
| 380 | - | }) | |
| 371 | + | // Start async filter loading | |
| 372 | + | // This allows filters to compile in the background while the browser starts | |
| 373 | + | go func() { | |
| 374 | + | if err := filtering.InitializeFiltersAsync(filterManager); err != nil { | |
| 375 | + | log.Printf("Warning: failed to initialize filters asynchronously: %v", err) | |
| 376 | + | } | |
| 381 | 377 | }() | |
| 382 | 378 | ||
| 383 | 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 | 43 | func (h *WindowShortcutHandler) initialize() error { | |
| 44 | - | // Initialize GTK4 global shortcuts | |
| 45 | - | h.shortcuts = h.window.InitializeGlobalShortcuts() | |
| 44 | + | // Create WindowShortcuts manager | |
| 45 | + | h.shortcuts = webkit.NewWindowShortcuts(h.window) | |
| 46 | 46 | if h.shortcuts == nil { | |
| 47 | 47 | return ErrFailedToInitialize | |
| 48 | 48 | } | |
| @@ -60,6 +60,7 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error { | |||
| 60 | 60 | {"ctrl+f", h.handleFindToggle, "Find in page"}, | |
| 61 | 61 | {"ctrl+shift+c", h.handleCopyURL, "Copy URL"}, | |
| 62 | 62 | {"ctrl+shift+p", h.handlePrint, "Print page"}, | |
| 63 | + | {"ctrl+t", h.handleNewTab, "New tab (no-op)"}, | |
| 63 | 64 | {"ctrl+w", h.handleClosePane, "Close current pane"}, | |
| 64 | 65 | {"F12", h.handleDevTools, "Developer tools"}, | |
| 65 | 66 | // Zoom shortcuts - global level for proper active pane targeting | |
| @@ -75,11 +76,7 @@ func (h *WindowShortcutHandler) registerGlobalShortcuts() error { | |||
| 75 | 76 | } | |
| 76 | 77 | ||
| 77 | 78 | for _, shortcut := range shortcuts { | |
| 78 | - | if err := h.shortcuts.RegisterGlobalShortcut(shortcut.key, shortcut.handler); err != nil { | |
| 79 | - | log.Printf("[window-shortcuts] Failed to register %s (%s): %v", | |
| 80 | - | shortcut.key, shortcut.desc, err) | |
| 81 | - | return err | |
| 82 | - | } | |
| 79 | + | h.shortcuts.RegisterShortcut(shortcut.key, shortcut.handler) | |
| 83 | 80 | log.Printf("[window-shortcuts] Registered global shortcut: %s (%s)", | |
| 84 | 81 | shortcut.key, shortcut.desc) | |
| 85 | 82 | } | |
| @@ -208,11 +205,7 @@ func (h *WindowShortcutHandler) handleZoom(action string, multiplier float64) { | |||
| 208 | 205 | log.Printf("[window-shortcuts] Zoom %s -> pane %p", action, activeWebView) | |
| 209 | 206 | ||
| 210 | 207 | // Get current zoom level | |
| 211 | - | currentZoom, err := activeWebView.GetZoom() | |
| 212 | - | if err != nil { | |
| 213 | - | log.Printf("[window-shortcuts] Failed to get current zoom: %v", err) | |
| 214 | - | return | |
| 215 | - | } | |
| 208 | + | currentZoom := activeWebView.GetZoom() | |
| 216 | 209 | ||
| 217 | 210 | // Calculate new zoom level | |
| 218 | 211 | var newZoom float64 | |
| @@ -234,6 +227,27 @@ func (h *WindowShortcutHandler) handleZoom(action string, multiplier float64) { | |||
| 234 | 227 | // Apply zoom to active pane | |
| 235 | 228 | if err := activeWebView.SetZoom(newZoom); err != nil { | |
| 236 | 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 | 275 | if pane == nil || pane.webView == nil { | |
| 262 | 276 | return "unknown" | |
| 263 | 277 | } | |
| 264 | - | return pane.webView.ID() | |
| 278 | + | return pane.webView.IDString() | |
| 265 | 279 | } | |
| 266 | 280 | ||
| 267 | 281 | func (h *WindowShortcutHandler) handleUIToggle(lastToggle *time.Time, featureName, action string) { | |
| @@ -320,10 +334,10 @@ func (h *WindowShortcutHandler) handleClosePane() { | |||
| 320 | 334 | for webView := range h.app.workspace.viewToNode { | |
| 321 | 335 | if webView != nil { | |
| 322 | 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 | 338 | if isActive { | |
| 325 | 339 | 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 | 341 | break | |
| 328 | 342 | } | |
| 329 | 343 | } | |
| @@ -342,7 +356,7 @@ func (h *WindowShortcutHandler) handleClosePane() { | |||
| 342 | 356 | // Use the proper close-popup message for popups | |
| 343 | 357 | msg := messaging.Message{ | |
| 344 | 358 | Event: "close-popup", | |
| 345 | - | WebViewID: activeWebView.ID(), | |
| 359 | + | WebViewID: activeWebView.IDString(), | |
| 346 | 360 | Reason: "user-ctrl-w", | |
| 347 | 361 | } | |
| 348 | 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 | 377 | // Cleanup releases resources | |
| 357 | 378 | func (h *WindowShortcutHandler) Cleanup() { | |
| 358 | - | if h.shortcuts != nil { | |
| 359 | - | h.shortcuts.Cleanup() | |
| 360 | - | h.shortcuts = nil | |
| 361 | - | } | |
| 379 | + | // WindowShortcuts doesn't require explicit cleanup | |
| 380 | + | h.shortcuts = nil | |
| 362 | 381 | } | |
| 363 | 382 | ||
| 364 | 383 | // Error definitions | |
M internal/app/browser/workspace_css.go +38 -24
| @@ -3,6 +3,7 @@ package browser | |||
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | 5 | "fmt" | |
| 6 | + | "log" | |
| 6 | 7 | "strings" | |
| 7 | 8 | ||
| 8 | 9 | "github.com/bnema/dumber/internal/config" | |
| @@ -72,31 +73,50 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string { | |||
| 72 | 73 | ||
| 73 | 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 | 82 | css := fmt.Sprintf(`window { | |
| 76 | 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 | 100 | .workspace-pane, .stacked-pane-container { | |
| 81 | - | outline: 2px solid transparent; | |
| 82 | - | outline-offset: -2px; | |
| 83 | - | transition: outline-color %dms ease-in-out; | |
| 101 | + | border: 2px solid %s; | |
| 102 | + | border-radius: %dpx; | |
| 103 | + | transition: border-color %dms ease-in-out; | |
| 104 | + | margin: 0; | |
| 84 | 105 | } | |
| 85 | 106 | ||
| 86 | - | /* Active pane border styling using outline (no layout impact) */ | |
| 107 | + | /* Active pane border styling */ | |
| 87 | 108 | .workspace-pane-active { | |
| 88 | - | outline-color: %s; | |
| 109 | + | border-color: %s; | |
| 89 | 110 | } | |
| 90 | 111 | ||
| 91 | 112 | /* Stacked panes styling */ | |
| 92 | 113 | .stacked-pane-container { | |
| 93 | 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 | 118 | .stacked-pane-container.workspace-pane-active { | |
| 99 | - | outline-color: %s; | |
| 119 | + | border-color: %s; | |
| 100 | 120 | } | |
| 101 | 121 | ||
| 102 | 122 | .stacked-pane-title { | |
| @@ -129,11 +149,14 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string { | |||
| 129 | 149 | /* Collapsed panes are hidden - handled in code via widget visibility */ | |
| 130 | 150 | }`, | |
| 131 | 151 | windowBackgroundColor, // window background | |
| 132 | - | styling.TransitionDuration, // base pane outline transition | |
| 133 | - | activeBorderColor, // workspace-pane-active outline-color | |
| 152 | + | paneModeColor, // window.pane-mode-active background (border color) | |
| 153 | + | windowBackgroundColor, // paned, box background | |
| 154 | + | inactiveBorderColor, // base pane border color (inactive) | |
| 155 | + | styling.BorderRadius, // base pane border radius | |
| 156 | + | styling.TransitionDuration, // base pane border transition | |
| 157 | + | activeBorderColor, // workspace-pane-active border color | |
| 134 | 158 | windowBackgroundColor, // stacked-pane-container background | |
| 135 | - | styling.BorderRadius, // stacked-pane-container border-radius | |
| 136 | - | activeBorderColor, // stacked-pane-container.workspace-pane-active outline-color | |
| 159 | + | inactiveBorderColor, // stacked-pane-container.workspace-pane-active border color (keep subtle) | |
| 137 | 160 | getStackTitleBg(isDark), // stacked-pane-title background | |
| 138 | 161 | inactiveBorderColor, // stacked-pane-title border-bottom | |
| 139 | 162 | styling.TransitionDuration, // stacked-pane-title transition | |
| @@ -144,12 +167,6 @@ func (wm *WorkspaceManager) generateWorkspaceCSS() string { | |||
| 144 | 167 | 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 | 170 | // Global CSS provider tracking to prevent duplication | |
| 154 | 171 | var ( | |
| 155 | 172 | globalCSSInitialized bool | |
| @@ -171,12 +188,9 @@ func (wm *WorkspaceManager) ensureWorkspaceStyles() { | |||
| 171 | 188 | webkit.AddCSSProvider(workspaceCSS) | |
| 172 | 189 | globalCSSInitialized = true | |
| 173 | 190 | 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 | 195 | 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 | 134 | parts = append(parts, fmt.Sprintf("split orientation=%d", node.orientation)) | |
| 135 | 135 | } | |
| 136 | 136 | ||
| 137 | - | if node.container != 0 { | |
| 138 | - | parts = append(parts, fmt.Sprintf("widget=%#x", node.container)) | |
| 137 | + | if node.container != nil { | |
| 138 | + | parts = append(parts, fmt.Sprintf("widget=%p", node.container)) | |
| 139 | 139 | } | |
| 140 | 140 | ||
| 141 | 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 | 50 | func (gv *GeometryValidator) GetPaneGeometry(node *paneNode) PaneGeometry { | |
| 51 | 51 | geometry := PaneGeometry{IsValid: false} | |
| 52 | 52 | ||
| 53 | - | if node == nil || node.container == 0 { | |
| 53 | + | if node == nil || node.container == nil { | |
| 54 | 54 | if gv.debugMode { | |
| 55 | 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 | 60 | // 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.X | |
| 64 | - | geometry.Y = allocation.Y | |
| 65 | - | geometry.Width = allocation.Width | |
| 66 | - | geometry.Height = allocation.Height | |
| 63 | + | geometry.X = x | |
| 64 | + | geometry.Y = y | |
| 65 | + | geometry.Width = width | |
| 66 | + | geometry.Height = height | |
| 67 | 67 | geometry.IsValid = true | |
| 68 | 68 | ||
| 69 | 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 | 13 | "github.com/bnema/dumber/internal/app/messaging" | |
| 14 | 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 | 19 | // WorkspaceManager coordinates Zellij-style pane operations. | |
| @@ -30,9 +32,10 @@ type WorkspaceManager struct { | |||
| 30 | 32 | createPaneFn func(*webkit.WebView) (*BrowserPane, error) | |
| 31 | 33 | ||
| 32 | 34 | // Coordination fields for preventing duplicate events | |
| 33 | - | paneModeSource *webkit.WebView // Which webview initiated pane mode | |
| 34 | - | lastPaneModeEntry time.Time // When pane mode was last entered | |
| 35 | - | paneMutex sync.Mutex // Protects pane mode state | |
| 35 | + | paneModeSource *webkit.WebView // Which webview initiated pane mode | |
| 36 | + | paneModeActivePane *paneNode // Which pane node has pane mode active | |
| 37 | + | paneModeContainer gtk.Widgetter // The container widget that has margin applied | |
| 38 | + | paneMutex sync.Mutex // Protects pane mode state | |
| 36 | 39 | ||
| 37 | 40 | // Focus throttling fields to prevent infinite loops | |
| 38 | 41 | lastFocusChange time.Time // When focus was last changed | |
| @@ -42,7 +45,8 @@ type WorkspaceManager struct { | |||
| 42 | 45 | lastStackOperation time.Time // When a stack operation was last performed | |
| 43 | 46 | ||
| 44 | 47 | // NEW: Pane creation deduplicator | |
| 45 | - | paneDeduplicator *messaging.PaneRequestDeduplicator | |
| 48 | + | // Popups waiting for ready-to-show lifecycle callback | |
| 49 | + | pendingPopups map[uint64]*pendingPopup | |
| 46 | 50 | ||
| 47 | 51 | // Specialized managers for different pane operations | |
| 48 | 52 | stackedPaneManager *StackedPaneManager | |
| @@ -76,14 +80,14 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag | |||
| 76 | 80 | debugLevel := getDebugLevel() | |
| 77 | 81 | ||
| 78 | 82 | manager := &WorkspaceManager{ | |
| 79 | - | app: app, | |
| 80 | - | window: rootPane.webView.Window(), | |
| 81 | - | viewToNode: make(map[*webkit.WebView]*paneNode), | |
| 82 | - | lastSplitMsg: make(map[*webkit.WebView]time.Time), | |
| 83 | - | lastExitMsg: make(map[*webkit.WebView]time.Time), | |
| 84 | - | paneDeduplicator: messaging.NewPaneRequestDeduplicator(), | |
| 85 | - | focusDebounce: 150 * time.Millisecond, | |
| 86 | - | debugLevel: debugLevel, | |
| 83 | + | app: app, | |
| 84 | + | window: rootPane.webView.Window(), | |
| 85 | + | viewToNode: make(map[*webkit.WebView]*paneNode), | |
| 86 | + | lastSplitMsg: make(map[*webkit.WebView]time.Time), | |
| 87 | + | lastExitMsg: make(map[*webkit.WebView]time.Time), | |
| 88 | + | pendingPopups: make(map[uint64]*pendingPopup), | |
| 89 | + | focusDebounce: 150 * time.Millisecond, | |
| 90 | + | debugLevel: debugLevel, | |
| 87 | 91 | } | |
| 88 | 92 | ||
| 89 | 93 | // Initialize validation components (opt-in based on debug level) | |
| @@ -114,10 +118,9 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag | |||
| 114 | 118 | if err != nil { | |
| 115 | 119 | return nil, err | |
| 116 | 120 | } | |
| 117 | - | // Pane WebViews are embedded inside the main application window, so they do not | |
| 118 | - | // need their own toplevel GtkWindow. Avoid creating per-pane windows to keep GTK | |
| 119 | - | // ownership simple (Epiphany follows the same pattern) and to prevent premature | |
| 120 | - | // swapchain destruction when panes are closed. | |
| 121 | + | // CRITICAL: Pane WebViews are embedded inside the workspace paned containers, | |
| 122 | + | // so they do not need their own toplevel GtkWindow. Setting CreateWindow=false | |
| 123 | + | // prevents GTK "widget already has parent" errors when adding to paned containers. | |
| 121 | 124 | cfg.CreateWindow = false | |
| 122 | 125 | return webkit.NewWebView(cfg) | |
| 123 | 126 | } | |
| @@ -166,6 +169,9 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag | |||
| 166 | 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 | 175 | if manager.debugPaneClose { | |
| 170 | 176 | manager.dumpTreeState("workspace_init") | |
| 171 | 177 | } | |
| @@ -173,213 +179,142 @@ func NewWorkspaceManager(app *BrowserApp, rootPane *BrowserPane) *WorkspaceManag | |||
| 173 | 179 | return manager | |
| 174 | 180 | } | |
| 175 | 181 | ||
| 176 | - | // OnWorkspaceMessage implements messaging.WorkspaceObserver. | |
| 177 | - | func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messaging.Message) { | |
| 178 | - | node := wm.viewToNode[source] | |
| 179 | - | if node == nil { | |
| 180 | - | log.Printf("[workspace] message from unknown webview: event=%s", msg.Event) | |
| 181 | - | return | |
| 182 | + | // shouldDebounce checks if an operation should be debounced based on timing | |
| 183 | + | func (wm *WorkspaceManager) shouldDebounce(source *webkit.WebView, threshold time.Duration) bool { | |
| 184 | + | if last, ok := wm.lastSplitMsg[source]; ok { | |
| 185 | + | if elapsed := time.Since(last); elapsed < threshold { | |
| 186 | + | log.Printf("[workspace] operation debounced: %.0fms", elapsed.Seconds()*1000) | |
| 187 | + | return true | |
| 188 | + | } | |
| 182 | 189 | } | |
| 190 | + | wm.lastSplitMsg[source] = time.Now() | |
| 191 | + | return false | |
| 192 | + | } | |
| 183 | 193 | ||
| 184 | - | switch event := strings.ToLower(msg.Event); event { | |
| 185 | - | case "pane-mode-entered": | |
| 186 | - | // Use mutex to prevent race conditions when multiple webviews try to enter pane mode | |
| 187 | - | wm.paneMutex.Lock() | |
| 188 | - | defer wm.paneMutex.Unlock() | |
| 194 | + | // withSplitLock executes an operation with atomic splitting lock protection | |
| 195 | + | func (wm *WorkspaceManager) withSplitLock(operation string, fn func() error) error { | |
| 196 | + | if !atomic.CompareAndSwapInt32(&wm.splitting, 0, 1) { | |
| 197 | + | log.Printf("[workspace] %s ignored: operation in progress", operation) | |
| 198 | + | return fmt.Errorf("operation in progress") | |
| 199 | + | } | |
| 189 | 200 | ||
| 190 | - | // Check if pane mode was already entered recently (within 200ms debounce window) | |
| 191 | - | if time.Since(wm.lastPaneModeEntry) < 200*time.Millisecond { | |
| 192 | - | log.Printf("[workspace] pane-mode-entered rejected: debounce protection (%.0fms ago) source=%p", | |
| 193 | - | time.Since(wm.lastPaneModeEntry).Seconds()*1000, source) | |
| 194 | - | return | |
| 201 | + | defer func() { | |
| 202 | + | atomic.StoreInt32(&wm.splitting, 0) | |
| 203 | + | if r := recover(); r != nil { | |
| 204 | + | log.Printf("[workspace] %s panicked: %v", operation, r) | |
| 205 | + | panic(r) | |
| 195 | 206 | } | |
| 207 | + | }() | |
| 196 | 208 | ||
| 197 | - | // Check if pane mode is already active from a different source | |
| 198 | - | if wm.paneModeActive && wm.paneModeSource != nil && wm.paneModeSource != source { | |
| 199 | - | log.Printf("[workspace] pane-mode-entered rejected: already active from different source (current=%p, new=%p)", | |
| 200 | - | wm.paneModeSource, source) | |
| 201 | - | return | |
| 202 | - | } | |
| 209 | + | return fn() | |
| 210 | + | } | |
| 203 | 211 | ||
| 204 | - | log.Printf("[workspace] pane-mode-entered accepted: source=%p", source) | |
| 205 | - | wm.paneModeActive = true | |
| 206 | - | wm.paneModeSource = source | |
| 207 | - | wm.lastPaneModeEntry = time.Now() | |
| 208 | - | wm.SetActivePane(node, SourceProgrammatic) | |
| 209 | - | case "pane-confirmed", "pane-cancelled", "pane-mode-exited": | |
| 210 | - | // Debounce pane-mode-exited events to prevent duplicate focus calls | |
| 211 | - | if event == "pane-mode-exited" { | |
| 212 | - | if last, ok := wm.lastExitMsg[source]; ok { | |
| 213 | - | if time.Since(last) < 100*time.Millisecond { | |
| 214 | - | log.Printf("[workspace] pane-mode-exited ignored: debounce (%.0fms)", time.Since(last).Seconds()*1000) | |
| 215 | - | return | |
| 216 | - | } | |
| 217 | - | } | |
| 218 | - | wm.lastExitMsg[source] = time.Now() | |
| 219 | - | } | |
| 212 | + | // cleanupPopupStorage removes localStorage entries for a closed popup | |
| 213 | + | func (wm *WorkspaceManager) cleanupPopupStorage(parentNode, targetNode *paneNode, webviewID string) { | |
| 214 | + | if parentNode.pane == nil || parentNode.pane.WebView() == nil || targetNode.requestID == "" { | |
| 215 | + | return | |
| 216 | + | } | |
| 220 | 217 | ||
| 221 | - | wm.paneMutex.Lock() | |
| 222 | - | wm.paneModeActive = false | |
| 223 | - | wm.paneModeSource = nil | |
| 224 | - | wm.paneMutex.Unlock() | |
| 218 | + | requestID := targetNode.requestID | |
| 219 | + | parentWebViewID := parentNode.pane.WebView().ID() | |
| 220 | + | ||
| 221 | + | script := fmt.Sprintf(` | |
| 222 | + | try { | |
| 223 | + | localStorage.removeItem('popup_mapping_%d'); | |
| 224 | + | const keys = ['popup_%s_parent_info', 'popup_%s_parent_action', | |
| 225 | + | 'popup_%s_message_to_popup', 'popup_%s_message_to_parent', | |
| 226 | + | 'oauth_callback_%s']; | |
| 227 | + | keys.forEach(k => { try { localStorage.removeItem(k); } catch(e) {} }); | |
| 228 | + | console.log('[workspace] Cleaned popup localStorage'); | |
| 229 | + | } catch(e) { console.warn('[workspace] localStorage cleanup failed:', e); } | |
| 230 | + | `, parentWebViewID, requestID, requestID, requestID, requestID, webviewID) | |
| 231 | + | ||
| 232 | + | if err := parentNode.pane.WebView().InjectScript(script); err != nil { | |
| 233 | + | log.Printf("[workspace] localStorage cleanup failed: %v", err) | |
| 234 | + | } else { | |
| 235 | + | log.Printf("[workspace] Cleaned localStorage for popup requestId=%s webviewId=%s", requestID, webviewID) | |
| 236 | + | } | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // OnWorkspaceMessage implements messaging.WorkspaceObserver. | |
| 240 | + | func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messaging.Message) { | |
| 241 | + | node := wm.viewToNode[source] | |
| 225 | 242 | ||
| 226 | - | wm.focusAfterPaneMode(node) | |
| 227 | - | case "pane-close": | |
| 228 | - | if !wm.paneModeActive { | |
| 229 | - | log.Printf("[workspace] close requested outside pane mode; ignoring") | |
| 230 | - | break | |
| 243 | + | if msg.WebViewID != "" { | |
| 244 | + | if explicitNode := wm.findNodeByWebViewID(msg.WebViewID); explicitNode != nil { | |
| 245 | + | if explicitNode.pane != nil && explicitNode.pane.webView != nil { | |
| 246 | + | node = explicitNode | |
| 247 | + | source = explicitNode.pane.webView | |
| 248 | + | } | |
| 231 | 249 | } | |
| 250 | + | } | |
| 232 | 251 | ||
| 233 | - | wm.paneMutex.Lock() | |
| 234 | - | wm.paneModeActive = false | |
| 235 | - | wm.paneModeSource = nil | |
| 236 | - | wm.paneMutex.Unlock() | |
| 252 | + | if node == nil { | |
| 253 | + | log.Printf("[workspace] message from unknown webview: event=%s webviewId=%s", msg.Event, msg.WebViewID) | |
| 254 | + | return | |
| 255 | + | } | |
| 237 | 256 | ||
| 238 | - | // Don't focus the node that's about to be closed - closeCurrentPane() will handle focus | |
| 239 | - | wm.closeCurrentPane() | |
| 257 | + | switch event := strings.ToLower(msg.Event); event { | |
| 240 | 258 | case "pane-split": | |
| 241 | 259 | wm.SetActivePane(node, SourceProgrammatic) | |
| 242 | 260 | direction := strings.ToLower(msg.Direction) | |
| 243 | 261 | if direction == "" { | |
| 244 | 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 conditions | |
| 254 | - | if !atomic.CompareAndSwapInt32(&wm.splitting, 0, 1) { | |
| 255 | - | log.Printf("[workspace] split ignored: operation already in progress") | |
| 265 | + | if wm.shouldDebounce(source, 200*time.Millisecond) { | |
| 256 | 266 | return | |
| 257 | 267 | } | |
| 258 | 268 | ||
| 259 | - | // CRITICAL FIX: Always clear splitting flag on exit (panic recovery) | |
| 260 | - | defer func() { | |
| 261 | - | atomic.StoreInt32(&wm.splitting, 0) | |
| 262 | - | if r := recover(); r != nil { | |
| 263 | - | log.Printf("[workspace] split operation panicked, flag cleared: %v", r) | |
| 264 | - | panic(r) // Re-panic to maintain error handling | |
| 269 | + | err := wm.withSplitLock("split", func() error { | |
| 270 | + | newNode, err := wm.SplitPane(node, direction) | |
| 271 | + | if err != nil { | |
| 272 | + | if glib.MainContextDefault().IsOwner() { | |
| 273 | + | glib.MainContextDefault().Iteration(false) | |
| 274 | + | } | |
| 275 | + | return err | |
| 265 | 276 | } | |
| 266 | - | }() | |
| 267 | - | ||
| 268 | - | wm.lastSplitMsg[source] = time.Now() | |
| 277 | + | wm.clonePaneState(node, newNode) | |
| 278 | + | return nil | |
| 279 | + | }) | |
| 269 | 280 | ||
| 270 | - | newNode, err := wm.SplitPane(node, direction) | |
| 271 | 281 | if err != nil { | |
| 272 | 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 | 285 | case "pane-stack": | |
| 281 | 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 flag | |
| 290 | - | if !atomic.CompareAndSwapInt32(&wm.splitting, 0, 1) { | |
| 291 | - | log.Printf("[workspace] stack ignored: operation already in progress") | |
| 288 | + | if wm.shouldDebounce(source, 200*time.Millisecond) { | |
| 292 | 289 | return | |
| 293 | 290 | } | |
| 294 | 291 | ||
| 295 | - | // CRITICAL FIX: Always clear splitting flag on exit (panic recovery) | |
| 296 | - | defer func() { | |
| 297 | - | atomic.StoreInt32(&wm.splitting, 0) | |
| 298 | - | if r := recover(); r != nil { | |
| 299 | - | log.Printf("[workspace] stack operation panicked, flag cleared: %v", r) | |
| 300 | - | panic(r) // Re-panic to maintain error handling | |
| 292 | + | err := wm.withSplitLock("stack", func() error { | |
| 293 | + | newNode, err := wm.stackedPaneManager.StackPane(node) | |
| 294 | + | if err != nil { | |
| 295 | + | if glib.MainContextDefault().IsOwner() { | |
| 296 | + | glib.MainContextDefault().Iteration(false) | |
| 297 | + | } | |
| 298 | + | return err | |
| 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 | 304 | if err != nil { | |
| 308 | 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 | 308 | case "close-popup": | |
| 372 | - | log.Printf("[workspace] close-popup requested: webviewId=%s reason=%s", msg.WebViewID, msg.Reason) | |
| 373 | - | ||
| 374 | 309 | if msg.WebViewID == "" { | |
| 375 | 310 | log.Printf("[workspace] close-popup: empty webviewId, ignoring") | |
| 376 | - | break | |
| 311 | + | return | |
| 377 | 312 | } | |
| 378 | 313 | ||
| 379 | - | // Find the popup pane by webview ID | |
| 314 | + | // Find popup pane by webview ID | |
| 380 | 315 | var targetNode *paneNode | |
| 381 | 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 | 318 | targetNode = node | |
| 384 | 319 | break | |
| 385 | 320 | } | |
| @@ -387,118 +322,57 @@ func (wm *WorkspaceManager) OnWorkspaceMessage(source *webkit.WebView, msg messa | |||
| 387 | 322 | ||
| 388 | 323 | if targetNode == nil { | |
| 389 | 324 | log.Printf("[workspace] close-popup: webview not found: %s", msg.WebViewID) | |
| 390 | - | break | |
| 325 | + | return | |
| 391 | 326 | } | |
| 392 | 327 | ||
| 393 | 328 | if !targetNode.isPopup { | |
| 394 | 329 | log.Printf("[workspace] close-popup: target is not a popup: %s", msg.WebViewID) | |
| 395 | - | break | |
| 330 | + | 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 closing | |
| 335 | + | // Remove from parent's active popup list | |
| 401 | 336 | if targetNode.parentPane != nil { | |
| 402 | 337 | parentNode := targetNode.parentPane | |
| 403 | 338 | for i, childID := range parentNode.activePopupChildren { | |
| 404 | 339 | if childID == msg.WebViewID { | |
| 405 | 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 | 342 | break | |
| 408 | 343 | } | |
| 409 | 344 | } | |
| 410 | 345 | ||
| 411 | - | // Clean up ALL localStorage entries for this popup in parent WebView | |
| 412 | - | // This includes popup_<requestId>_parent_info, popup_<requestId>_parent_action, etc. | |
| 413 | - | if parentNode.pane != nil && parentNode.pane.WebView() != nil && targetNode.requestID != "" { | |
| 414 | - | parentWebViewID := parentNode.pane.WebView().ID() | |
| 415 | - | requestID := targetNode.requestID | |
| 416 | - | ||
| 417 | - | cleanupScript := fmt.Sprintf(` | |
| 418 | - | try { | |
| 419 | - | // Clean up popup_mapping_<parentId> (from Go code) | |
| 420 | - | localStorage.removeItem('popup_mapping_%s'); | |
| 421 | - | ||
| 422 | - | // Clean up popup_<requestId>_* entries (from JavaScript) | |
| 423 | - | const requestId = '%s'; | |
| 424 | - | const keysToRemove = [ | |
| 425 | - | 'popup_' + requestId + '_parent_info', | |
| 426 | - | 'popup_' + requestId + '_parent_action', | |
| 427 | - | 'popup_' + requestId + '_message_to_popup', | |
| 428 | - | 'popup_' + requestId + '_message_to_parent', | |
| 429 | - | 'oauth_callback_' + '%s' | |
| 430 | - | ]; | |
| 431 | - | ||
| 432 | - | keysToRemove.forEach(key => { | |
| 433 | - | try { | |
| 434 | - | localStorage.removeItem(key); | |
| 435 | - | } catch(e) {} | |
| 436 | - | }); | |
| 437 | - | ||
| 438 | - | console.log('[workspace] Cleaned up popup localStorage for requestId:', requestId); | |
| 439 | - | } catch(e) { | |
| 440 | - | console.warn('[workspace] Failed to clean popup localStorage:', e); | |
| 441 | - | } | |
| 442 | - | `, parentWebViewID, requestID, msg.WebViewID) | |
| 443 | - | ||
| 444 | - | if err := parentNode.pane.WebView().InjectScript(cleanupScript); err != nil { | |
| 445 | - | log.Printf("[workspace] Failed to inject localStorage cleanup script: %v", err) | |
| 446 | - | } else { | |
| 447 | - | log.Printf("[workspace] Cleaned up localStorage for popup requestId=%s webviewId=%s", requestID, msg.WebViewID) | |
| 448 | - | } | |
| 449 | - | } | |
| 346 | + | wm.cleanupPopupStorage(parentNode, targetNode, msg.WebViewID) | |
| 450 | 347 | } | |
| 451 | 348 | ||
| 452 | - | // Close the popup pane | |
| 453 | 349 | if err := wm.ClosePane(targetNode); err != nil { | |
| 454 | - | log.Printf("[workspace] Failed to close popup pane: %v", err) | |
| 455 | - | } else { | |
| 456 | - | log.Printf("[workspace] Successfully closed popup pane: %s", msg.WebViewID) | |
| 457 | - | ||
| 458 | - | // Fix GTK rendering for parent pane after popup closes | |
| 459 | - | // The parent pane's rendering can become corrupted after popup closes | |
| 460 | - | if targetNode.parentPane != nil { | |
| 461 | - | parentNode := targetNode.parentPane | |
| 462 | - | if parentNode.container != 0 && webkit.WidgetIsValid(parentNode.container) { | |
| 463 | - | log.Printf("[workspace] Fixing parent pane rendering after popup close") | |
| 464 | - | ||
| 465 | - | // CRITICAL: Hide parent container to disconnect WebKitGTK rendering pipeline | |
| 466 | - | webkit.WidgetHide(parentNode.container) | |
| 467 | - | ||
| 468 | - | // Schedule showing and forcing GTK to reconnect rendering | |
| 469 | - | wm.scheduleIdleGuarded(func() bool { | |
| 470 | - | if parentNode == nil || !parentNode.widgetValid || parentNode.container == 0 { | |
| 471 | - | return false | |
| 472 | - | } | |
| 473 | - | // Show widget to reconnect WebKit rendering pipeline | |
| 474 | - | webkit.WidgetShow(parentNode.container) | |
| 475 | - | // Force GTK to recalculate size and recreate rendering surface | |
| 476 | - | webkit.WidgetQueueResize(parentNode.container) | |
| 477 | - | webkit.WidgetQueueDraw(parentNode.container) | |
| 478 | - | ||
| 479 | - | // Also queue resize+draw on the WebView widget itself | |
| 480 | - | if parentNode.pane != nil && parentNode.pane.WebView() != nil { | |
| 481 | - | webViewWidget := parentNode.pane.WebView().Widget() | |
| 482 | - | if webViewWidget != 0 && webkit.WidgetIsValid(webViewWidget) { | |
| 483 | - | webkit.WidgetQueueResize(webViewWidget) | |
| 484 | - | webkit.WidgetQueueDraw(webViewWidget) | |
| 485 | - | log.Printf("[workspace] Queued resize+draw for parent WebView widget") | |
| 486 | - | } | |
| 487 | - | } | |
| 488 | - | ||
| 489 | - | log.Printf("[workspace] Shown and queued resize+draw for parent pane after popup close") | |
| 490 | - | return false | |
| 491 | - | }, parentNode) | |
| 492 | - | } | |
| 493 | - | } | |
| 350 | + | log.Printf("[workspace] Failed to close popup: %v", err) | |
| 494 | 351 | } | |
| 352 | + | ||
| 495 | 353 | default: | |
| 496 | 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 | 371 | // GetActiveNode returns the currently active pane node | |
| 501 | 372 | func (wm *WorkspaceManager) GetActiveNode() *paneNode { | |
| 373 | + | if wm == nil || wm.focusStateMachine == nil { | |
| 374 | + | return nil | |
| 375 | + | } | |
| 502 | 376 | return wm.focusStateMachine.GetActivePane() | |
| 503 | 377 | } | |
| 504 | 378 | ||
| @@ -526,11 +400,62 @@ func (wm *WorkspaceManager) GetNodeForWebView(webView *webkit.WebView) *paneNode | |||
| 526 | 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 | 404 | func (wm *WorkspaceManager) RegisterNavigationHandler(webView *webkit.WebView) { | |
| 531 | 405 | if webView == nil { | |
| 532 | 406 | 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 | 9 | "time" | |
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/dumber/pkg/webkit" | |
| 12 | + | "github.com/diamondburned/gotk4/pkg/gtk/v4" | |
| 12 | 13 | ) | |
| 13 | 14 | ||
| 14 | 15 | const ( | |
| @@ -33,7 +34,7 @@ func (wm *WorkspaceManager) DispatchPaneFocusEvent(node *paneNode, active bool) | |||
| 33 | 34 | if err := node.pane.webView.DispatchCustomEvent("dumber:workspace-focus", detail); err != nil { | |
| 34 | 35 | log.Printf("[workspace] failed to dispatch focus event: %v", err) | |
| 35 | 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 node | |
| 92 | - | func (wm *WorkspaceManager) ensureHover(node *paneNode) { | |
| 93 | - | if wm == nil || node == nil || !node.isLeaf { | |
| 94 | - | return | |
| 95 | - | } | |
| 96 | - | if node.container == 0 || node.hoverToken != 0 { | |
| 97 | - | return | |
| 98 | - | } | |
| 99 | - | ||
| 100 | - | token := webkit.WidgetAddHoverHandler(node.container, func() { | |
| 101 | - | if wm == nil { | |
| 102 | - | return | |
| 103 | - | } | |
| 104 | - | wm.SetActivePane(node, SourceMouse) | |
| 105 | - | }) | |
| 106 | - | node.hoverToken = token | |
| 107 | - | if token == 0 { | |
| 108 | - | log.Printf("[workspace] failed to attach hover handler") | |
| 109 | - | } | |
| 110 | - | } | |
| 111 | - | ||
| 112 | - | // detachHover removes hover handlers from a pane node | |
| 113 | - | func (wm *WorkspaceManager) detachHover(node *paneNode) { | |
| 114 | - | if wm == nil || node == nil || node.hoverToken == 0 { | |
| 115 | - | return | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | if node.container == 0 || !webkit.WidgetIsValid(node.container) { | |
| 119 | - | node.hoverToken = 0 | |
| 120 | - | return | |
| 121 | - | } | |
| 122 | - | ||
| 123 | - | token := node.hoverToken | |
| 124 | - | node.hoverToken = 0 | |
| 125 | - | ||
| 126 | - | if webkit.WidgetIsValid(node.container) { | |
| 127 | - | webkit.WidgetRemoveHoverHandler(node.container, token) | |
| 128 | - | } | |
| 129 | - | } | |
| 92 | + | // Note: ensureHover and detachHover are now implemented in workspace_hover.go | |
| 130 | 93 | ||
| 131 | 94 | // detachFocus removes the GTK focus controller attached to a pane, if any. | |
| 132 | 95 | func (wm *WorkspaceManager) detachFocus(node *paneNode) { | |
| @@ -240,7 +203,7 @@ func (wm *WorkspaceManager) navigateStack(direction string) bool { | |||
| 240 | 203 | // focusAdjacent uses geometry-based calculations to find and focus adjacent panes | |
| 241 | 204 | func (wm *WorkspaceManager) focusAdjacent(direction string) bool { | |
| 242 | 205 | currentFocused := wm.GetActiveNode() | |
| 243 | - | if currentFocused == nil || !currentFocused.isLeaf || currentFocused.container == 0 { | |
| 206 | + | if currentFocused == nil || !currentFocused.isLeaf || currentFocused.container == nil { | |
| 244 | 207 | return false | |
| 245 | 208 | } | |
| 246 | 209 | ||
| @@ -249,14 +212,9 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool { | |||
| 249 | 212 | return true | |
| 250 | 213 | } | |
| 251 | 214 | ||
| 252 | - | currentBounds, ok := webkit.WidgetGetBounds(currentFocused.container) | |
| 253 | - | if !ok { | |
| 254 | - | log.Printf("[workspace] unable to compute bounds for active pane") | |
| 255 | - | return false | |
| 256 | - | } | |
| 257 | - | ||
| 258 | - | cx := currentBounds.X + currentBounds.Width/2.0 | |
| 259 | - | cy := currentBounds.Y + currentBounds.Height/2.0 | |
| 215 | + | curX, curY, curWidth, curHeight := webkit.WidgetGetAllocation(currentFocused.container) | |
| 216 | + | cx := float64(curX) + float64(curWidth)/2.0 | |
| 217 | + | cy := float64(curY) + float64(curHeight)/2.0 | |
| 260 | 218 | ||
| 261 | 219 | leaves := wm.collectLeaves() | |
| 262 | 220 | bestScore := math.MaxFloat64 | |
| @@ -264,16 +222,13 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool { | |||
| 264 | 222 | var debugCandidates []string | |
| 265 | 223 | ||
| 266 | 224 | for _, candidate := range leaves { | |
| 267 | - | if candidate == nil || candidate == currentFocused || candidate.container == 0 { | |
| 225 | + | if candidate == nil || candidate == currentFocused || candidate.container == nil { | |
| 268 | 226 | continue | |
| 269 | 227 | } | |
| 270 | 228 | ||
| 271 | - | bounds, ok := webkit.WidgetGetBounds(candidate.container) | |
| 272 | - | if !ok { | |
| 273 | - | continue | |
| 274 | - | } | |
| 275 | - | tx := bounds.X + bounds.Width/2.0 | |
| 276 | - | ty := bounds.Y + bounds.Height/2.0 | |
| 229 | + | x, y, width, height := webkit.WidgetGetAllocation(candidate.container) | |
| 230 | + | tx := float64(x) + float64(width)/2.0 | |
| 231 | + | ty := float64(y) + float64(height)/2.0 | |
| 277 | 232 | ||
| 278 | 233 | dx := tx - cx | |
| 279 | 234 | dy := ty - cy | |
| @@ -303,7 +258,7 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool { | |||
| 303 | 258 | } | |
| 304 | 259 | ||
| 305 | 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 | 264 | if score < bestScore { | |
| @@ -318,47 +273,44 @@ func (wm *WorkspaceManager) focusAdjacent(direction string) bool { | |||
| 318 | 273 | } | |
| 319 | 274 | ||
| 320 | 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 | 278 | return false | |
| 324 | 279 | } | |
| 325 | 280 | ||
| 326 | 281 | // structuralNeighbor finds neighbors based on the tree structure rather than geometry | |
| 327 | 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 | 284 | return nil | |
| 330 | 285 | } | |
| 331 | 286 | ||
| 332 | - | refBounds, ok := webkit.WidgetGetBounds(node.container) | |
| 333 | - | if !ok { | |
| 334 | - | return nil | |
| 335 | - | } | |
| 336 | - | cx := refBounds.X + refBounds.Width/2.0 | |
| 337 | - | cy := refBounds.Y + refBounds.Height/2.0 | |
| 287 | + | refX, refY, refWidth, refHeight := webkit.WidgetGetAllocation(node.container) | |
| 288 | + | cx := float64(refX) + float64(refWidth)/2.0 | |
| 289 | + | cy := float64(refY) + float64(refHeight)/2.0 | |
| 338 | 290 | axisVertical := direction == "up" || direction == "down" | |
| 339 | 291 | ||
| 340 | 292 | for parent := node.parent; parent != nil; parent = parent.parent { | |
| 341 | 293 | switch direction { | |
| 342 | 294 | case "up": | |
| 343 | - | if axisVertical && parent.orientation == webkit.OrientationVertical && parent.right == node { | |
| 295 | + | if axisVertical && parent.orientation == gtk.OrientationVertical && parent.right == node { | |
| 344 | 296 | if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil { | |
| 345 | 297 | return leaf | |
| 346 | 298 | } | |
| 347 | 299 | } | |
| 348 | 300 | case "down": | |
| 349 | - | if axisVertical && parent.orientation == webkit.OrientationVertical && parent.left == node { | |
| 301 | + | if axisVertical && parent.orientation == gtk.OrientationVertical && parent.left == node { | |
| 350 | 302 | if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil { | |
| 351 | 303 | return leaf | |
| 352 | 304 | } | |
| 353 | 305 | } | |
| 354 | 306 | case "left": | |
| 355 | - | if !axisVertical && parent.orientation == webkit.OrientationHorizontal && parent.right == node { | |
| 307 | + | if !axisVertical && parent.orientation == gtk.OrientationHorizontal && parent.right == node { | |
| 356 | 308 | if leaf := wm.closestLeafFromSubtree(parent.left, cx, cy, direction); leaf != nil { | |
| 357 | 309 | return leaf | |
| 358 | 310 | } | |
| 359 | 311 | } | |
| 360 | 312 | case "right": | |
| 361 | - | if !axisVertical && parent.orientation == webkit.OrientationHorizontal && parent.left == node { | |
| 313 | + | if !axisVertical && parent.orientation == gtk.OrientationHorizontal && parent.left == node { | |
| 362 | 314 | if leaf := wm.closestLeafFromSubtree(parent.right, cx, cy, direction); leaf != nil { | |
| 363 | 315 | return leaf | |
| 364 | 316 | } | |
| @@ -375,16 +327,13 @@ func (wm *WorkspaceManager) closestLeafFromSubtree(node *paneNode, cx, cy float6 | |||
| 375 | 327 | bestScore := math.MaxFloat64 | |
| 376 | 328 | var best *paneNode | |
| 377 | 329 | for _, leaf := range leaves { | |
| 378 | - | if leaf == nil || leaf.container == 0 { | |
| 330 | + | if leaf == nil || leaf.container == nil { | |
| 379 | 331 | continue | |
| 380 | 332 | } | |
| 381 | 333 | ||
| 382 | - | bounds, ok := webkit.WidgetGetBounds(leaf.container) | |
| 383 | - | if !ok { | |
| 384 | - | continue | |
| 385 | - | } | |
| 386 | - | tx := bounds.X + bounds.Width/2.0 | |
| 387 | - | ty := bounds.Y + bounds.Height/2.0 | |
| 334 | + | x, y, width, height := webkit.WidgetGetAllocation(leaf.container) | |
| 335 | + | tx := float64(x) + float64(width)/2.0 | |
| 336 | + | ty := float64(y) + float64(height)/2.0 | |
| 388 | 337 | dx := tx - cx | |
| 389 | 338 | dy := ty - cy | |
| 390 | 339 | 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 | 54 | // Step 2: Execute directly if we're already on the GTK main thread | |
| 55 | 55 | if webkit.IsMainThread() { | |
| 56 | 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 | 58 | if err != nil { | |
| 59 | 59 | return nil, err | |
| 60 | 60 | } | |
| @@ -78,7 +78,7 @@ func (wm *WorkspaceManager) SplitPane(target *paneNode, direction string) (*pane | |||
| 78 | 78 | done := make(chan struct{}) | |
| 79 | 79 | ||
| 80 | 80 | _ = webkit.IdleAdd(func() bool { | |
| 81 | - | newNode, splitErr = wm.splitNode(target, direction) | |
| 81 | + | newNode, splitErr = wm.splitNode(target, direction, nil) | |
| 82 | 82 | if splitErr == nil { | |
| 83 | 83 | if wm.debugLevel >= DebugBasic { | |
| 84 | 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 | 6 | "log" | |
| 7 | 7 | ||
| 8 | 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 | 13 | // closeContext manages the context for a pane close operation | |
| @@ -112,23 +114,29 @@ func (wm *WorkspaceManager) createPane(view *webkit.WebView) (*BrowserPane, erro | |||
| 112 | 114 | // clonePaneState sets up a new pane with cloned state from another pane | |
| 113 | 115 | func (wm *WorkspaceManager) clonePaneState(_ *paneNode, target *paneNode) { | |
| 114 | 116 | if wm == nil || target == nil { | |
| 117 | + | log.Printf("[workspace] clonePaneState: wm or target is nil") | |
| 115 | 118 | return | |
| 116 | 119 | } | |
| 117 | 120 | if target.pane == nil || target.pane.webView == nil { | |
| 121 | + | log.Printf("[workspace] clonePaneState: target pane or webView is nil") | |
| 118 | 122 | return | |
| 119 | 123 | } | |
| 120 | 124 | ||
| 125 | + | // Load about:blank and auto-open omnibox | |
| 121 | 126 | const blankURL = "about:blank" | |
| 122 | 127 | ||
| 128 | + | log.Printf("[workspace] clonePaneState: loading %s in pane %p", blankURL, target.pane) | |
| 123 | 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. | |
| 131 | - | // It marks nodes for reattachment once the widget hierarchy settles after the split operation. | |
| 136 | + | // safelyDetachControllersBeforeReparent marks nodes for controller reattachment after reparenting. | |
| 137 | + | // GTK4 automatically removes controllers when widgets are unparented, so we just clear our | |
| 138 | + | // references and mark for reattachment. Manually removing controllers causes GTK bloom filter | |
| 139 | + | // corruption (double-removal assertion failure). | |
| 132 | 140 | func (wm *WorkspaceManager) safelyDetachControllersBeforeReparent(node *paneNode) { | |
| 133 | 141 | if wm == nil || node == nil { | |
| 134 | 142 | return | |
| @@ -139,15 +147,18 @@ func (wm *WorkspaceManager) safelyDetachControllersBeforeReparent(node *paneNode | |||
| 139 | 147 | 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 | 153 | 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 { | |
| 148 | - | token := target.focusControllerToken | |
| 158 | + | if target.focusControllerToken != 0 { | |
| 149 | 159 | 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 workspace | |
| 288 | - | func (wm *WorkspaceManager) insertPopupPane(target *paneNode, newPane *BrowserPane, direction string) error { | |
| 289 | - | if target == nil { | |
| 290 | - | return errors.New("insert target cannot be nil") | |
| 291 | - | } | |
| 292 | - | ||
| 293 | - | // Accept both leaf panes and stacked panes as valid targets | |
| 294 | - | if !target.isLeaf && !target.isStacked { | |
| 295 | - | return errors.New("insert target must be a leaf pane or stacked pane") | |
| 296 | - | } | |
| 297 | - | ||
| 298 | - | // For leaf panes, require a pane | |
| 299 | - | if target.isLeaf && target.pane == nil { | |
| 300 | - | return errors.New("leaf target missing pane") | |
| 301 | - | } | |
| 302 | - | ||
| 303 | - | // For stacked panes, we trust the caller - production stacks have varied structures | |
| 304 | - | // Do not validate internal stack structure here | |
| 305 | - | ||
| 306 | - | if newPane == nil || newPane.webView == nil { | |
| 307 | - | return errors.New("new pane missing webview") | |
| 308 | - | } | |
| 309 | - | ||
| 310 | - | if handler := newPane.MessageHandler(); handler != nil { | |
| 311 | - | handler.SetWorkspaceObserver(wm) | |
| 312 | - | } | |
| 313 | - | ||
| 314 | - | newContainer := newPane.webView.RootWidget() | |
| 315 | - | if newContainer == 0 { | |
| 316 | - | return errors.New("new pane missing container") | |
| 317 | - | } | |
| 318 | - | ||
| 319 | - | webkit.WidgetSetHExpand(newContainer, true) | |
| 320 | - | webkit.WidgetSetVExpand(newContainer, true) | |
| 321 | - | webkit.WidgetRealizeInContainer(newContainer) | |
| 322 | - | ||
| 323 | - | // Also realize the WebView widget itself for proper popup rendering | |
| 324 | - | webViewWidget := newPane.webView.Widget() | |
| 325 | - | if webViewWidget != 0 { | |
| 326 | - | webkit.WidgetRealizeInContainer(webViewWidget) | |
| 327 | - | } | |
| 328 | - | ||
| 329 | - | // CRITICAL: Handle stacked panes correctly - same logic as splitNode | |
| 330 | - | // When the target is inside a stacked pane, we need to split around the entire stack, | |
| 331 | - | // not the individual pane within the stack | |
| 332 | - | originalTarget := target | |
| 333 | - | var stackContainer *paneNode | |
| 334 | - | if target.parent != nil && target.parent.isStacked { | |
| 335 | - | // Target is in a stack - we'll create the split around the entire stack | |
| 336 | - | stackContainer = target.parent | |
| 337 | - | log.Printf("[workspace] target is in stack, will split around the stack: originalTarget=%p stackContainer=%p", originalTarget, stackContainer) | |
| 338 | - | } | |
| 339 | - | ||
| 340 | - | // Determine what we're actually splitting | |
| 341 | - | var splitTarget *paneNode | |
| 342 | - | var splitTargetContainer uintptr | |
| 343 | - | ||
| 344 | - | if stackContainer != nil { | |
| 345 | - | // Case: splitting from inside a stack - we split around the entire stack | |
| 346 | - | splitTarget = stackContainer | |
| 347 | - | splitTargetContainer = stackContainer.container | |
| 348 | - | log.Printf("[workspace] splitting around stack: stackContainer=%p", splitTarget) | |
| 349 | - | } else { | |
| 350 | - | // Case: normal split from a simple pane or direct stack target | |
| 351 | - | splitTarget = target | |
| 352 | - | splitTargetContainer = target.container | |
| 353 | - | log.Printf("[workspace] normal split from pane: target=%p", splitTarget) | |
| 354 | - | } | |
| 355 | - | ||
| 356 | - | if splitTargetContainer == 0 { | |
| 357 | - | return errors.New("split target missing container") | |
| 358 | - | } | |
| 359 | - | ||
| 360 | - | orientation, existingFirst := mapDirection(direction) | |
| 361 | - | log.Printf("[workspace] inserting popup direction=%s orientation=%v existingFirst=%v splitTarget.parent=%p", direction, orientation, existingFirst, splitTarget.parent) | |
| 362 | - | ||
| 363 | - | paned := webkit.NewPaned(orientation) | |
| 364 | - | if paned == 0 { | |
| 365 | - | return errors.New("failed to create GtkPaned") | |
| 366 | - | } | |
| 367 | - | webkit.WidgetSetHExpand(paned, true) | |
| 368 | - | webkit.WidgetSetVExpand(paned, true) | |
| 369 | - | // For popup panes: preserve existing pane size, let popup take remaining space | |
| 370 | - | webkit.PanedSetResizeStart(paned, false) // existing pane keeps its size | |
| 371 | - | webkit.PanedSetResizeEnd(paned, true) // popup can resize | |
| 372 | - | log.Printf("[workspace] configured paned for popup: start=fixed, end=flexible") | |
| 373 | - | ||
| 374 | - | newLeaf := &paneNode{ | |
| 375 | - | pane: newPane, | |
| 376 | - | isLeaf: true, | |
| 377 | - | } | |
| 378 | - | wm.initializePaneWidgets(newLeaf, newContainer) | |
| 379 | - | ||
| 380 | - | split := &paneNode{ | |
| 381 | - | parent: splitTarget.parent, | |
| 382 | - | orientation: orientation, | |
| 383 | - | isLeaf: false, | |
| 384 | - | } | |
| 385 | - | wm.initializePaneWidgets(split, paned) | |
| 386 | - | ||
| 387 | - | parent := split.parent | |
| 388 | - | ||
| 389 | - | // CRITICAL: Capture parent paned's divider position BEFORE reparenting to preserve layout | |
| 390 | - | // Only attempt this if the parent container is actually a paned widget | |
| 391 | - | var parentDividerPos int | |
| 392 | - | if parent != nil && parent.container != 0 && webkit.WidgetIsValid(parent.container) && webkit.IsPaned(parent.container) { | |
| 393 | - | parentDividerPos = webkit.PanedGetPosition(parent.container) | |
| 394 | - | log.Printf("[workspace] captured parent divider position: %d", parentDividerPos) | |
| 395 | - | } else if parent != nil && parent.container != 0 { | |
| 396 | - | log.Printf("[workspace] parent container is not a paned widget (likely a stacked container)") | |
| 397 | - | } | |
| 398 | - | ||
| 399 | - | // Clear CSS classes before reparenting to avoid GTK bloom filter corruption | |
| 400 | - | if splitTargetContainer != 0 && webkit.WidgetIsValid(splitTargetContainer) { | |
| 401 | - | // Remove active border class if present (prevents GTK bloom filter corruption during unparent) | |
| 402 | - | if webkit.WidgetHasCSSClass(splitTargetContainer, activePaneClass) { | |
| 403 | - | webkit.WidgetRemoveCSSClass(splitTargetContainer, activePaneClass) | |
| 404 | - | } | |
| 405 | - | ||
| 406 | - | // CRITICAL: Hide widget before reparenting to disconnect WebKitGTK rendering pipeline | |
| 407 | - | // This forces WebKit to detach its compositor, preventing rendering corruption | |
| 408 | - | webkit.WidgetHide(splitTargetContainer) | |
| 409 | - | log.Printf("[workspace] Hidden split target container before reparenting: %#x", splitTargetContainer) | |
| 410 | - | } | |
| 411 | - | ||
| 412 | - | // Prevent GTK from auto-removing controllers while the widget is temporarily unparented. | |
| 413 | - | wm.safelyDetachControllersBeforeReparent(splitTarget) | |
| 414 | - | ||
| 415 | - | // Detach existing container from its current GTK parent before inserting into new paned. | |
| 416 | - | if parent == nil { | |
| 417 | - | // Split target is the root - remove it from the window | |
| 418 | - | log.Printf("[workspace] removing split target container=%#x from window", splitTargetContainer) | |
| 419 | - | if wm.window != nil { | |
| 420 | - | wm.window.SetChild(0) | |
| 421 | - | } | |
| 422 | - | // GTK4 will auto-unparent when we add to new paned - no manual unparent needed | |
| 423 | - | } else if parent.container != 0 { | |
| 424 | - | // Split target has a parent paned - unparent it from there | |
| 425 | - | log.Printf("[workspace] unparenting split target container from parent paned") | |
| 426 | - | if webkit.WidgetIsValid(parent.container) { | |
| 427 | - | log.Printf("[workspace] executing unparent: parentPtr=%#x", parent.container) | |
| 428 | - | if parent.left == splitTarget { | |
| 429 | - | webkit.PanedSetStartChild(parent.container, 0) | |
| 430 | - | } else if parent.right == splitTarget { | |
| 431 | - | webkit.PanedSetEndChild(parent.container, 0) | |
| 432 | - | } | |
| 433 | - | webkit.WidgetQueueAllocate(parent.container) | |
| 434 | - | } | |
| 435 | - | } | |
| 436 | - | ||
| 437 | - | // Add both containers to the new paned | |
| 438 | - | if existingFirst { | |
| 439 | - | split.left = splitTarget | |
| 440 | - | split.right = newLeaf | |
| 441 | - | webkit.PanedSetStartChild(paned, splitTargetContainer) | |
| 442 | - | webkit.PanedSetEndChild(paned, newContainer) | |
| 443 | - | log.Printf("[workspace] added splitTarget=%#x as start child, new=%#x as end child", splitTargetContainer, newContainer) | |
| 444 | - | } else { | |
| 445 | - | split.left = newLeaf | |
| 446 | - | split.right = splitTarget | |
| 447 | - | webkit.PanedSetStartChild(paned, newContainer) | |
| 448 | - | webkit.PanedSetEndChild(paned, splitTargetContainer) | |
| 449 | - | log.Printf("[workspace] added new=%#x as start child, splitTarget=%#x as end child", newContainer, splitTargetContainer) | |
| 450 | - | } | |
| 451 | - | ||
| 452 | - | splitTarget.parent = split | |
| 453 | - | newLeaf.parent = split | |
| 454 | - | ||
| 455 | - | if parent == nil { | |
| 456 | - | wm.root = split | |
| 457 | - | if wm.window != nil { | |
| 458 | - | wm.window.SetChild(paned) | |
| 459 | - | webkit.WidgetQueueAllocate(paned) | |
| 460 | - | log.Printf("[workspace] paned set as window child: paned=%#x", paned) | |
| 461 | - | } | |
| 462 | - | } else { | |
| 463 | - | if webkit.WidgetIsValid(parent.container) { | |
| 464 | - | log.Printf("[workspace] executing parent reparent: parentPtr=%#x, paned=%#x", parent.container, paned) | |
| 465 | - | if parent.left == splitTarget { | |
| 466 | - | parent.left = split | |
| 467 | - | webkit.PanedSetStartChild(parent.container, paned) | |
| 468 | - | } else if parent.right == splitTarget { | |
| 469 | - | parent.right = split | |
| 470 | - | webkit.PanedSetEndChild(parent.container, paned) | |
| 471 | - | } | |
| 472 | - | webkit.WidgetQueueAllocate(parent.container) | |
| 473 | - | webkit.WidgetQueueAllocate(paned) | |
| 474 | - | log.Printf("[workspace] paned inserted into parent successfully") | |
| 475 | - | ||
| 476 | - | // CRITICAL: Restore parent paned's divider position to preserve existing layout | |
| 477 | - | // Only attempt this if we captured a position and the parent is actually a paned widget | |
| 478 | - | if parentDividerPos > 0 && webkit.IsPaned(parent.container) { | |
| 479 | - | webkit.PanedSetPosition(parent.container, parentDividerPos) | |
| 480 | - | log.Printf("[workspace] restored parent divider position: %d", parentDividerPos) | |
| 481 | - | } | |
| 482 | - | } | |
| 483 | - | } | |
| 484 | - | ||
| 485 | - | webkit.WidgetShow(paned) | |
| 486 | - | ||
| 487 | - | // Force GTK to re-layout the new GtkPaned container once both children are attached. | |
| 488 | - | wm.scheduleIdleGuarded(func() bool { | |
| 489 | - | if split == nil || !split.widgetValid { | |
| 490 | - | return false | |
| 491 | - | } | |
| 492 | - | if split.container != 0 && webkit.WidgetIsValid(split.container) { | |
| 493 | - | webkit.WidgetQueueResize(split.container) | |
| 494 | - | webkit.WidgetQueueDraw(split.container) | |
| 495 | - | } | |
| 496 | - | return false | |
| 497 | - | }, split) | |
| 498 | - | ||
| 499 | - | // CRITICAL: Set initial 50/50 split position after showing paned | |
| 500 | - | // GTK needs the widget to be realized before we can set position based on allocation | |
| 501 | - | // Schedule this to run after GTK has allocated space to the paned | |
| 502 | - | wm.scheduleIdleGuarded(func() bool { | |
| 503 | - | if !webkit.WidgetIsValid(paned) { | |
| 504 | - | return false | |
| 505 | - | } | |
| 506 | - | // Get paned allocation to calculate 50% position | |
| 507 | - | alloc := webkit.WidgetGetAllocation(paned) | |
| 508 | - | var splitPos int | |
| 509 | - | if orientation == webkit.OrientationHorizontal { | |
| 510 | - | splitPos = alloc.Width / 2 | |
| 511 | - | } else { | |
| 512 | - | splitPos = alloc.Height / 2 | |
| 513 | - | } | |
| 514 | - | if splitPos > 0 { | |
| 515 | - | webkit.PanedSetPosition(paned, splitPos) | |
| 516 | - | log.Printf("[workspace] Set initial paned position to 50%%: %d (orientation=%d, size=%dx%d)", splitPos, orientation, alloc.Width, alloc.Height) | |
| 517 | - | } | |
| 518 | - | return false | |
| 519 | - | }, split) | |
| 520 | - | ||
| 521 | - | // CRITICAL: Show the split target container and force GTK to recreate rendering surface after reparenting | |
| 522 | - | // This reconnects WebKitGTK's rendering pipeline and fixes compositor sync issues | |
| 523 | - | if splitTarget != nil && splitTarget.container != 0 { | |
| 524 | - | wm.scheduleIdleGuarded(func() bool { | |
| 525 | - | if splitTarget == nil || !splitTarget.widgetValid || splitTarget.container == 0 { | |
| 526 | - | return false | |
| 527 | - | } | |
| 528 | - | // Show widget to reconnect WebKit rendering pipeline | |
| 529 | - | webkit.WidgetShow(splitTarget.container) | |
| 530 | - | // Force GTK to recalculate size and recreate rendering surface | |
| 531 | - | webkit.WidgetQueueResize(splitTarget.container) | |
| 532 | - | webkit.WidgetQueueDraw(splitTarget.container) | |
| 533 | - | log.Printf("[workspace] Shown and queued resize+draw for split target container after reparenting") | |
| 534 | - | return false | |
| 535 | - | }, splitTarget) | |
| 536 | - | } | |
| 537 | - | ||
| 538 | - | wm.viewToNode[newPane.webView] = newLeaf | |
| 539 | - | wm.ensureHover(newLeaf) | |
| 540 | - | wm.ensureHover(splitTarget) | |
| 541 | - | wm.app.panes = append(wm.app.panes, newPane) | |
| 542 | - | if newPane.zoomController != nil { | |
| 543 | - | newPane.zoomController.ApplyInitialZoom() | |
| 544 | - | } | |
| 545 | - | ||
| 546 | - | // Update CSS classes for all panes now that we have multiple panes | |
| 547 | - | ||
| 548 | - | reattachTargets := []*paneNode{splitTarget} | |
| 549 | - | if splitTarget != nil && splitTarget.isStacked { | |
| 550 | - | reattachTargets = append(reattachTargets, splitTarget.stackedPanes...) | |
| 551 | - | } | |
| 552 | - | guardNodes := append([]*paneNode{newLeaf}, reattachTargets...) | |
| 553 | - | wm.scheduleIdleGuarded(func() bool { | |
| 554 | - | if newLeaf == nil || !newLeaf.widgetValid { | |
| 555 | - | return false | |
| 556 | - | } | |
| 557 | - | if newContainer != 0 && webkit.WidgetIsValid(newContainer) { | |
| 558 | - | webkit.WidgetShow(newContainer) | |
| 559 | - | } | |
| 560 | - | // Attach GTK focus controller to new pane | |
| 561 | - | if wm.focusStateMachine != nil { | |
| 562 | - | wm.focusStateMachine.attachGTKController(newLeaf) | |
| 563 | - | } | |
| 564 | - | ||
| 565 | - | for _, candidate := range reattachTargets { | |
| 566 | - | if candidate == nil || !candidate.widgetValid { | |
| 567 | - | continue | |
| 568 | - | } | |
| 569 | - | if candidate.pendingHoverReattach { | |
| 570 | - | wm.ensureHover(candidate) | |
| 571 | - | if candidate.hoverToken != 0 { | |
| 572 | - | candidate.pendingHoverReattach = false | |
| 573 | - | } | |
| 574 | - | } | |
| 575 | - | if candidate.pendingFocusReattach && wm.focusStateMachine != nil { | |
| 576 | - | wm.focusStateMachine.attachGTKController(candidate) | |
| 577 | - | if candidate.focusControllerToken != 0 { | |
| 578 | - | candidate.pendingFocusReattach = false | |
| 579 | - | } | |
| 580 | - | } | |
| 581 | - | } | |
| 582 | - | ||
| 583 | - | wm.SetActivePane(newLeaf, SourceSplit) | |
| 584 | - | return false | |
| 585 | - | }, guardNodes...) | |
| 586 | - | ||
| 587 | - | return nil | |
| 588 | - | } | |
| 298 | + | // insertPopupPane has been REMOVED - all popup insertion now uses splitNode() with existing pane parameter. | |
| 299 | + | // This eliminates widget reparenting of existing WebViews, which was causing rendering corruption. | |
| 300 | + | // See splitNode() for the unified implementation. | |
| 589 | 301 | ||
| 590 | 302 | // 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 | 307 | if target == nil || !target.isLeaf || target.pane == nil { | |
| 593 | 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 | 321 | // Determine what we're actually splitting | |
| 607 | 322 | var splitTarget *paneNode | |
| 608 | - | var splitTargetContainer uintptr | |
| 323 | + | var splitTargetContainer gtk.Widgetter | |
| 609 | 324 | ||
| 610 | 325 | if stackContainer != nil { | |
| 611 | 326 | // 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 | 334 | log.Printf("[workspace] normal split from pane: target=%p", splitTarget) | |
| 620 | 335 | } | |
| 621 | 336 | ||
| 622 | - | newView, err := wm.createWebView() | |
| 623 | - | if err != nil { | |
| 624 | - | return nil, err | |
| 625 | - | } | |
| 337 | + | // Use provided pane or create new one | |
| 338 | + | var newPane *BrowserPane | |
| 626 | 339 | ||
| 627 | - | newPane, err := wm.createPane(newView) | |
| 628 | - | if err != nil { | |
| 629 | - | return nil, err | |
| 340 | + | if existingPane != nil { | |
| 341 | + | // Reuse existing pane (for popup insertion) | |
| 342 | + | newPane = existingPane | |
| 343 | + | log.Printf("[workspace] Using existing pane for split: %p", newPane) | |
| 344 | + | } else { | |
| 345 | + | // Create new WebView and pane (for normal splits) | |
| 346 | + | newView, err := wm.createWebView() | |
| 347 | + | if err != nil { | |
| 348 | + | return nil, err | |
| 349 | + | } | |
| 350 | + | ||
| 351 | + | newPane, err = wm.createPane(newView) | |
| 352 | + | if err != nil { | |
| 353 | + | return nil, err | |
| 354 | + | } | |
| 355 | + | log.Printf("[workspace] Created new pane for split: %p", newPane) | |
| 630 | 356 | } | |
| 631 | 357 | ||
| 632 | 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 | 362 | newContainer := newPane.webView.RootWidget() | |
| 637 | - | if newContainer == 0 { | |
| 363 | + | if newContainer == nil { | |
| 638 | 364 | return nil, errors.New("new pane missing container") | |
| 639 | 365 | } | |
| 640 | 366 | webkit.WidgetSetHExpand(newContainer, true) | |
| 641 | 367 | webkit.WidgetSetVExpand(newContainer, true) | |
| 642 | - | webkit.WidgetRealizeInContainer(newContainer) | |
| 643 | 368 | ||
| 644 | 369 | // Use the determined split target container | |
| 645 | - | if splitTargetContainer == 0 { | |
| 370 | + | if splitTargetContainer == nil { | |
| 646 | 371 | return nil, errors.New("split target missing container") | |
| 647 | 372 | } | |
| 648 | 373 | ||
| 649 | 374 | // Only apply GTK operations if this is a simple widget (leaf pane) | |
| 650 | 375 | // For stacked containers, we need to be more careful | |
| 651 | 376 | if splitTarget.isLeaf { | |
| 652 | - | if webkit.WidgetIsValid(splitTargetContainer) { | |
| 377 | + | if splitTargetContainer != nil { | |
| 653 | 378 | webkit.WidgetSetHExpand(splitTargetContainer, true) | |
| 654 | 379 | webkit.WidgetSetVExpand(splitTargetContainer, true) | |
| 655 | - | webkit.WidgetRealizeInContainer(splitTargetContainer) | |
| 656 | 380 | } | |
| 657 | 381 | } else if splitTarget.isStacked { | |
| 658 | 382 | // Stack containers need the same setup as regular panes for proper splitting | |
| 659 | - | if webkit.WidgetIsValid(splitTargetContainer) { | |
| 383 | + | if splitTargetContainer != nil { | |
| 660 | 384 | webkit.WidgetSetHExpand(splitTargetContainer, true) | |
| 661 | 385 | webkit.WidgetSetVExpand(splitTargetContainer, true) | |
| 662 | - | webkit.WidgetRealizeInContainer(splitTargetContainer) | |
| 663 | - | log.Printf("[workspace] configured stack wrapper for split: %#x", splitTargetContainer) | |
| 386 | + | log.Printf("[workspace] configured stack wrapper for split: %p", splitTargetContainer) | |
| 664 | 387 | } | |
| 665 | 388 | } | |
| 666 | 389 | ||
| 667 | 390 | orientation, existingFirst := mapDirection(direction) | |
| 668 | - | log.Printf("[workspace] splitting direction=%s orientation=%v existingFirst=%v splitTarget.parent=%p splitTarget.isStacked=%v", | |
| 669 | - | direction, orientation, existingFirst, splitTarget.parent, splitTarget.isStacked) | |
| 391 | + | log.Printf("[workspace] splitting direction=%s orientation=%v existingFirst=%v splitTarget.parent=%p splitTarget.isStacked=%v isPopup=%v", | |
| 392 | + | direction, orientation, existingFirst, splitTarget.parent, splitTarget.isStacked, existingPane != nil) | |
| 670 | 393 | ||
| 671 | - | paned := webkit.NewPaned(orientation) | |
| 672 | - | if paned == 0 { | |
| 394 | + | paned := gtk.NewPaned(orientation) | |
| 395 | + | if paned == nil { | |
| 673 | 396 | return nil, errors.New("failed to create GtkPaned") | |
| 674 | 397 | } | |
| 675 | - | webkit.WidgetSetHExpand(paned, true) | |
| 676 | - | webkit.WidgetSetVExpand(paned, true) | |
| 677 | - | webkit.PanedSetResizeStart(paned, true) | |
| 678 | - | webkit.PanedSetResizeEnd(paned, true) | |
| 398 | + | paned.SetHExpand(true) | |
| 399 | + | paned.SetVExpand(true) | |
| 400 | + | ||
| 401 | + | // Allow both children to resize; avoids shrinking existing pane to zero during root splits | |
| 402 | + | paned.SetResizeStartChild(true) | |
| 403 | + | paned.SetResizeEndChild(true) | |
| 679 | 404 | ||
| 680 | 405 | newLeaf := &paneNode{ | |
| 681 | 406 | pane: newPane, | |
| @@ -693,37 +418,35 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||
| 693 | 418 | parent := split.parent | |
| 694 | 419 | ||
| 695 | 420 | // 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 | 424 | // Remove active border class if present (prevents GTK bloom filter corruption during unparent) | |
| 698 | 425 | if webkit.WidgetHasCSSClass(splitTargetContainer, activePaneClass) { | |
| 699 | 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 | 431 | // Prevent GTK from auto-dropping controllers while the widget is temporarily unparented. | |
| 704 | 432 | wm.safelyDetachControllersBeforeReparent(splitTarget) | |
| 705 | 433 | ||
| 706 | - | // Detach split target container from its current GTK parent before inserting into new paned. | |
| 707 | - | if parent == nil { | |
| 708 | - | // Split target is the root - remove it from the window | |
| 709 | - | log.Printf("[workspace] removing split target container=%#x from window", splitTargetContainer) | |
| 710 | - | if wm.window != nil { | |
| 711 | - | wm.window.SetChild(0) | |
| 712 | - | } | |
| 713 | - | // GTK4 will auto-unparent when we add to new paned - no manual unparent needed | |
| 714 | - | } else if parent.container != 0 { | |
| 434 | + | // For non-root splits, unparent from parent paned | |
| 435 | + | if parent != nil && parent.container != nil { | |
| 715 | 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) | |
| 717 | - | if webkit.WidgetIsValid(parent.container) { | |
| 437 | + | log.Printf("[workspace] unparenting split target container=%p from parent paned=%p", splitTargetContainer, parent.container) | |
| 438 | + | if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil { | |
| 718 | 439 | if parent.left == splitTarget { | |
| 719 | - | webkit.PanedSetStartChild(parent.container, 0) | |
| 440 | + | parentPaned.SetStartChild(nil) | |
| 720 | 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 needed | |
| 444 | + | // GTK4 automatically unparents when we set paned child to nil - no manual unparent needed | |
| 724 | 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 | 451 | // Set up the tree structure first | |
| 729 | 452 | if existingFirst { | |
| @@ -750,81 +473,66 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||
| 750 | 473 | ||
| 751 | 474 | // GTK4 handles widget operations automatically - no need to force redraw | |
| 752 | 475 | ||
| 753 | - | // Add both containers to the new paned | |
| 754 | - | if existingFirst { | |
| 755 | - | if webkit.WidgetIsValid(splitTargetContainer) { | |
| 756 | - | webkit.PanedSetStartChild(paned, splitTargetContainer) | |
| 757 | - | webkit.PanedSetEndChild(paned, newContainer) | |
| 758 | - | log.Printf("[workspace] added splitTarget=%#x as start child, new=%#x as end child", splitTargetContainer, newContainer) | |
| 759 | - | } | |
| 760 | - | } else { | |
| 761 | - | if webkit.WidgetIsValid(splitTargetContainer) { | |
| 762 | - | webkit.PanedSetStartChild(paned, newContainer) | |
| 763 | - | webkit.PanedSetEndChild(paned, splitTargetContainer) | |
| 764 | - | log.Printf("[workspace] added new=%#x as start child, splitTarget=%#x as end child", newContainer, splitTargetContainer) | |
| 765 | - | } | |
| 766 | - | } | |
| 767 | - | ||
| 768 | - | // Attach the new paned to its parent | |
| 476 | + | // Handle root split: attach paned to window FIRST, then let GTK handle reparenting | |
| 769 | 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 | 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 | 485 | wm.window.SetChild(paned) | |
| 772 | - | webkit.WidgetQueueAllocate(paned) | |
| 773 | - | log.Printf("[workspace] paned set as window child: paned=%#x", paned) | |
| 486 | + | log.Printf("[workspace] ROOT SPLIT: attached paned %p to window", paned) | |
| 487 | + | } | |
| 488 | + | ||
| 489 | + | // Now add children to the paned - GTK will handle reparenting automatically | |
| 490 | + | if existingFirst { | |
| 491 | + | paned.SetStartChild(splitTargetContainer) | |
| 492 | + | paned.SetEndChild(newContainer) | |
| 493 | + | log.Printf("[workspace] ROOT SPLIT: added splitTarget=%p (start), new=%p (end)", splitTargetContainer, newContainer) | |
| 494 | + | } else { | |
| 495 | + | paned.SetStartChild(newContainer) | |
| 496 | + | paned.SetEndChild(splitTargetContainer) | |
| 497 | + | log.Printf("[workspace] ROOT SPLIT: added new=%p (start), splitTarget=%p (end)", newContainer, splitTargetContainer) | |
| 774 | 498 | } | |
| 775 | 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 | 519 | if parent.left == split { | |
| 778 | - | webkit.PanedSetStartChild(parent.container, paned) | |
| 520 | + | parentPaned.SetStartChild(paned) | |
| 779 | 521 | } else if parent.right == split { | |
| 780 | - | webkit.PanedSetEndChild(parent.container, paned) | |
| 522 | + | parentPaned.SetEndChild(paned) | |
| 781 | 523 | } | |
| 782 | 524 | webkit.WidgetQueueAllocate(parent.container) | |
| 783 | - | webkit.WidgetQueueAllocate(paned) | |
| 784 | - | log.Printf("[workspace] paned inserted into parent=%#x", parent.container) | |
| 525 | + | paned.QueueAllocate() | |
| 526 | + | log.Printf("[workspace] paned inserted into parent=%p", parent.container) | |
| 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 paned | |
| 791 | - | // GTK needs the widget to be realized before we can set position based on allocation | |
| 792 | - | // Schedule this to run after GTK has allocated space to the paned | |
| 793 | - | wm.scheduleIdleGuarded(func() bool { | |
| 794 | - | if !webkit.WidgetIsValid(paned) { | |
| 795 | - | return false | |
| 796 | - | } | |
| 797 | - | // Get paned allocation to calculate 50% position | |
| 798 | - | alloc := webkit.WidgetGetAllocation(paned) | |
| 799 | - | var splitPos int | |
| 800 | - | if orientation == webkit.OrientationHorizontal { | |
| 801 | - | splitPos = alloc.Width / 2 | |
| 802 | - | } else { | |
| 803 | - | splitPos = alloc.Height / 2 | |
| 804 | - | } | |
| 805 | - | if splitPos > 0 { | |
| 806 | - | webkit.PanedSetPosition(paned, splitPos) | |
| 807 | - | log.Printf("[workspace] Set initial paned position to 50%%: %d (orientation=%d, size=%dx%d)", splitPos, orientation, alloc.Width, alloc.Height) | |
| 808 | - | } | |
| 809 | - | return false | |
| 810 | - | }, split) | |
| 532 | + | // Synchronize the divider once the paned has a meaningful allocation. | |
| 533 | + | wm.syncPanedDivider(paned, orientation, split) | |
| 811 | 534 | ||
| 812 | - | // CRITICAL: Show the existing container and force GTK to recreate rendering surface after reparenting | |
| 813 | - | // This reconnects WebKitGTK's rendering pipeline and fixes compositor sync issues | |
| 814 | - | if target != nil && target.container != 0 { | |
| 815 | - | wm.scheduleIdleGuarded(func() bool { | |
| 816 | - | if target == nil || !target.widgetValid || target.container == 0 { | |
| 817 | - | return false | |
| 818 | - | } | |
| 819 | - | // Show widget to reconnect WebKit rendering pipeline | |
| 820 | - | webkit.WidgetShow(target.container) | |
| 821 | - | // Force GTK to recalculate size and recreate rendering surface | |
| 822 | - | webkit.WidgetQueueResize(target.container) | |
| 823 | - | webkit.WidgetQueueDraw(target.container) | |
| 824 | - | log.Printf("[workspace] Shown and queued resize+draw for target container after reparenting") | |
| 825 | - | return false | |
| 826 | - | }, target) | |
| 827 | - | } | |
| 535 | + | // GTK4 automatically handles widget rendering after reparenting - no manual updates needed | |
| 828 | 536 | ||
| 829 | 537 | wm.viewToNode[newPane.webView] = newLeaf | |
| 830 | 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 | 557 | if newPane.zoomController != nil { | |
| 843 | 558 | newPane.zoomController.ApplyInitialZoom() | |
| 844 | 559 | } | |
| @@ -854,9 +569,27 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||
| 854 | 569 | if newLeaf == nil || !newLeaf.widgetValid { | |
| 855 | 570 | return false | |
| 856 | 571 | } | |
| 857 | - | if newContainer != 0 && webkit.WidgetIsValid(newContainer) { | |
| 572 | + | if newContainer != nil { | |
| 858 | 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 | 593 | // Attach GTK focus controller to new pane | |
| 861 | 594 | if wm.focusStateMachine != nil { | |
| 862 | 595 | wm.focusStateMachine.attachGTKController(newLeaf) | |
| @@ -868,7 +601,7 @@ func (wm *WorkspaceManager) splitNode(target *paneNode, direction string) (*pane | |||
| 868 | 601 | } | |
| 869 | 602 | if candidate.pendingHoverReattach { | |
| 870 | 603 | wm.ensureHover(candidate) | |
| 871 | - | if candidate.hoverToken != 0 { | |
| 604 | + | if candidate.hoverToken != nil { | |
| 872 | 605 | candidate.pendingHoverReattach = false | |
| 873 | 606 | } | |
| 874 | 607 | } | |
| @@ -931,25 +664,25 @@ func (wm *WorkspaceManager) swapContainers(grand *paneNode, sibling *paneNode) { | |||
| 931 | 664 | return | |
| 932 | 665 | } | |
| 933 | 666 | // 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 | 668 | if webkit.WidgetHasCSSClass(sibling.container, activePaneClass) { | |
| 936 | 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 | 674 | // GTK4 PanedSetStartChild/EndChild auto-unparent from current parent | |
| 942 | 675 | // Focus was already cleared before unparenting closed pane | |
| 943 | - | if parent := webkit.WidgetGetParent(sibling.container); parent != 0 && parent != grand.container { | |
| 944 | - | log.Printf("[workspace] widget %#x reparenting from %#x to %#x (GTK4 will auto-unparent)", sibling.container, parent, grand.container) | |
| 676 | + | if parent := webkit.WidgetGetParent(sibling.container); parent != nil && parent != grand.container { | |
| 677 | + | log.Printf("[workspace] widget %p reparenting from %p to %p (GTK4 will auto-unparent)", sibling.container, parent, grand.container) | |
| 945 | 678 | } | |
| 946 | 679 | ||
| 947 | 680 | if grand.left == sibling { | |
| 948 | - | webkit.PanedSetStartChild(grand.container, sibling.container) | |
| 949 | - | log.Printf("[workspace] swapContainers: set widget %#x as start child of paned %#x", sibling.container, grand.container) | |
| 681 | + | grandPaned.SetStartChild(sibling.container) | |
| 682 | + | log.Printf("[workspace] swapContainers: set widget %p as start child of paned %p", sibling.container, grand.container) | |
| 950 | 683 | } else { | |
| 951 | - | webkit.PanedSetEndChild(grand.container, sibling.container) | |
| 952 | - | log.Printf("[workspace] swapContainers: set widget %#x as end child of paned %#x", sibling.container, grand.container) | |
| 684 | + | grandPaned.SetEndChild(sibling.container) | |
| 685 | + | log.Printf("[workspace] swapContainers: set widget %p as end child of paned %p", sibling.container, grand.container) | |
| 953 | 686 | } | |
| 954 | 687 | webkit.WidgetQueueAllocate(grand.container) | |
| 955 | 688 | } | |
| @@ -985,7 +718,7 @@ func (wm *WorkspaceManager) cascadePromotion(singleChildPaned *paneNode) { | |||
| 985 | 718 | log.Printf("[workspace] Only child %p promoted to root", onlyChild) | |
| 986 | 719 | ||
| 987 | 720 | // Attach to window | |
| 988 | - | if onlyChild.container != 0 && webkit.WidgetIsValid(onlyChild.container) { | |
| 721 | + | if onlyChild.container != nil { | |
| 989 | 722 | wm.attachRoot(onlyChild) | |
| 990 | 723 | } | |
| 991 | 724 | ||
| @@ -1002,12 +735,12 @@ func (wm *WorkspaceManager) cascadePromotion(singleChildPaned *paneNode) { | |||
| 1002 | 735 | log.Printf("[workspace] Only child %p attached to great-grandparent %p", onlyChild, greatGrandparent) | |
| 1003 | 736 | ||
| 1004 | 737 | // 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 | 739 | // GTK4 PanedSet*Child automatically handles unparenting | |
| 1007 | 740 | if greatGrandparent.left == onlyChild { | |
| 1008 | - | webkit.PanedSetStartChild(greatGrandparent.container, onlyChild.container) | |
| 741 | + | greatGrandPaned.SetStartChild(onlyChild.container) | |
| 1009 | 742 | } else { | |
| 1010 | - | webkit.PanedSetEndChild(greatGrandparent.container, onlyChild.container) | |
| 743 | + | greatGrandPaned.SetEndChild(onlyChild.container) | |
| 1011 | 744 | } | |
| 1012 | 745 | ||
| 1013 | 746 | webkit.WidgetSetHExpand(onlyChild.container, true) | |
| @@ -1018,7 +751,7 @@ func (wm *WorkspaceManager) cascadePromotion(singleChildPaned *paneNode) { | |||
| 1018 | 751 | if onlyChild == nil || !onlyChild.widgetValid { | |
| 1019 | 752 | return false | |
| 1020 | 753 | } | |
| 1021 | - | if onlyChild.container != 0 && webkit.WidgetIsValid(onlyChild.container) { | |
| 754 | + | if onlyChild.container != nil { | |
| 1022 | 755 | webkit.WidgetShow(onlyChild.container) | |
| 1023 | 756 | webkit.WidgetQueueResize(onlyChild.container) | |
| 1024 | 757 | webkit.WidgetQueueDraw(onlyChild.container) | |
| @@ -1040,50 +773,33 @@ func (wm *WorkspaceManager) cascadePromotion(singleChildPaned *paneNode) { | |||
| 1040 | 773 | ||
| 1041 | 774 | // attachRoot attaches a node as the new window root | |
| 1042 | 775 | func (wm *WorkspaceManager) attachRoot(root *paneNode) { | |
| 1043 | - | if root == nil || root.container == 0 || wm.window == nil { | |
| 1044 | - | return | |
| 1045 | - | } | |
| 1046 | - | if !webkit.WidgetIsValid(root.container) { | |
| 776 | + | if root == nil || root.container == nil || wm.window == nil { | |
| 1047 | 777 | return | |
| 1048 | 778 | } | |
| 1049 | 779 | ||
| 1050 | - | // Hold a transient reference so GTK does not destroy the widget while detached | |
| 1051 | - | releaseRef := webkit.WidgetRef(root.container) | |
| 1052 | - | if !releaseRef { | |
| 1053 | - | log.Printf("[workspace] WARNING: failed to acquire temporary ref for widget %#x; aborting attachRoot", root.container) | |
| 1054 | - | return | |
| 1055 | - | } | |
| 1056 | - | defer func() { | |
| 1057 | - | if webkit.WidgetIsValid(root.container) { | |
| 1058 | - | webkit.WidgetUnref(root.container) | |
| 1059 | - | } | |
| 1060 | - | }() | |
| 780 | + | // Note: In gotk4, reference counting is handled automatically by Go's GC | |
| 781 | + | // No need for manual Ref()/Unref() calls | |
| 1061 | 782 | ||
| 1062 | 783 | // Detach from previous container (paned, stack, etc.) before replacing the window child. | |
| 1063 | - | if parent := webkit.WidgetGetParent(root.container); parent != 0 { | |
| 1064 | - | log.Printf("[workspace] unparenting widget %#x from parent %#x before window attach", root.container, parent) | |
| 784 | + | // While GTK4 auto-unparents for paned operations, window.SetChild requires manual unparent | |
| 785 | + | if parent := webkit.WidgetGetParent(root.container); parent != nil { | |
| 786 | + | log.Printf("[workspace] unparenting widget %p from parent %p before window attach", root.container, parent) | |
| 1065 | 787 | webkit.WidgetUnparent(root.container) | |
| 1066 | - | if !webkit.WidgetIsValid(root.container) { | |
| 1067 | - | log.Printf("[workspace] WARNING: widget %#x became invalid during unparent; aborting attachRoot", root.container) | |
| 1068 | - | return | |
| 1069 | - | } | |
| 788 | + | // Note: In gotk4, the Go object reference remains valid after unparent | |
| 789 | + | // Only the GTK parent relationship is cleared | |
| 1070 | 790 | } | |
| 1071 | 791 | ||
| 1072 | 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 | 796 | webkit.WidgetSetHExpand(root.container, true) | |
| 1077 | 797 | webkit.WidgetSetVExpand(root.container, true) | |
| 1078 | 798 | } | |
| 1079 | 799 | ||
| 1080 | - | if !webkit.WidgetIsValid(root.container) { | |
| 1081 | - | log.Printf("[workspace] WARNING: widget %#x invalid prior to window attach", root.container) | |
| 1082 | - | return | |
| 1083 | - | } | |
| 1084 | - | ||
| 800 | + | // GTK4 will automatically unparent root.container from its previous parent when we attach it here | |
| 1085 | 801 | wm.window.SetChild(root.container) | |
| 1086 | - | if webkit.WidgetIsValid(root.container) { | |
| 802 | + | if root.container != nil { | |
| 1087 | 803 | webkit.WidgetQueueAllocate(root.container) | |
| 1088 | 804 | webkit.WidgetShow(root.container) | |
| 1089 | 805 | webkit.WidgetQueueResize(root.container) | |
| @@ -1094,7 +810,7 @@ func (wm *WorkspaceManager) attachRoot(root *paneNode) { | |||
| 1094 | 810 | if root == nil || !root.widgetValid { | |
| 1095 | 811 | return false | |
| 1096 | 812 | } | |
| 1097 | - | if root.container != 0 && webkit.WidgetIsValid(root.container) { | |
| 813 | + | if root.container != nil { | |
| 1098 | 814 | webkit.WidgetQueueResize(root.container) | |
| 1099 | 815 | webkit.WidgetQueueDraw(root.container) | |
| 1100 | 816 | } | |
| @@ -1102,10 +818,10 @@ func (wm *WorkspaceManager) attachRoot(root *paneNode) { | |||
| 1102 | 818 | }, root) | |
| 1103 | 819 | ||
| 1104 | 820 | // Verify attachment succeeded | |
| 1105 | - | if finalParent := webkit.WidgetGetParent(root.container); finalParent == 0 { | |
| 1106 | - | log.Printf("[workspace] WARNING: widget %#x has no parent after SetChild", root.container) | |
| 821 | + | if finalParent := webkit.WidgetGetParent(root.container); finalParent == nil { | |
| 822 | + | log.Printf("[workspace] WARNING: widget %p has no parent after SetChild", root.container) | |
| 1107 | 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 | 854 | node.pane = nil | |
| 1139 | 855 | } | |
| 1140 | 856 | ||
| 1141 | - | if node.container != 0 { | |
| 857 | + | if node.container != nil { | |
| 1142 | 858 | // Container widget will be destroyed by GTK when unparented | |
| 1143 | - | node.container = 0 | |
| 859 | + | node.container = nil | |
| 1144 | 860 | } | |
| 1145 | 861 | ||
| 1146 | 862 | node.pendingIdleHandles = nil | |
| @@ -1157,9 +873,10 @@ func (wm *WorkspaceManager) decommissionParent(parent *paneNode, generation uint | |||
| 1157 | 873 | ||
| 1158 | 874 | // For branch nodes (paneds), destroy the widget | |
| 1159 | 875 | // Children have already been unparented during promotion/close, no need to clear them | |
| 1160 | - | if !parent.isLeaf && parent.container != 0 && webkit.WidgetIsValid(parent.container) { | |
| 1161 | - | webkit.WidgetHookDestroy(parent.container) | |
| 1162 | - | log.Printf("[workspace] destroyed paned widget: %#x", parent.container) | |
| 876 | + | if !parent.isLeaf && parent.container != nil { | |
| 877 | + | // In gotk4, we don't need to manually destroy - GTK handles this when unparented | |
| 878 | + | // Just mark it for cleanup | |
| 879 | + | log.Printf("[workspace] marked paned widget for cleanup: %p", parent.container) | |
| 1163 | 880 | } | |
| 1164 | 881 | ||
| 1165 | 882 | wm.cleanupPane(parent, generation) | |
| @@ -1191,6 +908,8 @@ func (wm *WorkspaceManager) cleanupAndExit(node *paneNode) (*paneNode, error) { | |||
| 1191 | 908 | log.Printf("[workspace] closing final pane; exiting browser") | |
| 1192 | 909 | wm.cleanupPane(node, wm.nextCleanupGeneration()) | |
| 1193 | 910 | wm.detachHover(node) | |
| 911 | + | ||
| 912 | + | // Gracefully quit the GTK main loop | |
| 1194 | 913 | webkit.QuitMainLoop() | |
| 1195 | 914 | return nil, nil | |
| 1196 | 915 | } | |
| @@ -1223,7 +942,8 @@ func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) { | |||
| 1223 | 942 | wm.detachFocus(node) | |
| 1224 | 943 | ||
| 1225 | 944 | // 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 | 947 | return wm.cleanupAndExit(node) | |
| 1228 | 948 | } | |
| 1229 | 949 | if node == wm.root { | |
| @@ -1237,36 +957,52 @@ func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) { | |||
| 1237 | 957 | ||
| 1238 | 958 | // Clear grandparent's focus child BEFORE unparenting closed pane | |
| 1239 | 959 | // GTK propagates focus events upward, so we must clear grandparent focus first | |
| 1240 | - | if grandparent != nil && grandparent.container != 0 && webkit.WidgetIsValid(grandparent.container) { | |
| 1241 | - | webkit.WidgetSetFocusChild(grandparent.container, 0) | |
| 960 | + | if grandparent != nil && grandparent.container != nil { | |
| 961 | + | webkit.WidgetSetFocusChild(grandparent.container, nil) | |
| 1242 | 962 | } | |
| 1243 | 963 | ||
| 1244 | 964 | // 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 | 966 | if containerParent := webkit.WidgetGetParent(node.container); containerParent == parent.container { | |
| 1247 | - | if parent.left == node { | |
| 1248 | - | webkit.PanedSetStartChild(parent.container, 0) | |
| 1249 | - | } else if parent.right == node { | |
| 1250 | - | webkit.PanedSetEndChild(parent.container, 0) | |
| 967 | + | if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil { | |
| 968 | + | if parent.left == node { | |
| 969 | + | parentPaned.SetStartChild(nil) | |
| 970 | + | } else if parent.right == node { | |
| 971 | + | parentPaned.SetEndChild(nil) | |
| 972 | + | } | |
| 973 | + | log.Printf("[workspace] unparented closed pane container: %p", node.container) | |
| 974 | + | } | |
| 975 | + | } | |
| 976 | + | } | |
| 977 | + | ||
| 978 | + | // STEP 5: Unparent sibling from parent paned BEFORE promotion | |
| 979 | + | // The sibling needs to be detached from the parent paned before we can attach it elsewhere | |
| 980 | + | if sibling.container != nil && parent.container != nil { | |
| 981 | + | if parentPaned, ok := parent.container.(*gtk.Paned); ok && parentPaned != nil { | |
| 982 | + | if parent.left == sibling { | |
| 983 | + | parentPaned.SetStartChild(nil) | |
| 984 | + | } else if parent.right == sibling { | |
| 985 | + | parentPaned.SetEndChild(nil) | |
| 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 | 992 | wm.promoteSibling(grandparent, parent, sibling) | |
| 1258 | 993 | ||
| 1259 | - | // STEP 6: GTK handles all reparenting automatically | |
| 1260 | - | // PanedSetStartChild/EndChild and window.SetChild auto-unparent widgets | |
| 994 | + | // STEP 7: GTK widget reparenting | |
| 995 | + | // For paned containers: GTK4 PanedSetStartChild/EndChild auto-unparent from current parent | |
| 996 | + | // For window: we already manually unparented above, so now we can safely attach | |
| 1261 | 997 | wm.swapContainers(grandparent, sibling) | |
| 1262 | 998 | ||
| 1263 | 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 | 1001 | wm.scheduleIdleGuarded(func() bool { | |
| 1266 | 1002 | if sibling == nil || !sibling.widgetValid { | |
| 1267 | 1003 | return false | |
| 1268 | 1004 | } | |
| 1269 | - | if sibling.container != 0 && webkit.WidgetIsValid(sibling.container) { | |
| 1005 | + | if sibling.container != nil { | |
| 1270 | 1006 | webkit.WidgetShow(sibling.container) | |
| 1271 | 1007 | webkit.WidgetQueueResize(sibling.container) | |
| 1272 | 1008 | webkit.WidgetQueueDraw(sibling.container) | |
| @@ -1275,14 +1011,14 @@ func (wm *WorkspaceManager) closePane(node *paneNode) (*paneNode, error) { | |||
| 1275 | 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 | 1015 | if grandparent != nil && ((grandparent.left == nil) != (grandparent.right == nil)) { | |
| 1280 | 1016 | // Grandparent has exactly one child - it should be promoted too | |
| 1281 | 1017 | log.Printf("[workspace] Grandparent %p now has only one child, cascading promotion", grandparent) | |
| 1282 | 1018 | wm.cascadePromotion(grandparent) | |
| 1283 | 1019 | } | |
| 1284 | 1020 | ||
| 1285 | - | // STEP 8: Cleanup & focus | |
| 1021 | + | // STEP 9: Cleanup & focus | |
| 1286 | 1022 | wm.cleanupPane(node, ctx.Generation()) | |
| 1287 | 1023 | wm.decommissionParent(parent, ctx.Generation()) | |
| 1288 | 1024 | if sibling != nil { | |
| @@ -1335,17 +1071,17 @@ func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, e | |||
| 1335 | 1071 | } | |
| 1336 | 1072 | ||
| 1337 | 1073 | // 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 | 1075 | if stack.parent.left == remaining { | |
| 1340 | - | webkit.PanedSetStartChild(stack.parent.container, remaining.container) | |
| 1076 | + | parentPaned.SetStartChild(remaining.container) | |
| 1341 | 1077 | } else { | |
| 1342 | - | webkit.PanedSetEndChild(stack.parent.container, remaining.container) | |
| 1078 | + | parentPaned.SetEndChild(remaining.container) | |
| 1343 | 1079 | } | |
| 1344 | 1080 | } | |
| 1345 | 1081 | } else { | |
| 1346 | 1082 | // Stack was root | |
| 1347 | 1083 | wm.root = remaining | |
| 1348 | - | if webkit.WidgetIsValid(remaining.container) { | |
| 1084 | + | if remaining.container != nil { | |
| 1349 | 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 | 1100 | // Remove widget from GTK box | |
| 1365 | - | if webkit.WidgetIsValid(stack.container) && webkit.WidgetIsValid(node.container) { | |
| 1366 | - | webkit.BoxRemove(stack.container, node.container) | |
| 1101 | + | if stackBox, ok := stack.container.(*gtk.Box); ok && stackBox != nil && node.container != nil { | |
| 1102 | + | stackBox.Remove(node.container) | |
| 1367 | 1103 | } | |
| 1368 | 1104 | ||
| 1369 | 1105 | // Show new active pane | |
| 1370 | 1106 | if stack.activeStackIndex >= 0 { | |
| 1371 | 1107 | active := stack.stackedPanes[stack.activeStackIndex] | |
| 1372 | - | if webkit.WidgetIsValid(active.container) { | |
| 1108 | + | if active.container != nil { | |
| 1373 | 1109 | webkit.WidgetShow(active.container) | |
| 1374 | 1110 | } | |
| 1375 | 1111 | } | |
| @@ -1379,3 +1115,77 @@ func (wm *WorkspaceManager) closeStackedPaneCompat(node *paneNode) (*paneNode, e | |||
| 1379 | 1115 | ||
| 1380 | 1116 | 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 | 2 | package browser | |
| 3 | 3 | ||
| 4 | 4 | import ( | |
| 5 | - | "fmt" | |
| 6 | 5 | "log" | |
| 7 | 6 | "strings" | |
| 8 | 7 | "time" | |
| 9 | 8 | ||
| 10 | - | "github.com/bnema/dumber/internal/app/messaging" | |
| 9 | + | "github.com/bnema/dumber/internal/config" | |
| 11 | 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 | 15 | const ( | |
| @@ -17,262 +18,9 @@ const ( | |||
| 17 | 18 | defaultPopupMinHeight = 600 // Minimum height for OAuth popups | |
| 18 | 19 | ) | |
| 19 | 20 | ||
| 20 | - | // HandlePopup handles popup window creation requests from WebViews | |
| 21 | - | func (wm *WorkspaceManager) HandlePopup(source *webkit.WebView, url string) *webkit.WebView { | |
| 22 | - | log.Printf("[workspace] HandlePopup called for URL: %s", url) | |
| 23 | - | ||
| 24 | - | // Check for frame type markers added by WebKit layer | |
| 25 | - | isBlankTarget := strings.HasSuffix(url, "#__dumber_frame_blank") | |
| 26 | - | isPopupTarget := strings.HasSuffix(url, "#__dumber_frame_popup") | |
| 27 | - | ||
| 28 | - | // Clean the URL by removing our markers | |
| 29 | - | if isBlankTarget || isPopupTarget { | |
| 30 | - | if isBlankTarget { | |
| 31 | - | url = strings.TrimSuffix(url, "#__dumber_frame_blank") | |
| 32 | - | log.Printf("[workspace] Detected _blank target - will create regular pane for: %s", url) | |
| 33 | - | } else { | |
| 34 | - | url = strings.TrimSuffix(url, "#__dumber_frame_popup") | |
| 35 | - | log.Printf("[workspace] Detected popup target - will create popup pane for: %s", url) | |
| 36 | - | } | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | if wm == nil || source == nil { | |
| 40 | - | log.Printf("[workspace] HandlePopup: nil workspace manager or source - allowing native popup") | |
| 41 | - | return nil | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | node := wm.viewToNode[source] | |
| 45 | - | if node == nil { | |
| 46 | - | log.Printf("[workspace] popup from unknown webview - allowing native popup") | |
| 47 | - | return nil | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | // Note: HandlePopup is now obsolete - window.open is handled directly via JavaScript bypass | |
| 51 | - | ||
| 52 | - | cfg := wm.app.config | |
| 53 | - | if cfg == nil { | |
| 54 | - | log.Printf("[workspace] HandlePopup: nil config - allowing native popup") | |
| 55 | - | return nil | |
| 56 | - | } | |
| 57 | - | ||
| 58 | - | popCfg := cfg.Workspace.Popups | |
| 59 | - | log.Printf("[workspace] Popup config - OpenInNewPane: %v, Placement: %s", popCfg.OpenInNewPane, popCfg.Placement) | |
| 60 | - | ||
| 61 | - | if !popCfg.OpenInNewPane { | |
| 62 | - | log.Printf("[workspace] Popup creation disabled in config - allowing native popup") | |
| 63 | - | return nil | |
| 64 | - | } | |
| 65 | - | ||
| 66 | - | // Smart detection path: create temporary view and decide placement once type is known | |
| 67 | - | if popCfg.EnableSmartDetection { | |
| 68 | - | webkitCfg, err := wm.app.buildWebkitConfig() | |
| 69 | - | if err != nil { | |
| 70 | - | log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err) | |
| 71 | - | return nil | |
| 72 | - | } | |
| 73 | - | webkitCfg.CreateWindow = false | |
| 74 | - | // Create as related to avoid WindowFeatures crash; we'll decide final placement later | |
| 75 | - | newView, err := webkit.NewWebViewWithRelated(webkitCfg, source) | |
| 76 | - | if err != nil { | |
| 77 | - | log.Printf("[workspace] failed to create temp WebView: %v - allowing native popup", err) | |
| 78 | - | return nil | |
| 79 | - | } | |
| 80 | - | ||
| 81 | - | // Register detection callback | |
| 82 | - | newView.OnWindowTypeDetected(func(t webkit.WindowType, feat *webkit.WindowFeatures) { | |
| 83 | - | wm.RunOnUI(func() { | |
| 84 | - | wm.handleDetectedWindowType(node, newView, url, t, feat) | |
| 85 | - | }) | |
| 86 | - | }) | |
| 87 | - | ||
| 88 | - | // Fallback: if detection never fires, treat as popup as before | |
| 89 | - | go func() { | |
| 90 | - | time.Sleep(1500 * time.Millisecond) | |
| 91 | - | if newView != nil { | |
| 92 | - | wm.RunOnUI(func() { | |
| 93 | - | if wm.viewToNode[newView] == nil { // not yet placed | |
| 94 | - | wm.handleDetectedWindowType(node, newView, url, webkit.WindowTypePopup, nil) | |
| 95 | - | } | |
| 96 | - | }) | |
| 97 | - | } | |
| 98 | - | }() | |
| 99 | - | ||
| 100 | - | return newView | |
| 101 | - | } | |
| 102 | - | ||
| 103 | - | // Legacy path preserved | |
| 104 | - | webkitCfg, err := wm.app.buildWebkitConfig() | |
| 105 | - | if err != nil { | |
| 106 | - | log.Printf("[workspace] failed to build webkit config: %v - allowing native popup", err) | |
| 107 | - | return nil | |
| 108 | - | } | |
| 109 | - | webkitCfg.CreateWindow = false | |
| 110 | - | newView, err := webkit.NewWebViewWithRelated(webkitCfg, source) | |
| 111 | - | if err != nil { | |
| 112 | - | log.Printf("[workspace] failed to create placeholder WebView: %v - allowing native popup", err) | |
| 113 | - | return nil | |
| 114 | - | } | |
| 115 | - | ||
| 116 | - | // Workspace navigation shortcuts are now handled globally by WindowShortcutHandler | |
| 117 | - | ||
| 118 | - | // Create a pane for the new WebView | |
| 119 | - | newPane, err := wm.createPane(newView) | |
| 120 | - | if err != nil { | |
| 121 | - | log.Printf("[workspace] failed to create popup pane: %v - allowing native popup", err) | |
| 122 | - | return nil | |
| 123 | - | } | |
| 124 | - | ||
| 125 | - | // Determine placement direction | |
| 126 | - | direction := strings.ToLower(popCfg.Placement) | |
| 127 | - | if direction == "" { | |
| 128 | - | direction = "right" | |
| 129 | - | } | |
| 130 | - | ||
| 131 | - | // Determine target node for splitting | |
| 132 | - | target := node | |
| 133 | - | if !popCfg.FollowPaneContext && wm.GetActiveNode() != nil { | |
| 134 | - | target = wm.GetActiveNode() | |
| 135 | - | } | |
| 136 | - | ||
| 137 | - | // Add the popup pane to the workspace using manual pane insertion | |
| 138 | - | if err := wm.insertPopupPane(target, newPane, direction); err != nil { | |
| 139 | - | log.Printf("[workspace] popup pane insertion failed: %v - allowing native popup", err) | |
| 140 | - | return nil | |
| 141 | - | } | |
| 142 | - | ||
| 143 | - | // Get the new node and popup WebView ID for tracking | |
| 144 | - | newNode := wm.viewToNode[newView] | |
| 145 | - | popupWebViewID := newView.ID() | |
| 146 | - | ||
| 147 | - | // Apply different behavior based on target type | |
| 148 | - | if isBlankTarget { | |
| 149 | - | log.Printf("[workspace] Treating _blank target as regular pane - no auto-close behavior") | |
| 150 | - | // For _blank targets, set up parent relationship but no auto-close | |
| 151 | - | if newNode != nil { | |
| 152 | - | newNode.parentPane = node | |
| 153 | - | } | |
| 154 | - | ||
| 155 | - | // Register close handler to clean up activePopupChildren tracking | |
| 156 | - | newView.RegisterCloseHandler(func() { | |
| 157 | - | log.Printf("[workspace] _blank popup requesting close via window.close()") | |
| 158 | - | // Remove this popup from parent's activePopupChildren | |
| 159 | - | if node != nil && popupWebViewID != "" { | |
| 160 | - | for i, childID := range node.activePopupChildren { | |
| 161 | - | if childID == popupWebViewID { | |
| 162 | - | node.activePopupChildren = append(node.activePopupChildren[:i], node.activePopupChildren[i+1:]...) | |
| 163 | - | log.Printf("[workspace] Removed _blank popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(node.activePopupChildren)) | |
| 164 | - | break | |
| 165 | - | } | |
| 166 | - | } | |
| 167 | - | } | |
| 168 | - | }) | |
| 169 | - | } else { | |
| 170 | - | log.Printf("[workspace] Treating as popup pane - applying popup-specific behavior") | |
| 171 | - | // Mark as popup for auto-close handling (OAuth flows, etc.) | |
| 172 | - | if newNode != nil { | |
| 173 | - | newNode.isPopup = true | |
| 174 | - | newNode.parentPane = node | |
| 175 | - | log.Printf("[workspace] Marked pane as popup for auto-close handling") | |
| 176 | - | ||
| 177 | - | // Register close handler for popup auto-close on window.close() | |
| 178 | - | newView.RegisterCloseHandler(func() { | |
| 179 | - | log.Printf("[workspace] Popup requesting close via window.close()") | |
| 180 | - | ||
| 181 | - | // Remove this popup from parent's activePopupChildren | |
| 182 | - | if node != nil && popupWebViewID != "" { | |
| 183 | - | for i, childID := range node.activePopupChildren { | |
| 184 | - | if childID == popupWebViewID { | |
| 185 | - | node.activePopupChildren = append(node.activePopupChildren[:i], node.activePopupChildren[i+1:]...) | |
| 186 | - | log.Printf("[workspace] Removed popup %s from parent's activePopupChildren (remaining: %d)", popupWebViewID, len(node.activePopupChildren)) | |
| 187 | - | break | |
| 188 | - | } | |
| 189 | - | } | |
| 190 | - | } | |
| 191 | - | ||
| 192 | - | // Look up the node at close time | |
| 193 | - | if node := wm.viewToNode[newView]; node != nil && node.isPopup { | |
| 194 | - | log.Printf("[workspace] Closing popup pane") | |
| 195 | - | // Brief delay to allow any final redirects to complete | |
| 196 | - | time.AfterFunc(200*time.Millisecond, func() { | |
| 197 | - | wm.scheduleIdleGuarded(func() bool { | |
| 198 | - | if node == nil || !node.widgetValid { | |
| 199 | - | return false | |
| 200 | - | } | |
| 201 | - | if err := wm.ClosePane(node); err != nil { | |
| 202 | - | log.Printf("[workspace] Failed to close popup pane: %v", err) | |
| 203 | - | } | |
| 204 | - | return false | |
| 205 | - | }, node) | |
| 206 | - | }) | |
| 207 | - | } else { | |
| 208 | - | log.Printf("[workspace] Could not find popup node for close handler") | |
| 209 | - | } | |
| 210 | - | }) | |
| 211 | - | } else { | |
| 212 | - | log.Printf("[workspace] Warning: could not find node for popup WebView in viewToNode map") | |
| 213 | - | } | |
| 214 | - | } | |
| 215 | - | ||
| 216 | - | // Track this popup in the parent's activePopupChildren (for both _blank and popup types) | |
| 217 | - | // This is critical to prevent OAuth flows from hijacking the parent pane via window.opener | |
| 218 | - | if node != nil && popupWebViewID != "" { | |
| 219 | - | if node.activePopupChildren == nil { | |
| 220 | - | node.activePopupChildren = make([]string, 0) | |
| 221 | - | } | |
| 222 | - | node.activePopupChildren = append(node.activePopupChildren, popupWebViewID) | |
| 223 | - | log.Printf("[workspace] Added popup %s to parent's activePopupChildren (count: %d)", popupWebViewID, len(node.activePopupChildren)) | |
| 224 | - | ||
| 225 | - | // Register navigation policy handler for parent to block script-initiated navigations | |
| 226 | - | // when it has active popup children (prevents OAuth from hijacking parent pane) | |
| 227 | - | if node.pane != nil && node.pane.WebView() != nil { | |
| 228 | - | node.pane.WebView().RegisterNavigationPolicyHandler(func(url string, isUserGesture bool) bool { | |
| 229 | - | // Always allow user-initiated navigations | |
| 230 | - | if isUserGesture { | |
| 231 | - | return true | |
| 232 | - | } | |
| 233 | - | ||
| 234 | - | // Block script-initiated navigations if this pane has active popup children | |
| 235 | - | if len(node.activePopupChildren) > 0 { | |
| 236 | - | log.Printf("[workspace] BLOCKED script-initiated navigation in parent pane (has %d active popups): %s", len(node.activePopupChildren), url) | |
| 237 | - | return false | |
| 238 | - | } | |
| 239 | - | ||
| 240 | - | // Allow if no active popups | |
| 241 | - | return true | |
| 242 | - | }) | |
| 243 | - | log.Printf("[workspace] Registered navigation policy handler for parent pane") | |
| 244 | - | } | |
| 245 | - | } | |
| 246 | - | ||
| 247 | - | // Ensure GUI components are available in the new pane | |
| 248 | - | wm.ensureGUIInPane(newPane) | |
| 249 | - | ||
| 250 | - | // Inject GUI components into the popup pane | |
| 251 | - | wm.ensureGUIInPane(newPane) | |
| 252 | - | ||
| 253 | - | // Load the URL if provided | |
| 254 | - | if url != "" { | |
| 255 | - | paneType := "popup" | |
| 256 | - | if isBlankTarget { | |
| 257 | - | paneType = "_blank target" | |
| 258 | - | } | |
| 259 | - | log.Printf("[webkit] LoadURL (%s): %s", paneType, url) | |
| 260 | - | if err := newView.LoadURL(url); err != nil { | |
| 261 | - | log.Printf("[workspace] failed to load %s URL: %v", paneType, err) | |
| 262 | - | } | |
| 263 | - | // Ensure the WebView is visible after loading URL | |
| 264 | - | if err := newView.Show(); err != nil { | |
| 265 | - | log.Printf("[workspace] failed to show popup WebView: %v", err) | |
| 266 | - | } | |
| 267 | - | } | |
| 268 | - | ||
| 269 | - | if isBlankTarget { | |
| 270 | - | log.Printf("[workspace] Created regular pane for _blank target URL: %s", url) | |
| 271 | - | } else { | |
| 272 | - | log.Printf("[workspace] Created popup pane for URL: %s", url) | |
| 273 | - | } | |
| 274 | - | return newView | |
| 275 | - | } | |
| 21 | + | // HandlePopup is DEPRECATED and removed. | |
| 22 | + | // Popup lifecycle now uses WebKit's native create/ready-to-show/close signals. | |
| 23 | + | // See setupPopupHandling(), handlePopupReadyToShow(), and handlePopupClose() instead. | |
| 276 | 24 | ||
| 277 | 25 | // registerOAuthAutoClose sets up OAuth auto-close functionality for popups | |
| 278 | 26 | // 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 | 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 | 32 | // insertIndependentPane inserts a new independent pane next to the source | |
| 573 | 33 | func (wm *WorkspaceManager) insertIndependentPane(sourceNode *paneNode, webView *webkit.WebView, url string) error { | |
| 574 | 34 | newPane, err := wm.createPane(webView) | |
| @@ -579,7 +39,8 @@ func (wm *WorkspaceManager) insertIndependentPane(sourceNode *paneNode, webView | |||
| 579 | 39 | if direction == "" { | |
| 580 | 40 | direction = "right" | |
| 581 | 41 | } | |
| 582 | - | if err := wm.insertPopupPane(sourceNode, newPane, direction); err != nil { // reuse insertion primitive | |
| 42 | + | // Use splitNode with existing pane instead of insertPopupPane | |
| 43 | + | if _, err := wm.splitNode(sourceNode, direction, newPane); err != nil { | |
| 583 | 44 | return err | |
| 584 | 45 | } | |
| 585 | 46 | node := wm.viewToNode[webView] | |
| @@ -606,12 +67,13 @@ func (wm *WorkspaceManager) configureRelatedPopup(sourceNode *paneNode, webView | |||
| 606 | 67 | if direction == "" { | |
| 607 | 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 | 72 | log.Printf("[workspace] failed to insert related popup pane: %v", err) | |
| 611 | 73 | return | |
| 612 | 74 | } | |
| 613 | 75 | node := wm.viewToNode[related] | |
| 614 | - | popupWebViewID := related.ID() | |
| 76 | + | popupWebViewID := related.IDString() | |
| 615 | 77 | ||
| 616 | 78 | if node != nil { | |
| 617 | 79 | node.windowType = webkit.WindowTypePopup | |
| @@ -715,17 +177,17 @@ func (wm *WorkspaceManager) applyPopupSizeConstraints(view *webkit.WebView, widt | |||
| 715 | 177 | } | |
| 716 | 178 | ||
| 717 | 179 | // Apply to container | |
| 718 | - | if container := view.RootWidget(); container != 0 && webkit.WidgetIsValid(container) { | |
| 180 | + | if container := view.RootWidget(); container != nil { | |
| 719 | 181 | webkit.WidgetSetSizeRequest(container, minWidth, minHeight) | |
| 720 | 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 | 186 | // Apply to WebView widget | |
| 725 | - | if webViewWidget := view.Widget(); webViewWidget != 0 && webkit.WidgetIsValid(webViewWidget) { | |
| 187 | + | if webViewWidget := view.Widget(); webViewWidget != nil { | |
| 726 | 188 | webkit.WidgetSetSizeRequest(webViewWidget, minWidth, minHeight) | |
| 727 | 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 | 258 | log.Printf("[workspace] failed to build webkit config for tab: %v", err) | |
| 797 | 259 | return | |
| 798 | 260 | } | |
| 261 | + | // Tabs are embedded in workspace paned containers - no separate window needed | |
| 799 | 262 | webkitCfg.CreateWindow = false | |
| 800 | 263 | ||
| 801 | - | // Create independent WebView like handleIntentAsTab does | |
| 264 | + | // Create independent WebView matching our standard tab insertion path | |
| 802 | 265 | independentView, err := webkit.NewWebView(webkitCfg) | |
| 803 | 266 | if err != nil { | |
| 804 | 267 | log.Printf("[workspace] failed to create independent tab WebView: %v", err) | |
| @@ -819,3 +282,316 @@ func (wm *WorkspaceManager) handleDetectedWindowType(sourceNode *paneNode, webVi | |||
| 819 | 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 | 5 | "os" | |
| 6 | 6 | ||
| 7 | 7 | "github.com/bnema/dumber/pkg/webkit" | |
| 8 | + | "github.com/diamondburned/gotk4/pkg/gtk/v4" | |
| 8 | 9 | ) | |
| 9 | 10 | ||
| 10 | 11 | // DebugLevel controls validation and safety checks | |
| @@ -49,31 +50,35 @@ type paneNode struct { | |||
| 49 | 50 | left *paneNode | |
| 50 | 51 | right *paneNode | |
| 51 | 52 | ||
| 52 | - | // Widget management - direct GTK pointers (all ops on main thread) | |
| 53 | - | container uintptr // Main container: GtkPaned (branch), GtkBox (stack), or WebView container (leaf) | |
| 54 | - | orientation webkit.Orientation | |
| 53 | + | // Widget management - gotk4 widgets (all ops on main thread) | |
| 54 | + | container gtk.Widgetter // Main container: *gtk.Paned (branch), *gtk.Box (stack), or WebView widget (leaf) | |
| 55 | + | orientation gtk.Orientation | |
| 55 | 56 | isLeaf bool | |
| 56 | 57 | isPopup bool // Deprecated: use windowType instead | |
| 57 | 58 | ||
| 58 | 59 | // Window type tracking | |
| 59 | - | windowType webkit.WindowType // Tab or Popup | |
| 60 | - | windowFeatures *webkit.WindowFeatures // Features if popup | |
| 61 | - | isRelated bool // Shares context | |
| 62 | - | parentPane *paneNode // Parent for related views | |
| 63 | - | activePopupChildren []string // WebView IDs of active popup children (for tracking related popups) | |
| 64 | - | autoClose bool // Auto-close on OAuth success | |
| 65 | - | requestID string // Request ID for popup (used for localStorage cleanup) | |
| 66 | - | hoverToken uintptr | |
| 67 | - | focusControllerToken uintptr | |
| 68 | - | pendingHoverReattach bool | |
| 69 | - | pendingFocusReattach bool | |
| 60 | + | windowType webkit.WindowType // Tab or Popup | |
| 61 | + | windowFeatures *webkit.WindowFeatures // Features if popup | |
| 62 | + | isRelated bool // Shares context | |
| 63 | + | parentPane *paneNode // Parent for related views | |
| 64 | + | activePopupChildren []string // WebView IDs of active popup children (for tracking related popups) | |
| 65 | + | autoClose bool // Auto-close on OAuth success | |
| 66 | + | requestID string // Request ID for popup (used for localStorage cleanup) | |
| 67 | + | popupID uint64 // Unique popup ID (for bare WebView lifecycle tracking) | |
| 68 | + | hoverToken *gtk.EventControllerMotion // Hover event controller for mouse-based focus | |
| 69 | + | focusControllerToken uintptr // Focus controller token (stored as native pointer) | |
| 70 | + | pendingHoverReattach bool // Needs hover reattachment after reparent | |
| 71 | + | pendingFocusReattach bool // Needs focus reattachment after reparent | |
| 72 | + | lastHoverTime int64 // Timestamp of last hover event (for debouncing) | |
| 73 | + | lastFocusEnterTime int64 // Timestamp of last focus enter event (for deduplication) | |
| 74 | + | lastFocusLeaveTime int64 // Timestamp of last focus leave event (for deduplication) | |
| 70 | 75 | ||
| 71 | 76 | // Stacked panes support - terminal branch nodes | |
| 72 | - | isStacked bool // Whether this node contains stacked panes | |
| 73 | - | stackedPanes []*paneNode // List of stacked panes (if isStacked) | |
| 74 | - | activeStackIndex int // Index of currently visible pane in stack | |
| 75 | - | titleBar uintptr // GtkBox for title bar (when collapsed) | |
| 76 | - | stackWrapper uintptr // Internal GtkBox containing the actual stacked widgets (titles + webviews) | |
| 77 | + | isStacked bool // Whether this node contains stacked panes | |
| 78 | + | stackedPanes []*paneNode // List of stacked panes (if isStacked) | |
| 79 | + | activeStackIndex int // Index of currently visible pane in stack | |
| 80 | + | titleBar gtk.Widgetter // *gtk.Box for title bar (when collapsed) | |
| 81 | + | stackWrapper gtk.Widgetter // Internal *gtk.Box containing the actual stacked widgets (titles + webviews) | |
| 77 | 82 | ||
| 78 | 83 | // Cleanup tracking | |
| 79 | 84 | widgetValid bool // Guard flagged before GTK destruction | |
| @@ -90,5 +95,15 @@ const ( | |||
| 90 | 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 | 108 | // Focus calculation epsilon for geometric comparisons | |
| 94 | 109 | const focusEpsilon = 1e-3 | |
M internal/app/browser/workspace_utils.go +89 -106
| @@ -6,13 +6,10 @@ import ( | |||
| 6 | 6 | "log" | |
| 7 | 7 | ||
| 8 | 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 | 13 | // UpdateTitleBar updates the title bar label for a WebView in stacked panes | |
| 17 | 14 | func (wm *WorkspaceManager) UpdateTitleBar(webView *webkit.WebView, title string) { | |
| 18 | 15 | // Delegate to StackedPaneManager which has the correct logic | |
| @@ -36,171 +33,157 @@ func (wm *WorkspaceManager) ensureGUIInPane(pane *BrowserPane) { | |||
| 36 | 33 | } | |
| 37 | 34 | ||
| 38 | 35 | // 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 | 37 | switch direction { | |
| 41 | 38 | case "left": | |
| 42 | - | return webkit.OrientationHorizontal, false | |
| 39 | + | return gtk.OrientationHorizontal, false | |
| 43 | 40 | case "up": | |
| 44 | - | return webkit.OrientationVertical, false | |
| 41 | + | return gtk.OrientationVertical, false | |
| 45 | 42 | case "down": | |
| 46 | - | return webkit.OrientationVertical, true | |
| 43 | + | return gtk.OrientationVertical, true | |
| 47 | 44 | default: | |
| 48 | - | return webkit.OrientationHorizontal, true | |
| 45 | + | return gtk.OrientationHorizontal, true | |
| 49 | 46 | } | |
| 50 | 47 | } | |
| 51 | 48 | ||
| 52 | 49 | // Widget management utilities | |
| 53 | 50 | ||
| 54 | 51 | // initializePaneWidgets sets up widgets for a paneNode | |
| 55 | - | func (wm *WorkspaceManager) initializePaneWidgets(node *paneNode, containerPtr uintptr) { | |
| 56 | - | node.container = containerPtr | |
| 52 | + | func (wm *WorkspaceManager) initializePaneWidgets(node *paneNode, widget gtk.Widgetter) { | |
| 53 | + | node.container = widget | |
| 57 | 54 | ||
| 58 | 55 | // Add base CSS class to the container | |
| 59 | - | if node.container != 0 && webkit.WidgetIsValid(node.container) { | |
| 56 | + | if node.container != nil { | |
| 60 | 57 | webkit.WidgetAddCSSClass(node.container, basePaneClass) | |
| 61 | 58 | } | |
| 62 | 59 | ||
| 63 | - | // Initialize other widget fields as zero (will be set when needed) | |
| 64 | - | node.titleBar = 0 | |
| 65 | - | node.stackWrapper = 0 | |
| 60 | + | // Initialize other widget fields as nil (will be set when needed) | |
| 61 | + | node.titleBar = nil | |
| 62 | + | node.stackWrapper = nil | |
| 66 | 63 | ||
| 67 | 64 | // Initialize cleanup tracking fields | |
| 68 | 65 | node.widgetValid = true | |
| 69 | 66 | node.cleanupGeneration = 0 | |
| 70 | 67 | } | |
| 71 | 68 | ||
| 72 | - | // setContainer sets the container widget pointer | |
| 73 | - | func (wm *WorkspaceManager) setContainer(node *paneNode, ptr uintptr, typeInfo string) { | |
| 69 | + | // setContainer sets the container widget | |
| 70 | + | func (wm *WorkspaceManager) setContainer(node *paneNode, widget gtk.Widgetter, typeInfo string) { | |
| 74 | 71 | _ = typeInfo // Type info kept for API compatibility but not used | |
| 75 | - | node.container = ptr | |
| 72 | + | node.container = widget | |
| 76 | 73 | ||
| 77 | 74 | // Mark widget validity based on container presence so idle guards run for stacks too | |
| 78 | - | if ptr != 0 { | |
| 75 | + | if widget != nil { | |
| 79 | 76 | node.widgetValid = true | |
| 80 | 77 | } else { | |
| 81 | 78 | node.widgetValid = false | |
| 82 | 79 | } | |
| 83 | 80 | } | |
| 84 | 81 | ||
| 85 | - | // setTitleBar sets the titleBar widget pointer | |
| 86 | - | func (wm *WorkspaceManager) setTitleBar(node *paneNode, ptr uintptr) { | |
| 87 | - | node.titleBar = ptr | |
| 82 | + | // setTitleBar sets the titleBar widget | |
| 83 | + | func (wm *WorkspaceManager) setTitleBar(node *paneNode, widget gtk.Widgetter) { | |
| 84 | + | node.titleBar = widget | |
| 88 | 85 | } | |
| 89 | 86 | ||
| 90 | - | // setStackWrapper sets the stackWrapper widget pointer | |
| 91 | - | func (wm *WorkspaceManager) setStackWrapper(node *paneNode, ptr uintptr) { | |
| 92 | - | node.stackWrapper = ptr | |
| 87 | + | // setStackWrapper sets the stackWrapper widget | |
| 88 | + | func (wm *WorkspaceManager) setStackWrapper(node *paneNode, widget gtk.Widgetter) { | |
| 89 | + | node.stackWrapper = widget | |
| 93 | 90 | } | |
| 94 | 91 | ||
| 95 | 92 | // Centralized Active Pane Border Management System | |
| 96 | 93 | // Handles all pane types: regular, popup, stacked, split panes | |
| 97 | 94 | ||
| 98 | - | // PaneBorderContext holds the context for applying borders to any pane type | |
| 99 | - | type PaneBorderContext struct { | |
| 100 | - | webViewWidget uintptr // The WebView's native widget (for margin) | |
| 101 | - | borderContainer uintptr // The container that gets background color | |
| 102 | - | cssClasses []string // Additional CSS classes to apply | |
| 103 | - | paneType string // Description for debugging | |
| 104 | - | } | |
| 105 | - | ||
| 106 | - | // determineBorderContext analyzes a pane node and determines the correct border context | |
| 107 | - | func (wm *WorkspaceManager) determineBorderContext(node *paneNode) *PaneBorderContext { | |
| 95 | + | // borderTargets returns the widgets that should reflect the pane border state. | |
| 96 | + | func (wm *WorkspaceManager) borderTargets(node *paneNode) []gtk.Widgetter { | |
| 108 | 97 | if node == nil { | |
| 109 | 98 | return nil | |
| 110 | 99 | } | |
| 111 | 100 | ||
| 112 | - | ctx := &PaneBorderContext{} | |
| 113 | - | ||
| 114 | - | // Step 1: Get WebView widget for margin (same for all pane types) | |
| 115 | - | if node.pane != nil && node.pane.webView != nil { | |
| 116 | - | ctx.webViewWidget = node.pane.webView.RootWidget() | |
| 101 | + | targets := make([]gtk.Widgetter, 0, 4) | |
| 102 | + | seen := make(map[*gtk.Widget]struct{}) | |
| 103 | + | add := func(widget gtk.Widgetter) { | |
| 104 | + | if widget == nil { | |
| 105 | + | return | |
| 106 | + | } | |
| 107 | + | if base := gtk.BaseWidget(widget); base != nil { | |
| 108 | + | if _, exists := seen[base]; exists { | |
| 109 | + | return | |
| 110 | + | } | |
| 111 | + | seen[base] = struct{}{} | |
| 112 | + | } | |
| 113 | + | targets = append(targets, widget) | |
| 117 | 114 | } | |
| 118 | 115 | ||
| 119 | - | // Step 2: Determine the border container based on pane type | |
| 120 | - | switch { | |
| 121 | - | case node.windowType == webkit.WindowTypePopup: | |
| 122 | - | // Popup panes: Border on popup container | |
| 123 | - | ctx.borderContainer = node.container | |
| 124 | - | ctx.cssClasses = []string{activePaneClass} | |
| 125 | - | ctx.paneType = "popup" | |
| 116 | + | // Primary container for this node. | |
| 117 | + | add(node.container) | |
| 118 | + | add(node.stackWrapper) | |
| 126 | 119 | ||
| 127 | - | case node.parent != nil && node.parent.isStacked: | |
| 128 | - | // Pane in stack: Border on stack wrapper container | |
| 129 | - | ctx.borderContainer = node.parent.stackWrapper | |
| 130 | - | ctx.cssClasses = []string{activePaneClass} | |
| 131 | - | ctx.paneType = "stacked" | |
| 120 | + | // Highlight stacks via their outer container as well. | |
| 121 | + | if node.parent != nil && node.parent.isStacked { | |
| 122 | + | add(node.parent.container) | |
| 123 | + | add(node.parent.stackWrapper) | |
| 124 | + | } | |
| 132 | 125 | ||
| 133 | - | default: | |
| 134 | - | // Regular pane: Border on pane container | |
| 135 | - | ctx.borderContainer = node.container | |
| 136 | - | ctx.cssClasses = []string{activePaneClass} | |
| 137 | - | ctx.paneType = "regular" | |
| 126 | + | // Fallback to the WebView root widget to cover any edge cases where the | |
| 127 | + | // container reference is temporarily nil. | |
| 128 | + | if node.pane != nil && node.pane.webView != nil { | |
| 129 | + | add(node.pane.webView.RootWidget()) | |
| 138 | 130 | } | |
| 139 | 131 | ||
| 140 | - | return ctx | |
| 132 | + | 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 context | |
| 144 | - | func (wm *WorkspaceManager) applyActivePaneBorder(ctx *PaneBorderContext) { | |
| 145 | - | if ctx == nil { | |
| 146 | - | log.Printf("[workspace] applyActivePaneBorder: nil context") | |
| 138 | + | // applyActivePaneBorder marks the target widgets as active for CSS styling. | |
| 139 | + | func (wm *WorkspaceManager) applyActivePaneBorder(node *paneNode) { | |
| 140 | + | if wm == nil || node == nil { | |
| 147 | 141 | return | |
| 148 | 142 | } | |
| 149 | 143 | ||
| 150 | - | // Skip applying border if there's only one pane | |
| 151 | - | if !wm.hasMultiplePanes() { | |
| 152 | - | log.Printf("[workspace] Skipping active border: only one pane exists") | |
| 144 | + | // Only show active border if there are 2+ panes | |
| 145 | + | leaves := wm.collectLeaves() | |
| 146 | + | if len(leaves) < 2 { | |
| 153 | 147 | return | |
| 154 | 148 | } | |
| 155 | 149 | ||
| 156 | - | // Apply margin to WebView widget (creates space for border) | |
| 157 | - | if ctx.webViewWidget != 0 && webkit.WidgetIsValid(ctx.webViewWidget) { | |
| 158 | - | webkit.WidgetSetMargin(ctx.webViewWidget, 2) | |
| 159 | - | } | |
| 160 | - | ||
| 161 | - | // Apply CSS classes to border container | |
| 162 | - | if ctx.borderContainer != 0 && webkit.WidgetIsValid(ctx.borderContainer) { | |
| 163 | - | for _, class := range ctx.cssClasses { | |
| 164 | - | webkit.WidgetAddCSSClass(ctx.borderContainer, class) | |
| 150 | + | targets := wm.borderTargets(node) | |
| 151 | + | for _, widget := range targets { | |
| 152 | + | if widget == nil { | |
| 153 | + | continue | |
| 154 | + | } | |
| 155 | + | // Only add CSS class if it doesn't already exist to prevent bloom filter errors | |
| 156 | + | if !webkit.WidgetHasCSSClass(widget, activePaneClass) { | |
| 157 | + | webkit.WidgetAddCSSClass(widget, activePaneClass) | |
| 158 | + | log.Printf("[workspace] Applied active border to pane %p (widget=%p)", node, widget) | |
| 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 node | |
| 163 | + | // removeActivePaneBorder clears the active styling from a pane. | |
| 173 | 164 | func (wm *WorkspaceManager) removeActivePaneBorder(node *paneNode) { | |
| 174 | - | if node == nil { | |
| 175 | - | return | |
| 176 | - | } | |
| 177 | - | ||
| 178 | - | ctx := wm.determineBorderContext(node) | |
| 179 | - | if ctx == nil { | |
| 165 | + | if wm == nil || node == nil { | |
| 180 | 166 | return | |
| 181 | 167 | } | |
| 182 | 168 | ||
| 183 | - | // Remove margin from WebView widget | |
| 184 | - | if ctx.webViewWidget != 0 && webkit.WidgetIsValid(ctx.webViewWidget) { | |
| 185 | - | webkit.WidgetSetMargin(ctx.webViewWidget, 0) | |
| 186 | - | } | |
| 187 | - | ||
| 188 | - | // Remove CSS classes from border container | |
| 189 | - | if ctx.borderContainer != 0 && webkit.WidgetIsValid(ctx.borderContainer) { | |
| 190 | - | for _, class := range ctx.cssClasses { | |
| 191 | - | webkit.WidgetRemoveCSSClass(ctx.borderContainer, class) | |
| 169 | + | targets := wm.borderTargets(node) | |
| 170 | + | for _, widget := range targets { | |
| 171 | + | if widget == nil { | |
| 172 | + | continue | |
| 173 | + | } | |
| 174 | + | // Only remove CSS class if it exists to prevent bloom filter errors | |
| 175 | + | if webkit.WidgetHasCSSClass(widget, activePaneClass) { | |
| 176 | + | webkit.WidgetRemoveCSSClass(widget, activePaneClass) | |
| 177 | + | log.Printf("[workspace] Removed active border from pane %p (widget=%p)", node, widget) | |
| 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 | 182 | // Widget cleanup utilities | |
| 200 | 183 | ||
| 201 | 184 | // cleanupWidget safely invalidates and cleans up a widget | |
| 202 | - | func (wm *WorkspaceManager) cleanupWidget(ptr uintptr) { | |
| 203 | - | if ptr == 0 { | |
| 185 | + | func (wm *WorkspaceManager) cleanupWidget(widget gtk.Widgetter) { | |
| 186 | + | if widget == nil { | |
| 204 | 187 | return | |
| 205 | 188 | } | |
| 206 | 189 | ||
| @@ -216,7 +199,7 @@ func (wm *WorkspaceManager) scheduleIdleGuarded(fn func() bool, nodes ...*paneNo | |||
| 216 | 199 | } | |
| 217 | 200 | guards := uniquePaneNodes(nodes...) | |
| 218 | 201 | var handle uintptr | |
| 219 | - | handle = webkit.IdleAdd(func() bool { | |
| 202 | + | handle = uintptr(glib.IdleAdd(func() bool { | |
| 220 | 203 | defer wm.releaseIdleHandle(handle) | |
| 221 | 204 | for _, node := range guards { | |
| 222 | 205 | if node == nil { | |
| @@ -227,7 +210,7 @@ func (wm *WorkspaceManager) scheduleIdleGuarded(fn func() bool, nodes ...*paneNo | |||
| 227 | 210 | } | |
| 228 | 211 | } | |
| 229 | 212 | return fn() | |
| 230 | - | }) | |
| 213 | + | })) | |
| 231 | 214 | if handle != 0 { | |
| 232 | 215 | wm.registerIdleHandle(handle, guards...) | |
| 233 | 216 | } | |
| @@ -301,7 +284,7 @@ func (wm *WorkspaceManager) cancelIdleHandle(handle uintptr) { | |||
| 301 | 284 | return | |
| 302 | 285 | } | |
| 303 | 286 | wm.releaseIdleHandle(handle) | |
| 304 | - | webkit.IdleRemove(handle) | |
| 287 | + | glib.SourceRemove(glib.SourceHandle(handle)) | |
| 305 | 288 | } | |
| 306 | 289 | ||
| 307 | 290 | func (wm *WorkspaceManager) cancelIdleHandles(node *paneNode) { | |
| @@ -328,19 +311,19 @@ func formatPaneInfo(node *paneNode) string { | |||
| 328 | 311 | ||
| 329 | 312 | var info string | |
| 330 | 313 | if node.isLeaf { | |
| 331 | - | info = fmt.Sprintf("leaf(container=%#x", node.container) | |
| 314 | + | info = fmt.Sprintf("leaf(container=%p", node.container) | |
| 332 | 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 | 318 | if node.windowType == webkit.WindowTypePopup { | |
| 336 | 319 | info += " popup" | |
| 337 | 320 | } | |
| 338 | 321 | info += ")" | |
| 339 | 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 | 324 | len(node.stackedPanes), node.activeStackIndex, node.container) | |
| 342 | 325 | } else { | |
| 343 | - | info = fmt.Sprintf("branch(container=%#x orientation=%v)", | |
| 326 | + | info = fmt.Sprintf("branch(container=%p orientation=%v)", | |
| 344 | 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 | 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 | 56 | // NewHandler creates a new message handler | |
| 77 | 57 | func NewHandler(parserService *services.ParserService, browserService *services.BrowserService) *Handler { | |
| 78 | 58 | return &Handler{ | |
| @@ -123,8 +103,6 @@ func (h *Handler) Handle(payload string) { | |||
| 123 | 103 | h.handleHistoryDelete(msg) | |
| 124 | 104 | case "workspace": | |
| 125 | 105 | h.handleWorkspace(msg) | |
| 126 | - | case "handle-window-open": | |
| 127 | - | h.handleWindowOpen(msg) | |
| 128 | 106 | case "close-popup": | |
| 129 | 107 | h.handleClosePopup(msg) | |
| 130 | 108 | case "console-message": | |
| @@ -194,7 +172,7 @@ func (h *Handler) handleWorkspace(msg Message) { | |||
| 194 | 172 | log.Printf("[workspace] Ignoring workspace event %q: webview not attached", msg.Event) | |
| 195 | 173 | 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 | 176 | h.workspaceObserver.OnWorkspaceMessage(h.webView, msg) | |
| 199 | 177 | } | |
| 200 | 178 | ||
| @@ -530,33 +508,6 @@ func (h *Handler) handleHistoryDelete(msg Message) { | |||
| 530 | 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 | 511 | // handleConsoleMessage processes console-message from JavaScript | |
| 561 | 512 | func (h *Handler) handleConsoleMessage(msg Message) { | |
| 562 | 513 | // Check if console capture is enabled | |
| @@ -595,7 +546,7 @@ func (h *Handler) handleWebViewIDRequest(msg Message) { | |||
| 595 | 546 | } | |
| 596 | 547 | ||
| 597 | 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 | 551 | // Send the webview ID back to JavaScript via custom event | |
| 601 | 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 | 14 | "os" | |
| 15 | 15 | "path/filepath" | |
| 16 | 16 | "strings" | |
| 17 | + | "sync" | |
| 17 | 18 | "time" | |
| 18 | 19 | ||
| 19 | 20 | "github.com/bnema/dumber/internal/config" | |
| @@ -22,7 +23,8 @@ import ( | |||
| 22 | 23 | ||
| 23 | 24 | // FaviconCache manages favicon caching to local files | |
| 24 | 25 | type FaviconCache struct { | |
| 25 | - | cacheDir string | |
| 26 | + | cacheDir string | |
| 27 | + | downloadMutex sync.Mutex // Prevents concurrent downloads of the same favicon | |
| 26 | 28 | } | |
| 27 | 29 | ||
| 28 | 30 | // NewFaviconCache creates a new favicon cache | |
| @@ -123,7 +125,13 @@ func (fc *FaviconCache) downloadFavicon(faviconURL string) error { | |||
| 123 | 125 | filename := fc.getFilename(faviconURL) | |
| 124 | 126 | cachedPath := filepath.Join(fc.cacheDir, filename) | |
| 125 | 127 | ||
| 126 | - | // Don't download if already exists and is recent | |
| 128 | + | // Lock to prevent concurrent downloads of the same favicon | |
| 129 | + | // This prevents race conditions on temp files and duplicate downloads | |
| 130 | + | fc.downloadMutex.Lock() | |
| 131 | + | defer fc.downloadMutex.Unlock() | |
| 132 | + | ||
| 133 | + | // Double-check if already exists after acquiring lock | |
| 134 | + | // Another goroutine might have downloaded it while we were waiting | |
| 127 | 135 | if info, err := os.Stat(cachedPath); err == nil { | |
| 128 | 136 | if time.Since(info.ModTime()) < 7*24*time.Hour { | |
| 129 | 137 | return nil | |
| @@ -175,8 +183,11 @@ func (fc *FaviconCache) downloadFavicon(faviconURL string) error { | |||
| 175 | 183 | } | |
| 176 | 184 | }() | |
| 177 | 185 | defer func() { | |
| 178 | - | if err := os.Remove(tempDownloadFile); err != nil { | |
| 179 | - | log.Printf("[favicon] failed to remove temp download file: %v", err) | |
| 186 | + | // Clean up temp download file if it still exists | |
| 187 | + | if _, err := os.Stat(tempDownloadFile); err == nil { | |
| 188 | + | if err := os.Remove(tempDownloadFile); err != nil { | |
| 189 | + | log.Printf("[favicon] failed to remove temp download file: %v", err) | |
| 190 | + | } | |
| 180 | 191 | } | |
| 181 | 192 | }() | |
| 182 | 193 | ||
| @@ -235,9 +246,14 @@ func (fc *FaviconCache) savePNG(img image.Image, outputPath string) error { | |||
| 235 | 246 | if err != nil { | |
| 236 | 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 | 252 | defer func() { | |
| 239 | - | if err := file.Close(); err != nil { | |
| 240 | - | log.Printf("[favicon] failed to close file: %v", err) | |
| 253 | + | if !closed { | |
| 254 | + | if err := file.Close(); err != nil { | |
| 255 | + | log.Printf("[favicon] failed to close file: %v", err) | |
| 256 | + | } | |
| 241 | 257 | } | |
| 242 | 258 | }() | |
| 243 | 259 | ||
| @@ -248,7 +264,11 @@ func (fc *FaviconCache) savePNG(img image.Image, outputPath string) error { | |||
| 248 | 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 | 273 | // Atomic move | |
| 254 | 274 | if err := os.Rename(tempFile, outputPath); err != nil { | |
M internal/cli/root.go +4 -9
| @@ -11,7 +11,6 @@ import ( | |||
| 11 | 11 | "github.com/bnema/dumber/internal/cache" | |
| 12 | 12 | "github.com/bnema/dumber/internal/config" | |
| 13 | 13 | "github.com/bnema/dumber/internal/db" | |
| 14 | - | "github.com/bnema/dumber/internal/migrations" | |
| 15 | 14 | "github.com/bnema/dumber/internal/services" | |
| 16 | 15 | ||
| 17 | 16 | _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver for database/sql | |
| @@ -193,16 +192,12 @@ func NewRootCmd(version, commit, buildDate string) *cobra.Command { | |||
| 193 | 192 | return rootCmd | |
| 194 | 193 | } | |
| 195 | 194 | ||
| 196 | - | // initializeDatabase runs embedded migrations and ensures database is up to date | |
| 197 | - | func initializeDatabase(db *sql.DB, cfg *config.Config) error { | |
| 198 | - | // Run embedded migrations - this will create all tables and apply any new migrations | |
| 199 | - | if err := migrations.RunEmbeddedMigrations(db); err != nil { | |
| 200 | - | return fmt.Errorf("failed to run database migrations: %w", err) | |
| 201 | - | } | |
| 202 | - | ||
| 195 | + | // initializeDatabase ensures configured shortcuts are inserted | |
| 196 | + | // Migrations are run automatically in db.InitDB() | |
| 197 | + | func initializeDatabase(database *sql.DB, cfg *config.Config) error { | |
| 203 | 198 | // Insert configured shortcuts (these are additive, won't override existing) | |
| 204 | 199 | for shortcut, shortcutCfg := range cfg.SearchShortcuts { | |
| 205 | - | _, err := db.Exec( | |
| 200 | + | _, err := database.Exec( | |
| 206 | 201 | "INSERT OR IGNORE INTO shortcuts (shortcut, url_template, description) VALUES (?, ?, ?)", | |
| 207 | 202 | shortcut, shortcutCfg.URL, shortcutCfg.Description, | |
| 208 | 203 | ) | |
M internal/config/config.go +33 -4
| @@ -282,18 +282,42 @@ type TabKeyConfig struct { | |||
| 282 | 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 | 299 | // PopupBehaviorConfig defines handling for popup windows. | |
| 286 | 300 | type PopupBehaviorConfig struct { | |
| 287 | - | Placement string `mapstructure:"placement" yaml:"placement" json:"placement"` | |
| 288 | - | OpenInNewPane bool `mapstructure:"open_in_new_pane" yaml:"open_in_new_pane" json:"open_in_new_pane"` | |
| 289 | - | FollowPaneContext bool `mapstructure:"follow_pane_context" yaml:"follow_pane_context" json:"follow_pane_context"` | |
| 301 | + | // Behavior determines how popups are opened (split/stacked/tabbed/windowed) | |
| 302 | + | Behavior PopupBehavior `mapstructure:"behavior" yaml:"behavior" json:"behavior"` | |
| 303 | + | ||
| 304 | + | // Placement specifies direction for split behavior ("right", "left", "top", "bottom") | |
| 305 | + | // Only used when Behavior is "split" | |
| 306 | + | Placement string `mapstructure:"placement" yaml:"placement" json:"placement"` | |
| 307 | + | ||
| 308 | + | // OpenInNewPane controls whether popups are opened in workspace or blocked | |
| 309 | + | OpenInNewPane bool `mapstructure:"open_in_new_pane" yaml:"open_in_new_pane" json:"open_in_new_pane"` | |
| 310 | + | ||
| 311 | + | // FollowPaneContext determines if popup placement follows parent pane context | |
| 312 | + | FollowPaneContext bool `mapstructure:"follow_pane_context" yaml:"follow_pane_context" json:"follow_pane_context"` | |
| 290 | 313 | ||
| 291 | - | // New fields for distinguishing window types and behaviors | |
| 292 | 314 | // BlankTargetBehavior determines how to handle window.open(url, "_blank") intents | |
| 293 | 315 | // Accepted values: "pane" (default) or "tab" (future support) | |
| 294 | 316 | BlankTargetBehavior string `mapstructure:"blank_target_behavior" yaml:"blank_target_behavior" json:"blank_target_behavior"` | |
| 317 | + | ||
| 295 | 318 | // EnableSmartDetection uses WebKitWindowProperties to detect popup vs tab intents | |
| 296 | 319 | EnableSmartDetection bool `mapstructure:"enable_smart_detection" yaml:"enable_smart_detection" json:"enable_smart_detection"` | |
| 320 | + | ||
| 297 | 321 | // OAuthAutoClose enables auto-closing OAuth popups after successful auth redirects | |
| 298 | 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 | 328 | BorderWidth int `mapstructure:"border_width" yaml:"border_width" json:"border_width"` | |
| 305 | 329 | // BorderColor for focused panes (CSS color value or theme variable) | |
| 306 | 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 | 334 | // TransitionDuration in milliseconds for border animations | |
| 308 | 335 | TransitionDuration int `mapstructure:"transition_duration" yaml:"transition_duration" json:"transition_duration"` | |
| 309 | 336 | // BorderRadius in pixels for pane border corners | |
| @@ -733,6 +760,7 @@ func (m *Manager) setDefaults() { | |||
| 733 | 760 | m.viper.SetDefault("workspace.tabs.close_tab", defaults.Workspace.Tabs.CloseTab) | |
| 734 | 761 | m.viper.SetDefault("workspace.tabs.next_tab", defaults.Workspace.Tabs.NextTab) | |
| 735 | 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 | 764 | m.viper.SetDefault("workspace.popups.placement", defaults.Workspace.Popups.Placement) | |
| 737 | 765 | m.viper.SetDefault("workspace.popups.open_in_new_pane", defaults.Workspace.Popups.OpenInNewPane) | |
| 738 | 766 | m.viper.SetDefault("workspace.popups.follow_pane_context", defaults.Workspace.Popups.FollowPaneContext) | |
| @@ -742,6 +770,7 @@ func (m *Manager) setDefaults() { | |||
| 742 | 770 | m.viper.SetDefault("workspace.popups.oauth_auto_close", defaults.Workspace.Popups.OAuthAutoClose) | |
| 743 | 771 | m.viper.SetDefault("workspace.styling.border_width", defaults.Workspace.Styling.BorderWidth) | |
| 744 | 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 | 774 | m.viper.SetDefault("workspace.styling.transition_duration", defaults.Workspace.Styling.TransitionDuration) | |
| 746 | 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 | 32 | defaultPopupPlacement = "right" | |
| 33 | 33 | ||
| 34 | 34 | // Workspace styling defaults | |
| 35 | - | defaultBorderWidth = 2 | |
| 36 | - | defaultBorderColor = "@theme_selected_bg_color" | |
| 37 | - | defaultTransitionDuration = 120 | |
| 38 | - | defaultBorderRadius = 0 | |
| 35 | + | defaultBorderWidth = 2 | |
| 36 | + | defaultBorderColor = "@theme_selected_bg_color" | |
| 37 | + | defaultPaneModeBorderColor = "#FFA500" // Orange for pane mode indicator | |
| 38 | + | defaultTransitionDuration = 120 | |
| 39 | + | defaultBorderRadius = 0 | |
| 39 | 40 | ) | |
| 40 | 41 | ||
| 41 | 42 | // getDefaultLogDir returns the default log directory, falls back to empty string on error | |
| @@ -191,6 +192,7 @@ func DefaultConfig() *Config { | |||
| 191 | 192 | PreviousTab: "cmdorctrl+shift+tab", | |
| 192 | 193 | }, | |
| 193 | 194 | Popups: PopupBehaviorConfig{ | |
| 195 | + | Behavior: PopupBehaviorSplit, // Default: open popups in split panes | |
| 194 | 196 | Placement: defaultPopupPlacement, | |
| 195 | 197 | OpenInNewPane: true, | |
| 196 | 198 | FollowPaneContext: true, | |
| @@ -199,10 +201,11 @@ func DefaultConfig() *Config { | |||
| 199 | 201 | OAuthAutoClose: true, // Auto-close OAuth popups on success | |
| 200 | 202 | }, | |
| 201 | 203 | Styling: WorkspaceStylingConfig{ | |
| 202 | - | BorderWidth: defaultBorderWidth, | |
| 203 | - | BorderColor: defaultBorderColor, | |
| 204 | - | TransitionDuration: defaultTransitionDuration, | |
| 205 | - | BorderRadius: defaultBorderRadius, | |
| 204 | + | BorderWidth: defaultBorderWidth, | |
| 205 | + | BorderColor: defaultBorderColor, | |
| 206 | + | PaneModeBorderColor: defaultPaneModeBorderColor, | |
| 207 | + | TransitionDuration: defaultTransitionDuration, | |
| 208 | + | BorderRadius: defaultBorderRadius, | |
| 206 | 209 | }, | |
| 207 | 210 | }, | |
| 208 | 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 | 33 | const GetCertificateValidation = `-- name: GetCertificateValidation :one | |
| 34 | - | SELECT id, hostname, certificate_hash, user_decision, created_at, expires_at FROM certificate_validations | |
| 35 | - | WHERE hostname = ? AND certificate_hash = ? | |
| 34 | + | SELECT id, hostname, certificate_hash, user_decision, created_at, expires_at FROM certificate_validations | |
| 35 | + | WHERE hostname = ? AND certificate_hash = ? | |
| 36 | 36 | AND (expires_at IS NULL OR expires_at > datetime('now')) | |
| 37 | 37 | LIMIT 1 | |
| 38 | 38 | ` | |
| @@ -51,6 +51,28 @@ func (q *Queries) GetCertificateValidation(ctx context.Context, hostname string, | |||
| 51 | 51 | 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 | 76 | const StoreCertificateValidation = `-- name: StoreCertificateValidation :exec | |
| 55 | 77 | INSERT OR REPLACE INTO certificate_validations (hostname, certificate_hash, user_decision, expires_at) | |
| 56 | 78 | VALUES (?, ?, ?, ?) | |
M internal/db/init.go +12 -43
| @@ -8,6 +8,7 @@ import ( | |||
| 8 | 8 | "path/filepath" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/dumber/internal/config" | |
| 11 | + | "github.com/bnema/dumber/internal/migrations" | |
| 11 | 12 | _ "github.com/ncruces/go-sqlite3/driver" // SQLite driver | |
| 12 | 13 | _ "github.com/ncruces/go-sqlite3/embed" // Embed SQLite | |
| 13 | 14 | ) | |
| @@ -37,12 +38,20 @@ func InitDB(dbPath string) (*sql.DB, error) { | |||
| 37 | 38 | return nil, fmt.Errorf("failed to connect to database: %w", err) | |
| 38 | 39 | } | |
| 39 | 40 | ||
| 40 | - | // Initialize database schema | |
| 41 | - | if err := initializeSchema(database); err != nil { | |
| 41 | + | // Run embedded migrations - single source of truth for schema initialization | |
| 42 | + | if err := migrations.RunEmbeddedMigrations(database); err != nil { | |
| 42 | 43 | if err := database.Close(); err != nil { | |
| 43 | 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 | 57 | return database, nil | |
| @@ -66,46 +75,6 @@ func InitDBWithConfig(dbPath string, cfg *config.Config) (*sql.DB, error) { | |||
| 66 | 75 | 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 | 78 | // initializeShortcuts inserts configured shortcuts into the database | |
| 110 | 79 | func initializeShortcuts(db *sql.DB, cfg *config.Config) error { | |
| 111 | 80 | if cfg == nil || cfg.SearchShortcuts == nil { | |
M internal/db/querier.go +1 -0
| @@ -19,6 +19,7 @@ type Querier interface { | |||
| 19 | 19 | // Delete zoom level setting for a domain | |
| 20 | 20 | DeleteZoomLevel(ctx context.Context, domain string) error | |
| 21 | 21 | GetCertificateValidation(ctx context.Context, hostname string, certificateHash string) (CertificateValidation, error) | |
| 22 | + | GetCertificateValidationByHostname(ctx context.Context, hostname string) (CertificateValidation, error) | |
| 22 | 23 | GetHistory(ctx context.Context, limit int64) ([]History, error) | |
| 23 | 24 | GetHistoryEntry(ctx context.Context, url string) (History, error) | |
| 24 | 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 | 1 | -- name: GetCertificateValidation :one | |
| 2 | - | SELECT * FROM certificate_validations | |
| 3 | - | WHERE hostname = ? AND certificate_hash = ? | |
| 2 | + | SELECT * FROM certificate_validations | |
| 3 | + | WHERE hostname = ? AND certificate_hash = ? | |
| 4 | 4 | AND (expires_at IS NULL OR expires_at > datetime('now')) | |
| 5 | 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 | 14 | -- name: StoreCertificateValidation :exec | |
| 8 | 15 | INSERT OR REPLACE INTO certificate_validations (hostname, certificate_hash, user_decision, expires_at) | |
| 9 | 16 | VALUES (?, ?, ?, ?); | |
M internal/migrations/migrations.go +46 -0
| @@ -78,6 +78,10 @@ func RunEmbeddedMigrations(db *sql.DB) error { | |||
| 78 | 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 | 85 | migrations, err := GetMigrations() | |
| 82 | 86 | if err != nil { | |
| 83 | 87 | return fmt.Errorf("failed to load migrations: %w", err) | |
| @@ -188,3 +192,45 @@ func GetAppliedMigrations(db *sql.DB) ([]int, error) { | |||
| 188 | 192 | ||
| 189 | 193 | 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, ¬null, &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 | 93 | s.webView = view | |
| 94 | 94 | ||
| 95 | 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 | 98 | view.RegisterFaviconChangedHandler(func(data []byte) { | |
| 97 | 99 | if currentURL := view.GetCurrentURL(); currentURL != "" { | |
| 98 | 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) | |
| 104 | - | view.RegisterFaviconURIChangedHandler(func(pageURI, faviconURI string) { | |
| 105 | - | s.handleFaviconURIChanged(pageURI, faviconURI) | |
| 106 | - | }) | |
| 105 | + | // Note: RegisterFaviconURIChangedHandler is now only called in buildPane() | |
| 106 | + | // to avoid duplicate handler registration which causes multiple downloads | |
| 107 | + | // of the same favicon and file handle race conditions | |
| 107 | 108 | } | |
| 108 | 109 | ||
| 109 | 110 | // LoadGUIBundle loads the unified GUI bundle from assets | |
| @@ -124,28 +125,6 @@ func (s *BrowserService) GetGUIBundle() string { | |||
| 124 | 125 | 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 | 128 | // InjectToastSystem injects the GUI bundle and initializes the toast system | |
| 150 | 129 | func (s *BrowserService) InjectToastSystem(ctx context.Context, theme string) error { | |
| 151 | 130 | _ = ctx | |
| @@ -628,7 +607,7 @@ func (s *BrowserService) handleFaviconChanged(pageURL string, pngData []byte) { | |||
| 628 | 607 | ||
| 629 | 608 | // Store a reference to the cached file in the database | |
| 630 | 609 | // 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 | 611 | faviconNullString := sql.NullString{String: faviconCacheURL, Valid: true} | |
| 633 | 612 | if err := s.dbQueries.UpdateHistoryFavicon(ctx, faviconNullString, pageURL); err != nil { | |
| 634 | 613 | // 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 | 1 | package webkit | |
| 3 | 2 | ||
| 4 | 3 | import "errors" | |
| 5 | 4 | ||
| 6 | - | // ErrNotImplemented is returned by stub implementations pending WebKit2GTK migration. | |
| 7 | - | var ErrNotImplemented = errors.New("webkit: not implemented") | |
| 5 | + | var ( | |
| 6 | + | // ErrNotImplemented indicates a feature is not yet implemented | |
| 7 | + | ErrNotImplemented = errors.New("webkit: not implemented") | |
| 8 | 8 | ||
| 9 | - | // ErrWebViewNotInitialized is returned when WebView operations are attempted on uninitialized WebView | |
| 10 | - | var ErrWebViewNotInitialized = errors.New("webkit: webview not initialized") | |
| 9 | + | // ErrWebViewNotInitialized indicates the WebView was not properly initialized | |
| 10 | + | ErrWebViewNotInitialized = errors.New("webkit: webview not initialized") | |
| 11 | 11 | ||
| 12 | - | // ErrContentManagerNotFound is returned when WebKit user content manager cannot be retrieved | |
| 13 | - | var ErrContentManagerNotFound = errors.New("webkit: user content manager not found") | |
| 12 | + | // ErrWebViewDestroyed indicates the WebView has been destroyed | |
| 13 | + | ErrWebViewDestroyed = errors.New("webkit: webview destroyed") | |
| 14 | + | ||
| 15 | + | // ErrInvalidURL indicates an invalid URL was provided | |
| 16 | + | ErrInvalidURL = errors.New("webkit: invalid URL") | |
| 17 | + | ||
| 18 | + | // ErrMainThreadRequired indicates an operation must be called from the main thread | |
| 19 | + | ErrMainThreadRequired = errors.New("webkit: operation must be called from main thread") | |
| 20 | + | ) | |
A pkg/webkit/evaluate.go +78 -0
| @@ -0,0 +1,78 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | /* | |
| 4 | + | #cgo pkg-config: webkitgtk-6.0 | |
| 5 | + | #include <webkit/webkit.h> | |
| 6 | + | #include <stdlib.h> | |
| 7 | + | */ | |
| 8 | + | import "C" | |
| 9 | + | import ( | |
| 10 | + | "unsafe" | |
| 11 | + | ||
| 12 | + | javascriptcore "github.com/diamondburned/gotk4-webkitgtk/pkg/javascriptcore/v6" | |
| 13 | + | webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6" | |
| 14 | + | "github.com/diamondburned/gotk4/pkg/core/glib" | |
| 15 | + | ) | |
| 16 | + | ||
| 17 | + | // EvaluateJavascript executes JavaScript code in the WebView in the default world | |
| 18 | + | // This is a wrapper around webkit_web_view_evaluate_javascript that wasn't generated by gotk4 | |
| 19 | + | func EvaluateJavascript(view *webkit.WebView, script string) { | |
| 20 | + | EvaluateJavascriptInWorld(view, script, "") | |
| 21 | + | } | |
| 22 | + | ||
| 23 | + | // EvaluateJavascriptInWorld executes JavaScript code in a specific world | |
| 24 | + | // Pass empty string for worldName to use the main/page world | |
| 25 | + | func EvaluateJavascriptInWorld(view *webkit.WebView, script string, worldName string) { | |
| 26 | + | if view == nil { | |
| 27 | + | return | |
| 28 | + | } | |
| 29 | + | ||
| 30 | + | // Get the underlying GObject pointer from the gotk4 WebView | |
| 31 | + | obj := glib.BaseObject(view) | |
| 32 | + | if obj == nil { | |
| 33 | + | return | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | nativePtr := obj.Native() | |
| 37 | + | if nativePtr == 0 { | |
| 38 | + | return | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | cScript := C.CString(script) | |
| 42 | + | defer C.free(unsafe.Pointer(cScript)) | |
| 43 | + | ||
| 44 | + | var cWorldName *C.char | |
| 45 | + | if worldName != "" { | |
| 46 | + | cWorldName = C.CString(worldName) | |
| 47 | + | defer C.free(unsafe.Pointer(cWorldName)) | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Call webkit_web_view_evaluate_javascript | |
| 51 | + | // Parameters: web_view, script, length, world_name, source_uri, cancellable, callback, user_data | |
| 52 | + | C.webkit_web_view_evaluate_javascript( | |
| 53 | + | (*C.WebKitWebView)(unsafe.Pointer(nativePtr)), | |
| 54 | + | cScript, | |
| 55 | + | C.gssize(-1), // -1 means null-terminated string | |
| 56 | + | cWorldName, // world_name (NULL for main world, or specific world name) | |
| 57 | + | nil, // source_uri (NULL) | |
| 58 | + | nil, // cancellable (NULL) | |
| 59 | + | nil, // callback (NULL for fire-and-forget) | |
| 60 | + | nil, // user_data (NULL) | |
| 61 | + | ) | |
| 62 | + | } | |
| 63 | + | ||
| 64 | + | // JSCValueToString converts a JSCValue from a GTK signal to a Go string | |
| 65 | + | func JSCValueToString(value interface{}) string { | |
| 66 | + | if value == nil { | |
| 67 | + | return "" | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | // Type assert to *javascriptcore.Value from gotk4 bindings | |
| 71 | + | jscValue, ok := value.(*javascriptcore.Value) | |
| 72 | + | if !ok { | |
| 73 | + | return "" | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // Use the gotk4 String() method to convert JSCValue to string | |
| 77 | + | return jscValue.String() | |
| 78 | + | } | |
D pkg/webkit/filtering_cgo.go +0 -249
| @@ -1,249 +0,0 @@ | |||
| 1 | - | //go:build webkit_cgo | |
| 2 | - | ||
| 3 | - | package webkit | |
| 4 | - | ||
| 5 | - | /* | |
| 6 | - | #cgo pkg-config: webkitgtk-6.0 gtk4 | |
| 7 | - | #cgo CFLAGS: -I/usr/include/webkitgtk-6.0 | |
| 8 | - | #include <webkit/webkit.h> | |
| 9 | - | #include <stdlib.h> | |
| 10 | - | ||
| 11 | - | // Global filter store instance | |
| 12 | - | static WebKitUserContentFilterStore *filter_store = NULL; | |
| 13 | - | ||
| 14 | - | // External Go functions | |
| 15 | - | extern char* get_webkit_filter_store_path(); | |
| 16 | - | extern void log_filter_success(char* identifier); | |
| 17 | - | extern void log_filter_error(char* error_msg, char* identifier); | |
| 18 | - | ||
| 19 | - | // Callback for save operation completion | |
| 20 | - | static void filter_save_callback(GObject *source_object, | |
| 21 | - | GAsyncResult *result, | |
| 22 | - | gpointer user_data) { | |
| 23 | - | WebKitUserContentFilterStore *store = WEBKIT_USER_CONTENT_FILTER_STORE(source_object); | |
| 24 | - | WebKitUserContentManager *manager = WEBKIT_USER_CONTENT_MANAGER(user_data); | |
| 25 | - | GError *error = NULL; | |
| 26 | - | ||
| 27 | - | WebKitUserContentFilter *filter = webkit_user_content_filter_store_save_finish(store, result, &error); | |
| 28 | - | ||
| 29 | - | if (filter && !error) { | |
| 30 | - | webkit_user_content_manager_add_filter(manager, filter); | |
| 31 | - | const char *id = webkit_user_content_filter_get_identifier(filter); | |
| 32 | - | log_filter_success((char*)id); | |
| 33 | - | webkit_user_content_filter_unref(filter); | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | if (error) { | |
| 37 | - | char *msg = g_strdup(error->message); | |
| 38 | - | log_filter_error(msg, "unknown"); | |
| 39 | - | g_free(msg); | |
| 40 | - | g_error_free(error); | |
| 41 | - | } | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | // Initialize filter store if not already done | |
| 45 | - | static void ensure_filter_store() { | |
| 46 | - | if (filter_store == NULL) { | |
| 47 | - | // Get XDG-compliant path from Go (with tmp fallback) | |
| 48 | - | char *store_path = get_webkit_filter_store_path(); | |
| 49 | - | filter_store = webkit_user_content_filter_store_new(store_path); | |
| 50 | - | free(store_path); // Free the C string returned by Go | |
| 51 | - | } | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | static void apply_content_filter(WebKitUserContentManager *manager, | |
| 55 | - | const char *filter_json, | |
| 56 | - | const char *identifier) { | |
| 57 | - | ensure_filter_store(); | |
| 58 | - | ||
| 59 | - | GBytes *bytes = g_bytes_new(filter_json, strlen(filter_json)); | |
| 60 | - | ||
| 61 | - | // Save filter to store asynchronously - it will be applied in the callback | |
| 62 | - | webkit_user_content_filter_store_save(filter_store, | |
| 63 | - | identifier, | |
| 64 | - | bytes, | |
| 65 | - | NULL, | |
| 66 | - | filter_save_callback, | |
| 67 | - | manager); | |
| 68 | - | ||
| 69 | - | g_bytes_unref(bytes); | |
| 70 | - | } | |
| 71 | - | ||
| 72 | - | static void inject_cosmetic_script(WebKitUserContentManager *manager, | |
| 73 | - | const char *script_source) { | |
| 74 | - | // Use isolated script world to prevent interference with page JavaScript | |
| 75 | - | WebKitUserScript *script = webkit_user_script_new_for_world( | |
| 76 | - | script_source, | |
| 77 | - | WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, | |
| 78 | - | WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_END, // Changed from DOCUMENT_START to avoid preconnect interference | |
| 79 | - | "dumber-cosmetic", // Isolated world name | |
| 80 | - | NULL, NULL | |
| 81 | - | ); | |
| 82 | - | ||
| 83 | - | webkit_user_content_manager_add_script(manager, script); | |
| 84 | - | webkit_user_script_unref(script); | |
| 85 | - | } | |
| 86 | - | ||
| 87 | - | static void clear_all_filters(WebKitUserContentManager *manager) { | |
| 88 | - | webkit_user_content_manager_remove_all_filters(manager); | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | static void clear_all_scripts(WebKitUserContentManager *manager) { | |
| 92 | - | webkit_user_content_manager_remove_all_scripts(manager); | |
| 93 | - | } | |
| 94 | - | */ | |
| 95 | - | import "C" | |
| 96 | - | ||
| 97 | - | import ( | |
| 98 | - | "encoding/json" | |
| 99 | - | "fmt" | |
| 100 | - | "os" | |
| 101 | - | "path/filepath" | |
| 102 | - | "unsafe" | |
| 103 | - | ||
| 104 | - | "github.com/bnema/dumber/internal/config" | |
| 105 | - | "github.com/bnema/dumber/internal/logging" | |
| 106 | - | ) | |
| 107 | - | ||
| 108 | - | //export get_webkit_filter_store_path | |
| 109 | - | func get_webkit_filter_store_path() *C.char { | |
| 110 | - | // Try XDG directories first | |
| 111 | - | if filterCacheDir, err := config.GetFilterCacheDir(); err == nil { | |
| 112 | - | webkitFilterDir := filepath.Join(filterCacheDir, "webkit-store") | |
| 113 | - | if err := os.MkdirAll(webkitFilterDir, 0755); err == nil { | |
| 114 | - | return C.CString(webkitFilterDir) | |
| 115 | - | } | |
| 116 | - | } | |
| 117 | - | ||
| 118 | - | // Fallback to tmp if XDG fails | |
| 119 | - | tmpDir := os.TempDir() | |
| 120 | - | fallbackDir := filepath.Join(tmpDir, "dumber-filters") | |
| 121 | - | if err := os.MkdirAll(fallbackDir, 0755); err == nil { | |
| 122 | - | logging.Warn("[webkit] Using fallback tmp directory for filter store: " + fallbackDir) | |
| 123 | - | return C.CString(fallbackDir) | |
| 124 | - | } | |
| 125 | - | ||
| 126 | - | // Last resort - return tmp dir | |
| 127 | - | logging.Error("[webkit] Failed to create filter store directory, using tmp") | |
| 128 | - | return C.CString(tmpDir) | |
| 129 | - | } | |
| 130 | - | ||
| 131 | - | //export log_filter_success | |
| 132 | - | func log_filter_success(identifier *C.char) { | |
| 133 | - | id := C.GoString(identifier) | |
| 134 | - | logging.Info("[webkit] Network filter applied successfully: " + id) | |
| 135 | - | } | |
| 136 | - | ||
| 137 | - | //export log_filter_error | |
| 138 | - | func log_filter_error(errorMsg *C.char, identifier *C.char) { | |
| 139 | - | msg := C.GoString(errorMsg) | |
| 140 | - | id := C.GoString(identifier) | |
| 141 | - | logging.Error("[webkit] Failed to apply network filter '" + id + "': " + msg) | |
| 142 | - | } | |
| 143 | - | ||
| 144 | - | // ApplyContentFilters applies WebKit native content filters with error recovery | |
| 145 | - | func (w *WebView) ApplyContentFilters(filters []byte, identifier string) error { | |
| 146 | - | // Check WebView state with debug support | |
| 147 | - | if !w.CheckWebViewState() { | |
| 148 | - | w.LogDebugInfo() | |
| 149 | - | return ErrWebViewNotInitialized | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | // Validate input | |
| 153 | - | if len(filters) == 0 || identifier == "" { | |
| 154 | - | return fmt.Errorf("invalid filter parameters: empty filters or identifier") | |
| 155 | - | } | |
| 156 | - | ||
| 157 | - | // Check if filters are valid JSON | |
| 158 | - | if !json.Valid(filters) { | |
| 159 | - | return fmt.Errorf("invalid filter JSON for identifier: %s", identifier) | |
| 160 | - | } | |
| 161 | - | ||
| 162 | - | defer func() { | |
| 163 | - | if r := recover(); r != nil { | |
| 164 | - | logging.Error(fmt.Sprintf("[webkit] Panic in ApplyContentFilters: %v", r)) | |
| 165 | - | } | |
| 166 | - | }() | |
| 167 | - | ||
| 168 | - | manager := C.webkit_web_view_get_user_content_manager(w.native.wv) | |
| 169 | - | if manager == nil { | |
| 170 | - | return ErrContentManagerNotFound | |
| 171 | - | } | |
| 172 | - | ||
| 173 | - | cFilters := C.CString(string(filters)) | |
| 174 | - | cID := C.CString(identifier) | |
| 175 | - | defer C.free(unsafe.Pointer(cFilters)) | |
| 176 | - | defer C.free(unsafe.Pointer(cID)) | |
| 177 | - | ||
| 178 | - | logging.Info("[webkit] Applying network filters: " + identifier + " (" + fmt.Sprintf("%d", len(filters)) + " bytes)") | |
| 179 | - | ||
| 180 | - | // Apply filter with error checking | |
| 181 | - | C.apply_content_filter(manager, cFilters, cID) | |
| 182 | - | return nil | |
| 183 | - | } | |
| 184 | - | ||
| 185 | - | // InjectCosmeticFilter injects JavaScript for cosmetic filtering with error recovery | |
| 186 | - | func (w *WebView) InjectCosmeticFilter(script string) error { | |
| 187 | - | // Check WebView state with debug support | |
| 188 | - | if !w.CheckWebViewState() { | |
| 189 | - | w.LogDebugInfo() | |
| 190 | - | return ErrWebViewNotInitialized | |
| 191 | - | } | |
| 192 | - | ||
| 193 | - | // Validate input | |
| 194 | - | if len(script) == 0 { | |
| 195 | - | return fmt.Errorf("empty cosmetic filter script") | |
| 196 | - | } | |
| 197 | - | ||
| 198 | - | defer func() { | |
| 199 | - | if r := recover(); r != nil { | |
| 200 | - | logging.Error(fmt.Sprintf("[webkit] Panic in InjectCosmeticFilter: %v", r)) | |
| 201 | - | } | |
| 202 | - | }() | |
| 203 | - | ||
| 204 | - | manager := C.webkit_web_view_get_user_content_manager(w.native.wv) | |
| 205 | - | if manager == nil { | |
| 206 | - | return ErrContentManagerNotFound | |
| 207 | - | } | |
| 208 | - | ||
| 209 | - | cScript := C.CString(script) | |
| 210 | - | defer C.free(unsafe.Pointer(cScript)) | |
| 211 | - | ||
| 212 | - | logging.Info("[webkit] Injecting cosmetic filter script (" + fmt.Sprintf("%d", len(script)) + " chars)") | |
| 213 | - | ||
| 214 | - | // Inject script with error recovery | |
| 215 | - | C.inject_cosmetic_script(manager, cScript) | |
| 216 | - | return nil | |
| 217 | - | } | |
| 218 | - | ||
| 219 | - | // ClearAllFilters removes all content filters | |
| 220 | - | func (w *WebView) ClearAllFilters() error { | |
| 221 | - | if w.native == nil || w.native.wv == nil { | |
| 222 | - | return ErrWebViewNotInitialized | |
| 223 | - | } | |
| 224 | - | ||
| 225 | - | manager := C.webkit_web_view_get_user_content_manager(w.native.wv) | |
| 226 | - | if manager == nil { | |
| 227 | - | return ErrContentManagerNotFound | |
| 228 | - | } | |
| 229 | - | ||
| 230 | - | logging.Info("[webkit] Clearing all content filters") | |
| 231 | - | C.clear_all_filters(manager) | |
| 232 | - | return nil | |
| 233 | - | } | |
| 234 | - | ||
| 235 | - | // ClearAllScripts removes all injected scripts | |
| 236 | - | func (w *WebView) ClearAllScripts() error { | |
| 237 | - | if w.native == nil || w.native.wv == nil { | |
| 238 | - | return ErrWebViewNotInitialized | |
| 239 | - | } | |
| 240 | - | ||
| 241 | - | manager := C.webkit_web_view_get_user_content_manager(w.native.wv) | |
| 242 | - | if manager == nil { | |
| 243 | - | return ErrContentManagerNotFound | |
| 244 | - | } | |
| 245 | - | ||
| 246 | - | logging.Info("[webkit] Clearing all injected scripts") | |
| 247 | - | C.clear_all_scripts(manager) | |
| 248 | - | return nil | |
| 249 | - | } | |
D pkg/webkit/find.go +0 -35
| @@ -1,35 +0,0 @@ | |||
| 1 | - | package webkit | |
| 2 | - | ||
| 3 | - | import ( | |
| 4 | - | "fmt" | |
| 5 | - | "strconv" | |
| 6 | - | ) | |
| 7 | - | ||
| 8 | - | // OpenFind opens the in-page find overlay (reusing omnibox) with an optional initial query. | |
| 9 | - | func (w *WebView) OpenFind(initial string) error { | |
| 10 | - | if w == nil { | |
| 11 | - | return ErrNotImplemented | |
| 12 | - | } | |
| 13 | - | quoted := strconv.Quote(initial) | |
| 14 | - | // Prefer unified omnibox open via keyboard shortcut; fallback to legacy helper | |
| 15 | - | js := fmt.Sprintf("(window.__dumber?.omnibox?.open ? window.__dumber.omnibox.open('find', %s) : (window.__dumber_find_open && window.__dumber_find_open(%s)))", quoted, quoted) | |
| 16 | - | return w.InjectScript(js) | |
| 17 | - | } | |
| 18 | - | ||
| 19 | - | // FindQuery sets or updates the current find query and applies highlighting. | |
| 20 | - | func (w *WebView) FindQuery(q string) error { | |
| 21 | - | if w == nil { | |
| 22 | - | return ErrNotImplemented | |
| 23 | - | } | |
| 24 | - | quoted := strconv.Quote(q) | |
| 25 | - | js := fmt.Sprintf("(window.__dumber?.omnibox?.findQuery ? window.__dumber.omnibox.findQuery(%s) : (window.__dumber_find_query && window.__dumber_find_query(%s)))", quoted, quoted) | |
| 26 | - | return w.InjectScript(js) | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | // CloseFind closes the find overlay and clears highlights. | |
| 30 | - | func (w *WebView) CloseFind() error { | |
| 31 | - | if w == nil { | |
| 32 | - | return ErrNotImplemented | |
| 33 | - | } | |
| 34 | - | return w.InjectScript("(window.__dumber?.omnibox?.close ? window.__dumber.omnibox.close() : (window.__dumber_find_close && window.__dumber_find_close()))") | |
| 35 | - | } | |
A pkg/webkit/helpers.go +269 -0
| @@ -0,0 +1,269 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/diamondburned/gotk4/pkg/gdk/v4" | |
| 5 | + | "github.com/diamondburned/gotk4/pkg/glib/v2" | |
| 6 | + | "github.com/diamondburned/gotk4/pkg/gtk/v4" | |
| 7 | + | ) | |
| 8 | + | ||
| 9 | + | // IsNativeAvailable returns true if the native GTK/WebKit libraries are available | |
| 10 | + | // In gotk4, this is always true if the import succeeded | |
| 11 | + | func IsNativeAvailable() bool { | |
| 12 | + | return true | |
| 13 | + | } | |
| 14 | + | ||
| 15 | + | // IdleAdd adds a function to be called in an idle callback | |
| 16 | + | // Returns an idle source ID that can be used with IdleRemove | |
| 17 | + | func IdleAdd(fn func() bool) uint { | |
| 18 | + | return uint(glib.IdleAdd(fn)) | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | // IdleRemove removes an idle callback by its source ID | |
| 22 | + | func IdleRemove(sourceID uint) { | |
| 23 | + | glib.SourceRemove(glib.SourceHandle(sourceID)) | |
| 24 | + | } | |
| 25 | + | ||
| 26 | + | // WidgetIsValid checks if a widget pointer is valid (non-nil) | |
| 27 | + | func WidgetIsValid(w gtk.Widgetter) bool { | |
| 28 | + | return w != nil | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // WidgetSetMargin sets all margins for a widget | |
| 32 | + | func WidgetSetMargin(w gtk.Widgetter, margin int) { | |
| 33 | + | if widget := getWidget(w); widget != nil { | |
| 34 | + | widget.SetMarginStart(margin) | |
| 35 | + | widget.SetMarginEnd(margin) | |
| 36 | + | widget.SetMarginTop(margin) | |
| 37 | + | widget.SetMarginBottom(margin) | |
| 38 | + | } | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // WidgetSetMarginTop sets the top margin for a widget | |
| 42 | + | func WidgetSetMarginTop(w gtk.Widgetter, margin int) { | |
| 43 | + | if widget := getWidget(w); widget != nil { | |
| 44 | + | widget.SetMarginTop(margin) | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // WidgetSetMarginBottom sets the bottom margin for a widget | |
| 49 | + | func WidgetSetMarginBottom(w gtk.Widgetter, margin int) { | |
| 50 | + | if widget := getWidget(w); widget != nil { | |
| 51 | + | widget.SetMarginBottom(margin) | |
| 52 | + | } | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | // WidgetSetMarginStart sets the start margin for a widget | |
| 56 | + | func WidgetSetMarginStart(w gtk.Widgetter, margin int) { | |
| 57 | + | if widget := getWidget(w); widget != nil { | |
| 58 | + | widget.SetMarginStart(margin) | |
| 59 | + | } | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // WidgetSetMarginEnd sets the end margin for a widget | |
| 63 | + | func WidgetSetMarginEnd(w gtk.Widgetter, margin int) { | |
| 64 | + | if widget := getWidget(w); widget != nil { | |
| 65 | + | widget.SetMarginEnd(margin) | |
| 66 | + | } | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // WidgetAddCSSClass adds a CSS class to a widget | |
| 70 | + | func WidgetAddCSSClass(w gtk.Widgetter, className string) { | |
| 71 | + | if widget := getWidget(w); widget != nil { | |
| 72 | + | widget.AddCSSClass(className) | |
| 73 | + | } | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // WidgetRemoveCSSClass removes a CSS class from a widget | |
| 77 | + | func WidgetRemoveCSSClass(w gtk.Widgetter, className string) { | |
| 78 | + | if widget := getWidget(w); widget != nil { | |
| 79 | + | widget.RemoveCSSClass(className) | |
| 80 | + | } | |
| 81 | + | } | |
| 82 | + | ||
| 83 | + | // WidgetHasCSSClass checks if a widget has a CSS class | |
| 84 | + | func WidgetHasCSSClass(w gtk.Widgetter, className string) bool { | |
| 85 | + | if widget := getWidget(w); widget != nil { | |
| 86 | + | return widget.HasCSSClass(className) | |
| 87 | + | } | |
| 88 | + | return false | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // WidgetSetHExpand sets whether the widget should expand horizontally | |
| 92 | + | func WidgetSetHExpand(w gtk.Widgetter, expand bool) { | |
| 93 | + | if widget := getWidget(w); widget != nil { | |
| 94 | + | widget.SetHExpand(expand) | |
| 95 | + | } | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // WidgetSetVExpand sets whether the widget should expand vertically | |
| 99 | + | func WidgetSetVExpand(w gtk.Widgetter, expand bool) { | |
| 100 | + | if widget := getWidget(w); widget != nil { | |
| 101 | + | widget.SetVExpand(expand) | |
| 102 | + | } | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // WidgetRealizeInContainer realizes a widget within a container | |
| 106 | + | func WidgetRealizeInContainer(w gtk.Widgetter, container gtk.Widgetter) { | |
| 107 | + | if widget := getWidget(w); widget != nil { | |
| 108 | + | widget.Realize() | |
| 109 | + | } | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // WidgetRealize realizes a widget | |
| 113 | + | func WidgetRealize(w gtk.Widgetter) { | |
| 114 | + | if widget := getWidget(w); widget != nil { | |
| 115 | + | widget.Realize() | |
| 116 | + | } | |
| 117 | + | } | |
| 118 | + | ||
| 119 | + | // WidgetGetParent returns the parent widget | |
| 120 | + | func WidgetGetParent(w gtk.Widgetter) gtk.Widgetter { | |
| 121 | + | if widget := getWidget(w); widget != nil { | |
| 122 | + | return widget.Parent() | |
| 123 | + | } | |
| 124 | + | return nil | |
| 125 | + | } | |
| 126 | + | ||
| 127 | + | // WidgetUnparent removes a widget from its parent | |
| 128 | + | func WidgetUnparent(w gtk.Widgetter) { | |
| 129 | + | if widget := getWidget(w); widget != nil { | |
| 130 | + | widget.Unparent() | |
| 131 | + | } | |
| 132 | + | } | |
| 133 | + | ||
| 134 | + | // WidgetQueueAllocate queues an allocate operation | |
| 135 | + | func WidgetQueueAllocate(w gtk.Widgetter) { | |
| 136 | + | if widget := getWidget(w); widget != nil { | |
| 137 | + | widget.QueueAllocate() | |
| 138 | + | } | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // WidgetQueueDraw queues a redraw operation | |
| 142 | + | func WidgetQueueDraw(w gtk.Widgetter) { | |
| 143 | + | if widget := getWidget(w); widget != nil { | |
| 144 | + | widget.QueueDraw() | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | // WidgetGetAllocation returns the widget's allocated area | |
| 149 | + | func WidgetGetAllocation(w gtk.Widgetter) (x, y, width, height int) { | |
| 150 | + | if widget := getWidget(w); widget != nil { | |
| 151 | + | alloc := widget.Allocation() | |
| 152 | + | return alloc.X(), alloc.Y(), alloc.Width(), alloc.Height() | |
| 153 | + | } | |
| 154 | + | return 0, 0, 0, 0 | |
| 155 | + | } | |
| 156 | + | ||
| 157 | + | // WidgetGetBounds returns the widget's bounds relative to its parent | |
| 158 | + | func WidgetGetBounds(w gtk.Widgetter) (x, y, width, height float64) { | |
| 159 | + | if widget := getWidget(w); widget != nil { | |
| 160 | + | parent := widget.Parent() | |
| 161 | + | if parent != nil { | |
| 162 | + | rect, ok := widget.ComputeBounds(parent) | |
| 163 | + | if ok && rect != nil { | |
| 164 | + | // Get bounds directly from graphene.Rect | |
| 165 | + | return float64(rect.X()), float64(rect.Y()), | |
| 166 | + | float64(rect.Width()), float64(rect.Height()) | |
| 167 | + | } | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | return 0, 0, 0, 0 | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | // WidgetSetFocusChild sets the focus child of a widget | |
| 174 | + | func WidgetSetFocusChild(w gtk.Widgetter, child gtk.Widgetter) { | |
| 175 | + | if widget := getWidget(w); widget != nil { | |
| 176 | + | widget.SetFocusChild(child) | |
| 177 | + | } | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // WidgetAddFocusController adds a focus event controller to a widget | |
| 181 | + | func WidgetAddFocusController(w gtk.Widgetter, onEnter, onLeave func()) *gtk.EventControllerFocus { | |
| 182 | + | if widget := getWidget(w); widget != nil { | |
| 183 | + | controller := gtk.NewEventControllerFocus() | |
| 184 | + | if onEnter != nil { | |
| 185 | + | controller.ConnectEnter(onEnter) | |
| 186 | + | } | |
| 187 | + | if onLeave != nil { | |
| 188 | + | controller.ConnectLeave(onLeave) | |
| 189 | + | } | |
| 190 | + | widget.AddController(controller) | |
| 191 | + | return controller | |
| 192 | + | } | |
| 193 | + | return nil | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // WidgetRemoveFocusController removes a focus event controller from a widget | |
| 197 | + | func WidgetRemoveFocusController(w gtk.Widgetter, controller *gtk.EventControllerFocus) { | |
| 198 | + | if widget := getWidget(w); widget != nil && controller != nil { | |
| 199 | + | widget.RemoveController(controller) | |
| 200 | + | } | |
| 201 | + | } | |
| 202 | + | ||
| 203 | + | // WidgetAddHoverHandler registers a hover callback for the given widget and | |
| 204 | + | // returns a controller that can be used to detach it later. | |
| 205 | + | func WidgetAddHoverHandler(w gtk.Widgetter, fn func()) *gtk.EventControllerMotion { | |
| 206 | + | if widget := getWidget(w); widget != nil && fn != nil { | |
| 207 | + | motion := gtk.NewEventControllerMotion() | |
| 208 | + | motion.SetPropagationPhase(gtk.PhaseCapture) | |
| 209 | + | ||
| 210 | + | // Connect to enter signal - triggered when mouse enters the widget | |
| 211 | + | motion.ConnectEnter(func(x, y float64) { | |
| 212 | + | fn() | |
| 213 | + | }) | |
| 214 | + | ||
| 215 | + | widget.AddController(motion) | |
| 216 | + | return motion | |
| 217 | + | } | |
| 218 | + | return nil | |
| 219 | + | } | |
| 220 | + | ||
| 221 | + | // WidgetRemoveHoverHandler removes a previously registered hover controller | |
| 222 | + | func WidgetRemoveHoverHandler(w gtk.Widgetter, controller *gtk.EventControllerMotion) { | |
| 223 | + | if widget := getWidget(w); widget != nil && controller != nil { | |
| 224 | + | widget.RemoveController(controller) | |
| 225 | + | } | |
| 226 | + | } | |
| 227 | + | ||
| 228 | + | // WidgetAddController adds an event controller to a widget | |
| 229 | + | func WidgetAddController(w gtk.Widgetter, controller gtk.EventControllerer) { | |
| 230 | + | if widget := getWidget(w); widget != nil && controller != nil { | |
| 231 | + | widget.AddController(controller) | |
| 232 | + | } | |
| 233 | + | } | |
| 234 | + | ||
| 235 | + | // WidgetAllocation returns the widget's allocation (alias for WidgetGetAllocation) | |
| 236 | + | func WidgetAllocation(w gtk.Widgetter) (x, y, width, height int) { | |
| 237 | + | return WidgetGetAllocation(w) | |
| 238 | + | } | |
| 239 | + | ||
| 240 | + | // PrefersDarkTheme checks if the system prefers dark theme | |
| 241 | + | func PrefersDarkTheme() bool { | |
| 242 | + | settings := gtk.SettingsGetDefault() | |
| 243 | + | if settings != nil { | |
| 244 | + | // In GTK4, check the color scheme preference | |
| 245 | + | // The property is "gtk-application-prefer-dark-theme" | |
| 246 | + | obj := settings.Object | |
| 247 | + | if obj != nil { | |
| 248 | + | prop := obj.ObjectProperty("gtk-application-prefer-dark-theme") | |
| 249 | + | if v, ok := prop.(bool); ok && v { | |
| 250 | + | return true | |
| 251 | + | } | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | return false | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | // AddCSSProvider adds a CSS provider for styling | |
| 258 | + | func AddCSSProvider(css string) error { | |
| 259 | + | provider := gtk.NewCSSProvider() | |
| 260 | + | provider.LoadFromString(css) | |
| 261 | + | ||
| 262 | + | gtk.StyleContextAddProviderForDisplay( | |
| 263 | + | gdk.DisplayGetDefault(), | |
| 264 | + | provider, | |
| 265 | + | gtk.STYLE_PROVIDER_PRIORITY_APPLICATION, | |
| 266 | + | ) | |
| 267 | + | ||
| 268 | + | return nil | |
| 269 | + | } | |
D pkg/webkit/keyboard.go +0 -36
| @@ -1,36 +0,0 @@ | |||
| 1 | - | //go:build !webkit_cgo | |
| 2 | - | ||
| 3 | - | package webkit | |
| 4 | - | ||
| 5 | - | import "fmt" | |
| 6 | - | ||
| 7 | - | // RegisterKeyboardShortcut binds a global or window-scoped accelerator to a callback. | |
| 8 | - | func (w *WebView) RegisterKeyboardShortcut(accel string, callback func()) error { | |
| 9 | - | if w == nil || w.destroyed { | |
| 10 | - | return ErrNotImplemented | |
| 11 | - | } | |
| 12 | - | if accel == "" || callback == nil { | |
| 13 | - | return fmt.Errorf("invalid shortcut registration") | |
| 14 | - | } | |
| 15 | - | // Placeholder: in real impl, bind GTK accelerator; for now, store for tests. | |
| 16 | - | if shortcuts := getShortcutRegistry(w); shortcuts != nil { | |
| 17 | - | shortcuts[accel] = callback | |
| 18 | - | } | |
| 19 | - | return nil | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | // internal registry per view while no GTK binding exists. | |
| 23 | - | type shortcutRegistry map[string]func() | |
| 24 | - | ||
| 25 | - | var viewShortcuts = make(map[*WebView]shortcutRegistry) | |
| 26 | - | ||
| 27 | - | func getShortcutRegistry(w *WebView) shortcutRegistry { | |
| 28 | - | reg, ok := viewShortcuts[w] | |
| 29 | - | if !ok { | |
| 30 | - | reg = make(shortcutRegistry) | |
| 31 | - | viewShortcuts[w] = reg | |
| 32 | - | } | |
| 33 | - | return reg | |
| 34 | - | } | |
| 35 | - | ||
| 36 | - | // No layout-specific remaps in non-CGO build either. | |
A pkg/webkit/keyboard_bridge.go +284 -0
| @@ -0,0 +1,284 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "encoding/json" | |
| 5 | + | "fmt" | |
| 6 | + | "log" | |
| 7 | + | ||
| 8 | + | "github.com/diamondburned/gotk4/pkg/gdk/v4" | |
| 9 | + | "github.com/diamondburned/gotk4/pkg/gtk/v4" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // Keyboard shortcut modifier string constants | |
| 13 | + | const ( | |
| 14 | + | modifierCmdOrCtrl = "cmdorctrl" | |
| 15 | + | modifierShift = "shift" | |
| 16 | + | modifierAlt = "alt" | |
| 17 | + | ) | |
| 18 | + | ||
| 19 | + | // GDK key constants for special keys | |
| 20 | + | const ( | |
| 21 | + | gdkKeyLeft = 0xff51 | |
| 22 | + | gdkKeyRight = 0xff53 | |
| 23 | + | gdkKeyUp = 0xff52 | |
| 24 | + | gdkKeyDown = 0xff54 | |
| 25 | + | gdkKeyEscape = 0xff1b | |
| 26 | + | gdkKeyF12 = 0xffbe | |
| 27 | + | gdkKeyPlus = 0x2b | |
| 28 | + | gdkKeyEqual = 0x3d | |
| 29 | + | gdkKeyMinus = 0x2d | |
| 30 | + | ) | |
| 31 | + | ||
| 32 | + | // Key name constants for shortcut mapping | |
| 33 | + | const ( | |
| 34 | + | keyP = "p" | |
| 35 | + | keyL = "l" | |
| 36 | + | keyR = "r" | |
| 37 | + | keyD = "d" | |
| 38 | + | keyU = "u" | |
| 39 | + | keyF = "f" | |
| 40 | + | keyC = "c" | |
| 41 | + | keyW = "w" | |
| 42 | + | keyX = "x" | |
| 43 | + | keyS = "s" | |
| 44 | + | keyEscape = "escape" | |
| 45 | + | keyArrowLeft = "arrowleft" | |
| 46 | + | keyArrowRight = "arrowright" | |
| 47 | + | keyArrowUp = "arrowup" | |
| 48 | + | keyArrowDown = "arrowdown" | |
| 49 | + | keyF12 = "F12" | |
| 50 | + | keyPlus = "plus" | |
| 51 | + | keyMinus = "minus" | |
| 52 | + | keyZero = "0" | |
| 53 | + | altmod = "alt" | |
| 54 | + | ) | |
| 55 | + | ||
| 56 | + | // Pane mode action constants | |
| 57 | + | const ( | |
| 58 | + | actionClose = "close" | |
| 59 | + | actionSplitLeft = "split-left" | |
| 60 | + | actionSplitRight = "split-right" | |
| 61 | + | actionSplitDown = "split-down" | |
| 62 | + | actionSplitUp = "split-up" | |
| 63 | + | actionStack = "stack" | |
| 64 | + | actionExit = "exit" | |
| 65 | + | actionEnter = "enter" | |
| 66 | + | ) | |
| 67 | + | ||
| 68 | + | // Navigation direction constants | |
| 69 | + | const ( | |
| 70 | + | directionLeft = "left" | |
| 71 | + | directionRight = "right" | |
| 72 | + | directionUp = "up" | |
| 73 | + | directionDown = "down" | |
| 74 | + | ) | |
| 75 | + | ||
| 76 | + | // Map shortcuts to actions (unified for all keyboard handling) | |
| 77 | + | var shortcutActions = map[string]string{ | |
| 78 | + | // Pane mode actions (no modifier) | |
| 79 | + | keyX: actionClose, | |
| 80 | + | keyS: actionStack, | |
| 81 | + | keyL: actionSplitLeft, | |
| 82 | + | keyR: actionSplitRight, | |
| 83 | + | keyD: actionSplitDown, | |
| 84 | + | keyU: actionSplitUp, | |
| 85 | + | keyArrowLeft: actionSplitLeft, | |
| 86 | + | keyArrowRight: actionSplitRight, | |
| 87 | + | keyArrowDown: actionSplitDown, | |
| 88 | + | keyArrowUp: actionSplitUp, | |
| 89 | + | ||
| 90 | + | // Navigation (alt modifier) | |
| 91 | + | altmod + "+" + keyArrowLeft: directionLeft, | |
| 92 | + | altmod + "+" + keyArrowRight: directionRight, | |
| 93 | + | altmod + "+" + keyArrowDown: directionDown, | |
| 94 | + | altmod + "+" + keyArrowUp: directionUp, | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // AttachKeyboardBridge attaches an EventControllerKey to bridge keyboard events to JavaScript | |
| 98 | + | // This is critical for allowing JavaScript KeyboardService to receive keyboard events | |
| 99 | + | // that would otherwise be consumed by GTK shortcuts | |
| 100 | + | func (w *WebView) AttachKeyboardBridge() { | |
| 101 | + | if w == nil || w.view == nil { | |
| 102 | + | return | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // Create event controller for keyboard | |
| 106 | + | keyController := gtk.NewEventControllerKey() | |
| 107 | + | ||
| 108 | + | // Set to capture phase so we see events before WebView processes them | |
| 109 | + | keyController.SetPropagationPhase(gtk.PhaseCapture) | |
| 110 | + | ||
| 111 | + | // Connect to key-pressed signal | |
| 112 | + | keyController.ConnectKeyPressed(func(keyval, keycode uint, state gdk.ModifierType) bool { | |
| 113 | + | // Check if pane mode is active - block ALL keys except pane mode actions | |
| 114 | + | w.mu.RLock() | |
| 115 | + | paneModeChecker := w.isPaneModeActive | |
| 116 | + | w.mu.RUnlock() | |
| 117 | + | ||
| 118 | + | isPaneModeActive := paneModeChecker != nil && paneModeChecker() | |
| 119 | + | ||
| 120 | + | // Build normalized shortcut string | |
| 121 | + | var parts []string | |
| 122 | + | ||
| 123 | + | ctrl := state.Has(gdk.ControlMask) | |
| 124 | + | alt := state.Has(gdk.AltMask) | |
| 125 | + | shift := state.Has(gdk.ShiftMask) | |
| 126 | + | ||
| 127 | + | // Build modifier prefix using switch on modifier combination | |
| 128 | + | switch { | |
| 129 | + | case ctrl && shift: | |
| 130 | + | parts = append(parts, modifierCmdOrCtrl, modifierShift) | |
| 131 | + | case ctrl && !shift: | |
| 132 | + | parts = append(parts, modifierCmdOrCtrl) | |
| 133 | + | case alt: | |
| 134 | + | parts = append(parts, modifierAlt) | |
| 135 | + | case shift: | |
| 136 | + | parts = append(parts, modifierShift) | |
| 137 | + | default: | |
| 138 | + | // No modifiers - only forward if we match a key below | |
| 139 | + | } | |
| 140 | + | ||
| 141 | + | // Map keyval to key name | |
| 142 | + | var keyName string | |
| 143 | + | switch keyval { | |
| 144 | + | case 'p', 'P': | |
| 145 | + | keyName = keyP | |
| 146 | + | case 'l', 'L': | |
| 147 | + | keyName = keyL | |
| 148 | + | case 'r', 'R': | |
| 149 | + | keyName = keyR | |
| 150 | + | case 'd', 'D': | |
| 151 | + | keyName = keyD | |
| 152 | + | case 'u', 'U': | |
| 153 | + | keyName = keyU | |
| 154 | + | case 'f', 'F': | |
| 155 | + | keyName = keyF | |
| 156 | + | case 'c', 'C': | |
| 157 | + | keyName = keyC | |
| 158 | + | case 'w', 'W': | |
| 159 | + | keyName = keyW | |
| 160 | + | case 'x', 'X': | |
| 161 | + | keyName = keyX | |
| 162 | + | case 's', 'S': | |
| 163 | + | keyName = keyS | |
| 164 | + | case gdkKeyLeft: | |
| 165 | + | keyName = keyArrowLeft | |
| 166 | + | case gdkKeyRight: | |
| 167 | + | keyName = keyArrowRight | |
| 168 | + | case gdkKeyUp: | |
| 169 | + | keyName = keyArrowUp | |
| 170 | + | case gdkKeyDown: | |
| 171 | + | keyName = keyArrowDown | |
| 172 | + | case gdkKeyEscape: | |
| 173 | + | keyName = keyEscape | |
| 174 | + | case gdkKeyF12: | |
| 175 | + | keyName = keyF12 | |
| 176 | + | case gdkKeyPlus, gdkKeyEqual: | |
| 177 | + | keyName = keyPlus | |
| 178 | + | case gdkKeyMinus: | |
| 179 | + | keyName = keyMinus | |
| 180 | + | case '0': | |
| 181 | + | keyName = keyZero | |
| 182 | + | default: | |
| 183 | + | // Only forward shortcuts with modifiers | |
| 184 | + | if !ctrl && !alt && !shift { | |
| 185 | + | return false | |
| 186 | + | } | |
| 187 | + | return false | |
| 188 | + | } | |
| 189 | + | ||
| 190 | + | if keyName != "" { | |
| 191 | + | parts = append(parts, keyName) | |
| 192 | + | } | |
| 193 | + | ||
| 194 | + | shortcut := "" | |
| 195 | + | if len(parts) > 0 { | |
| 196 | + | shortcut = parts[0] | |
| 197 | + | for i := 1; i < len(parts); i++ { | |
| 198 | + | shortcut += "+" + parts[i] | |
| 199 | + | } | |
| 200 | + | } | |
| 201 | + | ||
| 202 | + | if shortcut != "" { | |
| 203 | + | // Special case: Ctrl+P enters pane mode | |
| 204 | + | if shortcut == modifierCmdOrCtrl+"+"+keyP { | |
| 205 | + | w.mu.RLock() | |
| 206 | + | handler := w.onPaneModeShortcut | |
| 207 | + | w.mu.RUnlock() | |
| 208 | + | if handler != nil && handler(actionEnter) { | |
| 209 | + | log.Printf("[keyboard-bridge] Pane mode: enter") | |
| 210 | + | return true | |
| 211 | + | } | |
| 212 | + | return true // Block even if no handler | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // Special case: Escape exits active mode | |
| 216 | + | if shortcut == keyEscape { | |
| 217 | + | w.mu.RLock() | |
| 218 | + | handler := w.onPaneModeShortcut | |
| 219 | + | w.mu.RUnlock() | |
| 220 | + | if handler != nil && handler(actionExit) { | |
| 221 | + | log.Printf("[keyboard-bridge] Escape: exited mode") | |
| 222 | + | return true | |
| 223 | + | } | |
| 224 | + | return false // Pass through to DOM if no active mode | |
| 225 | + | } | |
| 226 | + | ||
| 227 | + | // Check if shortcut maps to an action | |
| 228 | + | if action, exists := shortcutActions[shortcut]; exists { | |
| 229 | + | // Navigation actions (alt+arrow) | |
| 230 | + | if action == directionLeft || action == directionRight || action == directionUp || action == directionDown { | |
| 231 | + | w.mu.RLock() | |
| 232 | + | handler := w.onWorkspaceNavigation | |
| 233 | + | w.mu.RUnlock() | |
| 234 | + | if handler != nil && handler(action) { | |
| 235 | + | log.Printf("[keyboard-bridge] Navigation: %s", action) | |
| 236 | + | return true | |
| 237 | + | } | |
| 238 | + | return true // Block anyway to prevent scrolling | |
| 239 | + | } | |
| 240 | + | ||
| 241 | + | // Pane mode actions (x, l, r, d, u, arrows) | |
| 242 | + | w.mu.RLock() | |
| 243 | + | handler := w.onPaneModeShortcut | |
| 244 | + | w.mu.RUnlock() | |
| 245 | + | if handler != nil && handler(action) { | |
| 246 | + | log.Printf("[keyboard-bridge] Pane mode action: %s", action) | |
| 247 | + | return true | |
| 248 | + | } | |
| 249 | + | return false // Not in pane mode, pass through | |
| 250 | + | } | |
| 251 | + | ||
| 252 | + | // Pane mode active: block all other shortcuts | |
| 253 | + | if isPaneModeActive { | |
| 254 | + | log.Printf("[keyboard-bridge] Blocking '%s' during pane mode", shortcut) | |
| 255 | + | return true | |
| 256 | + | } | |
| 257 | + | ||
| 258 | + | // Forward to JavaScript using proper JSON marshaling to prevent any potential injection | |
| 259 | + | shortcutJSON, err := json.Marshal(shortcut) | |
| 260 | + | if err != nil { | |
| 261 | + | log.Printf("[keyboard-bridge] Failed to marshal shortcut: %v", err) | |
| 262 | + | return false | |
| 263 | + | } | |
| 264 | + | script := fmt.Sprintf(`document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:%s}}));`, shortcutJSON) | |
| 265 | + | if err := w.InjectScript(script); err != nil { | |
| 266 | + | log.Printf("[keyboard-bridge] Failed to dispatch: %v", err) | |
| 267 | + | } | |
| 268 | + | return false // Allow WebKit to handle | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | // Pane mode active: block ALL non-shortcut keys | |
| 272 | + | if isPaneModeActive { | |
| 273 | + | log.Printf("[keyboard-bridge] Blocking key during pane mode: keyval=%d", keyval) | |
| 274 | + | return true // Block all other keys | |
| 275 | + | } | |
| 276 | + | ||
| 277 | + | // No shortcut matched and not in pane mode - allow WebKit to handle normally | |
| 278 | + | return false | |
| 279 | + | }) | |
| 280 | + | ||
| 281 | + | // Attach controller to WebView | |
| 282 | + | w.view.AddController(keyController) | |
| 283 | + | log.Printf("[keyboard-bridge] EventControllerKey attached to WebView ID %d", w.id) | |
| 284 | + | } | |
D pkg/webkit/keyboard_cgo.go +0 -813
| @@ -1,813 +0,0 @@ | |||
| 1 | - | //go:build webkit_cgo | |
| 2 | - | ||
| 3 | - | package webkit | |
| 4 | - | ||
| 5 | - | /* | |
| 6 | - | #cgo pkg-config: gtk4 webkitgtk-6.0 | |
| 7 | - | #include <gtk/gtk.h> | |
| 8 | - | #include <gdk/gdk.h> | |
| 9 | - | #include <webkit/webkit.h> | |
| 10 | - | ||
| 11 | - | // Declarations for Go callbacks | |
| 12 | - | extern gboolean goOnKeyPress(unsigned long id, unsigned int keyval, GdkModifierType state); | |
| 13 | - | extern void goOnButtonPress(unsigned long id, unsigned int button, GdkModifierType state); | |
| 14 | - | extern void goOnScroll(unsigned long id, double dx, double dy, GdkModifierType state); | |
| 15 | - | ||
| 16 | - | // Key controller callback -> forward to Go | |
| 17 | - | static gboolean on_key_pressed(GtkEventControllerKey* controller, guint keyval, guint keycode, GdkModifierType state, gpointer user_data) { | |
| 18 | - | (void)controller; (void)keycode; | |
| 19 | - | return goOnKeyPress((unsigned long)user_data, keyval, state); | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | // Mouse click gesture -> forward side buttons to Go | |
| 23 | - | static void on_click_pressed(GtkGestureClick* gesture, gint n_press, gdouble x, gdouble y, gpointer user_data) { | |
| 24 | - | (void)n_press; (void)x; (void)y; | |
| 25 | - | guint button = gtk_gesture_single_get_current_button(GTK_GESTURE_SINGLE(gesture)); | |
| 26 | - | // Pass 0 for modifier state (not provided via gesture API) | |
| 27 | - | goOnButtonPress((unsigned long)user_data, button, 0); | |
| 28 | - | } | |
| 29 | - | ||
| 30 | - | // Attach controllers to a widget (GTK4) | |
| 31 | - | static void attach_key_controller(GtkWidget* widget, unsigned long id) { | |
| 32 | - | if (!widget) return; | |
| 33 | - | GtkEventController* keyc = gtk_event_controller_key_new(); | |
| 34 | - | // Capture phase so we see keys even if child consumes them (e.g., WebKit) | |
| 35 | - | gtk_event_controller_set_propagation_phase(keyc, GTK_PHASE_CAPTURE); | |
| 36 | - | g_signal_connect_data(keyc, "key-pressed", G_CALLBACK(on_key_pressed), (gpointer)id, NULL, 0); | |
| 37 | - | gtk_widget_add_controller(widget, keyc); | |
| 38 | - | } | |
| 39 | - | ||
| 40 | - | static void attach_mouse_gesture(GtkWidget* widget, unsigned long id) { | |
| 41 | - | if (!widget) return; | |
| 42 | - | GtkGesture* click = gtk_gesture_click_new(); | |
| 43 | - | g_signal_connect_data(click, "pressed", G_CALLBACK(on_click_pressed), (gpointer)id, NULL, 0); | |
| 44 | - | gtk_widget_add_controller(widget, GTK_EVENT_CONTROLLER(click)); | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | // Legacy controller to capture side buttons (8/9) reliably in GTK4 | |
| 48 | - | static gboolean on_legacy_event(GtkEventController* controller, GdkEvent* event, gpointer user_data) { | |
| 49 | - | (void)controller; | |
| 50 | - | if (!event) return FALSE; | |
| 51 | - | GdkEventType type = gdk_event_get_event_type(event); | |
| 52 | - | if (type == GDK_BUTTON_PRESS) { | |
| 53 | - | guint button = gdk_button_event_get_button(event); | |
| 54 | - | GdkModifierType state = gdk_event_get_modifier_state(event); | |
| 55 | - | if (button == 8 || button == 9) { | |
| 56 | - | goOnButtonPress((unsigned long)user_data, button, state); | |
| 57 | - | return TRUE; // consume side buttons | |
| 58 | - | } | |
| 59 | - | return FALSE; // let WebKit handle normal clicks | |
| 60 | - | } else if (type == GDK_SCROLL) { | |
| 61 | - | double dx = 0.0, dy = 0.0; | |
| 62 | - | gdk_scroll_event_get_deltas(event, &dx, &dy); | |
| 63 | - | GdkModifierType state = gdk_event_get_modifier_state(event); | |
| 64 | - | goOnScroll((unsigned long)user_data, dx, dy, state); | |
| 65 | - | return FALSE; // don't steal regular scrolling | |
| 66 | - | } | |
| 67 | - | return FALSE; | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | static void attach_mouse_legacy(GtkWidget* widget, unsigned long id) { | |
| 71 | - | if (!widget) return; | |
| 72 | - | GtkEventController* legacy = gtk_event_controller_legacy_new(); | |
| 73 | - | gtk_event_controller_set_propagation_phase(legacy, GTK_PHASE_CAPTURE); | |
| 74 | - | g_signal_connect_data(legacy, "event", G_CALLBACK(on_legacy_event), (gpointer)id, NULL, 0); | |
| 75 | - | gtk_widget_add_controller(widget, legacy); | |
| 76 | - | } | |
| 77 | - | */ | |
| 78 | - | import "C" | |
| 79 | - | ||
| 80 | - | import ( | |
| 81 | - | "encoding/json" | |
| 82 | - | "fmt" | |
| 83 | - | "log" | |
| 84 | - | "strings" | |
| 85 | - | "sync" | |
| 86 | - | "sync/atomic" | |
| 87 | - | "time" | |
| 88 | - | ) | |
| 89 | - | ||
| 90 | - | // Registry of accelerators per view id. | |
| 91 | - | type shortcutRegistry map[string]func() | |
| 92 | - | ||
| 93 | - | var ( | |
| 94 | - | regMu sync.RWMutex | |
| 95 | - | viewShortcuts = make(map[uintptr]shortcutRegistry) | |
| 96 | - | viewIDCounter uint64 | |
| 97 | - | viewByID = make(map[uintptr]*WebView) | |
| 98 | - | lastKeyTime = make(map[uintptr]map[string]time.Time) | |
| 99 | - | ) | |
| 100 | - | ||
| 101 | - | const shortcutDebounceWindow = 120 * time.Millisecond | |
| 102 | - | ||
| 103 | - | // shouldDebounceShortcut returns true when the given key should be skipped | |
| 104 | - | // because it was delivered within the debounce window for the same view. | |
| 105 | - | func shouldDebounceShortcut(uid uintptr, key string, now time.Time) bool { | |
| 106 | - | regMu.Lock() | |
| 107 | - | defer regMu.Unlock() | |
| 108 | - | ||
| 109 | - | if _, ok := lastKeyTime[uid]; !ok { | |
| 110 | - | lastKeyTime[uid] = make(map[string]time.Time) | |
| 111 | - | } | |
| 112 | - | last := lastKeyTime[uid][key] | |
| 113 | - | if now.Sub(last) < shortcutDebounceWindow { | |
| 114 | - | return true | |
| 115 | - | } | |
| 116 | - | lastKeyTime[uid][key] = now | |
| 117 | - | return false | |
| 118 | - | } | |
| 119 | - | ||
| 120 | - | // Locale hint function removed; no layout-specific remaps. | |
| 121 | - | ||
| 122 | - | // RegisterKeyboardShortcut registers a callback under an accelerator string. | |
| 123 | - | func (w *WebView) RegisterKeyboardShortcut(accel string, callback func()) error { | |
| 124 | - | if w == nil || w.destroyed { | |
| 125 | - | return ErrNotImplemented | |
| 126 | - | } | |
| 127 | - | if accel == "" || callback == nil { | |
| 128 | - | return fmt.Errorf("invalid shortcut registration") | |
| 129 | - | } | |
| 130 | - | regMu.Lock() | |
| 131 | - | if _, ok := viewShortcuts[w.id]; !ok { | |
| 132 | - | viewShortcuts[w.id] = make(shortcutRegistry) | |
| 133 | - | } | |
| 134 | - | viewShortcuts[w.id][accel] = callback | |
| 135 | - | regMu.Unlock() | |
| 136 | - | ||
| 137 | - | // Register in global shortcuts registry for automatic webpage blocking | |
| 138 | - | RegisterGlobalShortcut(accel) | |
| 139 | - | ||
| 140 | - | return nil | |
| 141 | - | } | |
| 142 | - | ||
| 143 | - | //export goOnKeyPress | |
| 144 | - | func goOnKeyPress(id C.ulong, keyval C.guint, state C.GdkModifierType) C.gboolean { | |
| 145 | - | // Normalize and dispatch | |
| 146 | - | uid := uintptr(id) | |
| 147 | - | kv := uint(keyval) | |
| 148 | - | st := uint(state) | |
| 149 | - | ||
| 150 | - | if dispatchAccelerator(uid, kv, st) { | |
| 151 | - | return C.gboolean(1) | |
| 152 | - | } | |
| 153 | - | return C.gboolean(0) | |
| 154 | - | } | |
| 155 | - | ||
| 156 | - | // getCLevelBridgeShortcut returns the shortcut string for shortcuts that need C-level bridge handling. | |
| 157 | - | // These shortcuts are blocked at the webpage level and have their callbacks triggered directly from C code. | |
| 158 | - | // Returns empty string if the shortcut doesn't need C-level handling. | |
| 159 | - | func getCLevelBridgeShortcut(ctrl, alt, shift bool, keyName string) string { | |
| 160 | - | // Alt+Arrow shortcuts - for workspace pane navigation | |
| 161 | - | if alt && !ctrl && !shift { | |
| 162 | - | switch keyName { | |
| 163 | - | case "ArrowLeft": | |
| 164 | - | return "alt+arrowleft" | |
| 165 | - | case "ArrowRight": | |
| 166 | - | return "alt+arrowright" | |
| 167 | - | case "ArrowUp": | |
| 168 | - | return "alt+arrowup" | |
| 169 | - | case "ArrowDown": | |
| 170 | - | return "alt+arrowdown" | |
| 171 | - | } | |
| 172 | - | } | |
| 173 | - | ||
| 174 | - | // Ctrl+W shortcut - for closing current pane via workspace manager | |
| 175 | - | if ctrl && !alt && !shift && keyName == "w" { | |
| 176 | - | return "ctrl+w" | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | // Add other C-level bridge shortcuts here as needed | |
| 180 | - | // Note: Most shortcuts (Ctrl+L, Ctrl+F, zoom) should NOT be here | |
| 181 | - | // They work fine through normal GTK4 handling without C-level intervention | |
| 182 | - | ||
| 183 | - | return "" | |
| 184 | - | } | |
| 185 | - | ||
| 186 | - | func dispatchAccelerator(uid uintptr, keyval uint, state uint) bool { | |
| 187 | - | ctrl := (state & uint(C.GDK_CONTROL_MASK)) != 0 | |
| 188 | - | alt := (state & uint(C.GDK_ALT_MASK)) != 0 | |
| 189 | - | shift := (state & uint(C.GDK_SHIFT_MASK)) != 0 | |
| 190 | - | // Map keyval to string names | |
| 191 | - | var keyName string | |
| 192 | - | logMinusEvent := false | |
| 193 | - | switch keyval { | |
| 194 | - | case uint(C.GDK_KEY_minus), uint(C.GDK_KEY_KP_Subtract), uint(C.GDK_KEY_underscore): | |
| 195 | - | // Normalize minus; log raw detection for diagnostics | |
| 196 | - | logMinusEvent = true | |
| 197 | - | keyName = "-" | |
| 198 | - | case uint(C.GDK_KEY_equal), uint(C.GDK_KEY_KP_Add): | |
| 199 | - | keyName = "=" | |
| 200 | - | case uint(C.GDK_KEY_0), uint(C.GDK_KEY_KP_0): | |
| 201 | - | keyName = "0" | |
| 202 | - | case uint(C.GDK_KEY_Left): | |
| 203 | - | keyName = "ArrowLeft" | |
| 204 | - | case uint(C.GDK_KEY_Right): | |
| 205 | - | keyName = "ArrowRight" | |
| 206 | - | case uint(C.GDK_KEY_Up): | |
| 207 | - | keyName = "ArrowUp" | |
| 208 | - | case uint(C.GDK_KEY_Down): | |
| 209 | - | keyName = "ArrowDown" | |
| 210 | - | case uint(C.GDK_KEY_KP_Left): | |
| 211 | - | keyName = "ArrowLeft" | |
| 212 | - | case uint(C.GDK_KEY_KP_Right): | |
| 213 | - | keyName = "ArrowRight" | |
| 214 | - | case uint(C.GDK_KEY_KP_Up): | |
| 215 | - | keyName = "ArrowUp" | |
| 216 | - | case uint(C.GDK_KEY_KP_Down): | |
| 217 | - | keyName = "ArrowDown" | |
| 218 | - | case uint(C.GDK_KEY_F12): | |
| 219 | - | keyName = "F12" | |
| 220 | - | case uint(C.GDK_KEY_F5): | |
| 221 | - | keyName = "F5" | |
| 222 | - | case uint(C.GDK_KEY_c), uint(C.GDK_KEY_C): | |
| 223 | - | keyName = "c" | |
| 224 | - | case uint(C.GDK_KEY_r), uint(C.GDK_KEY_R): | |
| 225 | - | keyName = "r" | |
| 226 | - | case uint(C.GDK_KEY_l), uint(C.GDK_KEY_L): | |
| 227 | - | keyName = "l" | |
| 228 | - | case uint(C.GDK_KEY_f), uint(C.GDK_KEY_F): | |
| 229 | - | keyName = "f" | |
| 230 | - | case uint(C.GDK_KEY_p), uint(C.GDK_KEY_P): | |
| 231 | - | keyName = "p" | |
| 232 | - | case uint(C.GDK_KEY_w), uint(C.GDK_KEY_W): | |
| 233 | - | keyName = "w" | |
| 234 | - | default: | |
| 235 | - | // Filter out modifier keys themselves (like Ctrl, Alt, Shift) | |
| 236 | - | // These keyvals shouldn't be logged as misses | |
| 237 | - | switch keyval { | |
| 238 | - | case 0xffe1, 0xffe2: // GDK_KEY_Shift_L, GDK_KEY_Shift_R | |
| 239 | - | return false | |
| 240 | - | case 0xffe3, 0xffe4: // GDK_KEY_Control_L, GDK_KEY_Control_R | |
| 241 | - | return false | |
| 242 | - | case 0xffe9, 0xffea: // GDK_KEY_Alt_L, GDK_KEY_Alt_R | |
| 243 | - | return false | |
| 244 | - | case 0xffeb, 0xffec: // GDK_KEY_Super_L, GDK_KEY_Super_R | |
| 245 | - | return false | |
| 246 | - | } | |
| 247 | - | ||
| 248 | - | // If Control held, log unknown keyval for diagnostics (but not modifier keys) | |
| 249 | - | if ctrl { | |
| 250 | - | log.Printf("[accelerator-miss] ctrl keyval=0x%x", keyval) | |
| 251 | - | } | |
| 252 | - | return false | |
| 253 | - | } | |
| 254 | - | ||
| 255 | - | // Check for shortcuts that need C-level handling and trigger if found | |
| 256 | - | shortcut := getCLevelBridgeShortcut(ctrl, alt, shift, keyName) | |
| 257 | - | if shortcut != "" { | |
| 258 | - | // For all C-level bridge shortcuts (Alt+Arrow and Ctrl+W), use global window shortcut handler | |
| 259 | - | // The window shortcut handler now properly handles both popup and regular panes | |
| 260 | - | shortcutsRegistryMu.RLock() | |
| 261 | - | handle, exists := globalShortcutHandles[shortcut] | |
| 262 | - | shortcutsRegistryMu.RUnlock() | |
| 263 | - | ||
| 264 | - | if exists && handle != 0 { | |
| 265 | - | invokeWindowShortcutCallback(handle) | |
| 266 | - | return true | |
| 267 | - | } | |
| 268 | - | } | |
| 269 | - | ||
| 270 | - | now := time.Now() | |
| 271 | - | rawKey := fmt.Sprintf("raw:%v:%v:%v:%s", ctrl, alt, shift, keyName) | |
| 272 | - | if shouldDebounceShortcut(uid, rawKey, now) { | |
| 273 | - | return false | |
| 274 | - | } | |
| 275 | - | ||
| 276 | - | if logMinusEvent { | |
| 277 | - | log.Printf("[accelerator-raw] minus key detected ctrl=%v alt=%v keyval=0x%x", ctrl, alt, keyval) | |
| 278 | - | } | |
| 279 | - | ||
| 280 | - | // Candidate accelerator strings in order | |
| 281 | - | handled := false | |
| 282 | - | candidates := []string{keyName} | |
| 283 | - | if ctrl && shift { | |
| 284 | - | // Ctrl+Shift combinations | |
| 285 | - | candidates = append([]string{"cmdorctrl+shift+" + keyName, "ctrl+shift+" + keyName}, candidates...) | |
| 286 | - | } else if ctrl { | |
| 287 | - | // Common style: modifier + '+' + keyName (e.g., cmdorctrl+=) | |
| 288 | - | candidates = append([]string{"cmdorctrl+" + keyName, "ctrl+" + keyName}, candidates...) | |
| 289 | - | // Also accept style without the extra '+' for punctuation keys (e.g., cmdorctrl-) | |
| 290 | - | candidates = append([]string{"cmdorctrl" + keyName, "ctrl" + keyName}, candidates...) | |
| 291 | - | } | |
| 292 | - | if alt { | |
| 293 | - | candidates = append([]string{"alt+" + keyName}, candidates...) | |
| 294 | - | } | |
| 295 | - | ||
| 296 | - | // Compute a single normalized shortcut string to forward to GUI (single source of truth) | |
| 297 | - | normalized := "" | |
| 298 | - | if ctrl && shift { | |
| 299 | - | normalized = "cmdorctrl+shift+" + keyName | |
| 300 | - | } else if ctrl { | |
| 301 | - | normalized = "cmdorctrl+" + keyName | |
| 302 | - | } else if alt { | |
| 303 | - | normalized = "alt+" + keyName | |
| 304 | - | } else if keyName != "" { | |
| 305 | - | // For future modes like Vim, we may want to forward non-modified keys | |
| 306 | - | // For now, only forward if a modifier is present to avoid noise | |
| 307 | - | normalized = "" | |
| 308 | - | } | |
| 309 | - | ||
| 310 | - | // Forward normalized shortcut to GUI KeyboardService via DOM event if present | |
| 311 | - | if normalized != "" && !shouldDebounceShortcut(uid, "dom:"+normalized, now) { | |
| 312 | - | regMu.RLock() | |
| 313 | - | vw := viewByID[uid] | |
| 314 | - | regMu.RUnlock() | |
| 315 | - | if vw != nil { | |
| 316 | - | _ = vw.InjectScript(fmt.Sprintf("document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'%s'}}));", normalized)) | |
| 317 | - | } | |
| 318 | - | } | |
| 319 | - | ||
| 320 | - | regMu.RLock() | |
| 321 | - | reg := viewShortcuts[uid] | |
| 322 | - | regMu.RUnlock() | |
| 323 | - | if reg == nil { | |
| 324 | - | return handled | |
| 325 | - | } | |
| 326 | - | ||
| 327 | - | for _, name := range candidates { | |
| 328 | - | if cb, ok := reg[name]; ok { | |
| 329 | - | if shouldDebounceShortcut(uid, "shortcut:"+name, now) { | |
| 330 | - | return handled | |
| 331 | - | } | |
| 332 | - | log.Printf("[accelerator] %s", name) | |
| 333 | - | cb() | |
| 334 | - | handled = true | |
| 335 | - | break | |
| 336 | - | } | |
| 337 | - | } | |
| 338 | - | ||
| 339 | - | return handled | |
| 340 | - | } | |
| 341 | - | ||
| 342 | - | func nextViewID() uintptr { | |
| 343 | - | return uintptr(atomic.AddUint64(&viewIDCounter, 1)) | |
| 344 | - | } | |
| 345 | - | ||
| 346 | - | func registerView(id uintptr, w *WebView) { | |
| 347 | - | regMu.Lock() | |
| 348 | - | viewByID[id] = w | |
| 349 | - | regMu.Unlock() | |
| 350 | - | } | |
| 351 | - | ||
| 352 | - | func unregisterView(id uintptr) { | |
| 353 | - | regMu.Lock() | |
| 354 | - | delete(viewByID, id) | |
| 355 | - | delete(viewShortcuts, id) | |
| 356 | - | regMu.Unlock() | |
| 357 | - | } | |
| 358 | - | ||
| 359 | - | // AttachKeyboardControllers attaches GTK4 key and mouse controllers to the view widget. | |
| 360 | - | func AttachKeyboardControllers(w *WebView) { | |
| 361 | - | if w == nil || w.native == nil || w.native.view == nil { | |
| 362 | - | return | |
| 363 | - | } | |
| 364 | - | C.attach_key_controller(w.native.view, C.ulong(w.id)) | |
| 365 | - | if w.native.win != nil { | |
| 366 | - | C.attach_key_controller(w.native.win, C.ulong(w.id)) | |
| 367 | - | } | |
| 368 | - | C.attach_mouse_gesture(w.native.view, C.ulong(w.id)) | |
| 369 | - | // Legacy controller captures raw button presses (8/9) for back/forward reliably | |
| 370 | - | C.attach_mouse_legacy(w.native.view, C.ulong(w.id)) | |
| 371 | - | log.Printf("[input] GTK4 key/mouse controllers attached") | |
| 372 | - | } | |
| 373 | - | ||
| 374 | - | //export goOnScroll | |
| 375 | - | func goOnScroll(id C.ulong, dx C.double, dy C.double, state C.GdkModifierType) { | |
| 376 | - | uid := uintptr(id) | |
| 377 | - | regMu.RLock() | |
| 378 | - | vw := viewByID[uid] | |
| 379 | - | regMu.RUnlock() | |
| 380 | - | if vw == nil { | |
| 381 | - | return | |
| 382 | - | } | |
| 383 | - | // Ctrl+scroll zooms | |
| 384 | - | if (uint(state) & uint(C.GDK_CONTROL_MASK)) != 0 { | |
| 385 | - | if float64(dy) < 0 { | |
| 386 | - | // scroll up -> zoom in | |
| 387 | - | nz := vw.zoom | |
| 388 | - | if nz <= 0 { | |
| 389 | - | nz = 1.0 | |
| 390 | - | } | |
| 391 | - | nz *= 1.1 | |
| 392 | - | if nz < 0.25 { | |
| 393 | - | nz = 0.25 | |
| 394 | - | } | |
| 395 | - | if nz > 5.0 { | |
| 396 | - | nz = 5.0 | |
| 397 | - | } | |
| 398 | - | _ = vw.SetZoom(nz) | |
| 399 | - | } else if float64(dy) > 0 { | |
| 400 | - | // scroll down -> zoom out | |
| 401 | - | nz := vw.zoom | |
| 402 | - | if nz <= 0 { | |
| 403 | - | nz = 1.0 | |
| 404 | - | } | |
| 405 | - | nz /= 1.1 | |
| 406 | - | if nz < 0.25 { | |
| 407 | - | nz = 0.25 | |
| 408 | - | } | |
| 409 | - | if nz > 5.0 { | |
| 410 | - | nz = 5.0 | |
| 411 | - | } | |
| 412 | - | _ = vw.SetZoom(nz) | |
| 413 | - | } | |
| 414 | - | } | |
| 415 | - | } | |
| 416 | - | ||
| 417 | - | //export goOnThemeChanged | |
| 418 | - | func goOnThemeChanged(id C.ulong, preferDark C.int) { | |
| 419 | - | uid := uintptr(id) | |
| 420 | - | regMu.RLock() | |
| 421 | - | vw := viewByID[uid] | |
| 422 | - | regMu.RUnlock() | |
| 423 | - | if vw == nil { | |
| 424 | - | return | |
| 425 | - | } | |
| 426 | - | // Inject runtime theme update into the page | |
| 427 | - | dval := "false" | |
| 428 | - | if int(preferDark) != 0 { | |
| 429 | - | dval = "true" | |
| 430 | - | } | |
| 431 | - | js := fmt.Sprintf(`(() => { try { const d=%s; const cs=d?'dark':'light'; console.log('[dumber] theme change:'+cs); | |
| 432 | - | let m=document.querySelector('meta[name="color-scheme"]'); | |
| 433 | - | if(!m){ m=document.createElement('meta'); m.name='color-scheme'; document.head.appendChild(m); } | |
| 434 | - | m.setAttribute('content', d ? 'dark light' : 'light dark'); | |
| 435 | - | let s=document.getElementById('__dumber_theme_style'); | |
| 436 | - | if(!s){ s=document.createElement('style'); s.id='__dumber_theme_style'; document.documentElement.appendChild(s); } | |
| 437 | - | s.textContent=':root{color-scheme:' + (d?'dark':'light') + ';}'; | |
| 438 | - | // Call the unified theme setter for Tailwind dark mode | |
| 439 | - | if (window.__dumber_setTheme) { | |
| 440 | - | window.__dumber_setTheme(cs); | |
| 441 | - | } else { | |
| 442 | - | // Retry after a short delay in case bridge hasn't loaded yet | |
| 443 | - | setTimeout(() => { | |
| 444 | - | if (window.__dumber_setTheme) { | |
| 445 | - | window.__dumber_setTheme(cs); | |
| 446 | - | } else { | |
| 447 | - | // Fallback: directly apply dark class | |
| 448 | - | console.warn('[dumber] Theme setter not available, using fallback'); | |
| 449 | - | if (d) { | |
| 450 | - | document.documentElement.classList.add('dark'); | |
| 451 | - | } else { | |
| 452 | - | document.documentElement.classList.remove('dark'); | |
| 453 | - | } | |
| 454 | - | } | |
| 455 | - | }, 100); | |
| 456 | - | } | |
| 457 | - | } catch(e) { console.warn('[dumber] theme runtime update failed', e); } })();`, dval) | |
| 458 | - | _ = vw.InjectScript(js) | |
| 459 | - | } | |
| 460 | - | ||
| 461 | - | //export goOnUcmMessage | |
| 462 | - | func goOnUcmMessage(id C.ulong, json *C.char) { | |
| 463 | - | uid := uintptr(id) | |
| 464 | - | regMu.RLock() | |
| 465 | - | vw := viewByID[uid] | |
| 466 | - | regMu.RUnlock() | |
| 467 | - | if vw == nil || json == nil { | |
| 468 | - | return | |
| 469 | - | } | |
| 470 | - | goPayload := C.GoString(json) | |
| 471 | - | ||
| 472 | - | // Handle rendering backend detection messages specially | |
| 473 | - | if handleRenderingBackendMessage(goPayload) { | |
| 474 | - | return | |
| 475 | - | } | |
| 476 | - | ||
| 477 | - | // Handle keyboard blocking messages | |
| 478 | - | if handleKeyboardBlockingMessage(vw, goPayload) { | |
| 479 | - | return | |
| 480 | - | } | |
| 481 | - | ||
| 482 | - | vw.dispatchScriptMessage(goPayload) | |
| 483 | - | } | |
| 484 | - | ||
| 485 | - | //export goOnTitleChanged | |
| 486 | - | func goOnTitleChanged(id C.ulong, ctitle *C.char) { | |
| 487 | - | uid := uintptr(id) | |
| 488 | - | regMu.RLock() | |
| 489 | - | vw := viewByID[uid] | |
| 490 | - | regMu.RUnlock() | |
| 491 | - | if vw == nil || ctitle == nil { | |
| 492 | - | return | |
| 493 | - | } | |
| 494 | - | title := C.GoString(ctitle) | |
| 495 | - | vw.dispatchTitleChanged(title) | |
| 496 | - | } | |
| 497 | - | ||
| 498 | - | //export goOnURIChanged | |
| 499 | - | func goOnURIChanged(id C.ulong, curi *C.char) { | |
| 500 | - | uid := uintptr(id) | |
| 501 | - | regMu.RLock() | |
| 502 | - | vw := viewByID[uid] | |
| 503 | - | regMu.RUnlock() | |
| 504 | - | if vw == nil || curi == nil { | |
| 505 | - | return | |
| 506 | - | } | |
| 507 | - | uri := C.GoString(curi) | |
| 508 | - | vw.dispatchURIChanged(uri) | |
| 509 | - | } | |
| 510 | - | ||
| 511 | - | //export goOnFaviconURIChanged | |
| 512 | - | func goOnFaviconURIChanged(id C.ulong, pageURI *C.char, faviconURI *C.char) { | |
| 513 | - | uid := uintptr(id) | |
| 514 | - | pageURLStr := C.GoString(pageURI) | |
| 515 | - | faviconURLStr := C.GoString(faviconURI) | |
| 516 | - | ||
| 517 | - | log.Printf("[favicon] WebKit favicon URI changed for WebView ID %d - favicon: %s", uid, faviconURLStr) | |
| 518 | - | ||
| 519 | - | regMu.RLock() | |
| 520 | - | vw := viewByID[uid] | |
| 521 | - | regMu.RUnlock() | |
| 522 | - | if vw == nil { | |
| 523 | - | log.Printf("[favicon] WebView not found for ID %d", uid) | |
| 524 | - | return | |
| 525 | - | } | |
| 526 | - | ||
| 527 | - | vw.dispatchFaviconURIChanged(pageURLStr, faviconURLStr) | |
| 528 | - | } | |
| 529 | - | ||
| 530 | - | //export goHandleNewWindowPolicy | |
| 531 | - | func goHandleNewWindowPolicy(id C.ulong, curi *C.char, navType C.int, cframeName *C.char, isUserGesture C.gboolean, modifiers C.guint, mouseButton C.guint) C.gboolean { | |
| 532 | - | uid := uintptr(id) | |
| 533 | - | regMu.RLock() | |
| 534 | - | vw := viewByID[uid] | |
| 535 | - | regMu.RUnlock() | |
| 536 | - | ||
| 537 | - | if vw == nil { | |
| 538 | - | log.Printf("[webkit] Policy decision: no WebView found for ID %d", uid) | |
| 539 | - | return 0 // FALSE | |
| 540 | - | } | |
| 541 | - | ||
| 542 | - | uri := "" | |
| 543 | - | if curi != nil { | |
| 544 | - | uri = C.GoString(curi) | |
| 545 | - | } | |
| 546 | - | ||
| 547 | - | frameName := "" | |
| 548 | - | if cframeName != nil { | |
| 549 | - | frameName = C.GoString(cframeName) | |
| 550 | - | } | |
| 551 | - | ||
| 552 | - | // Navigation type constants from WebKit headers | |
| 553 | - | navTypeNames := map[int]string{ | |
| 554 | - | 0: "LINK_CLICKED", | |
| 555 | - | 1: "FORM_SUBMITTED", | |
| 556 | - | 2: "BACK_FORWARD", | |
| 557 | - | 3: "RELOAD", | |
| 558 | - | 4: "FORM_RESUBMITTED", | |
| 559 | - | 5: "OTHER", | |
| 560 | - | } | |
| 561 | - | ||
| 562 | - | navTypeName := navTypeNames[int(navType)] | |
| 563 | - | if navTypeName == "" { | |
| 564 | - | navTypeName = fmt.Sprintf("UNKNOWN(%d)", int(navType)) | |
| 565 | - | } | |
| 566 | - | ||
| 567 | - | // Convert parameters for analysis | |
| 568 | - | userGesture := isUserGesture != 0 | |
| 569 | - | mods := uint(modifiers) | |
| 570 | - | button := uint(mouseButton) | |
| 571 | - | ||
| 572 | - | // Check for modifier keys that indicate new tab intent | |
| 573 | - | const ( | |
| 574 | - | GDK_CONTROL_MASK = 0x04 | |
| 575 | - | GDK_META_MASK = 0x10000000 // Command key on macOS | |
| 576 | - | ) | |
| 577 | - | hasCtrlOrCmd := (mods&GDK_CONTROL_MASK != 0) || (mods&GDK_META_MASK != 0) | |
| 578 | - | ||
| 579 | - | log.Printf("[webkit] Policy decision for new window: URI=%s, NavType=%s, FrameName=%s, UserGesture=%v, Modifiers=0x%x, Button=%d, CtrlOrCmd=%v", | |
| 580 | - | uri, navTypeName, frameName, userGesture, mods, button, hasCtrlOrCmd) | |
| 581 | - | ||
| 582 | - | // Enhanced detection logic for _blank vs popup using reliable heuristics | |
| 583 | - | isRegularPane := false | |
| 584 | - | ||
| 585 | - | // Criteria for treating as regular pane (new panel): | |
| 586 | - | // 1. Explicit _blank frame name (target="_blank" links) | |
| 587 | - | // 2. User gesture with Ctrl/Cmd+click (open in new panel intent) | |
| 588 | - | // 3. User-initiated OTHER navigation with no frame name (window.open("url", "_blank")) | |
| 589 | - | if frameName == "_blank" { | |
| 590 | - | isRegularPane = true | |
| 591 | - | log.Printf("[webkit] _blank frame detected - will handle as new panel") | |
| 592 | - | } else if userGesture && hasCtrlOrCmd && (navType == 0 || navType == 5) { | |
| 593 | - | // WEBKIT_NAVIGATION_TYPE_LINK_CLICKED = 0, WEBKIT_NAVIGATION_TYPE_OTHER = 5 | |
| 594 | - | isRegularPane = true | |
| 595 | - | log.Printf("[webkit] Ctrl/Cmd+click detected - will handle as new panel") | |
| 596 | - | } else if userGesture && navType == 5 && frameName == "" { | |
| 597 | - | // window.open("url", "_blank") typically: user gesture + OTHER + no frame name | |
| 598 | - | isRegularPane = true | |
| 599 | - | log.Printf("[webkit] User-initiated window.open() detected - will handle as new panel") | |
| 600 | - | } | |
| 601 | - | ||
| 602 | - | // Handle _blank targets by creating new panels directly (prevent WindowFeatures crash) | |
| 603 | - | if isRegularPane { | |
| 604 | - | log.Printf("[webkit] Creating new panel for _blank target: %s", uri) | |
| 605 | - | ||
| 606 | - | // Create new panel through workspace manager using existing popup handler | |
| 607 | - | if vw.popupHandler != nil { | |
| 608 | - | // Call popup handler to create a new panel | |
| 609 | - | // The workspace manager will handle this as a regular panel based on URI markers | |
| 610 | - | vw.popupHandler(uri + "#__dumber_frame_blank") | |
| 611 | - | } | |
| 612 | - | ||
| 613 | - | return 1 // TRUE - we handled it by creating a new panel | |
| 614 | - | } | |
| 615 | - | ||
| 616 | - | // For actual popups, let the create signal handle it | |
| 617 | - | if vw.popupHandler != nil { | |
| 618 | - | log.Printf("[webkit] Letting popup go through create signal for WindowFeatures handling: %s", uri) | |
| 619 | - | return 0 // FALSE - let WebKit call create signal for popup handling | |
| 620 | - | } else { | |
| 621 | - | log.Printf("[webkit] No popup handler registered - allowing native popup for: %s", uri) | |
| 622 | - | return 0 // FALSE - no handler, allow default popup | |
| 623 | - | } | |
| 624 | - | } | |
| 625 | - | ||
| 626 | - | //export goHandleLoadChanged | |
| 627 | - | func goHandleLoadChanged(id C.ulong, curi *C.char, loadEvent C.int) { | |
| 628 | - | uid := uintptr(id) | |
| 629 | - | regMu.RLock() | |
| 630 | - | vw := viewByID[uid] | |
| 631 | - | regMu.RUnlock() | |
| 632 | - | ||
| 633 | - | if vw == nil { | |
| 634 | - | return | |
| 635 | - | } | |
| 636 | - | ||
| 637 | - | // OAuth auto-close is handled via WebKit's "close" signal when providers call window.close() | |
| 638 | - | // No need to detect OAuth callback URLs - let the providers handle it | |
| 639 | - | _ = curi // Suppress unused parameter warning | |
| 640 | - | } | |
| 641 | - | ||
| 642 | - | //export goHandleWebViewClose | |
| 643 | - | func goHandleWebViewClose(id C.ulong) { | |
| 644 | - | uid := uintptr(id) | |
| 645 | - | regMu.RLock() | |
| 646 | - | vw := viewByID[uid] | |
| 647 | - | regMu.RUnlock() | |
| 648 | - | ||
| 649 | - | if vw == nil { | |
| 650 | - | log.Printf("[webkit] WebView close signal: no WebView found for ID %d", uid) | |
| 651 | - | return | |
| 652 | - | } | |
| 653 | - | ||
| 654 | - | log.Printf("[webkit] WebView close signal received for view_id: %d", uid) | |
| 655 | - | ||
| 656 | - | if vw.closeHandler != nil { | |
| 657 | - | log.Printf("[webkit] Calling close handler for view_id: %d", uid) | |
| 658 | - | vw.closeHandler() | |
| 659 | - | } else { | |
| 660 | - | log.Printf("[webkit] No close handler registered for view_id: %d", uid) | |
| 661 | - | } | |
| 662 | - | } | |
| 663 | - | ||
| 664 | - | //export goCreateNewPanel | |
| 665 | - | func goCreateNewPanel(id C.ulong, curi *C.char) { | |
| 666 | - | uid := uintptr(id) | |
| 667 | - | regMu.RLock() | |
| 668 | - | vw := viewByID[uid] | |
| 669 | - | regMu.RUnlock() | |
| 670 | - | ||
| 671 | - | if vw == nil { | |
| 672 | - | log.Printf("[webkit] Create panel: no WebView found for ID %d", uid) | |
| 673 | - | return | |
| 674 | - | } | |
| 675 | - | ||
| 676 | - | uri := "" | |
| 677 | - | if curi != nil { | |
| 678 | - | uri = C.GoString(curi) | |
| 679 | - | } | |
| 680 | - | ||
| 681 | - | log.Printf("[webkit] Creating new panel for _blank target: %s", uri) | |
| 682 | - | ||
| 683 | - | // Create new panel through popup handler if available | |
| 684 | - | if vw.popupHandler != nil { | |
| 685 | - | vw.popupHandler(uri) | |
| 686 | - | log.Printf("[webkit] New panel creation handled by popup handler") | |
| 687 | - | } else { | |
| 688 | - | log.Printf("[webkit] No popup handler available for panel creation") | |
| 689 | - | } | |
| 690 | - | } | |
| 691 | - | ||
| 692 | - | //export goHandlePopupGeometry | |
| 693 | - | func goHandlePopupGeometry(id C.ulong, x, y, width, height C.int) { | |
| 694 | - | // Store geometry for workspace manager to use | |
| 695 | - | uid := uintptr(id) | |
| 696 | - | regMu.Lock() | |
| 697 | - | if vw := viewByID[uid]; vw != nil { | |
| 698 | - | // For now, we'll store this in a simple way | |
| 699 | - | // The workspace manager will handle the geometry | |
| 700 | - | log.Printf("[webkit] Popup geometry: %dx%d at (%d,%d)", | |
| 701 | - | int(width), int(height), int(x), int(y)) | |
| 702 | - | } | |
| 703 | - | regMu.Unlock() | |
| 704 | - | } | |
| 705 | - | ||
| 706 | - | //export goOnButtonPress | |
| 707 | - | func goOnButtonPress(id C.ulong, button C.guint, state C.GdkModifierType) { | |
| 708 | - | uid := uintptr(id) | |
| 709 | - | btn := uint(button) | |
| 710 | - | log.Printf("[mouse] button=%d state=0x%x", btn, uint(state)) | |
| 711 | - | regMu.RLock() | |
| 712 | - | vw := viewByID[uid] | |
| 713 | - | regMu.RUnlock() | |
| 714 | - | if vw == nil { | |
| 715 | - | return | |
| 716 | - | } | |
| 717 | - | switch btn { | |
| 718 | - | case 8: // Back button on many mice | |
| 719 | - | _ = vw.GoBack() | |
| 720 | - | case 9: // Forward button on many mice | |
| 721 | - | _ = vw.GoForward() | |
| 722 | - | } | |
| 723 | - | } | |
| 724 | - | ||
| 725 | - | //export goQuitMainLoop | |
| 726 | - | func goQuitMainLoop() { | |
| 727 | - | log.Printf("[webkit] Window close requested - quitting main loop") | |
| 728 | - | QuitMainLoop() | |
| 729 | - | } | |
| 730 | - | ||
| 731 | - | // handleRenderingBackendMessage processes rendering backend detection messages | |
| 732 | - | func handleRenderingBackendMessage(payload string) bool { | |
| 733 | - | // Parse JSON to check if this is a rendering backend detection message | |
| 734 | - | if !strings.Contains(payload, "rendering_backend_detection") { | |
| 735 | - | return false | |
| 736 | - | } | |
| 737 | - | ||
| 738 | - | // Simple JSON parsing for the specific structure we expect | |
| 739 | - | // {"type":"rendering_backend_detection","data":{...}} | |
| 740 | - | var msg struct { | |
| 741 | - | Type string `json:"type"` | |
| 742 | - | Data struct { | |
| 743 | - | WebGLAvailable bool `json:"webgl_available"` | |
| 744 | - | Renderer string `json:"renderer"` | |
| 745 | - | Vendor string `json:"vendor"` | |
| 746 | - | Version string `json:"version"` | |
| 747 | - | ShadingLanguageVersion string `json:"shading_language_version"` | |
| 748 | - | MaxTextureSize int `json:"max_texture_size"` | |
| 749 | - | HardwareAccelerated bool `json:"hardware_accelerated"` | |
| 750 | - | } `json:"data"` | |
| 751 | - | } | |
| 752 | - | ||
| 753 | - | if err := json.Unmarshal([]byte(payload), &msg); err != nil { | |
| 754 | - | log.Printf("[webkit] Failed to parse rendering backend message: %v", err) | |
| 755 | - | return false | |
| 756 | - | } | |
| 757 | - | ||
| 758 | - | if msg.Type != "rendering_backend_detection" { | |
| 759 | - | return false | |
| 760 | - | } | |
| 761 | - | ||
| 762 | - | // Log the detected rendering backend information | |
| 763 | - | backendType := "SOFTWARE" | |
| 764 | - | if msg.Data.HardwareAccelerated { | |
| 765 | - | backendType = "HARDWARE" | |
| 766 | - | } | |
| 767 | - | ||
| 768 | - | log.Printf("[webkit] Detected rendering backend: %s", backendType) | |
| 769 | - | log.Printf("[webkit] WebGL available: %v", msg.Data.WebGLAvailable) | |
| 770 | - | log.Printf("[webkit] Renderer: %s", msg.Data.Renderer) | |
| 771 | - | log.Printf("[webkit] Vendor: %s", msg.Data.Vendor) | |
| 772 | - | log.Printf("[webkit] Version: %s", msg.Data.Version) | |
| 773 | - | log.Printf("[webkit] Max texture size: %d", msg.Data.MaxTextureSize) | |
| 774 | - | ||
| 775 | - | return true | |
| 776 | - | } | |
| 777 | - | ||
| 778 | - | // handleKeyboardBlockingMessage handles keyboard blocking control messages from omnibox | |
| 779 | - | func handleKeyboardBlockingMessage(vw *WebView, payload string) bool { | |
| 780 | - | if vw == nil || vw.IsDestroyed() { | |
| 781 | - | return false | |
| 782 | - | } | |
| 783 | - | ||
| 784 | - | var msg struct { | |
| 785 | - | Type string `json:"type"` | |
| 786 | - | Action string `json:"action"` | |
| 787 | - | } | |
| 788 | - | ||
| 789 | - | if err := json.Unmarshal([]byte(payload), &msg); err != nil { | |
| 790 | - | return false | |
| 791 | - | } | |
| 792 | - | ||
| 793 | - | if msg.Type != "keyboard_blocking" { | |
| 794 | - | return false | |
| 795 | - | } | |
| 796 | - | ||
| 797 | - | switch msg.Action { | |
| 798 | - | case "enable": | |
| 799 | - | log.Printf("[webkit] Enabling page keyboard blocking for omnibox") | |
| 800 | - | if err := vw.EnablePageKeyboardBlocking(); err != nil { | |
| 801 | - | log.Printf("[webkit] Failed to enable keyboard blocking: %v", err) | |
| 802 | - | } | |
| 803 | - | case "disable": | |
| 804 | - | log.Printf("[webkit] Disabling page keyboard blocking") | |
| 805 | - | if err := vw.DisablePageKeyboardBlocking(); err != nil { | |
| 806 | - | log.Printf("[webkit] Failed to disable keyboard blocking: %v", err) | |
| 807 | - | } | |
| 808 | - | default: | |
| 809 | - | log.Printf("[webkit] Unknown keyboard blocking action: %s", msg.Action) | |
| 810 | - | } | |
| 811 | - | ||
| 812 | - | return true | |
| 813 | - | } | |
D pkg/webkit/log_capture.go +0 -125
| @@ -1,125 +0,0 @@ | |||
| 1 | - | //go:build webkit_cgo | |
| 2 | - | ||
| 3 | - | package webkit | |
| 4 | - | ||
| 5 | - | /* | |
| 6 | - | #include "log_capture.h" | |
| 7 | - | */ | |
| 8 | - | import "C" | |
| 9 | - | ||
| 10 | - | import ( | |
| 11 | - | "time" | |
| 12 | - | "unsafe" | |
| 13 | - | ||
| 14 | - | "github.com/bnema/dumber/internal/logging" | |
| 15 | - | ) | |
| 16 | - | ||
| 17 | - | var webkitCaptureActive = false | |
| 18 | - | var webkitCaptureChan chan struct{} | |
| 19 | - | ||
| 20 | - | //export goWebKitLogHandler | |
| 21 | - | func goWebKitLogHandler(domain *C.char, level C.int, message *C.char) { | |
| 22 | - | goDomain := C.GoString(domain) | |
| 23 | - | goMessage := C.GoString(message) | |
| 24 | - | ||
| 25 | - | var levelStr string | |
| 26 | - | switch level { | |
| 27 | - | case C.G_LOG_LEVEL_ERROR: | |
| 28 | - | levelStr = "ERROR" | |
| 29 | - | case C.G_LOG_LEVEL_CRITICAL: | |
| 30 | - | levelStr = "CRITICAL" | |
| 31 | - | case C.G_LOG_LEVEL_WARNING: | |
| 32 | - | levelStr = "WARNING" | |
| 33 | - | case C.G_LOG_LEVEL_MESSAGE: | |
| 34 | - | levelStr = "MESSAGE" | |
| 35 | - | case C.G_LOG_LEVEL_INFO: | |
| 36 | - | levelStr = "INFO" | |
| 37 | - | case C.G_LOG_LEVEL_DEBUG: | |
| 38 | - | levelStr = "DEBUG" | |
| 39 | - | default: | |
| 40 | - | levelStr = "UNKNOWN" | |
| 41 | - | } | |
| 42 | - | ||
| 43 | - | logMessage := goMessage | |
| 44 | - | if goDomain != "" { | |
| 45 | - | logMessage = "[" + goDomain + "] " + goMessage | |
| 46 | - | } | |
| 47 | - | ||
| 48 | - | // Send to logging system with appropriate level | |
| 49 | - | if logger := logging.GetLogger(); logger != nil { | |
| 50 | - | logger.WriteTagged("WEBKIT-"+levelStr, logMessage) | |
| 51 | - | } | |
| 52 | - | } | |
| 53 | - | ||
| 54 | - | // InitWebKitLogCapture initializes the WebKit log capture system | |
| 55 | - | func InitWebKitLogCapture() error { | |
| 56 | - | if webkitCaptureActive { | |
| 57 | - | return nil | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | // Initialize C-level log capture | |
| 61 | - | C.webkit_init_log_capture() | |
| 62 | - | ||
| 63 | - | // Set up GLib log handlers | |
| 64 | - | C.webkit_setup_glib_log_handlers() | |
| 65 | - | ||
| 66 | - | // Create channel for stopping capture | |
| 67 | - | webkitCaptureChan = make(chan struct{}) | |
| 68 | - | ||
| 69 | - | // Start goroutine to read captured C output | |
| 70 | - | go webkitLogReader() | |
| 71 | - | ||
| 72 | - | webkitCaptureActive = true | |
| 73 | - | return nil | |
| 74 | - | } | |
| 75 | - | ||
| 76 | - | // StopWebKitLogCapture stops the WebKit log capture | |
| 77 | - | func StopWebKitLogCapture() { | |
| 78 | - | if !webkitCaptureActive { | |
| 79 | - | return | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | // Signal the reader goroutine to stop | |
| 83 | - | close(webkitCaptureChan) | |
| 84 | - | ||
| 85 | - | // Stop C-level capture | |
| 86 | - | C.webkit_stop_log_capture() | |
| 87 | - | ||
| 88 | - | webkitCaptureActive = false | |
| 89 | - | } | |
| 90 | - | ||
| 91 | - | // webkitLogReader reads captured C output in a goroutine | |
| 92 | - | func webkitLogReader() { | |
| 93 | - | buffer := make([]byte, 4096) | |
| 94 | - | ||
| 95 | - | for { | |
| 96 | - | select { | |
| 97 | - | case <-webkitCaptureChan: | |
| 98 | - | return | |
| 99 | - | default: | |
| 100 | - | // Try to read captured output | |
| 101 | - | n := int(C.webkit_read_captured_log((*C.char)(unsafe.Pointer(&buffer[0])), C.int(len(buffer)))) | |
| 102 | - | if n > 0 { | |
| 103 | - | message := string(buffer[:n]) | |
| 104 | - | if logger := logging.GetLogger(); logger != nil { | |
| 105 | - | logger.WriteTagged("WEBKIT-PRINTF", message) | |
| 106 | - | } | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | // Small delay to prevent busy waiting | |
| 110 | - | time.Sleep(10 * time.Millisecond) | |
| 111 | - | } | |
| 112 | - | } | |
| 113 | - | } | |
| 114 | - | ||
| 115 | - | // StartWebKitOutputCapture starts capturing webkit stdout/stderr | |
| 116 | - | func StartWebKitOutputCapture() { | |
| 117 | - | if webkitCaptureActive { | |
| 118 | - | C.webkit_start_log_capture() | |
| 119 | - | } | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | // IsWebKitLogCaptureActive returns whether WebKit log capture is active | |
| 123 | - | func IsWebKitLogCaptureActive() bool { | |
| 124 | - | return webkitCaptureActive | |
| 125 | - | } | |
D pkg/webkit/log_capture.h +0 -108
| @@ -1,108 +0,0 @@ | |||
| 1 | - | #ifndef WEBKIT_LOG_CAPTURE_H | |
| 2 | - | #define WEBKIT_LOG_CAPTURE_H | |
| 3 | - | ||
| 4 | - | #include <stdio.h> | |
| 5 | - | #include <stdlib.h> | |
| 6 | - | #include <string.h> | |
| 7 | - | #include <unistd.h> | |
| 8 | - | #include <glib.h> | |
| 9 | - | ||
| 10 | - | static int webkit_log_pipe[2]; | |
| 11 | - | static FILE* webkit_original_stdout; | |
| 12 | - | static FILE* webkit_original_stderr; | |
| 13 | - | static gboolean webkit_capture_enabled = FALSE; | |
| 14 | - | ||
| 15 | - | // Initialize log capture pipes | |
| 16 | - | static inline void webkit_init_log_capture() { | |
| 17 | - | if (webkit_capture_enabled) return; | |
| 18 | - | ||
| 19 | - | // Create pipe for capturing C output | |
| 20 | - | if (pipe(webkit_log_pipe) == 0) { | |
| 21 | - | // Duplicate stdout/stderr for restoration later | |
| 22 | - | webkit_original_stdout = fdopen(dup(1), "w"); | |
| 23 | - | webkit_original_stderr = fdopen(dup(2), "w"); | |
| 24 | - | webkit_capture_enabled = TRUE; | |
| 25 | - | ||
| 26 | - | g_debug("WebKit log capture initialized"); | |
| 27 | - | } else { | |
| 28 | - | g_warning("Failed to initialize WebKit log capture"); | |
| 29 | - | } | |
| 30 | - | } | |
| 31 | - | ||
| 32 | - | // Start capturing WebKit output | |
| 33 | - | static inline void webkit_start_log_capture() { | |
| 34 | - | if (!webkit_capture_enabled) return; | |
| 35 | - | ||
| 36 | - | // Redirect stdout/stderr to pipe (for C printf statements) | |
| 37 | - | dup2(webkit_log_pipe[1], 1); | |
| 38 | - | dup2(webkit_log_pipe[1], 2); | |
| 39 | - | } | |
| 40 | - | ||
| 41 | - | // Stop capturing and restore original output | |
| 42 | - | static inline void webkit_stop_log_capture() { | |
| 43 | - | if (!webkit_capture_enabled) return; | |
| 44 | - | ||
| 45 | - | // Restore original stdout/stderr | |
| 46 | - | if (webkit_original_stdout) { | |
| 47 | - | dup2(fileno(webkit_original_stdout), 1); | |
| 48 | - | fclose(webkit_original_stdout); | |
| 49 | - | } | |
| 50 | - | if (webkit_original_stderr) { | |
| 51 | - | dup2(fileno(webkit_original_stderr), 2); | |
| 52 | - | fclose(webkit_original_stderr); | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | close(webkit_log_pipe[0]); | |
| 56 | - | close(webkit_log_pipe[1]); | |
| 57 | - | webkit_capture_enabled = FALSE; | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | // Read captured output (non-blocking) | |
| 61 | - | static inline int webkit_read_captured_log(char* buffer, int buffer_size) { | |
| 62 | - | if (!webkit_capture_enabled) return 0; | |
| 63 | - | ||
| 64 | - | fd_set read_fds; | |
| 65 | - | struct timeval timeout; | |
| 66 | - | ||
| 67 | - | FD_ZERO(&read_fds); | |
| 68 | - | FD_SET(webkit_log_pipe[0], &read_fds); | |
| 69 | - | ||
| 70 | - | // Non-blocking check | |
| 71 | - | timeout.tv_sec = 0; | |
| 72 | - | timeout.tv_usec = 0; | |
| 73 | - | ||
| 74 | - | int result = select(webkit_log_pipe[0] + 1, &read_fds, NULL, NULL, &timeout); | |
| 75 | - | if (result > 0 && FD_ISSET(webkit_log_pipe[0], &read_fds)) { | |
| 76 | - | int n = read(webkit_log_pipe[0], buffer, buffer_size - 1); | |
| 77 | - | if (n > 0) { | |
| 78 | - | buffer[n] = '\0'; | |
| 79 | - | return n; | |
| 80 | - | } | |
| 81 | - | } | |
| 82 | - | return 0; | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | // GLib log handler to capture GLib/GTK/WebKit messages | |
| 86 | - | static inline void webkit_glib_log_handler(const gchar *log_domain, | |
| 87 | - | GLogLevelFlags log_level, | |
| 88 | - | const gchar *message, | |
| 89 | - | gpointer user_data) { | |
| 90 | - | // Forward to Go callback | |
| 91 | - | extern void goWebKitLogHandler(char* domain, int level, char* message); | |
| 92 | - | goWebKitLogHandler((char*)log_domain, (int)log_level, (char*)message); | |
| 93 | - | } | |
| 94 | - | ||
| 95 | - | // Set up GLib log handlers for various domains | |
| 96 | - | static inline void webkit_setup_glib_log_handlers() { | |
| 97 | - | // Set up log handlers for WebKit and GTK domains | |
| 98 | - | g_log_set_handler("WebKit", G_LOG_LEVEL_MASK | G_LOG_FLAG_FATAL | G_LOG_FLAG_RECURSION, | |
| 99 | - | webkit_glib_log_handler, NULL); | |
| 100 | - | g_log_set_handler("Gtk", G_LOG_LEVEL_MASK | G_LOG_FLAG_FATAL | G_LOG_FLAG_RECURSION, | |
| 101 | - | webkit_glib_log_handler, NULL); | |
| 102 | - | g_log_set_handler("GLib", G_LOG_LEVEL_MASK | G_LOG_FLAG_FATAL | G_LOG_FLAG_RECURSION, | |
| 103 | - | webkit_glib_log_handler, NULL); | |
| 104 | - | g_log_set_handler(NULL, G_LOG_LEVEL_MASK | G_LOG_FLAG_FATAL | G_LOG_FLAG_RECURSION, | |
| 105 | - | webkit_glib_log_handler, NULL); | |
| 106 | - | } | |
| 107 | - | ||
| 108 | - | #endif // WEBKIT_LOG_CAPTURE_H⏎̸ | |
D pkg/webkit/log_capture_stub.go +0 -24
| @@ -1,24 +0,0 @@ | |||
| 1 | - | //go:build !webkit_cgo | |
| 2 | - | ||
| 3 | - | package webkit | |
| 4 | - | ||
| 5 | - | // InitWebKitLogCapture initializes the WebKit log capture system (stub version) | |
| 6 | - | func InitWebKitLogCapture() error { | |
| 7 | - | // No-op for stub version | |
| 8 | - | return nil | |
| 9 | - | } | |
| 10 | - | ||
| 11 | - | // StopWebKitLogCapture stops the WebKit log capture (stub version) | |
| 12 | - | func StopWebKitLogCapture() { | |
| 13 | - | // No-op for stub version | |
| 14 | - | } | |
| 15 | - | ||
| 16 | - | // StartWebKitOutputCapture starts capturing webkit stdout/stderr (stub version) | |
| 17 | - | func StartWebKitOutputCapture() { | |
| 18 | - | // No-op for stub version | |
| 19 | - | } | |
| 20 | - | ||
| 21 | - | // IsWebKitLogCaptureActive returns whether WebKit log capture is active (stub version) | |
| 22 | - | func IsWebKitLogCaptureActive() bool { | |
| 23 | - | return false | |
| 24 | - | } | |
A pkg/webkit/logging.go +29 -0
| @@ -0,0 +1,29 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | // Logging and debugging functions for WebKit | |
| 4 | + | ||
| 5 | + | // InitWebKitLogCapture initializes WebKit log capture | |
| 6 | + | // In gotk4, WebKit logging is handled differently | |
| 7 | + | func InitWebKitLogCapture() error { | |
| 8 | + | // WebKit logging in gotk4 uses environment variables: | |
| 9 | + | // WEBKIT_DEBUG for debug output | |
| 10 | + | // G_MESSAGES_DEBUG for GLib messages | |
| 11 | + | return nil | |
| 12 | + | } | |
| 13 | + | ||
| 14 | + | // StopWebKitLogCapture stops WebKit log capture | |
| 15 | + | func StopWebKitLogCapture() { | |
| 16 | + | // No-op in gotk4 - logging is controlled via environment variables | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // StartWebKitOutputCapture starts capturing WebKit output | |
| 20 | + | func StartWebKitOutputCapture() { | |
| 21 | + | // No-op in gotk4 | |
| 22 | + | } | |
| 23 | + | ||
| 24 | + | // SetupWebKitDebugLogging enables WebKit debug logging | |
| 25 | + | func SetupWebKitDebugLogging() { | |
| 26 | + | // In gotk4, enable debugging via environment variables before starting the app: | |
| 27 | + | // export WEBKIT_DEBUG=all | |
| 28 | + | // export G_MESSAGES_DEBUG=all | |
| 29 | + | } | |
D pkg/webkit/loop.go +0 -13
| @@ -1,13 +0,0 @@ | |||
| 1 | - | //go:build !webkit_cgo | |
| 2 | - | ||
| 3 | - | package webkit | |
| 4 | - | ||
| 5 | - | import "log" | |
| 6 | - | ||
| 7 | - | // RunMainLoop is a no-op in non-CGO builds. | |
| 8 | - | func RunMainLoop() { | |
| 9 | - | log.Printf("[webkit] RunMainLoop (non-CGO): no GUI loop available") | |
| 10 | - | } | |
| 11 | - | ||
| 12 | - | // QuitMainLoop is a no-op in non-CGO builds. | |
| 13 | - | func QuitMainLoop() {} | |
D pkg/webkit/loop_cgo.go +0 -27
| @@ -1,27 +0,0 @@ | |||
| 1 | - | //go:build webkit_cgo | |
| 2 | - | ||
| 3 | - | package webkit | |
| 4 | - | ||
| 5 | - | /* | |
| 6 | - | #cgo pkg-config: gtk4 | |
| 7 | - | #include <gtk/gtk.h> | |
| 8 | - | #include <glib.h> | |
| 9 | - | */ | |
| 10 | - | import "C" | |
| 11 | - | ||
| 12 | - | var mainLoop *C.GMainLoop | |
| 13 | - | ||
| 14 | - | // RunMainLoop enters the GLib main loop (GTK4) and blocks until quit. | |
| 15 | - | func RunMainLoop() { | |
| 16 | - | if mainLoop == nil { | |
| 17 | - | mainLoop = C.g_main_loop_new(nil, C.gboolean(0)) | |
| 18 | - | } | |
| 19 | - | C.g_main_loop_run(mainLoop) | |
| 20 | - | } | |
| 21 | - | ||
| 22 | - | // QuitMainLoop requests the main loop to exit. | |
| 23 | - | func QuitMainLoop() { | |
| 24 | - | if mainLoop != nil { | |
| 25 | - | C.g_main_loop_quit(mainLoop) | |
| 26 | - | } | |
| 27 | - | } | |
A pkg/webkit/mainloop.go +67 -0
| @@ -0,0 +1,67 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "runtime" | |
| 5 | + | ||
| 6 | + | "github.com/diamondburned/gotk4/pkg/glib/v2" | |
| 7 | + | "github.com/diamondburned/gotk4/pkg/gtk/v4" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | var ( | |
| 11 | + | mainLoop *glib.MainLoop | |
| 12 | + | mainThreadID = runtime.LockOSThread // Store the main thread | |
| 13 | + | isInitialized bool | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // InitMainThread locks the current goroutine to the OS thread for GTK operations | |
| 17 | + | // and initializes GTK. This must be called before any GTK operations. | |
| 18 | + | func InitMainThread() { | |
| 19 | + | if !isInitialized { | |
| 20 | + | runtime.LockOSThread() | |
| 21 | + | ||
| 22 | + | // Initialize GTK - this is required before creating any GTK widgets | |
| 23 | + | gtk.Init() | |
| 24 | + | ||
| 25 | + | isInitialized = true | |
| 26 | + | } | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | // RunMainLoop starts the GTK main event loop. | |
| 30 | + | // This function blocks until QuitMainLoop is called. | |
| 31 | + | func RunMainLoop() { | |
| 32 | + | InitMainThread() | |
| 33 | + | ||
| 34 | + | if mainLoop == nil { | |
| 35 | + | mainLoop = glib.NewMainLoop(nil, false) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | mainLoop.Run() | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | // QuitMainLoop stops the GTK main event loop. | |
| 42 | + | func QuitMainLoop() { | |
| 43 | + | if mainLoop != nil { | |
| 44 | + | mainLoop.Quit() | |
| 45 | + | } | |
| 46 | + | } | |
| 47 | + | ||
| 48 | + | // IsMainThread returns true if called from the GTK main thread. | |
| 49 | + | func IsMainThread() bool { | |
| 50 | + | // In gotk4, we're always on the main thread since we lock it | |
| 51 | + | return isInitialized | |
| 52 | + | } | |
| 53 | + | ||
| 54 | + | // RunOnMainThread executes a function on the GTK main thread. | |
| 55 | + | // If already on the main thread, executes immediately. | |
| 56 | + | // Otherwise, schedules the function via glib.IdleAdd. | |
| 57 | + | func RunOnMainThread(fn func()) { | |
| 58 | + | if IsMainThread() { | |
| 59 | + | fn() | |
| 60 | + | return | |
| 61 | + | } | |
| 62 | + | ||
| 63 | + | glib.IdleAdd(func() bool { | |
| 64 | + | fn() | |
| 65 | + | return false // Remove the idle handler after execution | |
| 66 | + | }) | |
| 67 | + | } | |
D pkg/webkit/memory.go +0 -286
| @@ -1,286 +0,0 @@ | |||
| 1 | - | //go:build webkit_cgo | |
| 2 | - | ||
| 3 | - | package webkit | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "fmt" | |
| 7 | - | "log" | |
| 8 | - | "os" | |
| 9 | - | "strconv" | |
| 10 | - | "strings" | |
| 11 | - | "time" | |
| 12 | - | ) | |
| 13 | - | ||
| 14 | - | // ProcessMemoryInfo contains memory information for a WebKit process | |
| 15 | - | type ProcessMemoryInfo struct { | |
| 16 | - | PID int `json:"pid"` | |
| 17 | - | VmRSS int64 `json:"vm_rss"` // Resident Set Size in KB | |
| 18 | - | VmSize int64 `json:"vm_size"` // Virtual Memory Size in KB | |
| 19 | - | VmPeak int64 `json:"vm_peak"` // Peak Virtual Memory in KB | |
| 20 | - | ProcessName string `json:"process_name"` | |
| 21 | - | } | |
| 22 | - | ||
| 23 | - | // WebViewMemoryManager handles memory monitoring and lifecycle management for WebViews | |
| 24 | - | type WebViewMemoryManager struct { | |
| 25 | - | views map[uintptr]*WebView | |
| 26 | - | enableMonitoring bool | |
| 27 | - | recycleThreshold int | |
| 28 | - | monitoringInterval time.Duration | |
| 29 | - | stopMonitoring chan struct{} | |
| 30 | - | lastLoggedMemory int64 | |
| 31 | - | } | |
| 32 | - | ||
| 33 | - | // NewWebViewMemoryManager creates a new memory manager | |
| 34 | - | func NewWebViewMemoryManager(enableMonitoring bool, recycleThreshold int, monitoringInterval time.Duration) *WebViewMemoryManager { | |
| 35 | - | return &WebViewMemoryManager{ | |
| 36 | - | views: make(map[uintptr]*WebView), | |
| 37 | - | enableMonitoring: enableMonitoring, | |
| 38 | - | recycleThreshold: recycleThreshold, | |
| 39 | - | monitoringInterval: monitoringInterval, | |
| 40 | - | stopMonitoring: make(chan struct{}), | |
| 41 | - | } | |
| 42 | - | } | |
| 43 | - | ||
| 44 | - | // RegisterWebView registers a WebView for monitoring | |
| 45 | - | func (mgr *WebViewMemoryManager) RegisterWebView(view *WebView) { | |
| 46 | - | if mgr == nil || view == nil { | |
| 47 | - | return | |
| 48 | - | } | |
| 49 | - | mgr.views[view.id] = view | |
| 50 | - | ||
| 51 | - | if mgr.enableMonitoring && len(mgr.views) == 1 { | |
| 52 | - | // Start monitoring when first view is registered | |
| 53 | - | go mgr.startMemoryMonitoring() | |
| 54 | - | } | |
| 55 | - | } | |
| 56 | - | ||
| 57 | - | // UnregisterWebView removes a WebView from monitoring | |
| 58 | - | func (mgr *WebViewMemoryManager) UnregisterWebView(viewID uintptr) { | |
| 59 | - | if mgr == nil { | |
| 60 | - | return | |
| 61 | - | } | |
| 62 | - | delete(mgr.views, viewID) | |
| 63 | - | ||
| 64 | - | if len(mgr.views) == 0 { | |
| 65 | - | // Stop monitoring when no views left | |
| 66 | - | close(mgr.stopMonitoring) | |
| 67 | - | mgr.stopMonitoring = make(chan struct{}) | |
| 68 | - | } | |
| 69 | - | } | |
| 70 | - | ||
| 71 | - | // startMemoryMonitoring starts the background memory monitoring routine | |
| 72 | - | func (mgr *WebViewMemoryManager) startMemoryMonitoring() { | |
| 73 | - | if mgr == nil || mgr.monitoringInterval <= 0 { | |
| 74 | - | return | |
| 75 | - | } | |
| 76 | - | ||
| 77 | - | ticker := time.NewTicker(mgr.monitoringInterval) | |
| 78 | - | defer ticker.Stop() | |
| 79 | - | ||
| 80 | - | for { | |
| 81 | - | select { | |
| 82 | - | case <-ticker.C: | |
| 83 | - | mgr.checkMemoryUsage() | |
| 84 | - | case <-mgr.stopMonitoring: | |
| 85 | - | return | |
| 86 | - | } | |
| 87 | - | } | |
| 88 | - | } | |
| 89 | - | ||
| 90 | - | // checkMemoryUsage monitors memory usage of WebKit processes | |
| 91 | - | func (mgr *WebViewMemoryManager) checkMemoryUsage() { | |
| 92 | - | if mgr == nil { | |
| 93 | - | return | |
| 94 | - | } | |
| 95 | - | ||
| 96 | - | processes, err := mgr.getWebKitProcesses() | |
| 97 | - | if err != nil { | |
| 98 | - | if mgr.enableMonitoring { | |
| 99 | - | log.Printf("[webkit] Error monitoring processes: %v", err) | |
| 100 | - | } | |
| 101 | - | return | |
| 102 | - | } | |
| 103 | - | ||
| 104 | - | totalMemory := int64(0) | |
| 105 | - | for _, proc := range processes { | |
| 106 | - | totalMemory += proc.VmRSS | |
| 107 | - | } | |
| 108 | - | ||
| 109 | - | // Only log if memory usage changed by more than 10MB (10240 KB) | |
| 110 | - | const memoryChangeThreshold = 10240 // 10MB in KB | |
| 111 | - | if mgr.enableMonitoring { | |
| 112 | - | memoryDiff := totalMemory - mgr.lastLoggedMemory | |
| 113 | - | if memoryDiff < 0 { | |
| 114 | - | memoryDiff = -memoryDiff | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | if mgr.lastLoggedMemory == 0 || memoryDiff >= memoryChangeThreshold { | |
| 118 | - | log.Printf("[webkit] Memory usage: %d processes, total %.1f MB RSS", | |
| 119 | - | len(processes), float64(totalMemory)/1024.0) | |
| 120 | - | mgr.lastLoggedMemory = totalMemory | |
| 121 | - | } | |
| 122 | - | } | |
| 123 | - | ||
| 124 | - | // Check for recycling needs | |
| 125 | - | for _, view := range mgr.views { | |
| 126 | - | if view.memStats != nil && mgr.recycleThreshold > 0 { | |
| 127 | - | if view.memStats.pageLoadCount >= mgr.recycleThreshold { | |
| 128 | - | if mgr.enableMonitoring { | |
| 129 | - | log.Printf("[webkit] WebView %d needs recycling (%d pages loaded)", | |
| 130 | - | view.id, view.memStats.pageLoadCount) | |
| 131 | - | } | |
| 132 | - | } | |
| 133 | - | } | |
| 134 | - | } | |
| 135 | - | } | |
| 136 | - | ||
| 137 | - | // getWebKitProcesses returns memory info for all WebKit-related processes | |
| 138 | - | func (mgr *WebViewMemoryManager) getWebKitProcesses() ([]ProcessMemoryInfo, error) { | |
| 139 | - | var processes []ProcessMemoryInfo | |
| 140 | - | ||
| 141 | - | // Find all WebKit processes by scanning /proc | |
| 142 | - | procDir, err := os.ReadDir("/proc") | |
| 143 | - | if err != nil { | |
| 144 | - | return nil, fmt.Errorf("failed to read /proc: %w", err) | |
| 145 | - | } | |
| 146 | - | ||
| 147 | - | for _, entry := range procDir { | |
| 148 | - | if !entry.IsDir() { | |
| 149 | - | continue | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | pid, err := strconv.Atoi(entry.Name()) | |
| 153 | - | if err != nil { | |
| 154 | - | continue // Not a PID directory | |
| 155 | - | } | |
| 156 | - | ||
| 157 | - | // Check if this is a WebKit process | |
| 158 | - | cmdlinePath := fmt.Sprintf("/proc/%d/cmdline", pid) | |
| 159 | - | cmdlineBytes, err := os.ReadFile(cmdlinePath) | |
| 160 | - | if err != nil { | |
| 161 | - | continue // Process might have exited | |
| 162 | - | } | |
| 163 | - | ||
| 164 | - | cmdline := string(cmdlineBytes) | |
| 165 | - | if !strings.Contains(cmdline, "WebKit") && !strings.Contains(cmdline, "dumber") { | |
| 166 | - | continue // Not a WebKit or dumber process | |
| 167 | - | } | |
| 168 | - | ||
| 169 | - | // Get memory information | |
| 170 | - | statusPath := fmt.Sprintf("/proc/%d/status", pid) | |
| 171 | - | statusData, err := os.ReadFile(statusPath) | |
| 172 | - | if err != nil { | |
| 173 | - | continue | |
| 174 | - | } | |
| 175 | - | ||
| 176 | - | memInfo := mgr.parseMemoryStatus(string(statusData), pid) | |
| 177 | - | if memInfo != nil { | |
| 178 | - | // Extract process name from cmdline | |
| 179 | - | parts := strings.Split(cmdline, "\x00") | |
| 180 | - | if len(parts) > 0 { | |
| 181 | - | memInfo.ProcessName = parts[0] | |
| 182 | - | } | |
| 183 | - | processes = append(processes, *memInfo) | |
| 184 | - | } | |
| 185 | - | } | |
| 186 | - | ||
| 187 | - | return processes, nil | |
| 188 | - | } | |
| 189 | - | ||
| 190 | - | // parseMemoryStatus parses /proc/[pid]/status for memory information | |
| 191 | - | func (mgr *WebViewMemoryManager) parseMemoryStatus(status string, pid int) *ProcessMemoryInfo { | |
| 192 | - | lines := strings.Split(status, "\n") | |
| 193 | - | info := &ProcessMemoryInfo{PID: pid} | |
| 194 | - | ||
| 195 | - | for _, line := range lines { | |
| 196 | - | if strings.HasPrefix(line, "VmRSS:") { | |
| 197 | - | if val := mgr.extractMemoryValue(line); val > 0 { | |
| 198 | - | info.VmRSS = val | |
| 199 | - | } | |
| 200 | - | } else if strings.HasPrefix(line, "VmSize:") { | |
| 201 | - | if val := mgr.extractMemoryValue(line); val > 0 { | |
| 202 | - | info.VmSize = val | |
| 203 | - | } | |
| 204 | - | } else if strings.HasPrefix(line, "VmPeak:") { | |
| 205 | - | if val := mgr.extractMemoryValue(line); val > 0 { | |
| 206 | - | info.VmPeak = val | |
| 207 | - | } | |
| 208 | - | } | |
| 209 | - | } | |
| 210 | - | ||
| 211 | - | // Only return if we found at least VmRSS | |
| 212 | - | if info.VmRSS > 0 { | |
| 213 | - | return info | |
| 214 | - | } | |
| 215 | - | ||
| 216 | - | return nil | |
| 217 | - | } | |
| 218 | - | ||
| 219 | - | // extractMemoryValue extracts memory value in KB from status lines like "VmRSS: 12345 kB" | |
| 220 | - | func (mgr *WebViewMemoryManager) extractMemoryValue(line string) int64 { | |
| 221 | - | parts := strings.Fields(line) | |
| 222 | - | if len(parts) >= 2 { | |
| 223 | - | val, err := strconv.ParseInt(parts[1], 10, 64) | |
| 224 | - | if err == nil { | |
| 225 | - | return val | |
| 226 | - | } | |
| 227 | - | } | |
| 228 | - | return 0 | |
| 229 | - | } | |
| 230 | - | ||
| 231 | - | // GetTotalMemoryUsage returns total memory usage of all WebKit processes in MB | |
| 232 | - | func (mgr *WebViewMemoryManager) GetTotalMemoryUsage() (float64, error) { | |
| 233 | - | if mgr == nil { | |
| 234 | - | return 0, fmt.Errorf("memory manager is nil") | |
| 235 | - | } | |
| 236 | - | ||
| 237 | - | processes, err := mgr.getWebKitProcesses() | |
| 238 | - | if err != nil { | |
| 239 | - | return 0, err | |
| 240 | - | } | |
| 241 | - | ||
| 242 | - | totalKB := int64(0) | |
| 243 | - | for _, proc := range processes { | |
| 244 | - | totalKB += proc.VmRSS | |
| 245 | - | } | |
| 246 | - | ||
| 247 | - | return float64(totalKB) / 1024.0, nil | |
| 248 | - | } | |
| 249 | - | ||
| 250 | - | // GetDetailedMemoryInfo returns detailed memory information for all WebKit processes | |
| 251 | - | func (mgr *WebViewMemoryManager) GetDetailedMemoryInfo() ([]ProcessMemoryInfo, error) { | |
| 252 | - | if mgr == nil { | |
| 253 | - | return nil, fmt.Errorf("memory manager is nil") | |
| 254 | - | } | |
| 255 | - | ||
| 256 | - | return mgr.getWebKitProcesses() | |
| 257 | - | } | |
| 258 | - | ||
| 259 | - | // ShouldRecycleWebView checks if a WebView should be recycled based on memory pressure | |
| 260 | - | func (mgr *WebViewMemoryManager) ShouldRecycleWebView(view *WebView) bool { | |
| 261 | - | if mgr == nil || view == nil || view.memStats == nil { | |
| 262 | - | return false | |
| 263 | - | } | |
| 264 | - | ||
| 265 | - | if mgr.recycleThreshold > 0 && view.memStats.pageLoadCount >= mgr.recycleThreshold { | |
| 266 | - | return true | |
| 267 | - | } | |
| 268 | - | ||
| 269 | - | return false | |
| 270 | - | } | |
| 271 | - | ||
| 272 | - | // Global memory manager instance | |
| 273 | - | var globalMemoryManager *WebViewMemoryManager | |
| 274 | - | ||
| 275 | - | // InitializeGlobalMemoryManager initializes the global memory manager | |
| 276 | - | func InitializeGlobalMemoryManager(enableMonitoring bool, recycleThreshold int, monitoringInterval time.Duration) { | |
| 277 | - | if monitoringInterval == 0 { | |
| 278 | - | monitoringInterval = 30 * time.Second // Default 30 seconds | |
| 279 | - | } | |
| 280 | - | globalMemoryManager = NewWebViewMemoryManager(enableMonitoring, recycleThreshold, monitoringInterval) | |
| 281 | - | } | |
| 282 | - | ||
| 283 | - | // GetGlobalMemoryManager returns the global memory manager | |
| 284 | - | func GetGlobalMemoryManager() *WebViewMemoryManager { | |
| 285 | - | return globalMemoryManager | |
| 286 | - | } | |
D pkg/webkit/memory_example.go +0 -189
| @@ -1,189 +0,0 @@ | |||
| 1 | - | package webkit | |
| 2 | - | ||
| 3 | - | // GetMemoryOptimizedConfig returns a Config optimized for low memory usage | |
| 4 | - | // This configuration aims to reduce memory usage by 40-60% compared to defaults | |
| 5 | - | func GetMemoryOptimizedConfig() *Config { | |
| 6 | - | return &Config{ | |
| 7 | - | Memory: MemoryConfig{ | |
| 8 | - | // Limit each WebView to 256MB (default: unlimited) | |
| 9 | - | MemoryLimitMB: 256, | |
| 10 | - | ||
| 11 | - | // Aggressive memory pressure thresholds | |
| 12 | - | ConservativeThreshold: 0.2, // Trigger cleanup at 20% (default: 33%) | |
| 13 | - | StrictThreshold: 0.35, // Aggressive cleanup at 35% (default: 50%) | |
| 14 | - | KillThreshold: 0.8, // Kill process at 80% to prevent OOM | |
| 15 | - | ||
| 16 | - | // Check memory usage every 2 seconds | |
| 17 | - | PollIntervalSeconds: 2.0, | |
| 18 | - | ||
| 19 | - | // Use minimal caching for lowest memory usage | |
| 20 | - | CacheModel: CacheModelDocumentViewer, | |
| 21 | - | ||
| 22 | - | // Disable memory-intensive caches | |
| 23 | - | EnablePageCache: false, // Saves ~30-50MB but slower back/forward | |
| 24 | - | EnableOfflineAppCache: false, // Saves ~10-20MB | |
| 25 | - | ||
| 26 | - | // Recycle WebView after 50 page loads to prevent memory accumulation | |
| 27 | - | ProcessRecycleThreshold: 50, | |
| 28 | - | ||
| 29 | - | // Trigger JavaScript GC every 60 seconds | |
| 30 | - | EnableGCInterval: 60, | |
| 31 | - | ||
| 32 | - | // Enable detailed memory monitoring and logging | |
| 33 | - | EnableMemoryMonitoring: true, | |
| 34 | - | }, | |
| 35 | - | ||
| 36 | - | // Disable developer tools to save memory (unless debugging) | |
| 37 | - | EnableDeveloperExtras: false, | |
| 38 | - | ||
| 39 | - | // Use CPU rendering to save GPU memory (trade-off: slower rendering) | |
| 40 | - | Rendering: RenderingConfig{ | |
| 41 | - | Mode: "cpu", | |
| 42 | - | }, | |
| 43 | - | } | |
| 44 | - | } | |
| 45 | - | ||
| 46 | - | // GetBalancedConfig returns a Config with moderate memory optimizations | |
| 47 | - | // This configuration aims to reduce memory usage by 20-30% with minimal performance impact | |
| 48 | - | func GetBalancedConfig() *Config { | |
| 49 | - | return &Config{ | |
| 50 | - | Memory: MemoryConfig{ | |
| 51 | - | // Limit each WebView to 512MB | |
| 52 | - | MemoryLimitMB: 512, | |
| 53 | - | ||
| 54 | - | // Moderate memory pressure thresholds | |
| 55 | - | ConservativeThreshold: 0.25, | |
| 56 | - | StrictThreshold: 0.4, | |
| 57 | - | KillThreshold: 0.7, | |
| 58 | - | ||
| 59 | - | PollIntervalSeconds: 5.0, | |
| 60 | - | ||
| 61 | - | // Use default web browser caching | |
| 62 | - | CacheModel: CacheModelWebBrowser, | |
| 63 | - | ||
| 64 | - | // Keep page cache but disable offline app cache | |
| 65 | - | EnablePageCache: true, | |
| 66 | - | EnableOfflineAppCache: false, | |
| 67 | - | ||
| 68 | - | // Recycle after more page loads | |
| 69 | - | ProcessRecycleThreshold: 100, | |
| 70 | - | ||
| 71 | - | // Less frequent GC | |
| 72 | - | EnableGCInterval: 120, | |
| 73 | - | ||
| 74 | - | EnableMemoryMonitoring: true, | |
| 75 | - | }, | |
| 76 | - | ||
| 77 | - | EnableDeveloperExtras: false, | |
| 78 | - | ||
| 79 | - | // Use auto rendering (WebKit decides GPU vs CPU) | |
| 80 | - | Rendering: RenderingConfig{ | |
| 81 | - | Mode: "auto", | |
| 82 | - | }, | |
| 83 | - | } | |
| 84 | - | } | |
| 85 | - | ||
| 86 | - | // GetHighPerformanceConfig returns a Config optimized for performance over memory | |
| 87 | - | // This uses default/high memory settings for maximum performance | |
| 88 | - | func GetHighPerformanceConfig() *Config { | |
| 89 | - | return &Config{ | |
| 90 | - | Memory: MemoryConfig{ | |
| 91 | - | // No memory limit (use system default) | |
| 92 | - | MemoryLimitMB: 0, | |
| 93 | - | ||
| 94 | - | // Relaxed memory pressure thresholds | |
| 95 | - | ConservativeThreshold: 0.4, | |
| 96 | - | StrictThreshold: 0.6, | |
| 97 | - | KillThreshold: 0, // Disable kill threshold | |
| 98 | - | ||
| 99 | - | PollIntervalSeconds: 10.0, | |
| 100 | - | ||
| 101 | - | // Maximum caching for best performance | |
| 102 | - | CacheModel: CacheModelPrimaryWebBrowser, | |
| 103 | - | ||
| 104 | - | // Enable all caches | |
| 105 | - | EnablePageCache: true, | |
| 106 | - | EnableOfflineAppCache: true, | |
| 107 | - | ||
| 108 | - | // Disable recycling | |
| 109 | - | ProcessRecycleThreshold: 0, | |
| 110 | - | ||
| 111 | - | // Disable periodic GC | |
| 112 | - | EnableGCInterval: 0, | |
| 113 | - | ||
| 114 | - | EnableMemoryMonitoring: false, | |
| 115 | - | }, | |
| 116 | - | ||
| 117 | - | EnableDeveloperExtras: true, | |
| 118 | - | ||
| 119 | - | // Use GPU rendering for best performance | |
| 120 | - | Rendering: RenderingConfig{ | |
| 121 | - | Mode: "gpu", | |
| 122 | - | }, | |
| 123 | - | } | |
| 124 | - | } | |
| 125 | - | ||
| 126 | - | // ValidateMemoryConfig validates memory configuration parameters | |
| 127 | - | func ValidateMemoryConfig(config *MemoryConfig) error { | |
| 128 | - | if config == nil { | |
| 129 | - | return nil // Use defaults | |
| 130 | - | } | |
| 131 | - | ||
| 132 | - | if config.ConservativeThreshold < 0 || config.ConservativeThreshold > 1 { | |
| 133 | - | return &ValidationError{Field: "ConservativeThreshold", Message: "must be between 0.0 and 1.0"} | |
| 134 | - | } | |
| 135 | - | ||
| 136 | - | if config.StrictThreshold < 0 || config.StrictThreshold > 1 { | |
| 137 | - | return &ValidationError{Field: "StrictThreshold", Message: "must be between 0.0 and 1.0"} | |
| 138 | - | } | |
| 139 | - | ||
| 140 | - | if config.KillThreshold < 0 || config.KillThreshold > 1 { | |
| 141 | - | return &ValidationError{Field: "KillThreshold", Message: "must be between 0.0 and 1.0"} | |
| 142 | - | } | |
| 143 | - | ||
| 144 | - | if config.ConservativeThreshold >= config.StrictThreshold { | |
| 145 | - | return &ValidationError{Field: "StrictThreshold", Message: "must be greater than ConservativeThreshold"} | |
| 146 | - | } | |
| 147 | - | ||
| 148 | - | if config.PollIntervalSeconds < 0 { | |
| 149 | - | return &ValidationError{Field: "PollIntervalSeconds", Message: "must be non-negative"} | |
| 150 | - | } | |
| 151 | - | ||
| 152 | - | return nil | |
| 153 | - | } | |
| 154 | - | ||
| 155 | - | // ValidationError represents a configuration validation error | |
| 156 | - | type ValidationError struct { | |
| 157 | - | Field string | |
| 158 | - | Message string | |
| 159 | - | } | |
| 160 | - | ||
| 161 | - | func (e *ValidationError) Error() string { | |
| 162 | - | return "validation error for " + e.Field + ": " + e.Message | |
| 163 | - | } | |
| 164 | - | ||
| 165 | - | // LogMemoryConfiguration logs the current memory configuration for debugging | |
| 166 | - | func LogMemoryConfiguration(config *Config) { | |
| 167 | - | if config == nil || !config.Memory.EnableMemoryMonitoring { | |
| 168 | - | return | |
| 169 | - | } | |
| 170 | - | ||
| 171 | - | log := func(format string, args ...interface{}) { | |
| 172 | - | // Use Go's standard log package | |
| 173 | - | // log.Printf("[webkit-config] "+format, args...) | |
| 174 | - | // For now, we'll just ignore since we don't have access to log here | |
| 175 | - | _ = format | |
| 176 | - | _ = args | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | log("Memory configuration:") | |
| 180 | - | log(" MemoryLimitMB: %d", config.Memory.MemoryLimitMB) | |
| 181 | - | log(" ConservativeThreshold: %.2f", config.Memory.ConservativeThreshold) | |
| 182 | - | log(" StrictThreshold: %.2f", config.Memory.StrictThreshold) | |
| 183 | - | log(" KillThreshold: %.2f", config.Memory.KillThreshold) | |
| 184 | - | log(" CacheModel: %s", config.Memory.CacheModel) | |
| 185 | - | log(" EnablePageCache: %t", config.Memory.EnablePageCache) | |
| 186 | - | log(" EnableOfflineAppCache: %t", config.Memory.EnableOfflineAppCache) | |
| 187 | - | log(" ProcessRecycleThreshold: %d", config.Memory.ProcessRecycleThreshold) | |
| 188 | - | log(" EnableGCInterval: %d seconds", config.Memory.EnableGCInterval) | |
| 189 | - | } | |
A pkg/webkit/network_session.go +104 -0
| @@ -0,0 +1,104 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "fmt" | |
| 5 | + | "log" | |
| 6 | + | ||
| 7 | + | webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | var ( | |
| 11 | + | // globalNetworkSession holds the persistent network session to prevent garbage collection | |
| 12 | + | // This must be kept alive for the entire application lifetime | |
| 13 | + | globalNetworkSession *webkit.NetworkSession | |
| 14 | + | ) | |
| 15 | + | ||
| 16 | + | // InitPersistentSession creates and initializes a persistent NetworkSession | |
| 17 | + | // with the provided data and cache directories. | |
| 18 | + | // This should be called before creating any WebViews to ensure they use persistent storage. | |
| 19 | + | // Returns an error if initialization fails. | |
| 20 | + | // | |
| 21 | + | // IMPORTANT: Once created, this session is stored globally and will be used by all | |
| 22 | + | // WebViews created afterward, as per WebKitGTK 6.0's design where the first created | |
| 23 | + | // session becomes the default for the network process. | |
| 24 | + | func InitPersistentSession(dataDir, cacheDir string) error { | |
| 25 | + | // If session already exists, return success (idempotent) | |
| 26 | + | // This ensures we only create ONE session for the entire application | |
| 27 | + | if globalNetworkSession != nil { | |
| 28 | + | log.Printf("[webkit] Using existing persistent network session") | |
| 29 | + | return nil | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // Create persistent network session using gotk4 API | |
| 33 | + | // Per WebKitGTK 6.0 docs: "The first WebKitNetworkSession created becomes the default" | |
| 34 | + | session := webkit.NewNetworkSession(dataDir, cacheDir) | |
| 35 | + | if session == nil { | |
| 36 | + | return fmt.Errorf("failed to create persistent network session") | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | // Store the session globally to prevent garbage collection | |
| 40 | + | // This is CRITICAL - if the session is GC'd, WebKit falls back to ephemeral storage | |
| 41 | + | globalNetworkSession = session | |
| 42 | + | ||
| 43 | + | log.Printf("[webkit] Persistent network session created: data=%s, cache=%s", dataDir, cacheDir) | |
| 44 | + | ||
| 45 | + | // Verify the session is not ephemeral | |
| 46 | + | if session.IsEphemeral() { | |
| 47 | + | return fmt.Errorf("created session is ephemeral despite providing data directories") | |
| 48 | + | } | |
| 49 | + | ||
| 50 | + | // Verify the WebsiteDataManager is not ephemeral | |
| 51 | + | websiteDataManager := session.WebsiteDataManager() | |
| 52 | + | if websiteDataManager == nil { | |
| 53 | + | return fmt.Errorf("failed to get website data manager from network session") | |
| 54 | + | } | |
| 55 | + | if websiteDataManager.IsEphemeral() { | |
| 56 | + | return fmt.Errorf("website data manager is ephemeral despite providing data directories") | |
| 57 | + | } | |
| 58 | + | log.Printf("[webkit] WebsiteDataManager verified as non-ephemeral") | |
| 59 | + | ||
| 60 | + | // Configure CookieManager for persistent cookie storage | |
| 61 | + | // This is REQUIRED - without this, cookies are not persisted to disk | |
| 62 | + | cookieManager := session.CookieManager() | |
| 63 | + | if cookieManager == nil { | |
| 64 | + | return fmt.Errorf("failed to get cookie manager from network session") | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Set cookie persistent storage to SQLite database | |
| 68 | + | // Format: filepath.Join(dataDir, "cookies.db") | |
| 69 | + | cookiePath := dataDir + "/cookies.db" | |
| 70 | + | cookieManager.SetPersistentStorage(cookiePath, webkit.CookiePersistentStorageSqlite) | |
| 71 | + | ||
| 72 | + | // Set cookie accept policy to no third-party (default) | |
| 73 | + | cookieManager.SetAcceptPolicy(webkit.CookiePolicyAcceptNoThirdParty) | |
| 74 | + | ||
| 75 | + | log.Printf("[webkit] Cookie manager configured: storage=%s, policy=no-third-party", cookiePath) | |
| 76 | + | ||
| 77 | + | // Enable persistent credential storage (HTTP auth, etc.) | |
| 78 | + | session.SetPersistentCredentialStorageEnabled(true) | |
| 79 | + | ||
| 80 | + | // Configure TLS errors to emit signals instead of failing silently | |
| 81 | + | // This enables the load-failed-with-tls-errors signal | |
| 82 | + | session.SetTLSErrorsPolicy(webkit.TLSErrorsPolicyFail) | |
| 83 | + | log.Printf("[webkit] TLS errors policy set to FAIL (signals enabled)") | |
| 84 | + | ||
| 85 | + | // Verify this is indeed the default session now | |
| 86 | + | defaultSession := webkit.NetworkSessionGetDefault() | |
| 87 | + | if defaultSession == nil { | |
| 88 | + | return fmt.Errorf("default network session is nil after creating persistent session") | |
| 89 | + | } | |
| 90 | + | ||
| 91 | + | // Check if they're the same session by comparing ephemeral status | |
| 92 | + | if defaultSession.IsEphemeral() { | |
| 93 | + | return fmt.Errorf("default network session is ephemeral after creating persistent session") | |
| 94 | + | } | |
| 95 | + | ||
| 96 | + | log.Printf("[webkit] Network session verified as persistent and set as default") | |
| 97 | + | return nil | |
| 98 | + | } | |
| 99 | + | ||
| 100 | + | // GetGlobalNetworkSession returns the global network session. | |
| 101 | + | // Returns nil if InitPersistentSession hasn't been called successfully. | |
| 102 | + | func GetGlobalNetworkSession() *webkit.NetworkSession { | |
| 103 | + | return globalNetworkSession | |
| 104 | + | } | |
D pkg/webkit/print_cgo.go +0 -46
| @@ -1,46 +0,0 @@ | |||
| 1 | - | //go:build webkit_cgo | |
| 2 | - | ||
| 3 | - | package webkit | |
| 4 | - | ||
| 5 | - | /* | |
| 6 | - | #cgo pkg-config: webkitgtk-6.0 gtk4 | |
| 7 | - | #cgo CFLAGS: -I/usr/include/webkitgtk-6.0 | |
| 8 | - | #include <webkit/webkit.h> | |
| 9 | - | #include <gtk/gtk.h> | |
| 10 | - | */ | |
| 11 | - | import "C" | |
| 12 | - | import "unsafe" | |
| 13 | - | ||
| 14 | - | // ShowPrintDialog shows the print dialog for the WebView | |
| 15 | - | func (w *WebView) ShowPrintDialog() error { | |
| 16 | - | if w == nil || w.native == nil || w.native.wv == nil { | |
| 17 | - | return ErrNotImplemented | |
| 18 | - | } | |
| 19 | - | ||
| 20 | - | // Create print operation | |
| 21 | - | printOp := C.webkit_print_operation_new(w.native.wv) | |
| 22 | - | if printOp == nil { | |
| 23 | - | return ErrNotImplemented | |
| 24 | - | } | |
| 25 | - | ||
| 26 | - | // Pass nil for parent window - print dialog will work without it | |
| 27 | - | var parentWindow *C.GtkWindow = nil | |
| 28 | - | ||
| 29 | - | // Show print dialog | |
| 30 | - | response := C.webkit_print_operation_run_dialog(printOp, parentWindow) | |
| 31 | - | ||
| 32 | - | // Clean up print operation | |
| 33 | - | C.g_object_unref(C.gpointer(unsafe.Pointer(printOp))) | |
| 34 | - | ||
| 35 | - | // Handle the response | |
| 36 | - | switch response { | |
| 37 | - | case C.WEBKIT_PRINT_OPERATION_RESPONSE_PRINT: | |
| 38 | - | // User clicked print - the operation will proceed | |
| 39 | - | return nil | |
| 40 | - | case C.WEBKIT_PRINT_OPERATION_RESPONSE_CANCEL: | |
| 41 | - | // User cancelled - not an error | |
| 42 | - | return nil | |
| 43 | - | default: | |
| 44 | - | return ErrNotImplemented | |
| 45 | - | } | |
| 46 | - | } | |
D pkg/webkit/scheme.go +0 -9
| @@ -1,9 +0,0 @@ | |||
| 1 | - | //go:build !webkit_cgo | |
| 2 | - | ||
| 3 | - | package webkit | |
| 4 | - | ||
| 5 | - | // SchemeResolver provides bytes and mime for a given custom URI. | |
| 6 | - | type SchemeResolver func(uri string) (mime string, data []byte, ok bool) | |
| 7 | - | ||
| 8 | - | // SetURISchemeResolver is a no-op in non-CGO builds. | |
| 9 | - | func SetURISchemeResolver(r SchemeResolver) { /* no-op */ } | |
D pkg/webkit/scheme_cgo.go +0 -51
| @@ -1,51 +0,0 @@ | |||
| 1 | - | //go:build webkit_cgo | |
| 2 | - | ||
| 3 | - | package webkit | |
| 4 | - | ||
| 5 | - | /* | |
| 6 | - | #include <stdlib.h> | |
| 7 | - | */ | |
| 8 | - | import "C" | |
| 9 | - | ||
| 10 | - | import ( | |
| 11 | - | "unsafe" | |
| 12 | - | ) | |
| 13 | - | ||
| 14 | - | // Resolver provides bytes and mime for a given custom URI. | |
| 15 | - | type SchemeResolver func(uri string) (mime string, data []byte, ok bool) | |
| 16 | - | ||
| 17 | - | var globalResolver SchemeResolver | |
| 18 | - | ||
| 19 | - | // SetURISchemeResolver sets the resolver used by the custom URI scheme handler. | |
| 20 | - | func SetURISchemeResolver(r SchemeResolver) { globalResolver = r } | |
| 21 | - | ||
| 22 | - | //export goResolveURIScheme | |
| 23 | - | func goResolveURIScheme(curi *C.char, outLen *C.size_t, outMime **C.char) unsafe.Pointer { | |
| 24 | - | if curi == nil || outLen == nil || outMime == nil { | |
| 25 | - | return nil | |
| 26 | - | } | |
| 27 | - | uri := C.GoString(curi) | |
| 28 | - | if globalResolver == nil { | |
| 29 | - | *outLen = 0 | |
| 30 | - | *outMime = nil | |
| 31 | - | return nil | |
| 32 | - | } | |
| 33 | - | mime, data, ok := globalResolver(uri) | |
| 34 | - | if !ok || len(data) == 0 { | |
| 35 | - | *outLen = 0 | |
| 36 | - | *outMime = nil | |
| 37 | - | return nil | |
| 38 | - | } | |
| 39 | - | buf := C.malloc(C.size_t(len(data))) | |
| 40 | - | if buf == nil { | |
| 41 | - | *outLen = 0 | |
| 42 | - | *outMime = nil | |
| 43 | - | return nil | |
| 44 | - | } | |
| 45 | - | // Copy data into C buffer | |
| 46 | - | dst := (*[1 << 30]byte)(buf)[:len(data):len(data)] | |
| 47 | - | copy(dst, data) | |
| 48 | - | *outLen = C.size_t(len(data)) | |
| 49 | - | *outMime = C.CString(mime) | |
| 50 | - | return buf | |
| 51 | - | } | |
D pkg/webkit/script.go +0 -13
| @@ -1,13 +0,0 @@ | |||
| 1 | - | //go:build !webkit_cgo | |
| 2 | - | ||
| 3 | - | package webkit | |
| 4 | - | ||
| 5 | - | // InjectScript evaluates JavaScript in the active WebView context. | |
| 6 | - | func (w *WebView) InjectScript(js string) error { | |
| 7 | - | if w == nil || w.destroyed { | |
| 8 | - | return ErrNotImplemented | |
| 9 | - | } | |
| 10 | - | // Store last script for potential debugging/testing. | |
| 11 | - | _ = js | |
| 12 | - | return nil | |
| 13 | - | } | |
D pkg/webkit/script_cgo.go +0 -81
| @@ -1,81 +0,0 @@ | |||
| 1 | - | //go:build webkit_cgo | |
| 2 | - | ||
| 3 | - | package webkit | |
| 4 | - | ||
| 5 | - | /* | |
| 6 | - | #cgo pkg-config: webkitgtk-6.0 gtk4 | |
| 7 | - | #cgo CFLAGS: -I/usr/include/webkitgtk-6.0 | |
| 8 | - | #include <stdlib.h> | |
| 9 | - | #include <webkit/webkit.h> | |
| 10 | - | ||
| 11 | - | static gboolean dumber_is_valid_web_view(WebKitWebView *wv) { | |
| 12 | - | return WEBKIT_IS_WEB_VIEW(wv); | |
| 13 | - | } | |
| 14 | - | */ | |
| 15 | - | import "C" | |
| 16 | - | ||
| 17 | - | import "unsafe" | |
| 18 | - | ||
| 19 | - | func (w *WebView) canEvaluateJavaScript() bool { | |
| 20 | - | if w == nil || w.destroyed || w.native == nil || w.native.wv == nil { | |
| 21 | - | return false | |
| 22 | - | } | |
| 23 | - | if C.dumber_is_valid_web_view(w.native.wv) == C.gboolean(0) { | |
| 24 | - | if w.native != nil { | |
| 25 | - | w.native.wv = nil | |
| 26 | - | w.native.view = nil | |
| 27 | - | } | |
| 28 | - | w.destroyed = true | |
| 29 | - | return false | |
| 30 | - | } | |
| 31 | - | return true | |
| 32 | - | } | |
| 33 | - | ||
| 34 | - | func (w *WebView) InjectScript(js string) error { | |
| 35 | - | if !w.canEvaluateJavaScript() { | |
| 36 | - | return ErrNotImplemented | |
| 37 | - | } | |
| 38 | - | cjs := C.CString(js) | |
| 39 | - | defer C.free(unsafe.Pointer(cjs)) | |
| 40 | - | // Fire-and-forget evaluation using the modern API | |
| 41 | - | // length: -1 (NUL-terminated), world_name/source_uri: NULL, starting_line_number: 0 | |
| 42 | - | C.webkit_web_view_evaluate_javascript( | |
| 43 | - | w.native.wv, | |
| 44 | - | (*C.gchar)(cjs), | |
| 45 | - | C.gssize(-1), | |
| 46 | - | nil, // world_name | |
| 47 | - | nil, // source_uri | |
| 48 | - | nil, // cancellable | |
| 49 | - | nil, // callback | |
| 50 | - | nil, // user_data | |
| 51 | - | ) | |
| 52 | - | return nil | |
| 53 | - | } | |
| 54 | - | ||
| 55 | - | // InjectScriptIntoWorld evaluates JavaScript in the specified script world. | |
| 56 | - | func (w *WebView) InjectScriptIntoWorld(js string, worldName string) error { | |
| 57 | - | if !w.canEvaluateJavaScript() { | |
| 58 | - | return ErrNotImplemented | |
| 59 | - | } | |
| 60 | - | cjs := C.CString(js) | |
| 61 | - | defer C.free(unsafe.Pointer(cjs)) | |
| 62 | - | ||
| 63 | - | var cworld *C.gchar | |
| 64 | - | if worldName != "" { | |
| 65 | - | cworld = (*C.gchar)(C.CString(worldName)) | |
| 66 | - | defer C.free(unsafe.Pointer(cworld)) | |
| 67 | - | } | |
| 68 | - | ||
| 69 | - | // Evaluate JavaScript in the specified world | |
| 70 | - | C.webkit_web_view_evaluate_javascript( | |
| 71 | - | w.native.wv, | |
| 72 | - | (*C.gchar)(cjs), | |
| 73 | - | C.gssize(-1), | |
| 74 | - | cworld, // world_name | |
| 75 | - | nil, // source_uri | |
| 76 | - | nil, // cancellable | |
| 77 | - | nil, // callback | |
| 78 | - | nil, // user_data | |
| 79 | - | ) | |
| 80 | - | return nil | |
| 81 | - | } | |
D pkg/webkit/settings.go +0 -145
| @@ -1,145 +0,0 @@ | |||
| 1 | - | package webkit | |
| 2 | - | ||
| 3 | - | // Config holds initialization settings for the WebKit-based browser components. | |
| 4 | - | // This will map to WebKit2GTK and GTK settings in the real implementation. | |
| 5 | - | type Config struct { | |
| 6 | - | // Assets provides access to embedded GUI files | |
| 7 | - | Assets interface{ ReadFile(string) ([]byte, error) } | |
| 8 | - | ||
| 9 | - | // InitialURL is the first URL to load when creating a WebView. | |
| 10 | - | InitialURL string | |
| 11 | - | ||
| 12 | - | // UserAgent allows overriding the default user agent string. | |
| 13 | - | UserAgent string | |
| 14 | - | ||
| 15 | - | // EnableDeveloperExtras enables devtools/inspector. | |
| 16 | - | EnableDeveloperExtras bool | |
| 17 | - | ||
| 18 | - | // ZoomDefault sets an initial zoom factor (1.0 = 100%). | |
| 19 | - | ZoomDefault float64 | |
| 20 | - | ||
| 21 | - | // DataDir is the base directory for persistent website data (cookies, localStorage, etc.). | |
| 22 | - | DataDir string | |
| 23 | - | // CacheDir is the base directory for cache data. | |
| 24 | - | CacheDir string | |
| 25 | - | ||
| 26 | - | // Fonts apply to pages that don't specify fonts (browser defaults). | |
| 27 | - | DefaultSansFont string | |
| 28 | - | DefaultSerifFont string | |
| 29 | - | DefaultMonospaceFont string | |
| 30 | - | DefaultFontSize int // CSS px (~points) | |
| 31 | - | ||
| 32 | - | // Rendering controls GPU/CPU selection and debug options | |
| 33 | - | Rendering RenderingConfig | |
| 34 | - | ||
| 35 | - | // VideoAcceleration controls video hardware acceleration settings | |
| 36 | - | VideoAcceleration VideoAccelerationConfig | |
| 37 | - | ||
| 38 | - | // Memory controls memory optimization settings | |
| 39 | - | Memory MemoryConfig | |
| 40 | - | ||
| 41 | - | // CodecPreferences for media playback | |
| 42 | - | CodecPreferences CodecPreferencesConfig | |
| 43 | - | ||
| 44 | - | // APIToken holds the per-session token used to authenticate dumb://api requests | |
| 45 | - | // It's optional; if empty, API token enforcement may be disabled by the app. | |
| 46 | - | APIToken string | |
| 47 | - | ||
| 48 | - | // UseDomZoom toggles CSS/DOM-based zoom instead of WebKit's native zoom. | |
| 49 | - | UseDomZoom bool | |
| 50 | - | ||
| 51 | - | // Colors defines the light/dark palette tokens for the GUI. | |
| 52 | - | Colors ColorPalettes | |
| 53 | - | ||
| 54 | - | // CreateWindow controls whether to create a window during WebView initialization. | |
| 55 | - | // Set to false when creating WebViews for workspace panes that will be reparented. | |
| 56 | - | CreateWindow bool | |
| 57 | - | } | |
| 58 | - | ||
| 59 | - | // ColorPalette holds semantic UI tokens for a theme. | |
| 60 | - | type ColorPalette struct { | |
| 61 | - | Background string | |
| 62 | - | Surface string | |
| 63 | - | SurfaceVariant string | |
| 64 | - | Text string | |
| 65 | - | Muted string | |
| 66 | - | Accent string | |
| 67 | - | Border string | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | // ColorPalettes hosts both light and dark color palettes. | |
| 71 | - | type ColorPalettes struct { | |
| 72 | - | Light ColorPalette | |
| 73 | - | Dark ColorPalette | |
| 74 | - | } | |
| 75 | - | ||
| 76 | - | // CodecPreferencesConfig holds codec preferences for WebKit media playback | |
| 77 | - | type CodecPreferencesConfig struct { | |
| 78 | - | PreferredCodecs []string | |
| 79 | - | BlockedCodecs []string | |
| 80 | - | ForceAV1 bool | |
| 81 | - | CustomUserAgent string | |
| 82 | - | DisableTwitchCodecControl bool | |
| 83 | - | } | |
| 84 | - | ||
| 85 | - | // RenderingConfig controls hardware acceleration preferences. | |
| 86 | - | // Mode accepts: "auto" (default), "gpu", or "cpu". | |
| 87 | - | // DebugGPU enables compositing indicators if supported. | |
| 88 | - | type RenderingConfig struct { | |
| 89 | - | Mode string | |
| 90 | - | DebugGPU bool | |
| 91 | - | } | |
| 92 | - | ||
| 93 | - | // VideoAccelerationConfig controls video hardware acceleration settings. | |
| 94 | - | type VideoAccelerationConfig struct { | |
| 95 | - | EnableVAAPI bool | |
| 96 | - | AutoDetectGPU bool | |
| 97 | - | VAAPIDriverName string | |
| 98 | - | EnableAllDrivers bool | |
| 99 | - | LegacyVAAPI bool | |
| 100 | - | } | |
| 101 | - | ||
| 102 | - | // CacheModel represents WebKit cache model options for memory optimization. | |
| 103 | - | type CacheModel string | |
| 104 | - | ||
| 105 | - | const ( | |
| 106 | - | CacheModelDocumentViewer CacheModel = "document_viewer" // Minimal caching, lowest memory | |
| 107 | - | CacheModelWebBrowser CacheModel = "web_browser" // Default, balanced caching | |
| 108 | - | CacheModelPrimaryWebBrowser CacheModel = "primary_web_browser" // Maximum caching, highest memory | |
| 109 | - | ) | |
| 110 | - | ||
| 111 | - | // MemoryConfig controls memory optimization settings. | |
| 112 | - | type MemoryConfig struct { | |
| 113 | - | // MemoryLimitMB sets the maximum memory limit per WebView in MB (0 = unlimited) | |
| 114 | - | MemoryLimitMB int | |
| 115 | - | ||
| 116 | - | // ConservativeThreshold triggers early memory cleanup (0.0-1.0, default 0.33) | |
| 117 | - | ConservativeThreshold float64 | |
| 118 | - | ||
| 119 | - | // StrictThreshold triggers aggressive memory cleanup (0.0-1.0, default 0.5) | |
| 120 | - | StrictThreshold float64 | |
| 121 | - | ||
| 122 | - | // KillThreshold kills processes at this memory usage (0.0-1.0, 0 = disabled) | |
| 123 | - | KillThreshold float64 | |
| 124 | - | ||
| 125 | - | // PollIntervalSeconds sets how often to check memory usage (default 2.0) | |
| 126 | - | PollIntervalSeconds float64 | |
| 127 | - | ||
| 128 | - | // CacheModel determines caching strategy (default: web_browser) | |
| 129 | - | CacheModel CacheModel | |
| 130 | - | ||
| 131 | - | // EnablePageCache enables back/forward page caching (default: true) | |
| 132 | - | EnablePageCache bool | |
| 133 | - | ||
| 134 | - | // EnableOfflineAppCache enables offline application caching (default: true) | |
| 135 | - | EnableOfflineAppCache bool | |
| 136 | - | ||
| 137 | - | // ProcessRecycleThreshold recycles WebView after N page loads (0 = disabled) | |
| 138 | - | ProcessRecycleThreshold int | |
| 139 | - | ||
| 140 | - | // EnableGCInterval enables periodic JS garbage collection in seconds (0 = disabled) | |
| 141 | - | EnableGCInterval int | |
| 142 | - | ||
| 143 | - | // EnableMemoryMonitoring logs memory usage and pressure events | |
| 144 | - | EnableMemoryMonitoring bool | |
| 145 | - | } | |
A pkg/webkit/shortcuts.go +161 -0
| @@ -0,0 +1,161 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "log" | |
| 5 | + | "strings" | |
| 6 | + | ||
| 7 | + | "github.com/diamondburned/gotk4/pkg/gdk/v4" | |
| 8 | + | "github.com/diamondburned/gotk4/pkg/glib/v2" | |
| 9 | + | "github.com/diamondburned/gotk4/pkg/gtk/v4" | |
| 10 | + | ) | |
| 11 | + | ||
| 12 | + | // WindowShortcuts manages keyboard shortcuts for a window | |
| 13 | + | type WindowShortcuts struct { | |
| 14 | + | window *Window | |
| 15 | + | shortcuts map[string]*gtk.Shortcut | |
| 16 | + | controller *gtk.ShortcutController | |
| 17 | + | } | |
| 18 | + | ||
| 19 | + | // NewWindowShortcuts creates a new WindowShortcuts manager | |
| 20 | + | func NewWindowShortcuts(win *Window) *WindowShortcuts { | |
| 21 | + | controller := gtk.NewShortcutController() | |
| 22 | + | ||
| 23 | + | // Set scope to GLOBAL so shortcuts work at the window level | |
| 24 | + | // and aren't captured by child widgets (like the WebView) | |
| 25 | + | controller.SetScope(gtk.ShortcutScopeGlobal) | |
| 26 | + | ||
| 27 | + | if win != nil && win.win != nil { | |
| 28 | + | win.win.AddController(controller) | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | return &WindowShortcuts{ | |
| 32 | + | window: win, | |
| 33 | + | shortcuts: make(map[string]*gtk.Shortcut), | |
| 34 | + | controller: controller, | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // InitializeGlobalShortcuts initializes global keyboard shortcuts for a window | |
| 39 | + | func (w *Window) InitializeGlobalShortcuts(shortcuts map[string]func()) { | |
| 40 | + | if w == nil || w.win == nil { | |
| 41 | + | return | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // Create event controller for keyboard events | |
| 45 | + | controller := gtk.NewEventControllerKey() | |
| 46 | + | ||
| 47 | + | controller.ConnectKeyPressed(func(keyval, keycode uint, state gdk.ModifierType) bool { | |
| 48 | + | // Handle keyboard shortcuts | |
| 49 | + | // TODO: Implement proper shortcut matching | |
| 50 | + | return false | |
| 51 | + | }) | |
| 52 | + | ||
| 53 | + | w.win.AddController(controller) | |
| 54 | + | } | |
| 55 | + | ||
| 56 | + | // RegisterShortcut registers a keyboard shortcut | |
| 57 | + | func (ws *WindowShortcuts) RegisterShortcut(accelerator string, handler func()) { | |
| 58 | + | if ws == nil || ws.controller == nil { | |
| 59 | + | return | |
| 60 | + | } | |
| 61 | + | ||
| 62 | + | // Convert to GTK format (e.g., "ctrl+plus" -> "<Control>plus") | |
| 63 | + | gtkFormat := convertToGtkFormat(accelerator) | |
| 64 | + | if gtkFormat == "" { | |
| 65 | + | log.Printf("[shortcuts] Invalid key format: %s", accelerator) | |
| 66 | + | return | |
| 67 | + | } | |
| 68 | + | ||
| 69 | + | // Create a callback action that will trigger our handler | |
| 70 | + | // ShortcutFunc signature: func(widget Widgetter, args *glib.Variant) (ok bool) | |
| 71 | + | action := gtk.NewCallbackAction(func(widget gtk.Widgetter, args *glib.Variant) bool { | |
| 72 | + | if handler != nil { | |
| 73 | + | log.Printf("[shortcuts] Shortcut triggered: %s", accelerator) | |
| 74 | + | handler() | |
| 75 | + | } | |
| 76 | + | // Return false to allow event propagation to WebView | |
| 77 | + | // This lets JavaScript KeyboardService also handle events if needed | |
| 78 | + | return false | |
| 79 | + | }) | |
| 80 | + | ||
| 81 | + | // Parse the accelerator string into a GDK key trigger | |
| 82 | + | // GTK4 uses gtk.ShortcutTrigger for key combinations | |
| 83 | + | trigger := gtk.NewShortcutTriggerParseString(gtkFormat) | |
| 84 | + | if trigger == nil { | |
| 85 | + | log.Printf("[shortcuts] Failed to parse trigger: %s", gtkFormat) | |
| 86 | + | return | |
| 87 | + | } | |
| 88 | + | ||
| 89 | + | // Create the shortcut with trigger and action | |
| 90 | + | shortcut := gtk.NewShortcut(trigger, action) | |
| 91 | + | ||
| 92 | + | // Add shortcut to the shared controller | |
| 93 | + | ws.controller.AddShortcut(shortcut) | |
| 94 | + | ||
| 95 | + | // Store the shortcut for potential cleanup | |
| 96 | + | ws.shortcuts[accelerator] = shortcut | |
| 97 | + | ||
| 98 | + | log.Printf("[shortcuts] Registered: %s -> %s", accelerator, gtkFormat) | |
| 99 | + | } | |
| 100 | + | ||
| 101 | + | // convertToGtkFormat converts common key formats to GTK shortcut format | |
| 102 | + | // Examples: "ctrl+plus" -> "<Control>plus", "ctrl+l" -> "<Control>l" | |
| 103 | + | func convertToGtkFormat(key string) string { | |
| 104 | + | key = strings.ToLower(key) | |
| 105 | + | ||
| 106 | + | // Handle cmdorctrl -> Control | |
| 107 | + | key = strings.ReplaceAll(key, "cmdorctrl+", "ctrl+") | |
| 108 | + | key = strings.ReplaceAll(key, "cmd+", "ctrl+") | |
| 109 | + | ||
| 110 | + | // Split by + to get components | |
| 111 | + | components := strings.Split(key, "+") | |
| 112 | + | if len(components) == 0 { | |
| 113 | + | return "" | |
| 114 | + | } | |
| 115 | + | ||
| 116 | + | var parts []string | |
| 117 | + | for i, part := range components { | |
| 118 | + | if i == len(components)-1 { | |
| 119 | + | // Last part is the actual key | |
| 120 | + | switch part { | |
| 121 | + | case "f12": | |
| 122 | + | parts = append(parts, "F12") | |
| 123 | + | case "f5": | |
| 124 | + | parts = append(parts, "F5") | |
| 125 | + | case "plus": | |
| 126 | + | parts = append(parts, "plus") | |
| 127 | + | case "equal": | |
| 128 | + | parts = append(parts, "equal") | |
| 129 | + | case "minus": | |
| 130 | + | parts = append(parts, "minus") | |
| 131 | + | case "0": | |
| 132 | + | parts = append(parts, "0") | |
| 133 | + | case "left", "arrowleft": | |
| 134 | + | parts = append(parts, "Left") | |
| 135 | + | case "right", "arrowright": | |
| 136 | + | parts = append(parts, "Right") | |
| 137 | + | case "up", "arrowup": | |
| 138 | + | parts = append(parts, "Up") | |
| 139 | + | case "down", "arrowdown": | |
| 140 | + | parts = append(parts, "Down") | |
| 141 | + | default: | |
| 142 | + | // Single letter keys like l, f, c, w, etc. | |
| 143 | + | parts = append(parts, part) | |
| 144 | + | } | |
| 145 | + | } else { | |
| 146 | + | // Modifier keys | |
| 147 | + | switch part { | |
| 148 | + | case "ctrl": | |
| 149 | + | parts = append(parts, "<Control>") | |
| 150 | + | case "shift": | |
| 151 | + | parts = append(parts, "<Shift>") | |
| 152 | + | case "alt": | |
| 153 | + | parts = append(parts, "<Alt>") | |
| 154 | + | case "super", "meta": | |
| 155 | + | parts = append(parts, "<Super>") | |
| 156 | + | } | |
| 157 | + | } | |
| 158 | + | } | |
| 159 | + | ||
| 160 | + | return strings.Join(parts, "") | |
| 161 | + | } | |
D pkg/webkit/shortcuts_cgo.go +0 -565
| @@ -1,565 +0,0 @@ | |||
| 1 | - | //go:build webkit_cgo | |
| 2 | - | ||
| 3 | - | package webkit | |
| 4 | - | ||
| 5 | - | import ( | |
| 6 | - | "fmt" | |
| 7 | - | "log" | |
| 8 | - | "strings" | |
| 9 | - | "sync" | |
| 10 | - | "sync/atomic" | |
| 11 | - | "unsafe" | |
| 12 | - | ) | |
| 13 | - | ||
| 14 | - | /* | |
| 15 | - | #cgo pkg-config: gtk4 webkitgtk-6.0 | |
| 16 | - | #include <gtk/gtk.h> | |
| 17 | - | #include <webkit/webkit.h> | |
| 18 | - | #include <stdint.h> | |
| 19 | - | #include <stdlib.h> | |
| 20 | - | ||
| 21 | - | extern void goHandleWindowShortcut(uintptr_t handle); | |
| 22 | - | ||
| 23 | - | static GtkShortcutController* create_global_shortcut_controller(GtkWindow* window) { | |
| 24 | - | if (!window) { | |
| 25 | - | return NULL; | |
| 26 | - | } | |
| 27 | - | GtkEventController* event_controller = gtk_shortcut_controller_new(); | |
| 28 | - | if (!event_controller) { | |
| 29 | - | return NULL; | |
| 30 | - | } | |
| 31 | - | GtkShortcutController* controller = GTK_SHORTCUT_CONTROLLER(event_controller); | |
| 32 | - | gtk_shortcut_controller_set_scope(controller, GTK_SHORTCUT_SCOPE_GLOBAL); | |
| 33 | - | gtk_widget_add_controller(GTK_WIDGET(window), event_controller); | |
| 34 | - | return controller; | |
| 35 | - | } | |
| 36 | - | ||
| 37 | - | static gboolean shortcut_callback_bridge(GtkWidget* widget, GVariant* args, gpointer user_data) { | |
| 38 | - | (void)widget; | |
| 39 | - | // Properly handle the GVariant to avoid NULL string assertion | |
| 40 | - | if (args != NULL && g_variant_is_of_type(args, G_VARIANT_TYPE_STRING)) { | |
| 41 | - | // If args contains a string, we can safely ignore it | |
| 42 | - | const gchar* str = g_variant_get_string(args, NULL); | |
| 43 | - | (void)str; | |
| 44 | - | } | |
| 45 | - | uintptr_t handle = (uintptr_t)user_data; | |
| 46 | - | if (handle == 0) { | |
| 47 | - | return FALSE; | |
| 48 | - | } | |
| 49 | - | goHandleWindowShortcut(handle); | |
| 50 | - | return TRUE; // Indicate we handled the shortcut | |
| 51 | - | } | |
| 52 | - | ||
| 53 | - | // Alternative callback that doesn't use GVariant at all | |
| 54 | - | static gboolean simple_shortcut_callback(GtkWidget* widget, gpointer user_data) { | |
| 55 | - | (void)widget; | |
| 56 | - | uintptr_t handle = (uintptr_t)user_data; | |
| 57 | - | if (handle == 0) { | |
| 58 | - | return FALSE; | |
| 59 | - | } | |
| 60 | - | goHandleWindowShortcut(handle); | |
| 61 | - | return TRUE; | |
| 62 | - | } | |
| 63 | - | ||
| 64 | - | static void add_shortcut_to_controller(GtkShortcutController* controller, | |
| 65 | - | const char* trigger_str, | |
| 66 | - | uintptr_t handle) { | |
| 67 | - | if (!controller || !trigger_str || handle == 0) { | |
| 68 | - | return; | |
| 69 | - | } | |
| 70 | - | GtkShortcutTrigger* trigger = gtk_shortcut_trigger_parse_string(trigger_str); | |
| 71 | - | if (!trigger) { | |
| 72 | - | return; | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | // Try to create an action that doesn't involve problematic GVariant handling | |
| 76 | - | // Use gtk_nothing_action for the shortcut and handle activation separately | |
| 77 | - | GtkShortcutAction* action = gtk_callback_action_new((GtkShortcutFunc)shortcut_callback_bridge, | |
| 78 | - | (gpointer)handle, | |
| 79 | - | NULL); | |
| 80 | - | if (!action) { | |
| 81 | - | g_object_unref(trigger); | |
| 82 | - | return; | |
| 83 | - | } | |
| 84 | - | GtkShortcut* shortcut = gtk_shortcut_new(trigger, action); | |
| 85 | - | gtk_shortcut_controller_add_shortcut(controller, shortcut); | |
| 86 | - | } | |
| 87 | - | */ | |
| 88 | - | import "C" | |
| 89 | - | ||
| 90 | - | var ( | |
| 91 | - | globalShortcutMu sync.Mutex | |
| 92 | - | globalShortcutCounter uint64 | |
| 93 | - | globalShortcutCallbacks = make(map[uintptr]func()) | |
| 94 | - | ) | |
| 95 | - | ||
| 96 | - | func registerWindowShortcutCallback(cb func()) uintptr { | |
| 97 | - | if cb == nil { | |
| 98 | - | return 0 | |
| 99 | - | } | |
| 100 | - | id := uintptr(atomic.AddUint64(&globalShortcutCounter, 1)) | |
| 101 | - | if id == 0 { | |
| 102 | - | // Should never happen, but guard against wrapping to 0 | |
| 103 | - | id = uintptr(atomic.AddUint64(&globalShortcutCounter, 1)) | |
| 104 | - | } | |
| 105 | - | globalShortcutMu.Lock() | |
| 106 | - | globalShortcutCallbacks[id] = cb | |
| 107 | - | globalShortcutMu.Unlock() | |
| 108 | - | return id | |
| 109 | - | } | |
| 110 | - | ||
| 111 | - | func unregisterWindowShortcutCallback(id uintptr) { | |
| 112 | - | if id == 0 { | |
| 113 | - | return | |
| 114 | - | } | |
| 115 | - | globalShortcutMu.Lock() | |
| 116 | - | delete(globalShortcutCallbacks, id) | |
| 117 | - | globalShortcutMu.Unlock() | |
| 118 | - | } | |
| 119 | - | ||
| 120 | - | func invokeWindowShortcutCallback(id uintptr) { | |
| 121 | - | if id == 0 { | |
| 122 | - | return | |
| 123 | - | } | |
| 124 | - | globalShortcutMu.Lock() | |
| 125 | - | cb := globalShortcutCallbacks[id] | |
| 126 | - | globalShortcutMu.Unlock() | |
| 127 | - | if cb == nil { | |
| 128 | - | log.Printf("[shortcuts] no handler for shortcut id=%d", id) | |
| 129 | - | return | |
| 130 | - | } | |
| 131 | - | cb() | |
| 132 | - | } | |
| 133 | - | ||
| 134 | - | //export goHandleWindowShortcut | |
| 135 | - | func goHandleWindowShortcut(handle C.uintptr_t) { | |
| 136 | - | invokeWindowShortcutCallback(uintptr(handle)) | |
| 137 | - | } | |
| 138 | - | ||
| 139 | - | // Go wrapper functions for CGO - these can be called from other Go files | |
| 140 | - | ||
| 141 | - | // CreateGlobalShortcutController creates a GTK4 global shortcut controller for a window | |
| 142 | - | func CreateGlobalShortcutController(window uintptr) uintptr { | |
| 143 | - | if window == 0 { | |
| 144 | - | return 0 | |
| 145 | - | } | |
| 146 | - | controller := C.create_global_shortcut_controller((*C.GtkWindow)(unsafe.Pointer(window))) | |
| 147 | - | return uintptr(unsafe.Pointer(controller)) | |
| 148 | - | } | |
| 149 | - | ||
| 150 | - | // AddShortcutToController adds a shortcut to the controller with a callback handle | |
| 151 | - | func AddShortcutToController(controller uintptr, key string, handle uintptr) error { | |
| 152 | - | if controller == 0 || key == "" || handle == 0 { | |
| 153 | - | return ErrNotImplemented | |
| 154 | - | } | |
| 155 | - | ||
| 156 | - | keyStr := C.CString(key) | |
| 157 | - | defer C.free(unsafe.Pointer(keyStr)) | |
| 158 | - | ||
| 159 | - | C.add_shortcut_to_controller( | |
| 160 | - | (*C.GtkShortcutController)(unsafe.Pointer(controller)), | |
| 161 | - | keyStr, | |
| 162 | - | C.uintptr_t(handle), | |
| 163 | - | ) | |
| 164 | - | ||
| 165 | - | return nil | |
| 166 | - | } | |
| 167 | - | ||
| 168 | - | // Global shortcuts registry to track all registered shortcuts for automatic blocking | |
| 169 | - | var ( | |
| 170 | - | globalShortcutsRegistry = make(map[string]bool) | |
| 171 | - | shortcutsRegistryMu sync.RWMutex | |
| 172 | - | // Map from shortcut key to callback handle for C-level triggering | |
| 173 | - | globalShortcutHandles = make(map[string]uintptr) | |
| 174 | - | ) | |
| 175 | - | ||
| 176 | - | // WebView tracking for dynamic protection refresh | |
| 177 | - | var ( | |
| 178 | - | activeWebViews = make(map[*WebView]bool) | |
| 179 | - | activeWebViewsMu sync.RWMutex | |
| 180 | - | ) | |
| 181 | - | ||
| 182 | - | // RegisterGlobalShortcut adds a shortcut to the global registry for automatic webpage blocking | |
| 183 | - | func RegisterGlobalShortcut(shortcut string) { | |
| 184 | - | shortcutsRegistryMu.Lock() | |
| 185 | - | // Normalize shortcut format (convert to lowercase, standardize modifiers) | |
| 186 | - | normalized := normalizeShortcut(shortcut) | |
| 187 | - | ||
| 188 | - | // Only proceed if this is a new shortcut registration | |
| 189 | - | wasNew := !globalShortcutsRegistry[normalized] | |
| 190 | - | if wasNew { | |
| 191 | - | globalShortcutsRegistry[normalized] = true | |
| 192 | - | log.Printf("[shortcuts] Registered global shortcut for blocking: %s -> %s", shortcut, normalized) | |
| 193 | - | } | |
| 194 | - | shortcutsRegistryMu.Unlock() | |
| 195 | - | ||
| 196 | - | // If we added a new shortcut, refresh protection on all active WebViews | |
| 197 | - | if wasNew { | |
| 198 | - | RefreshGlobalShortcutsProtection() | |
| 199 | - | } | |
| 200 | - | } | |
| 201 | - | ||
| 202 | - | // RegisterGlobalShortcutWithHandle adds a shortcut with its callback handle for C-level triggering | |
| 203 | - | func RegisterGlobalShortcutWithHandle(shortcut string, handle uintptr) { | |
| 204 | - | // First register the shortcut for blocking | |
| 205 | - | RegisterGlobalShortcut(shortcut) | |
| 206 | - | ||
| 207 | - | // Then store the callback handle for C-level triggering | |
| 208 | - | shortcutsRegistryMu.Lock() | |
| 209 | - | normalized := normalizeShortcut(shortcut) | |
| 210 | - | globalShortcutHandles[normalized] = handle | |
| 211 | - | log.Printf("[shortcuts] Registered callback handle for shortcut: %s", shortcut) | |
| 212 | - | shortcutsRegistryMu.Unlock() | |
| 213 | - | } | |
| 214 | - | ||
| 215 | - | // GetRegisteredShortcuts returns a copy of all registered shortcuts | |
| 216 | - | func GetRegisteredShortcuts() []string { | |
| 217 | - | shortcutsRegistryMu.RLock() | |
| 218 | - | defer shortcutsRegistryMu.RUnlock() | |
| 219 | - | ||
| 220 | - | shortcuts := make([]string, 0, len(globalShortcutsRegistry)) | |
| 221 | - | for shortcut := range globalShortcutsRegistry { | |
| 222 | - | shortcuts = append(shortcuts, shortcut) | |
| 223 | - | } | |
| 224 | - | return shortcuts | |
| 225 | - | } | |
| 226 | - | ||
| 227 | - | // RegisterActiveWebView adds a WebView to the active registry for dynamic protection updates | |
| 228 | - | func RegisterActiveWebView(w *WebView) { | |
| 229 | - | if w == nil { | |
| 230 | - | return | |
| 231 | - | } | |
| 232 | - | activeWebViewsMu.Lock() | |
| 233 | - | defer activeWebViewsMu.Unlock() | |
| 234 | - | activeWebViews[w] = true | |
| 235 | - | // WebView registered for protection updates | |
| 236 | - | } | |
| 237 | - | ||
| 238 | - | // UnregisterActiveWebView removes a WebView from the active registry | |
| 239 | - | func UnregisterActiveWebView(w *WebView) { | |
| 240 | - | if w == nil { | |
| 241 | - | return | |
| 242 | - | } | |
| 243 | - | activeWebViewsMu.Lock() | |
| 244 | - | defer activeWebViewsMu.Unlock() | |
| 245 | - | delete(activeWebViews, w) | |
| 246 | - | // WebView unregistered from protection updates | |
| 247 | - | } | |
| 248 | - | ||
| 249 | - | // RefreshGlobalShortcutsProtection updates all active WebViews with current shortcut protection | |
| 250 | - | func RefreshGlobalShortcutsProtection() { | |
| 251 | - | activeWebViewsMu.RLock() | |
| 252 | - | webViews := make([]*WebView, 0, len(activeWebViews)) | |
| 253 | - | for wv := range activeWebViews { | |
| 254 | - | webViews = append(webViews, wv) | |
| 255 | - | } | |
| 256 | - | activeWebViewsMu.RUnlock() | |
| 257 | - | ||
| 258 | - | if len(webViews) == 0 { | |
| 259 | - | return | |
| 260 | - | } | |
| 261 | - | ||
| 262 | - | for _, wv := range webViews { | |
| 263 | - | if wv != nil && !wv.destroyed { | |
| 264 | - | // Re-enable protection which will inject updated shortcut list | |
| 265 | - | if err := wv.EnableGlobalShortcutsProtection(); err != nil { | |
| 266 | - | log.Printf("[shortcuts] Failed to refresh protection for WebView: %v", err) | |
| 267 | - | } | |
| 268 | - | } | |
| 269 | - | } | |
| 270 | - | } | |
| 271 | - | ||
| 272 | - | // normalizeShortcut converts shortcuts to a standardized format for JavaScript blocking | |
| 273 | - | func normalizeShortcut(shortcut string) string { | |
| 274 | - | // Convert to lowercase and replace common variations | |
| 275 | - | normalized := strings.ToLower(shortcut) | |
| 276 | - | ||
| 277 | - | // Standardize modifier keys | |
| 278 | - | normalized = strings.ReplaceAll(normalized, "cmdorctrl+", "ctrl+") | |
| 279 | - | normalized = strings.ReplaceAll(normalized, "cmd+", "ctrl+") // Treat cmd as ctrl for blocking | |
| 280 | - | normalized = strings.ReplaceAll(normalized, "control+", "ctrl+") | |
| 281 | - | ||
| 282 | - | // Standardize arrow key names (handle both formats) | |
| 283 | - | normalized = strings.ReplaceAll(normalized, "left", "arrowleft") | |
| 284 | - | normalized = strings.ReplaceAll(normalized, "right", "arrowright") | |
| 285 | - | normalized = strings.ReplaceAll(normalized, "up", "arrowup") | |
| 286 | - | normalized = strings.ReplaceAll(normalized, "down", "arrowdown") | |
| 287 | - | ||
| 288 | - | // Fix double arrow prefixes that might occur from above replacements | |
| 289 | - | normalized = strings.ReplaceAll(normalized, "arrowarrowleft", "arrowleft") | |
| 290 | - | normalized = strings.ReplaceAll(normalized, "arrowarrowright", "arrowright") | |
| 291 | - | normalized = strings.ReplaceAll(normalized, "arrowarrowup", "arrowup") | |
| 292 | - | normalized = strings.ReplaceAll(normalized, "arrowarrowdown", "arrowdown") | |
| 293 | - | ||
| 294 | - | // Other key normalizations | |
| 295 | - | normalized = strings.ReplaceAll(normalized, "equal", "equal") // For Ctrl+= | |
| 296 | - | normalized = strings.ReplaceAll(normalized, "plus", "equal") // Normalize + to = | |
| 297 | - | ||
| 298 | - | return normalized | |
| 299 | - | } | |
| 300 | - | ||
| 301 | - | // Keyboard event blocking functionality for global shortcuts protection | |
| 302 | - | ||
| 303 | - | // keyboardBlockerScript holds reference to the main-world keyboard blocking script | |
| 304 | - | var keyboardBlockerScript *C.WebKitUserScript = nil | |
| 305 | - | ||
| 306 | - | // EnableGlobalShortcutsProtection injects a script to block all registered global shortcuts from reaching webpages | |
| 307 | - | func (w *WebView) EnableGlobalShortcutsProtection() error { | |
| 308 | - | if w == nil || w.destroyed || w.native == nil || w.native.ucm == nil { | |
| 309 | - | return ErrWebViewNotInitialized | |
| 310 | - | } | |
| 311 | - | ||
| 312 | - | // Get all registered shortcuts | |
| 313 | - | shortcuts := GetRegisteredShortcuts() | |
| 314 | - | if len(shortcuts) == 0 { | |
| 315 | - | log.Printf("[shortcuts] No global shortcuts registered, skipping protection") | |
| 316 | - | return nil | |
| 317 | - | } | |
| 318 | - | ||
| 319 | - | // Generate JavaScript array of shortcuts to block | |
| 320 | - | shortcutsJS := "[" | |
| 321 | - | for i, shortcut := range shortcuts { | |
| 322 | - | if i > 0 { | |
| 323 | - | shortcutsJS += "," | |
| 324 | - | } | |
| 325 | - | shortcutsJS += `"` + shortcut + `"` | |
| 326 | - | } | |
| 327 | - | shortcutsJS += "]" | |
| 328 | - | ||
| 329 | - | // Create a version-based blocking script that can be updated dynamically | |
| 330 | - | protectionVersion := len(shortcuts) // Use shortcuts count as version | |
| 331 | - | globalProtectionScript := ` | |
| 332 | - | (() => { | |
| 333 | - | const newVersion = ` + fmt.Sprintf("%d", protectionVersion) + `; | |
| 334 | - | const currentVersion = window.__dumber_shortcuts_protection_version || 0; | |
| 335 | - | ||
| 336 | - | // Only update if this is a newer version or first time | |
| 337 | - | if (newVersion > currentVersion) { | |
| 338 | - | // Clean up old protection if it exists | |
| 339 | - | if (window.__dumber_cleanup_global_shortcuts) { | |
| 340 | - | window.__dumber_cleanup_global_shortcuts(); | |
| 341 | - | } | |
| 342 | - | ||
| 343 | - | window.__dumber_shortcuts_protection_version = newVersion; | |
| 344 | - | ||
| 345 | - | // List of global shortcuts to block from reaching the webpage | |
| 346 | - | const globalShortcuts = ` + shortcutsJS + `; | |
| 347 | - | console.log('[dumber] Protecting global shortcuts (v' + newVersion + '):', globalShortcuts); | |
| 348 | - | ||
| 349 | - | // Function to check if a key event matches any global shortcut | |
| 350 | - | const isGlobalShortcut = (e) => { | |
| 351 | - | const key = e.key.toLowerCase(); | |
| 352 | - | const code = e.code.toLowerCase(); | |
| 353 | - | ||
| 354 | - | // Build shortcut string (similar to GTK format) | |
| 355 | - | let shortcut = ''; | |
| 356 | - | if (e.ctrlKey) shortcut += 'ctrl+'; | |
| 357 | - | if (e.altKey) shortcut += 'alt+'; | |
| 358 | - | if (e.shiftKey) shortcut += 'shift+'; | |
| 359 | - | if (e.metaKey) shortcut += 'meta+'; | |
| 360 | - | ||
| 361 | - | // Try both key and code for matching | |
| 362 | - | const keyShortcut = shortcut + key; | |
| 363 | - | const codeShortcut = shortcut + code; | |
| 364 | - | ||
| 365 | - | // Special case mappings for common shortcuts | |
| 366 | - | const specialMappings = { | |
| 367 | - | 'ctrl+equal': ['ctrl+=', 'ctrl+plus'], | |
| 368 | - | 'ctrl+minus': ['ctrl+-'], | |
| 369 | - | 'f12': ['f12'], | |
| 370 | - | 'ctrl+r': ['ctrl+r'], | |
| 371 | - | 'ctrl+shift+r': ['ctrl+shift+r'], | |
| 372 | - | 'f5': ['f5'], | |
| 373 | - | // Alt+Arrow key mappings (handle both key and code variations) | |
| 374 | - | 'alt+arrowleft': ['alt+left', 'alt+arrowleft'], | |
| 375 | - | 'alt+arrowright': ['alt+right', 'alt+arrowright'], | |
| 376 | - | 'alt+arrowup': ['alt+up', 'alt+arrowup'], | |
| 377 | - | 'alt+arrowdown': ['alt+down', 'alt+arrowdown'] | |
| 378 | - | }; | |
| 379 | - | ||
| 380 | - | // Check direct matches | |
| 381 | - | if (globalShortcuts.includes(keyShortcut) || globalShortcuts.includes(codeShortcut)) { | |
| 382 | - | return true; | |
| 383 | - | } | |
| 384 | - | ||
| 385 | - | // Check special mappings | |
| 386 | - | for (const [canonical, variants] of Object.entries(specialMappings)) { | |
| 387 | - | if (variants.includes(keyShortcut) || variants.includes(codeShortcut)) { | |
| 388 | - | if (globalShortcuts.includes(canonical)) { | |
| 389 | - | return true; | |
| 390 | - | } | |
| 391 | - | } | |
| 392 | - | } | |
| 393 | - | ||
| 394 | - | return false; | |
| 395 | - | }; | |
| 396 | - | ||
| 397 | - | // Block only global shortcuts at capture phase | |
| 398 | - | const blockGlobalShortcut = (e) => { | |
| 399 | - | if (isGlobalShortcut(e)) { | |
| 400 | - | console.log('[dumber] Blocked global shortcut from reaching page:', e.key, e.ctrlKey, e.altKey, e.shiftKey); | |
| 401 | - | e.stopImmediatePropagation(); | |
| 402 | - | e.preventDefault(); | |
| 403 | - | e.stopPropagation(); | |
| 404 | - | return false; | |
| 405 | - | } | |
| 406 | - | // Let other key events through normally | |
| 407 | - | }; | |
| 408 | - | ||
| 409 | - | // Install listeners for keyboard events | |
| 410 | - | document.addEventListener('keydown', blockGlobalShortcut, true); | |
| 411 | - | document.addEventListener('keyup', blockGlobalShortcut, true); | |
| 412 | - | document.addEventListener('keypress', blockGlobalShortcut, true); | |
| 413 | - | ||
| 414 | - | // Store cleanup function | |
| 415 | - | window.__dumber_cleanup_global_shortcuts = () => { | |
| 416 | - | document.removeEventListener('keydown', blockGlobalShortcut, true); | |
| 417 | - | document.removeEventListener('keyup', blockGlobalShortcut, true); | |
| 418 | - | document.removeEventListener('keypress', blockGlobalShortcut, true); | |
| 419 | - | delete window.__dumber_shortcuts_protection_version; | |
| 420 | - | delete window.__dumber_cleanup_global_shortcuts; | |
| 421 | - | console.log('[dumber] Global shortcuts protection disabled'); | |
| 422 | - | }; | |
| 423 | - | ||
| 424 | - | console.log('[dumber] Global shortcuts protection enabled for', globalShortcuts.length, 'shortcuts (v' + newVersion + ')'); | |
| 425 | - | } else { | |
| 426 | - | console.log('[dumber] Global shortcuts protection already up to date (v' + currentVersion + ')'); | |
| 427 | - | } | |
| 428 | - | })(); | |
| 429 | - | ` | |
| 430 | - | ||
| 431 | - | // Inject the script into the main world at document start | |
| 432 | - | cScript := C.CString(globalProtectionScript) | |
| 433 | - | defer C.free(unsafe.Pointer(cScript)) | |
| 434 | - | ||
| 435 | - | script := C.webkit_user_script_new( | |
| 436 | - | (*C.gchar)(cScript), | |
| 437 | - | C.WEBKIT_USER_CONTENT_INJECT_TOP_FRAME, | |
| 438 | - | C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, | |
| 439 | - | nil, nil) // nil = main world | |
| 440 | - | ||
| 441 | - | if script == nil { | |
| 442 | - | return ErrNotImplemented | |
| 443 | - | } | |
| 444 | - | ||
| 445 | - | C.webkit_user_content_manager_add_script(w.native.ucm, script) | |
| 446 | - | C.webkit_user_script_unref(script) // Clean up reference | |
| 447 | - | ||
| 448 | - | log.Printf("[shortcuts] Global shortcuts protection enabled for %d shortcuts", len(shortcuts)) | |
| 449 | - | return nil | |
| 450 | - | } | |
| 451 | - | ||
| 452 | - | // DisableGlobalShortcutsProtection removes the global shortcuts protection | |
| 453 | - | func (w *WebView) DisableGlobalShortcutsProtection() error { | |
| 454 | - | if w == nil || w.destroyed || w.native == nil { | |
| 455 | - | return ErrWebViewNotInitialized | |
| 456 | - | } | |
| 457 | - | ||
| 458 | - | // Execute cleanup script | |
| 459 | - | cleanupScript := ` | |
| 460 | - | if (window.__dumber_cleanup_global_shortcuts) { | |
| 461 | - | window.__dumber_cleanup_global_shortcuts(); | |
| 462 | - | } | |
| 463 | - | ` | |
| 464 | - | ||
| 465 | - | return w.InjectScript(cleanupScript) | |
| 466 | - | } | |
| 467 | - | ||
| 468 | - | // EnablePageKeyboardBlocking injects a script into the main world to block all keyboard events | |
| 469 | - | // This prevents page JavaScript from receiving keyboard events while omnibox is active | |
| 470 | - | func (w *WebView) EnablePageKeyboardBlocking() error { | |
| 471 | - | if w == nil || w.destroyed || w.native == nil || w.native.ucm == nil { | |
| 472 | - | return ErrWebViewNotInitialized | |
| 473 | - | } | |
| 474 | - | ||
| 475 | - | // Remove any existing blocker script first | |
| 476 | - | w.DisablePageKeyboardBlocking() | |
| 477 | - | ||
| 478 | - | // Script to block all keyboard and related events in the main world | |
| 479 | - | blockingScript := ` | |
| 480 | - | (() => { | |
| 481 | - | // Store original state to restore later | |
| 482 | - | if (!window.__dumber_event_blocker_installed) { | |
| 483 | - | window.__dumber_event_blocker_installed = true; | |
| 484 | - | ||
| 485 | - | // List of events to block | |
| 486 | - | const eventTypes = [ | |
| 487 | - | 'keydown', 'keyup', 'keypress', | |
| 488 | - | 'input', 'beforeinput', | |
| 489 | - | 'compositionstart', 'compositionupdate', 'compositionend' | |
| 490 | - | ]; | |
| 491 | - | ||
| 492 | - | // Block all keyboard-related events at capture phase | |
| 493 | - | const blockEvent = (e) => { | |
| 494 | - | e.stopImmediatePropagation(); | |
| 495 | - | e.preventDefault(); | |
| 496 | - | e.stopPropagation(); | |
| 497 | - | return false; | |
| 498 | - | }; | |
| 499 | - | ||
| 500 | - | // Install capture-phase event blockers | |
| 501 | - | eventTypes.forEach(type => { | |
| 502 | - | document.addEventListener(type, blockEvent, true); | |
| 503 | - | }); | |
| 504 | - | ||
| 505 | - | // Store cleanup function | |
| 506 | - | window.__dumber_cleanup_event_blocker = () => { | |
| 507 | - | eventTypes.forEach(type => { | |
| 508 | - | document.removeEventListener(type, blockEvent, true); | |
| 509 | - | }); | |
| 510 | - | delete window.__dumber_event_blocker_installed; | |
| 511 | - | delete window.__dumber_cleanup_event_blocker; | |
| 512 | - | }; | |
| 513 | - | ||
| 514 | - | console.log('[dumber] Page keyboard event blocking enabled'); | |
| 515 | - | } | |
| 516 | - | })(); | |
| 517 | - | ` | |
| 518 | - | ||
| 519 | - | cScript := C.CString(blockingScript) | |
| 520 | - | defer C.free(unsafe.Pointer(cScript)) | |
| 521 | - | ||
| 522 | - | // Inject into main world (nil world parameter = main world) | |
| 523 | - | keyboardBlockerScript = C.webkit_user_script_new( | |
| 524 | - | (*C.gchar)(cScript), | |
| 525 | - | C.WEBKIT_USER_CONTENT_INJECT_TOP_FRAME, | |
| 526 | - | C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, | |
| 527 | - | nil, nil) // nil = main world | |
| 528 | - | ||
| 529 | - | if keyboardBlockerScript == nil { | |
| 530 | - | return ErrNotImplemented | |
| 531 | - | } | |
| 532 | - | ||
| 533 | - | C.webkit_user_content_manager_add_script(w.native.ucm, keyboardBlockerScript) | |
| 534 | - | log.Printf("[webkit] Page keyboard event blocking enabled") | |
| 535 | - | return nil | |
| 536 | - | } | |
| 537 | - | ||
| 538 | - | // DisablePageKeyboardBlocking removes the keyboard blocking script and restores normal page behavior | |
| 539 | - | func (w *WebView) DisablePageKeyboardBlocking() error { | |
| 540 | - | if w == nil || w.destroyed || w.native == nil || w.native.ucm == nil { | |
| 541 | - | return ErrWebViewNotInitialized | |
| 542 | - | } | |
| 543 | - | ||
| 544 | - | // Execute cleanup script to remove event listeners | |
| 545 | - | cleanupScript := ` | |
| 546 | - | if (window.__dumber_cleanup_event_blocker) { | |
| 547 | - | window.__dumber_cleanup_event_blocker(); | |
| 548 | - | console.log('[dumber] Page keyboard event blocking disabled'); | |
| 549 | - | } | |
| 550 | - | ` | |
| 551 | - | // Double-check WebView is still valid before script injection | |
| 552 | - | if !w.IsDestroyed() { | |
| 553 | - | w.InjectScript(cleanupScript) | |
| 554 | - | } | |
| 555 | - | ||
| 556 | - | // Remove the user script if it exists | |
| 557 | - | if keyboardBlockerScript != nil { | |
| 558 | - | C.webkit_user_content_manager_remove_script(w.native.ucm, keyboardBlockerScript) | |
| 559 | - | C.webkit_user_script_unref(keyboardBlockerScript) | |
| 560 | - | keyboardBlockerScript = nil | |
| 561 | - | log.Printf("[webkit] Keyboard blocker script removed") | |
| 562 | - | } | |
| 563 | - | ||
| 564 | - | return nil | |
| 565 | - | } | |
D pkg/webkit/thread_helpers.c +0 -20
| @@ -1,20 +0,0 @@ | |||
| 1 | - | //go:build webkit_cgo | |
| 2 | - | // +build webkit_cgo | |
| 3 | - | ||
| 4 | - | #include <glib.h> | |
| 5 | - | #include <pthread.h> | |
| 6 | - | ||
| 7 | - | static pthread_t main_thread_id; | |
| 8 | - | ||
| 9 | - | void store_main_thread_id() { | |
| 10 | - | main_thread_id = pthread_self(); | |
| 11 | - | } | |
| 12 | - | ||
| 13 | - | int is_main_thread() { | |
| 14 | - | return pthread_equal(pthread_self(), main_thread_id); | |
| 15 | - | } | |
| 16 | - | ||
| 17 | - | int iterate_main_loop() { | |
| 18 | - | GMainContext *ctx = g_main_context_default(); | |
| 19 | - | return g_main_context_iteration(ctx, FALSE); | |
| 20 | - | } | |
A pkg/webkit/tls_dialog_cgo.go +34 -0
| @@ -0,0 +1,34 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | /* | |
| 4 | + | #cgo pkg-config: gtk4 | |
| 5 | + | #include <gtk/gtk.h> | |
| 6 | + | #include <stdlib.h> | |
| 7 | + | ||
| 8 | + | // Create a GtkAlertDialog with a message | |
| 9 | + | static GtkAlertDialog* create_alert_dialog(const char* message) { | |
| 10 | + | return gtk_alert_dialog_new("%s", message); | |
| 11 | + | } | |
| 12 | + | */ | |
| 13 | + | import "C" | |
| 14 | + | import ( | |
| 15 | + | "unsafe" | |
| 16 | + | ||
| 17 | + | "github.com/diamondburned/gotk4/pkg/core/glib" | |
| 18 | + | "github.com/diamondburned/gotk4/pkg/gtk/v4" | |
| 19 | + | ) | |
| 20 | + | ||
| 21 | + | // newAlertDialog creates a new GTK4 AlertDialog using CGO | |
| 22 | + | func newAlertDialog(message string) *gtk.AlertDialog { | |
| 23 | + | cstr := C.CString(message) | |
| 24 | + | defer C.free(unsafe.Pointer(cstr)) | |
| 25 | + | ||
| 26 | + | cDialog := C.create_alert_dialog(cstr) | |
| 27 | + | if cDialog == nil { | |
| 28 | + | return nil | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | // Wrap the C object in a Go AlertDialog | |
| 32 | + | obj := glib.AssumeOwnership(unsafe.Pointer(cDialog)) | |
| 33 | + | return >k.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 | 1 | package webkit | |
| 4 | 2 | ||
| 5 | 3 | import ( | |
| 4 | + | "encoding/json" | |
| 6 | 5 | "fmt" | |
| 7 | 6 | "log" | |
| 8 | 7 | "strconv" | |
| 8 | + | "sync" | |
| 9 | 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 WebViews | |
| 14 | - | var stubViewIDCounter uint64 | |
| 15 | + | var ( | |
| 16 | + | viewIDCounter uint64 | |
| 17 | + | viewRegistry = make(map[uint64]*WebView) | |
| 18 | + | viewMu sync.RWMutex | |
| 19 | + | ) | |
| 15 | 20 | ||
| 16 | - | // nextStubViewID generates a unique ID for stub WebViews | |
| 17 | - | func nextStubViewID() uintptr { | |
| 18 | - | return uintptr(atomic.AddUint64(&stubViewIDCounter, 1)) | |
| 21 | + | // WebView wraps a WebKitGTK WebView | |
| 22 | + | type WebView struct { | |
| 23 | + | view *webkit.WebView | |
| 24 | + | container *gtk.Box // Container that holds the WebView | |
| 25 | + | window *Window // Optional window (only if CreateWindow=true) | |
| 26 | + | id uint64 | |
| 27 | + | ||
| 28 | + | // State | |
| 29 | + | config *Config | |
| 30 | + | destroyed bool | |
| 31 | + | mu sync.RWMutex | |
| 32 | + | ||
| 33 | + | // Event handlers | |
| 34 | + | onScriptMessage func(string) | |
| 35 | + | onTitleChanged func(string) | |
| 36 | + | onURIChanged func(string) | |
| 37 | + | onFaviconChanged func([]byte) | |
| 38 | + | onFaviconURIChanged func(pageURI, faviconURI string) | |
| 39 | + | onZoomChanged func(float64) | |
| 40 | + | onPopupCreate func(*webkit.NavigationAction) *WebView // New WebKit create signal handler | |
| 41 | + | onReadyToShow func() // WebKit ready-to-show signal handler | |
| 42 | + | onClose func() | |
| 43 | + | onNavigationPolicy func(url string, isUserGesture bool) bool | |
| 44 | + | onWindowTypeDetected func(WindowType, *WindowFeatures) | |
| 45 | + | onWorkspaceNavigation func(direction string) bool // Workspace pane navigation | |
| 46 | + | onPaneModeShortcut func(action string) bool // Pane mode shortcuts (enter, actions, exit) | |
| 47 | + | isPaneModeActive func() bool // Check if pane mode is active | |
| 19 | 48 | } | |
| 20 | 49 | ||
| 21 | - | // WebView represents a browser view powered by WebKit2GTK. | |
| 22 | - | // Methods are currently stubs returning ErrNotImplemented to satisfy TDD ordering. | |
| 23 | - | type WebView struct { | |
| 24 | - | config *Config | |
| 25 | - | visible bool | |
| 26 | - | zoom float64 | |
| 27 | - | url string | |
| 28 | - | destroyed bool | |
| 29 | - | window *Window | |
| 30 | - | msgHandler func(payload string) | |
| 31 | - | titleHandler func(title string) | |
| 32 | - | uriHandler func(uri string) | |
| 33 | - | faviconHandler func(data []byte) | |
| 34 | - | faviconURIHandler func(pageURI, faviconURI string) | |
| 35 | - | zoomHandler func(level float64) | |
| 36 | - | popupHandler func(string) *WebView | |
| 37 | - | closeHandler func() | |
| 38 | - | navigationPolicyHandler func(url string, isUserGesture bool) bool | |
| 39 | - | useDomZoom bool | |
| 40 | - | domZoomSeed float64 | |
| 41 | - | container uintptr | |
| 42 | - | id uintptr // WebView unique identifier | |
| 43 | - | ||
| 44 | - | // Window type fields (no-op in stub) | |
| 45 | - | windowType WindowType | |
| 46 | - | windowFeatures *WindowFeatures | |
| 47 | - | windowTypeCallback func(WindowType, *WindowFeatures) | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | // NewWebView constructs a new WebView instance. | |
| 50 | + | // NewWebView creates a new WebView with the given configuration | |
| 51 | 51 | func NewWebView(cfg *Config) (*WebView, error) { | |
| 52 | 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.") | |
| 56 | - | wv := &WebView{config: cfg, useDomZoom: cfg.UseDomZoom} | |
| 57 | - | if cfg.ZoomDefault <= 0 { | |
| 58 | - | wv.zoom = 1.0 | |
| 78 | + | ||
| 79 | + | // Verify the WebView is using the persistent session | |
| 80 | + | viewSession := wkView.NetworkSession() | |
| 81 | + | if viewSession == nil { | |
| 82 | + | return nil, fmt.Errorf("WebView has no network session") | |
| 83 | + | } | |
| 84 | + | if viewSession.IsEphemeral() { | |
| 85 | + | return nil, fmt.Errorf("WebView is using ephemeral session despite persistent session initialization") | |
| 86 | + | } | |
| 87 | + | ||
| 88 | + | // CRITICAL: Configure CookieManager on the WebView's actual session | |
| 89 | + | // This ensures cookies are persisted regardless of whether the WebView | |
| 90 | + | // uses our global session or creates its own | |
| 91 | + | cookieManager := viewSession.CookieManager() | |
| 92 | + | if cookieManager == nil { | |
| 93 | + | return nil, fmt.Errorf("failed to get cookie manager from WebView's network session") | |
| 94 | + | } | |
| 95 | + | cookiePath := cfg.DataDir + "/cookies.db" | |
| 96 | + | cookieManager.SetPersistentStorage(cookiePath, webkit.CookiePersistentStorageSqlite) | |
| 97 | + | cookieManager.SetAcceptPolicy(webkit.CookiePolicyAcceptNoThirdParty) | |
| 98 | + | ||
| 99 | + | // Setup UserContentManager and inject GUI scripts (including webview ID) | |
| 100 | + | // This must be done BEFORE any pages are loaded | |
| 101 | + | if err := SetupUserContentManager(wkView, cfg.AppearanceConfigJSON, id); err != nil { | |
| 102 | + | return nil, fmt.Errorf("failed to setup user content manager: %w", err) | |
| 103 | + | } | |
| 104 | + | ||
| 105 | + | // Create container (GtkBox) to hold the WebView | |
| 106 | + | // This allows the WebView to be reparented into workspace panes | |
| 107 | + | container := gtk.NewBox(gtk.OrientationVertical, 0) | |
| 108 | + | container.SetHExpand(true) | |
| 109 | + | container.SetVExpand(true) | |
| 110 | + | ||
| 111 | + | // Configure WebView widget for expansion | |
| 112 | + | wkView.SetHExpand(true) | |
| 113 | + | wkView.SetVExpand(true) | |
| 114 | + | ||
| 115 | + | // Add WebView to container | |
| 116 | + | container.Append(wkView) | |
| 117 | + | ||
| 118 | + | wv := &WebView{ | |
| 119 | + | view: wkView, | |
| 120 | + | container: container, | |
| 121 | + | window: nil, // Will be set below if CreateWindow is true | |
| 122 | + | id: id, | |
| 123 | + | config: cfg, | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | // Apply configuration | |
| 127 | + | if err := wv.applyConfig(); err != nil { | |
| 128 | + | return nil, err | |
| 129 | + | } | |
| 130 | + | ||
| 131 | + | // Setup event handlers | |
| 132 | + | wv.setupEventHandlers() | |
| 133 | + | ||
| 134 | + | // Attach keyboard bridge to forward keyboard events to JavaScript | |
| 135 | + | wv.AttachKeyboardBridge() | |
| 136 | + | ||
| 137 | + | // Only create window if requested (standalone WebViews) | |
| 138 | + | // Workspace panes will set CreateWindow=false and manage the container themselves | |
| 139 | + | if cfg.CreateWindow { | |
| 140 | + | window, err := NewWindow("Dumber Browser") | |
| 141 | + | if err != nil { | |
| 142 | + | return nil, err | |
| 143 | + | } | |
| 144 | + | wv.window = window | |
| 145 | + | // Add container as child of window | |
| 146 | + | window.SetChild(container) | |
| 147 | + | log.Printf("[webkit] Created standalone window for WebView ID %d", id) | |
| 59 | 148 | } else { | |
| 60 | - | wv.zoom = cfg.ZoomDefault | |
| 149 | + | log.Printf("[webkit] Created WebView ID %d without window (for workspace embedding)", id) | |
| 61 | 150 | } | |
| 62 | - | wv.domZoomSeed = wv.zoom | |
| 63 | - | wv.id = nextStubViewID() // Assign unique ID | |
| 64 | - | // Construction succeeds; create a logical window placeholder. | |
| 65 | - | wv.window = &Window{Title: "Dumber Browser"} | |
| 66 | - | wv.container = newWidgetHandle() | |
| 151 | + | ||
| 152 | + | // Register in global registry | |
| 153 | + | viewMu.Lock() | |
| 154 | + | viewRegistry[id] = wv | |
| 155 | + | viewMu.Unlock() | |
| 156 | + | ||
| 67 | 157 | return wv, nil | |
| 68 | 158 | } | |
| 69 | 159 | ||
| 70 | - | // NewWebViewWithRelated creates a new WebView related to an existing one (non-CGO stub) | |
| 71 | - | func NewWebViewWithRelated(cfg *Config, relatedView *WebView) (*WebView, error) { | |
| 72 | - | // In non-CGO build, just create a regular WebView | |
| 73 | - | return NewWebView(cfg) | |
| 160 | + | // applyConfig applies the configuration to the WebView settings | |
| 161 | + | func (w *WebView) applyConfig() error { | |
| 162 | + | settings := w.view.Settings() | |
| 163 | + | if settings == nil { | |
| 164 | + | return fmt.Errorf("webkit: failed to get settings") | |
| 165 | + | } | |
| 166 | + | ||
| 167 | + | // Apply settings from config | |
| 168 | + | settings.SetEnableJavascript(w.config.EnableJavaScript) | |
| 169 | + | settings.SetEnableWebgl(w.config.EnableWebGL) | |
| 170 | + | settings.SetDefaultFontSize(uint32(w.config.DefaultFontSize)) | |
| 171 | + | settings.SetMinimumFontSize(uint32(w.config.MinimumFontSize)) | |
| 172 | + | ||
| 173 | + | if w.config.UserAgent != "" { | |
| 174 | + | settings.SetUserAgent(w.config.UserAgent) | |
| 175 | + | } | |
| 176 | + | ||
| 177 | + | // Enable developer tools (F12, inspector) | |
| 178 | + | settings.SetEnableDeveloperExtras(true) | |
| 179 | + | ||
| 180 | + | // Enable hardware acceleration if configured | |
| 181 | + | settings.SetHardwareAccelerationPolicy(webkit.HardwareAccelerationPolicyAlways) | |
| 182 | + | ||
| 183 | + | return nil | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // setupEventHandlers connects GTK signals to internal handlers | |
| 187 | + | func (w *WebView) setupEventHandlers() { | |
| 188 | + | // Title changed - connect to notify::title signal | |
| 189 | + | w.view.Connect("notify::title", func() { | |
| 190 | + | if w.onTitleChanged != nil { | |
| 191 | + | title := w.view.Title() | |
| 192 | + | w.onTitleChanged(title) | |
| 193 | + | } | |
| 194 | + | }) | |
| 195 | + | ||
| 196 | + | // URI changed - connect to notify::uri signal | |
| 197 | + | w.view.Connect("notify::uri", func() { | |
| 198 | + | if w.onURIChanged != nil { | |
| 199 | + | uri := w.view.URI() | |
| 200 | + | w.onURIChanged(uri) | |
| 201 | + | } | |
| 202 | + | }) | |
| 203 | + | ||
| 204 | + | // Zoom level changed - connect to notify::zoom-level signal | |
| 205 | + | w.view.Connect("notify::zoom-level", func() { | |
| 206 | + | if w.onZoomChanged != nil { | |
| 207 | + | zoomLevel := w.view.ZoomLevel() | |
| 208 | + | w.onZoomChanged(zoomLevel) | |
| 209 | + | } | |
| 210 | + | }) | |
| 211 | + | ||
| 212 | + | // Favicon changed - connect to FaviconDatabase | |
| 213 | + | w.setupFaviconHandlers() | |
| 214 | + | ||
| 215 | + | // Load changed | |
| 216 | + | w.view.ConnectLoadChanged(func(event webkit.LoadEvent) { | |
| 217 | + | // Handle load events if needed | |
| 218 | + | }) | |
| 219 | + | ||
| 220 | + | // TLS error handling - connect to load-failed-with-tls-errors signal | |
| 221 | + | w.setupTLSErrorHandler() | |
| 222 | + | ||
| 223 | + | // Close | |
| 224 | + | w.view.ConnectClose(func() { | |
| 225 | + | if w.onClose != nil { | |
| 226 | + | w.onClose() | |
| 227 | + | } | |
| 228 | + | }) | |
| 229 | + | ||
| 230 | + | // Create signal - for popup lifecycle management | |
| 231 | + | w.view.ConnectCreate(func(navigationAction *webkit.NavigationAction) gtk.Widgetter { | |
| 232 | + | log.Printf("[webkit] ConnectCreate callback fired for parent WebView ID=%d", w.id) | |
| 233 | + | if w.onPopupCreate != nil { | |
| 234 | + | log.Printf("[webkit] Calling onPopupCreate handler") | |
| 235 | + | newWebView := w.onPopupCreate(navigationAction) | |
| 236 | + | if newWebView != nil { | |
| 237 | + | log.Printf("[webkit] onPopupCreate returned WebView wrapper ID=%d", newWebView.id) | |
| 238 | + | log.Printf("[webkit] Extracting underlying gotk4 WebView from wrapper") | |
| 239 | + | underlyingView := newWebView.view | |
| 240 | + | log.Printf("[webkit] Extracted gotk4 WebView=%p, about to return to WebKit", underlyingView) | |
| 241 | + | // Return the underlying WebView widget to WebKit | |
| 242 | + | return underlyingView | |
| 243 | + | } | |
| 244 | + | log.Printf("[webkit] onPopupCreate returned nil, blocking popup") | |
| 245 | + | } | |
| 246 | + | // Return nil to cancel popup creation | |
| 247 | + | log.Printf("[webkit] No onPopupCreate handler, blocking popup") | |
| 248 | + | return nil | |
| 249 | + | }) | |
| 250 | + | ||
| 251 | + | // Ready-to-show signal - emitted when popup is ready to be displayed | |
| 252 | + | w.view.ConnectReadyToShow(func() { | |
| 253 | + | if w.onReadyToShow != nil { | |
| 254 | + | w.onReadyToShow() | |
| 255 | + | } | |
| 256 | + | }) | |
| 257 | + | ||
| 258 | + | // Script message received - connect to UserContentManager's script-message-received signal | |
| 259 | + | // This receives messages from JavaScript via webkit.messageHandlers.dumber.postMessage() | |
| 260 | + | // The signal name includes the handler name as a detail: "script-message-received::dumber" | |
| 261 | + | ucm := w.view.UserContentManager() | |
| 262 | + | if ucm != nil { | |
| 263 | + | // GTK signals pass the sender as the first parameter, the actual signal data as subsequent parameters | |
| 264 | + | ucm.Connect("script-message-received::dumber", func(sender interface{}, jscValue interface{}) { | |
| 265 | + | if w.onScriptMessage != nil && jscValue != nil { | |
| 266 | + | // Convert JSCValue to string using gotk4 javascriptcore bindings | |
| 267 | + | valueStr := JSCValueToString(jscValue) | |
| 268 | + | if valueStr != "" { | |
| 269 | + | w.onScriptMessage(valueStr) | |
| 270 | + | } | |
| 271 | + | } | |
| 272 | + | }) | |
| 273 | + | } | |
| 274 | + | } | |
| 275 | + | ||
| 276 | + | // LoadURL loads the given URL in the WebView | |
| 277 | + | func (w *WebView) LoadURL(url string) error { | |
| 278 | + | w.mu.RLock() | |
| 279 | + | defer w.mu.RUnlock() | |
| 280 | + | ||
| 281 | + | if w.destroyed { | |
| 282 | + | return ErrWebViewDestroyed | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | if url == "" { | |
| 286 | + | return ErrInvalidURL | |
| 287 | + | } | |
| 288 | + | ||
| 289 | + | w.view.LoadURI(url) | |
| 290 | + | return nil | |
| 291 | + | } | |
| 292 | + | ||
| 293 | + | // GetCurrentURL returns the current URL | |
| 294 | + | func (w *WebView) GetCurrentURL() string { | |
| 295 | + | w.mu.RLock() | |
| 296 | + | defer w.mu.RUnlock() | |
| 297 | + | ||
| 298 | + | if w.destroyed { | |
| 299 | + | return "" | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | return w.view.URI() | |
| 303 | + | } | |
| 304 | + | ||
| 305 | + | // GetTitle returns the current page title | |
| 306 | + | func (w *WebView) GetTitle() string { | |
| 307 | + | w.mu.RLock() | |
| 308 | + | defer w.mu.RUnlock() | |
| 309 | + | ||
| 310 | + | if w.destroyed { | |
| 311 | + | return "" | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | return w.view.Title() | |
| 74 | 315 | } | |
| 75 | 316 | ||
| 76 | - | // LoadURL navigates the webview to the specified URL. | |
| 77 | - | func (w *WebView) LoadURL(rawURL string) error { | |
| 78 | - | if w == nil || w.destroyed { | |
| 79 | - | return ErrNotImplemented | |
| 317 | + | // GoBack navigates back in history | |
| 318 | + | func (w *WebView) GoBack() error { | |
| 319 | + | w.mu.RLock() | |
| 320 | + | defer w.mu.RUnlock() | |
| 321 | + | ||
| 322 | + | if w.destroyed { | |
| 323 | + | return ErrWebViewDestroyed | |
| 80 | 324 | } | |
| 81 | - | if rawURL == "" { | |
| 82 | - | return fmt.Errorf("url cannot be empty") | |
| 325 | + | ||
| 326 | + | w.view.GoBack() | |
| 327 | + | return nil | |
| 328 | + | } | |
| 329 | + | ||
| 330 | + | // GoForward navigates forward in history | |
| 331 | + | func (w *WebView) GoForward() error { | |
| 332 | + | w.mu.RLock() | |
| 333 | + | defer w.mu.RUnlock() | |
| 334 | + | ||
| 335 | + | if w.destroyed { | |
| 336 | + | return ErrWebViewDestroyed | |
| 337 | + | } | |
| 338 | + | ||
| 339 | + | w.view.GoForward() | |
| 340 | + | return nil | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | // Reload reloads the current page | |
| 344 | + | func (w *WebView) Reload() error { | |
| 345 | + | w.mu.RLock() | |
| 346 | + | defer w.mu.RUnlock() | |
| 347 | + | ||
| 348 | + | if w.destroyed { | |
| 349 | + | return ErrWebViewDestroyed | |
| 83 | 350 | } | |
| 84 | - | // Minimal validation; actual navigation handled by CGO bridge later. | |
| 85 | - | w.url = rawURL | |
| 86 | - | log.Printf("[webkit] LoadURL (non-CGO stub): %s", rawURL) | |
| 351 | + | ||
| 352 | + | w.view.Reload() | |
| 87 | 353 | 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 | 370 | func (w *WebView) Show() error { | |
| 92 | - | if w == nil || w.destroyed { | |
| 93 | - | return ErrNotImplemented | |
| 371 | + | w.mu.RLock() | |
| 372 | + | defer w.mu.RUnlock() | |
| 373 | + | ||
| 374 | + | if w.destroyed { | |
| 375 | + | return ErrWebViewDestroyed | |
| 376 | + | } | |
| 377 | + | ||
| 378 | + | // Make the WebView widget visible | |
| 379 | + | w.view.SetVisible(true) | |
| 380 | + | ||
| 381 | + | // Show the window containing the WebView | |
| 382 | + | if w.window != nil { | |
| 383 | + | w.window.Show() | |
| 94 | 384 | } | |
| 95 | - | w.visible = true | |
| 96 | - | log.Printf("[webkit] Show (non-CGO stub): no native window will appear") | |
| 385 | + | ||
| 97 | 386 | return nil | |
| 98 | 387 | } | |
| 99 | 388 | ||
| 100 | - | // Hide hides the WebView window. | |
| 389 | + | // Hide makes the WebView invisible | |
| 101 | 390 | func (w *WebView) Hide() error { | |
| 102 | - | if w == nil || w.destroyed { | |
| 103 | - | return ErrNotImplemented | |
| 391 | + | w.mu.RLock() | |
| 392 | + | defer w.mu.RUnlock() | |
| 393 | + | ||
| 394 | + | if w.destroyed { | |
| 395 | + | return ErrWebViewDestroyed | |
| 104 | 396 | } | |
| 105 | - | w.visible = false | |
| 397 | + | ||
| 398 | + | w.view.SetVisible(false) | |
| 106 | 399 | return nil | |
| 107 | 400 | } | |
| 108 | 401 | ||
| 109 | - | // Destroy releases native resources. | |
| 402 | + | // Destroy destroys the WebView and releases resources | |
| 110 | 403 | func (w *WebView) Destroy() error { | |
| 111 | - | if w == nil { | |
| 112 | - | return ErrNotImplemented | |
| 404 | + | w.mu.Lock() | |
| 405 | + | defer w.mu.Unlock() | |
| 406 | + | ||
| 407 | + | if w.destroyed { | |
| 408 | + | return nil | |
| 113 | 409 | } | |
| 410 | + | ||
| 114 | 411 | 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 | 419 | return nil | |
| 116 | 420 | } | |
| 117 | 421 | ||
| 118 | - | // IsDestroyed returns whether this WebView has been destroyed | |
| 422 | + | // IsDestroyed returns true if the WebView has been destroyed | |
| 119 | 423 | func (w *WebView) IsDestroyed() bool { | |
| 120 | - | return w != nil && w.destroyed | |
| 424 | + | w.mu.RLock() | |
| 425 | + | defer w.mu.RUnlock() | |
| 426 | + | return w.destroyed | |
| 427 | + | } | |
| 428 | + | ||
| 429 | + | // ID returns the unique identifier for this WebView | |
| 430 | + | func (w *WebView) ID() uint64 { | |
| 431 | + | return w.id | |
| 432 | + | } | |
| 433 | + | ||
| 434 | + | // IDString returns the WebView ID as a string | |
| 435 | + | // This is a convenience method for compatibility with code expecting string IDs | |
| 436 | + | func (w *WebView) IDString() string { | |
| 437 | + | return fmt.Sprintf("%d", w.id) | |
| 438 | + | } | |
| 439 | + | ||
| 440 | + | // ParseWebViewID converts a string WebView ID to uint64 | |
| 441 | + | // Returns 0 if the string cannot be parsed | |
| 442 | + | func ParseWebViewID(idStr string) uint64 { | |
| 443 | + | id, err := strconv.ParseUint(idStr, 10, 64) | |
| 444 | + | if err != nil { | |
| 445 | + | return 0 | |
| 446 | + | } | |
| 447 | + | return id | |
| 448 | + | } | |
| 449 | + | ||
| 450 | + | // AsWidget returns the WebView as a gtk.Widgetter | |
| 451 | + | func (w *WebView) AsWidget() gtk.Widgetter { | |
| 452 | + | if w == nil || w.view == nil { | |
| 453 | + | return nil | |
| 454 | + | } | |
| 455 | + | return w.view | |
| 121 | 456 | } | |
| 122 | 457 | ||
| 123 | - | // Window returns the associated native window wrapper (non-nil). | |
| 124 | - | func (w *WebView) Window() *Window { return w.window } | |
| 458 | + | // Event handler registration methods | |
| 459 | + | ||
| 460 | + | // RegisterScriptMessageHandler registers a handler for script messages | |
| 461 | + | func (w *WebView) RegisterScriptMessageHandler(handler func(string)) { | |
| 462 | + | w.mu.Lock() | |
| 463 | + | defer w.mu.Unlock() | |
| 464 | + | w.onScriptMessage = handler | |
| 465 | + | } | |
| 125 | 466 | ||
| 126 | - | // GetCurrentURL returns the last requested URL (non-CGO build approximation). | |
| 127 | - | func (w *WebView) GetCurrentURL() string { return w.url } | |
| 467 | + | // RegisterTitleChangedHandler registers a handler for title changes | |
| 468 | + | func (w *WebView) RegisterTitleChangedHandler(handler func(string)) { | |
| 469 | + | w.mu.Lock() | |
| 470 | + | defer w.mu.Unlock() | |
| 471 | + | w.onTitleChanged = handler | |
| 472 | + | } | |
| 128 | 473 | ||
| 129 | - | // GetTitle returns a stub page title. | |
| 130 | - | func (w *WebView) GetTitle() string { return "New Tab" } | |
| 474 | + | // RegisterURIChangedHandler registers a handler for URI changes | |
| 475 | + | func (w *WebView) RegisterURIChangedHandler(handler func(string)) { | |
| 476 | + | w.mu.Lock() | |
| 477 | + | defer w.mu.Unlock() | |
| 478 | + | w.onURIChanged = handler | |
| 479 | + | } | |
| 131 | 480 | ||
| 132 | - | // GoBack is not supported in the non-CGO stub. | |
| 133 | - | func (w *WebView) GoBack() error { return ErrNotImplemented } | |
| 481 | + | // RegisterFaviconChangedHandler registers a handler for favicon changes | |
| 482 | + | func (w *WebView) RegisterFaviconChangedHandler(handler func([]byte)) { | |
| 483 | + | w.mu.Lock() | |
| 484 | + | defer w.mu.Unlock() | |
| 485 | + | w.onFaviconChanged = handler | |
| 486 | + | } | |
| 134 | 487 | ||
| 135 | - | // GoForward is not supported in the non-CGO stub. | |
| 136 | - | func (w *WebView) GoForward() error { return ErrNotImplemented } | |
| 488 | + | // RegisterFaviconURIChangedHandler registers a handler for favicon URI changes | |
| 489 | + | func (w *WebView) RegisterFaviconURIChangedHandler(handler func(pageURI, faviconURI string)) { | |
| 490 | + | w.mu.Lock() | |
| 491 | + | defer w.mu.Unlock() | |
| 492 | + | w.onFaviconURIChanged = handler | |
| 493 | + | } | |
| 137 | 494 | ||
| 138 | - | // Reload is not supported in the non-CGO stub. | |
| 139 | - | func (w *WebView) Reload() error { return ErrNotImplemented } | |
| 495 | + | // RegisterZoomChangedHandler registers a handler for zoom changes | |
| 496 | + | func (w *WebView) RegisterZoomChangedHandler(handler func(float64)) { | |
| 497 | + | w.mu.Lock() | |
| 498 | + | defer w.mu.Unlock() | |
| 499 | + | w.onZoomChanged = handler | |
| 500 | + | } | |
| 140 | 501 | ||
| 141 | - | // ReloadBypassCache is not supported in the non-CGO stub. | |
| 142 | - | func (w *WebView) ReloadBypassCache() error { return ErrNotImplemented } | |
| 502 | + | // RegisterCloseHandler registers a handler for close requests | |
| 503 | + | func (w *WebView) RegisterCloseHandler(handler func()) { | |
| 504 | + | w.mu.Lock() | |
| 505 | + | defer w.mu.Unlock() | |
| 506 | + | w.onClose = handler | |
| 507 | + | } | |
| 143 | 508 | ||
| 144 | - | // ShowDevTools is a no-op in the non-CGO build. | |
| 145 | - | func (w *WebView) ShowDevTools() error { return nil } | |
| 509 | + | // RegisterPopupCreateHandler registers a handler for WebKit's create signal | |
| 510 | + | // This is called when JavaScript calls window.open() or a link with target="_blank" is clicked | |
| 511 | + | // The handler should create and return a new WebView for the popup, or return nil to block it | |
| 512 | + | // The returned WebView should NOT be shown yet - WebKit will emit ready-to-show when it's ready | |
| 513 | + | func (w *WebView) RegisterPopupCreateHandler(handler func(*webkit.NavigationAction) *WebView) { | |
| 514 | + | w.mu.Lock() | |
| 515 | + | defer w.mu.Unlock() | |
| 516 | + | w.onPopupCreate = handler | |
| 517 | + | } | |
| 146 | 518 | ||
| 147 | - | // CloseDevTools is a no-op in the non-CGO build. | |
| 148 | - | func (w *WebView) CloseDevTools() error { return nil } | |
| 519 | + | // RegisterReadyToShowHandler registers a handler for WebKit's ready-to-show signal | |
| 520 | + | // This is called when a popup WebView is ready to be displayed | |
| 521 | + | // At this point it's safe to insert the popup into the UI (workspace, window, etc.) | |
| 522 | + | func (w *WebView) RegisterReadyToShowHandler(handler func()) { | |
| 523 | + | w.mu.Lock() | |
| 524 | + | defer w.mu.Unlock() | |
| 525 | + | w.onReadyToShow = handler | |
| 526 | + | } | |
| 149 | 527 | ||
| 150 | - | // ShowPrintDialog is a no-op in the non-CGO build. | |
| 151 | - | func (w *WebView) ShowPrintDialog() error { return nil } | |
| 528 | + | // RegisterNavigationPolicyHandler registers a handler for navigation policy decisions | |
| 529 | + | func (w *WebView) RegisterNavigationPolicyHandler(handler func(url string, isUserGesture bool) bool) { | |
| 530 | + | w.mu.Lock() | |
| 531 | + | defer w.mu.Unlock() | |
| 532 | + | w.onNavigationPolicy = handler | |
| 533 | + | } | |
| 152 | 534 | ||
| 153 | - | // RegisterScriptMessageHandler registers a callback invoked when the content script posts a message. | |
| 154 | - | func (w *WebView) RegisterScriptMessageHandler(cb func(payload string)) { w.msgHandler = cb } | |
| 535 | + | // OnWindowTypeDetected registers a handler for window type detection | |
| 536 | + | func (w *WebView) OnWindowTypeDetected(handler func(WindowType, *WindowFeatures)) { | |
| 537 | + | w.mu.Lock() | |
| 538 | + | defer w.mu.Unlock() | |
| 539 | + | w.onWindowTypeDetected = handler | |
| 540 | + | } | |
| 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) { | |
| 161 | - | w.navigationPolicyHandler = cb | |
| 552 | + | // Widget returns the WebView widget (for compatibility with old code expecting uintptr) | |
| 553 | + | // Deprecated: Use AsWidget() instead | |
| 554 | + | func (w *WebView) Widget() gtk.Widgetter { | |
| 555 | + | return w.AsWidget() | |
| 162 | 556 | } | |
| 163 | 557 | ||
| 164 | - | func (w *WebView) dispatchScriptMessage(payload string) { //nolint:unused // Called from CGO WebKit callbacks | |
| 165 | - | if w != nil && w.msgHandler != nil { | |
| 166 | - | w.msgHandler(payload) | |
| 558 | + | // RootWidget returns the root container widget for this WebView | |
| 559 | + | // This is the GtkBox container that holds the WebView and can be reparented | |
| 560 | + | func (w *WebView) RootWidget() gtk.Widgetter { | |
| 561 | + | if w.container != nil { | |
| 562 | + | return w.container | |
| 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 callbacks | |
| 171 | - | if w != nil && w.popupHandler != nil { | |
| 172 | - | return w.popupHandler(uri) | |
| 568 | + | // SetZoom sets the zoom level of the WebView | |
| 569 | + | func (w *WebView) SetZoom(zoom float64) error { | |
| 570 | + | w.mu.RLock() | |
| 571 | + | defer w.mu.RUnlock() | |
| 572 | + | ||
| 573 | + | if w.destroyed { | |
| 574 | + | return ErrWebViewDestroyed | |
| 173 | 575 | } | |
| 174 | - | return nil | |
| 175 | - | } | |
| 176 | 576 | ||
| 177 | - | // GetNativePointer returns nil for non-CGO build (stub implementation) | |
| 178 | - | func (w *WebView) GetNativePointer() unsafe.Pointer { //nolint:unused // Needed for interface compatibility | |
| 577 | + | w.view.SetZoomLevel(zoom) | |
| 179 | 578 | return nil | |
| 180 | 579 | } | |
| 181 | 580 | ||
| 182 | - | // RegisterTitleChangedHandler registers a callback invoked when the page title changes. | |
| 183 | - | func (w *WebView) RegisterTitleChangedHandler(cb func(title string)) { w.titleHandler = cb } | |
| 581 | + | // GetZoom returns the current zoom level | |
| 582 | + | func (w *WebView) GetZoom() float64 { | |
| 583 | + | w.mu.RLock() | |
| 584 | + | defer w.mu.RUnlock() | |
| 184 | 585 | ||
| 185 | - | func (w *WebView) dispatchTitleChanged(title string) { //nolint:unused // Called from CGO WebKit callbacks | |
| 186 | - | if w != nil && w.titleHandler != nil { | |
| 187 | - | w.titleHandler(title) | |
| 586 | + | if w.destroyed { | |
| 587 | + | return 1.0 | |
| 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. | |
| 192 | - | func (w *WebView) RegisterURIChangedHandler(cb func(uri string)) { w.uriHandler = cb } | |
| 605 | + | // InjectScript executes JavaScript in the WebView | |
| 606 | + | func (w *WebView) InjectScript(script string) error { | |
| 607 | + | w.mu.RLock() | |
| 608 | + | defer w.mu.RUnlock() | |
| 193 | 609 | ||
| 194 | - | func (w *WebView) dispatchURIChanged(uri string) { //nolint:unused // Called from CGO WebKit callbacks | |
| 195 | - | if w != nil && w.uriHandler != nil { | |
| 196 | - | w.uriHandler(uri) | |
| 610 | + | if w.destroyed { | |
| 611 | + | return ErrWebViewDestroyed | |
| 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. | |
| 201 | - | func (w *WebView) RegisterFaviconChangedHandler(cb func(data []byte)) { w.faviconHandler = cb } | |
| 619 | + | // DispatchCustomEvent dispatches a custom event via JavaScript | |
| 620 | + | func (w *WebView) DispatchCustomEvent(eventName string, data interface{}) error { | |
| 621 | + | // Serialize data to JSON | |
| 622 | + | jsonData, err := json.Marshal(data) | |
| 623 | + | if err != nil { | |
| 624 | + | return fmt.Errorf("failed to marshal event data: %w", err) | |
| 625 | + | } | |
| 202 | 626 | ||
| 203 | - | // RegisterFaviconURIChangedHandler registers a callback invoked when WebKit detects a favicon URI. | |
| 204 | - | func (w *WebView) RegisterFaviconURIChangedHandler(cb func(pageURI, faviconURI string)) { | |
| 205 | - | w.faviconURIHandler = cb | |
| 627 | + | log.Printf("[webkit] Dispatching event '%s' to WebView ID %d with data: %s", eventName, w.id, string(jsonData)) | |
| 628 | + | ||
| 629 | + | // Use document.dispatchEvent (not window) so events cross JavaScript world boundaries | |
| 630 | + | // The GUI scripts run in an isolated world but share the same Document object | |
| 631 | + | script := fmt.Sprintf(` | |
| 632 | + | (function() { | |
| 633 | + | try { | |
| 634 | + | var detail = %s; | |
| 635 | + | console.log('[dumber] Dispatching event: %s', detail); | |
| 636 | + | document.dispatchEvent(new CustomEvent('%s', { detail: detail })); | |
| 637 | + | } catch (e) { | |
| 638 | + | console.error('[dumber] Failed to dispatch %s', e); | |
| 639 | + | } | |
| 640 | + | })(); | |
| 641 | + | `, string(jsonData), eventName, eventName, eventName) | |
| 642 | + | return w.InjectScript(script) | |
| 206 | 643 | } | |
| 207 | 644 | ||
| 208 | - | func (w *WebView) dispatchFaviconChanged(data []byte) { //nolint:unused // Called from CGO WebKit callbacks | |
| 209 | - | if w != nil && w.faviconHandler != nil { | |
| 210 | - | w.faviconHandler(data) | |
| 645 | + | // ShowDevTools opens the WebKit inspector/developer tools | |
| 646 | + | func (w *WebView) ShowDevTools() error { | |
| 647 | + | w.mu.RLock() | |
| 648 | + | defer w.mu.RUnlock() | |
| 649 | + | ||
| 650 | + | if w.destroyed { | |
| 651 | + | return ErrWebViewDestroyed | |
| 652 | + | } | |
| 653 | + | ||
| 654 | + | inspector := w.view.Inspector() | |
| 655 | + | if inspector != nil { | |
| 656 | + | inspector.Show() | |
| 211 | 657 | } | |
| 658 | + | return nil | |
| 212 | 659 | } | |
| 213 | 660 | ||
| 214 | - | // RegisterZoomChangedHandler registers a callback invoked when zoom level changes. | |
| 215 | - | func (w *WebView) RegisterZoomChangedHandler(cb func(level float64)) { w.zoomHandler = cb } | |
| 661 | + | // ShowPrintDialog shows the print dialog for the current page | |
| 662 | + | func (w *WebView) ShowPrintDialog() error { | |
| 663 | + | w.mu.RLock() | |
| 664 | + | defer w.mu.RUnlock() | |
| 216 | 665 | ||
| 217 | - | func (w *WebView) dispatchZoomChanged(level float64) { | |
| 218 | - | if w != nil && w.zoomHandler != nil { | |
| 219 | - | w.zoomHandler(level) | |
| 666 | + | if w.destroyed { | |
| 667 | + | return ErrWebViewDestroyed | |
| 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. | |
| 224 | - | func (w *WebView) Widget() uintptr { return 0 } | |
| 684 | + | // SetWindowFeatures sets window features for popup windows | |
| 685 | + | func (w *WebView) SetWindowFeatures(features *WindowFeatures) { | |
| 686 | + | // This is typically used for popup windows | |
| 687 | + | // The features would be applied when creating the window | |
| 688 | + | } | |
| 225 | 689 | ||
| 226 | - | // RootWidget returns the container widget handle in CGO builds. Stub returns 0. | |
| 227 | - | func (w *WebView) RootWidget() uintptr { return w.container } | |
| 690 | + | // IsActive returns whether this WebView is currently active/focused | |
| 691 | + | func (w *WebView) IsActive() bool { | |
| 692 | + | w.mu.RLock() | |
| 693 | + | defer w.mu.RUnlock() | |
| 228 | 694 | ||
| 229 | - | // DestroyWindow is a no-op in stub builds. | |
| 230 | - | func (w *WebView) DestroyWindow() {} | |
| 695 | + | if w.destroyed { | |
| 696 | + | return false | |
| 697 | + | } | |
| 231 | 698 | ||
| 232 | - | // RunOnMainThread executes fn immediately in non-CGO builds. | |
| 233 | - | func (w *WebView) RunOnMainThread(fn func()) { | |
| 234 | - | if fn != nil { | |
| 235 | - | fn() | |
| 699 | + | widget := w.view | |
| 700 | + | if widget != nil { | |
| 701 | + | return widget.IsFocus() | |
| 236 | 702 | } | |
| 703 | + | return false | |
| 237 | 704 | } | |
| 238 | 705 | ||
| 239 | - | // PrepareForReparenting is a no-op in stub builds. | |
| 240 | - | func (w *WebView) PrepareForReparenting() {} | |
| 706 | + | // Window returns the parent Window of this WebView | |
| 707 | + | func (w *WebView) Window() *Window { | |
| 708 | + | w.mu.RLock() | |
| 709 | + | defer w.mu.RUnlock() | |
| 241 | 710 | ||
| 242 | - | // RefreshAfterReparenting is a no-op in stub builds. | |
| 243 | - | func (w *WebView) RefreshAfterReparenting() {} | |
| 711 | + | if w.destroyed { | |
| 712 | + | return nil | |
| 713 | + | } | |
| 244 | 714 | ||
| 245 | - | // UsesDomZoom reports whether DOM-based zoom is enabled in this WebView. | |
| 246 | - | func (w *WebView) UsesDomZoom() bool { | |
| 247 | - | return w != nil && w.useDomZoom | |
| 715 | + | return w.window | |
| 248 | 716 | } | |
| 249 | 717 | ||
| 250 | - | // SeedDomZoom stores the desired DOM zoom level for the next navigation (stub implementation). | |
| 251 | - | func (w *WebView) SeedDomZoom(level float64) { | |
| 252 | - | if w == nil || !w.useDomZoom { | |
| 253 | - | return | |
| 254 | - | } | |
| 255 | - | if level < 0.25 { | |
| 256 | - | level = 0.25 | |
| 257 | - | } else if level > 5.0 { | |
| 258 | - | level = 5.0 | |
| 718 | + | // UpdateContentFilters updates the content filtering rules | |
| 719 | + | func (w *WebView) UpdateContentFilters(rules string) error { | |
| 720 | + | w.mu.RLock() | |
| 721 | + | defer w.mu.RUnlock() | |
| 722 | + | ||
| 723 | + | if w.destroyed { | |
| 724 | + | return ErrWebViewDestroyed | |
| 259 | 725 | } | |
| 260 | - | w.domZoomSeed = level | |
| 261 | - | } | |
| 262 | 726 | ||
| 263 | - | // CreateRelatedView returns a new WebView (stub creates independent) | |
| 264 | - | func (w *WebView) CreateRelatedView() *WebView { | |
| 265 | - | nw, _ := NewWebView(w.config) | |
| 266 | - | return nw | |
| 727 | + | // Note: Content filtering is handled via InitializeContentBlocking() | |
| 728 | + | // This method is kept for backward compatibility but is deprecated | |
| 729 | + | log.Printf("[webkit] UpdateContentFilters called but is deprecated - use InitializeContentBlocking instead") | |
| 730 | + | return nil | |
| 267 | 731 | } | |
| 268 | 732 | ||
| 269 | - | // OnWindowTypeDetected registers a callback (stored but never invoked in stub) | |
| 270 | - | func (w *WebView) OnWindowTypeDetected(callback func(WindowType, *WindowFeatures)) { | |
| 271 | - | if w != nil { | |
| 272 | - | w.windowTypeCallback = callback | |
| 733 | + | // InitializeContentBlocking initializes content blocking with filter lists | |
| 734 | + | func (w *WebView) InitializeContentBlocking(filterJSON []byte) error { | |
| 735 | + | w.mu.RLock() | |
| 736 | + | defer w.mu.RUnlock() | |
| 737 | + | ||
| 738 | + | if w.destroyed { | |
| 739 | + | return ErrWebViewDestroyed | |
| 273 | 740 | } | |
| 274 | - | } | |
| 275 | 741 | ||
| 276 | - | // InitializeContentBlocking initializes WebKit content blocking with filter manager (stub) | |
| 277 | - | func (w *WebView) InitializeContentBlocking(filterManager interface{}) error { | |
| 278 | - | return ErrNotImplemented | |
| 742 | + | if len(filterJSON) == 0 { | |
| 743 | + | return fmt.Errorf("no filter rules provided") | |
| 744 | + | } | |
| 745 | + | ||
| 746 | + | // Apply filters using the content blocking API | |
| 747 | + | return ApplyFiltersToWebView(w, filterJSON) | |
| 279 | 748 | } | |
| 280 | 749 | ||
| 281 | - | // OnNavigate sets up domain-specific cosmetic filtering on navigation (stub) | |
| 282 | - | func (w *WebView) OnNavigate(url string, filterManager interface{}, whitelist []string) { | |
| 283 | - | // No-op in stub | |
| 750 | + | // OnNavigate registers a navigation handler | |
| 751 | + | func (w *WebView) OnNavigate(handler func(url string)) { | |
| 752 | + | w.mu.Lock() | |
| 753 | + | defer w.mu.Unlock() | |
| 754 | + | ||
| 755 | + | // This wraps the URI changed handler | |
| 756 | + | w.onURIChanged = handler | |
| 284 | 757 | } | |
| 285 | 758 | ||
| 286 | - | // UpdateContentFilters updates the content filters dynamically (stub) | |
| 287 | - | func (w *WebView) UpdateContentFilters(filterManager interface{}) error { | |
| 288 | - | return ErrNotImplemented | |
| 759 | + | // RegisterWorkspaceNavigationHandler registers a handler for workspace pane navigation | |
| 760 | + | // The handler receives the direction ("up", "down", "left", "right") and returns true if handled | |
| 761 | + | func (w *WebView) RegisterWorkspaceNavigationHandler(handler func(direction string) bool) { | |
| 762 | + | w.mu.Lock() | |
| 763 | + | defer w.mu.Unlock() | |
| 764 | + | w.onWorkspaceNavigation = handler | |
| 289 | 765 | } | |
| 290 | 766 | ||
| 291 | - | // SetWindowFeatures sets the window features for this WebView (stub) | |
| 292 | - | func (w *WebView) SetWindowFeatures(features *WindowFeatures) { | |
| 293 | - | w.windowFeatures = features | |
| 767 | + | // RegisterPaneModeHandler registers a handler for pane mode shortcuts | |
| 768 | + | // The handler receives the action ("enter", "close", "split-right", etc.) and returns true if handled | |
| 769 | + | func (w *WebView) RegisterPaneModeHandler(handler func(action string) bool, isActiveChecker func() bool) { | |
| 770 | + | w.mu.Lock() | |
| 771 | + | defer w.mu.Unlock() | |
| 772 | + | w.onPaneModeShortcut = handler | |
| 773 | + | w.isPaneModeActive = isActiveChecker | |
| 294 | 774 | } | |
| 295 | 775 | ||
| 296 | - | // SetActive sets whether this WebView is currently active/focused (stub) | |
| 297 | - | func (w *WebView) SetActive(active bool) { | |
| 298 | - | // No-op in stub | |
| 776 | + | // setupFaviconHandlers connects to the FaviconDatabase signals | |
| 777 | + | func (w *WebView) setupFaviconHandlers() { | |
| 778 | + | // Get the NetworkSession from the WebView | |
| 779 | + | session := w.view.NetworkSession() | |
| 780 | + | if session == nil { | |
| 781 | + | log.Printf("[webkit] Warning: No NetworkSession available for favicon handling") | |
| 782 | + | return | |
| 783 | + | } | |
| 784 | + | ||
| 785 | + | // Get the WebsiteDataManager from the NetworkSession | |
| 786 | + | dataManager := session.WebsiteDataManager() | |
| 787 | + | if dataManager == nil { | |
| 788 | + | log.Printf("[webkit] Warning: No WebsiteDataManager available for favicon handling") | |
| 789 | + | return | |
| 790 | + | } | |
| 791 | + | ||
| 792 | + | // Enable favicons if not already enabled | |
| 793 | + | if !dataManager.FaviconsEnabled() { | |
| 794 | + | dataManager.SetFaviconsEnabled(true) | |
| 795 | + | log.Printf("[webkit] Enabled favicons for WebView ID %d", w.id) | |
| 796 | + | } | |
| 797 | + | ||
| 798 | + | // Get the FaviconDatabase | |
| 799 | + | faviconDB := dataManager.FaviconDatabase() | |
| 800 | + | if faviconDB == nil { | |
| 801 | + | log.Printf("[webkit] Warning: No FaviconDatabase available") | |
| 802 | + | return | |
| 803 | + | } | |
| 804 | + | ||
| 805 | + | // Connect to the favicon-changed signal | |
| 806 | + | faviconDB.ConnectFaviconChanged(func(pageURI, faviconURI string) { | |
| 807 | + | log.Printf("[favicon] Favicon changed for %s: %s", pageURI, faviconURI) | |
| 808 | + | ||
| 809 | + | // Call the URI handler if registered | |
| 810 | + | w.mu.RLock() | |
| 811 | + | handler := w.onFaviconURIChanged | |
| 812 | + | w.mu.RUnlock() | |
| 813 | + | ||
| 814 | + | if handler != nil { | |
| 815 | + | handler(pageURI, faviconURI) | |
| 816 | + | } | |
| 817 | + | }) | |
| 818 | + | ||
| 819 | + | log.Printf("[webkit] Connected to FaviconDatabase for WebView ID %d", w.id) | |
| 299 | 820 | } | |
| 300 | 821 | ||
| 301 | - | // IsActive returns whether this WebView is currently active/focused (stub) | |
| 302 | - | func (w *WebView) IsActive() bool { | |
| 303 | - | return false | |
| 822 | + | // GtkWebView returns the underlying gotk4 WebView for advanced operations | |
| 823 | + | // This is used when creating related views (popups) that need to share the same session | |
| 824 | + | func (w *WebView) GtkWebView() *webkit.WebView { | |
| 825 | + | w.mu.RLock() | |
| 826 | + | defer w.mu.RUnlock() | |
| 827 | + | return w.view | |
| 304 | 828 | } | |
| 305 | 829 | ||
| 306 | - | // ID returns the unique identifier for this WebView as a string (stub) | |
| 307 | - | func (w *WebView) ID() string { | |
| 308 | - | if w == nil { | |
| 309 | - | return "" | |
| 830 | + | // WrapBareWebView creates a minimal WebView wrapper around a bare gotk4 WebView | |
| 831 | + | // This is used during popup creation to return a WebView to WebKit before initialization | |
| 832 | + | // Full initialization (container, scripts, event handlers) should be done later | |
| 833 | + | func WrapBareWebView(bareView *webkit.WebView) *WebView { | |
| 834 | + | if bareView == nil { | |
| 835 | + | return nil | |
| 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) | |
| 315 | - | func PrefersDarkTheme() bool { | |
| 316 | - | return false // Fallback to light theme when not using CGO | |
| 859 | + | // InitializeFromBare completes initialization of a bare WebView wrapper | |
| 860 | + | // This should be called in ready-to-show after WebKit has configured the view | |
| 861 | + | func (w *WebView) InitializeFromBare(cfg *Config) error { | |
| 862 | + | w.mu.Lock() | |
| 863 | + | defer w.mu.Unlock() | |
| 864 | + | ||
| 865 | + | if w.config != nil { | |
| 866 | + | return fmt.Errorf("WebView already initialized") | |
| 867 | + | } | |
| 868 | + | ||
| 869 | + | if cfg == nil { | |
| 870 | + | cfg = GetDefaultConfig() | |
| 871 | + | } | |
| 872 | + | ||
| 873 | + | w.config = cfg | |
| 874 | + | ||
| 875 | + | // Verify persistent session | |
| 876 | + | viewSession := w.view.NetworkSession() | |
| 877 | + | if viewSession == nil { | |
| 878 | + | return fmt.Errorf("WebView has no network session") | |
| 879 | + | } | |
| 880 | + | ||
| 881 | + | // Setup UserContentManager and inject GUI scripts | |
| 882 | + | if err := SetupUserContentManager(w.view, cfg.AppearanceConfigJSON, w.id); err != nil { | |
| 883 | + | return fmt.Errorf("failed to setup user content manager: %w", err) | |
| 884 | + | } | |
| 885 | + | ||
| 886 | + | // Create container (GtkBox) to hold the WebView | |
| 887 | + | container := gtk.NewBox(gtk.OrientationVertical, 0) | |
| 888 | + | container.SetHExpand(true) | |
| 889 | + | container.SetVExpand(true) | |
| 890 | + | ||
| 891 | + | // Configure WebView widget for expansion | |
| 892 | + | w.view.SetHExpand(true) | |
| 893 | + | w.view.SetVExpand(true) | |
| 894 | + | ||
| 895 | + | // Add WebView to container | |
| 896 | + | container.Append(w.view) | |
| 897 | + | ||
| 898 | + | w.container = container | |
| 899 | + | ||
| 900 | + | // Apply configuration | |
| 901 | + | if err := w.applyConfig(); err != nil { | |
| 902 | + | return err | |
| 903 | + | } | |
| 904 | + | ||
| 905 | + | // Setup event handlers | |
| 906 | + | w.setupEventHandlers() | |
| 907 | + | ||
| 908 | + | // Attach keyboard bridge | |
| 909 | + | w.AttachKeyboardBridge() | |
| 910 | + | ||
| 911 | + | log.Printf("[webkit] Completed initialization for WebView ID %d", w.id) | |
| 912 | + | ||
| 913 | + | return nil | |
| 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
| @@ -1,2846 +0,0 @@ | |||
| 1 | - | //go:build webkit_cgo | |
| 2 | - | ||
| 3 | - | package webkit | |
| 4 | - | ||
| 5 | - | /* | |
| 6 | - | #cgo pkg-config: webkitgtk-6.0 gtk4 javascriptcoregtk-6.0 | |
| 7 | - | #cgo CFLAGS: -I/usr/include/webkitgtk-6.0 | |
| 8 | - | #include <stdlib.h> | |
| 9 | - | #include <string.h> | |
| 10 | - | #include <stdint.h> | |
| 11 | - | #include <gtk/gtk.h> | |
| 12 | - | #include <webkit/webkit.h> | |
| 13 | - | #include <glib-object.h> | |
| 14 | - | #include <gdk/gdk.h> | |
| 15 | - | #include <jsc/jsc.h> | |
| 16 | - | #include <glib.h> | |
| 17 | - | #include <gio/gio.h> | |
| 18 | - | #include "webview_callbacks.h" | |
| 19 | - | ||
| 20 | - | static GtkWidget* new_window() { return GTK_WIDGET(gtk_window_new()); } | |
| 21 | - | ||
| 22 | - | // Forward declaration | |
| 23 | - | extern void goQuitMainLoop(); | |
| 24 | - | extern void goInvokeHandle(uintptr_t handle); | |
| 25 | - | extern gboolean goHandleNewWindowPolicy(unsigned long id, char* uri, int nav_type, char* frame_name, gboolean is_user_gesture, guint modifiers, guint mouse_button); | |
| 26 | - | extern void goHandlePopupGeometry(unsigned long id, int x, int y, int width, int height); | |
| 27 | - | extern void goHandleLoadChanged(unsigned long id, char* uri, int load_event); | |
| 28 | - | extern void goHandleWebViewClose(unsigned long id); | |
| 29 | - | extern void goCreateNewPanel(unsigned long id, char* uri); | |
| 30 | - | static gboolean invoke_handle_cb(gpointer data) { | |
| 31 | - | goInvokeHandle((uintptr_t)data); | |
| 32 | - | return G_SOURCE_REMOVE; | |
| 33 | - | } | |
| 34 | - | ||
| 35 | - | static void schedule_on_main_thread(uintptr_t handle) { | |
| 36 | - | g_idle_add_full(G_PRIORITY_DEFAULT, invoke_handle_cb, (gpointer)handle, NULL); | |
| 37 | - | } | |
| 38 | - | ||
| 39 | - | // Connect window close signal to quit main loop | |
| 40 | - | static void connect_destroy_quit(GtkWidget* w) { | |
| 41 | - | if (!w) return; | |
| 42 | - | g_signal_connect(G_OBJECT(w), "close-request", G_CALLBACK(on_close_request), NULL); | |
| 43 | - | } | |
| 44 | - | static WebKitWebView* as_webview(GtkWidget* w) { return WEBKIT_WEB_VIEW(w); } | |
| 45 | - | // WebsiteDataManager creation will be handled via GTK4/WebKit6 APIs in Go code. | |
| 46 | - | ||
| 47 | - | // Forward declare helpers used below | |
| 48 | - | static void maybe_set_cookie_policy(WebKitCookieManager* cm, int policy); | |
| 49 | - | ||
| 50 | - | // TLS error handling forward declarations | |
| 51 | - | extern gboolean goHandleTLSError(char* failing_uri, char* host, int error_flags, char* cert_info); | |
| 52 | - | ||
| 53 | - | // Console message handling forward declarations | |
| 54 | - | extern void goHandleConsoleMessage(char* message); | |
| 55 | - | ||
| 56 | - | static gboolean on_load_failed_with_tls_errors(WebKitWebView *web_view, | |
| 57 | - | const char *failing_uri, | |
| 58 | - | GTlsCertificate *certificate, | |
| 59 | - | GTlsCertificateFlags errors, | |
| 60 | - | gpointer user_data); | |
| 61 | - | ||
| 62 | - | ||
| 63 | - | // Handle load_changed signal for OAuth completion detection | |
| 64 | - | static void on_load_changed(WebKitWebView *web_view, WebKitLoadEvent load_event, gpointer user_data) { | |
| 65 | - | const char* uri = webkit_web_view_get_uri(web_view); | |
| 66 | - | unsigned long view_id = (unsigned long)user_data; | |
| 67 | - | ||
| 68 | - | // Only process WEBKIT_LOAD_FINISHED events | |
| 69 | - | if (load_event == WEBKIT_LOAD_FINISHED && uri) { | |
| 70 | - | printf("[webkit-load] Load finished for URI: %s\n", uri); | |
| 71 | - | goHandleLoadChanged(view_id, (char*)uri, load_event); | |
| 72 | - | } | |
| 73 | - | } | |
| 74 | - | ||
| 75 | - | // Handle close signal when JavaScript calls window.close() | |
| 76 | - | static void on_webview_close(WebKitWebView* web_view, gpointer user_data) { | |
| 77 | - | unsigned long view_id = (unsigned long)user_data; | |
| 78 | - | printf("[webkit-close] WebView close signal from view_id: %lu\n", view_id); | |
| 79 | - | goHandleWebViewClose(view_id); | |
| 80 | - | } | |
| 81 | - | ||
| 82 | - | ||
| 83 | - | // Forward declare navigation policy handler | |
| 84 | - | extern gboolean goHandleNavigationPolicy(unsigned long view_id, char* uri, gboolean is_user_gesture); | |
| 85 | - | ||
| 86 | - | // Handle decide-policy signal for new window requests and navigations | |
| 87 | - | static gboolean on_decide_policy(WebKitWebView *web_view, | |
| 88 | - | WebKitPolicyDecision *decision, | |
| 89 | - | WebKitPolicyDecisionType type, | |
| 90 | - | gpointer user_data) { | |
| 91 | - | // Debug: log all policy decisions | |
| 92 | - | const char* type_names[] = {"NAVIGATION_ACTION", "NEW_WINDOW_ACTION", "RESPONSE"}; | |
| 93 | - | const char* type_name = (type >= 0 && type < 3) ? type_names[type] : "UNKNOWN"; | |
| 94 | - | printf("[webkit-policy] Policy decision: type=%s (%d)\n", type_name, type); | |
| 95 | - | ||
| 96 | - | unsigned long view_id = (unsigned long)user_data; | |
| 97 | - | ||
| 98 | - | if (type == WEBKIT_POLICY_DECISION_TYPE_NAVIGATION_ACTION) { | |
| 99 | - | WebKitNavigationPolicyDecision *nav_decision = | |
| 100 | - | WEBKIT_NAVIGATION_POLICY_DECISION(decision); | |
| 101 | - | WebKitNavigationAction *action = | |
| 102 | - | webkit_navigation_policy_decision_get_navigation_action(nav_decision); | |
| 103 | - | WebKitURIRequest *request = | |
| 104 | - | webkit_navigation_action_get_request(action); | |
| 105 | - | const char* uri = webkit_uri_request_get_uri(request); | |
| 106 | - | gboolean is_user_gesture = webkit_navigation_action_is_user_gesture(action); | |
| 107 | - | ||
| 108 | - | // Ask Go if this navigation should be allowed | |
| 109 | - | gboolean allowed = goHandleNavigationPolicy(view_id, (char*)uri, is_user_gesture); | |
| 110 | - | ||
| 111 | - | if (!allowed) { | |
| 112 | - | printf("[webkit-policy] Navigation BLOCKED by Go handler: uri=%s is_user_gesture=%d\n", uri, is_user_gesture); | |
| 113 | - | webkit_policy_decision_ignore(decision); | |
| 114 | - | return TRUE; | |
| 115 | - | } | |
| 116 | - | ||
| 117 | - | // Allow navigation | |
| 118 | - | webkit_policy_decision_use(decision); | |
| 119 | - | return TRUE; | |
| 120 | - | } | |
| 121 | - | ||
| 122 | - | if (type == WEBKIT_POLICY_DECISION_TYPE_NEW_WINDOW_ACTION) { | |
| 123 | - | WebKitNavigationPolicyDecision *nav_decision = | |
| 124 | - | WEBKIT_NAVIGATION_POLICY_DECISION(decision); | |
| 125 | - | WebKitNavigationAction *action = | |
| 126 | - | webkit_navigation_policy_decision_get_navigation_action(nav_decision); | |
| 127 | - | WebKitURIRequest *request = | |
| 128 | - | webkit_navigation_action_get_request(action); | |
| 129 | - | const char* uri = webkit_uri_request_get_uri(request); | |
| 130 | - | ||
| 131 | - | // Get navigation type to understand how popup was triggered | |
| 132 | - | WebKitNavigationType nav_type = webkit_navigation_action_get_navigation_type(action); | |
| 133 | - | ||
| 134 | - | // Get frame name to distinguish _blank from other popups | |
| 135 | - | const char* frame_name = webkit_navigation_action_get_frame_name(action); | |
| 136 | - | ||
| 137 | - | // Get additional properties to better distinguish _blank from popups | |
| 138 | - | gboolean is_user_gesture = webkit_navigation_action_is_user_gesture(action); | |
| 139 | - | guint modifiers = webkit_navigation_action_get_modifiers(action); | |
| 140 | - | guint mouse_button = webkit_navigation_action_get_mouse_button(action); | |
| 141 | - | ||
| 142 | - | // Call Go handler for popup creation | |
| 143 | - | gboolean handled = goHandleNewWindowPolicy(view_id, (char*)uri, nav_type, (char*)frame_name, | |
| 144 | - | is_user_gesture, modifiers, mouse_button); | |
| 145 | - | ||
| 146 | - | if (handled) { | |
| 147 | - | webkit_policy_decision_ignore(decision); | |
| 148 | - | } else { | |
| 149 | - | webkit_policy_decision_use(decision); | |
| 150 | - | } | |
| 151 | - | return TRUE; | |
| 152 | - | } | |
| 153 | - | return FALSE; | |
| 154 | - | } | |
| 155 | - | ||
| 156 | - | static void connect_tls_error_handler(WebKitWebView* wv); | |
| 157 | - | ||
| 158 | - | ||
| 159 | - | // Dialog callback structure for modern AlertDialog async handling | |
| 160 | - | typedef struct { | |
| 161 | - | int response; | |
| 162 | - | gboolean completed; | |
| 163 | - | GMainLoop *loop; | |
| 164 | - | } AlertDialogResponse; | |
| 165 | - | ||
| 166 | - | static void on_alert_dialog_choose_done(GObject *source, GAsyncResult *result, gpointer user_data); | |
| 167 | - | static gboolean show_tls_warning_dialog_sync(GtkWindow *parent, const char* hostname, const char* error_msg); | |
| 168 | - | static char* extract_certificate_info(GTlsCertificate* certificate); | |
| 169 | - | ||
| 170 | - | // Setup video acceleration environment variables | |
| 171 | - | static void setup_video_acceleration(const char* driver_name, int enable_all, int legacy) { | |
| 172 | - | if (legacy) { | |
| 173 | - | // Legacy mode: force old VA-API plugins, disable modern ones | |
| 174 | - | setenv("WEBKIT_GST_ENABLE_LEGACY_VAAPI", "1", 1); | |
| 175 | - | // Don't set other env vars in legacy mode to avoid conflicts | |
| 176 | - | return; | |
| 177 | - | } | |
| 178 | - | ||
| 179 | - | // Modern mode (default): use improved VA-API handling | |
| 180 | - | if (driver_name && strlen(driver_name) > 0) { | |
| 181 | - | if (strcmp(driver_name, "nvidia") == 0) { | |
| 182 | - | // NVIDIA uses different env vars for hardware video acceleration | |
| 183 | - | setenv("VDPAU_DRIVER", "nvidia", 1); | |
| 184 | - | setenv("LIBVA_DRIVER_NAME", "vdpau", 1); | |
| 185 | - | } else if (strcmp(driver_name, "vdpau") == 0) { | |
| 186 | - | // Explicit VDPAU backend (for NVIDIA via VA-API) | |
| 187 | - | setenv("LIBVA_DRIVER_NAME", "vdpau", 1); | |
| 188 | - | } else { | |
| 189 | - | // AMD/Intel use VA-API directly | |
| 190 | - | setenv("LIBVA_DRIVER_NAME", driver_name, 1); | |
| 191 | - | } | |
| 192 | - | } | |
| 193 | - | ||
| 194 | - | if (enable_all) { | |
| 195 | - | setenv("GST_VAAPI_ALL_DRIVERS", "1", 1); | |
| 196 | - | } | |
| 197 | - | } | |
| 198 | - | ||
| 199 | - | // Construct a WebKitWebView via g_object_new with a fresh UserContentManager. | |
| 200 | - | static GtkWidget* new_webview_with_ucm_and_session(const char* data_dir, const char* cache_dir, const char* cookie_path, WebKitUserContentManager** out_ucm, WebKitMemoryPressureSettings* pressure_settings) { | |
| 201 | - | WebKitUserContentManager* u = webkit_user_content_manager_new(); | |
| 202 | - | if (!u) return NULL; | |
| 203 | - | // WebKitGTK 6: create NetworkSession with data/cache directories | |
| 204 | - | WebKitNetworkSession* sess = webkit_network_session_new( | |
| 205 | - | data_dir ? data_dir : "", | |
| 206 | - | cache_dir ? cache_dir : ""); | |
| 207 | - | if (!sess) { g_object_unref(u); return NULL; } | |
| 208 | - | // Apply memory pressure settings to the session when provided | |
| 209 | - | if (pressure_settings) { | |
| 210 | - | // WebKitGTK 6.0 API: setter expects only the settings pointer | |
| 211 | - | webkit_network_session_set_memory_pressure_settings(pressure_settings); | |
| 212 | - | } | |
| 213 | - | // Configure cookie persistence and policy (no third-party by default) | |
| 214 | - | WebKitCookieManager* cm = webkit_network_session_get_cookie_manager(sess); | |
| 215 | - | if (cm) { | |
| 216 | - | if (cookie_path && cookie_path[0]) { | |
| 217 | - | webkit_cookie_manager_set_persistent_storage(cm, cookie_path, WEBKIT_COOKIE_PERSISTENT_STORAGE_SQLITE); | |
| 218 | - | } | |
| 219 | - | // 2 => no-third-party (default), 0 => always, 1 => never | |
| 220 | - | maybe_set_cookie_policy(cm, 2); | |
| 221 | - | } | |
| 222 | - | // Persist credentials (HTTP auth, etc.) | |
| 223 | - | webkit_network_session_set_persistent_credential_storage_enabled(sess, TRUE); | |
| 224 | - | // Configure TLS errors to emit signals instead of failing silently | |
| 225 | - | webkit_network_session_set_tls_errors_policy(sess, WEBKIT_TLS_ERRORS_POLICY_FAIL); | |
| 226 | - | GtkWidget* w = GTK_WIDGET(g_object_new(WEBKIT_TYPE_WEB_VIEW, | |
| 227 | - | "user-content-manager", u, | |
| 228 | - | "network-session", sess, | |
| 229 | - | NULL)); | |
| 230 | - | // WebView holds refs to provided objects; drop our temporary refs | |
| 231 | - | g_object_unref(sess); | |
| 232 | - | // Set WebView background to black (easier on eyes when pages are loading) | |
| 233 | - | GdkRGBA black_color = { 0.0, 0.0, 0.0, 1.0 }; | |
| 234 | - | webkit_web_view_set_background_color(WEBKIT_WEB_VIEW(w), &black_color); | |
| 235 | - | ||
| 236 | - | // Connect TLS error handler | |
| 237 | - | connect_tls_error_handler(WEBKIT_WEB_VIEW(w)); | |
| 238 | - | ||
| 239 | - | if (out_ucm) { *out_ucm = u; } | |
| 240 | - | return w; | |
| 241 | - | } | |
| 242 | - | ||
| 243 | - | // Create a new WebView related to an existing one for process sharing | |
| 244 | - | static GtkWidget* new_webview_related(WebKitWebView* related_view, WebKitUserContentManager** out_ucm) { | |
| 245 | - | if (!related_view) { | |
| 246 | - | // Fallback to regular WebView creation - we need to provide the required parameters | |
| 247 | - | return new_webview_with_ucm_and_session(NULL, NULL, NULL, out_ucm, NULL); | |
| 248 | - | } | |
| 249 | - | ||
| 250 | - | // Get the network session and user content manager from the related view | |
| 251 | - | WebKitNetworkSession* related_session = webkit_web_view_get_network_session(related_view); | |
| 252 | - | WebKitUserContentManager* related_ucm = webkit_web_view_get_user_content_manager(related_view); | |
| 253 | - | ||
| 254 | - | // Create content manager if needed | |
| 255 | - | WebKitUserContentManager* u = related_ucm; | |
| 256 | - | if (!u) { | |
| 257 | - | u = webkit_user_content_manager_new(); | |
| 258 | - | } else { | |
| 259 | - | g_object_ref(u); // Add ref since we'll pass it to new WebView | |
| 260 | - | } | |
| 261 | - | ||
| 262 | - | // Create WebView with related view for process sharing (network session is inherited) | |
| 263 | - | GtkWidget* w = GTK_WIDGET(g_object_new(WEBKIT_TYPE_WEB_VIEW, | |
| 264 | - | "user-content-manager", u, | |
| 265 | - | "related-view", related_view, | |
| 266 | - | NULL)); | |
| 267 | - | ||
| 268 | - | // Connect TLS error handler | |
| 269 | - | connect_tls_error_handler(WEBKIT_WEB_VIEW(w)); | |
| 270 | - | ||
| 271 | - | // WebView holds refs to provided objects; drop our temporary refs | |
| 272 | - | g_object_unref(u); | |
| 273 | - | if (out_ucm) { *out_ucm = u; } | |
| 274 | - | return w; | |
| 275 | - | } | |
| 276 | - | ||
| 277 | - | static void connect_policy_handler(WebKitWebView* web_view, unsigned long id) { | |
| 278 | - | if (!web_view) return; | |
| 279 | - | // Connect decide-policy signal for navigation control | |
| 280 | - | g_signal_connect_data(G_OBJECT(web_view), "decide-policy", G_CALLBACK(on_decide_policy), (gpointer)id, NULL, 0); | |
| 281 | - | printf("[webkit-debug] Policy handler connected for WebView id: %lu\n", id); | |
| 282 | - | } | |
| 283 | - | ||
| 284 | - | // Connect close signal to a WebView for popup auto-close functionality | |
| 285 | - | static void connect_close_signal(WebKitWebView* web_view, unsigned long id) { | |
| 286 | - | if (!web_view) return; | |
| 287 | - | g_signal_connect_data(G_OBJECT(web_view), "close", G_CALLBACK(on_webview_close), (gpointer)id, NULL, 0); | |
| 288 | - | printf("[webkit-connect] Connected close signal to WebView with id: %lu\n", id); | |
| 289 | - | } | |
| 290 | - | ||
| 291 | - | // Callback for print signal - intercepts Ctrl+P and can redirect behavior | |
| 292 | - | static gboolean on_print_request(WebKitWebView* web_view, WebKitPrintOperation* print_operation, gpointer user_data) { | |
| 293 | - | (void)web_view; | |
| 294 | - | (void)print_operation; | |
| 295 | - | (void)user_data; | |
| 296 | - | ||
| 297 | - | // Return TRUE to prevent default print dialog | |
| 298 | - | // Return FALSE to allow default print behavior | |
| 299 | - | // For now, we'll block the print dialog entirely since Ctrl+P should trigger pane mode | |
| 300 | - | printf("[webkit-print] Print request intercepted - blocking to prevent conflict with pane mode\n"); | |
| 301 | - | return TRUE; // Block the print dialog | |
| 302 | - | } | |
| 303 | - | ||
| 304 | - | // Connect print signal to a WebView to intercept Ctrl+P | |
| 305 | - | static void connect_print_signal(WebKitWebView* web_view, unsigned long id) { | |
| 306 | - | if (!web_view) return; | |
| 307 | - | g_signal_connect_data(G_OBJECT(web_view), "print", G_CALLBACK(on_print_request), (gpointer)id, NULL, 0); | |
| 308 | - | printf("[webkit-connect] Connected print signal to WebView with id: %lu\n", id); | |
| 309 | - | } | |
| 310 | - | ||
| 311 | - | static gboolean gtk_prefers_dark() { | |
| 312 | - | // Method 1: Check GNOME desktop interface color-scheme (primary method) | |
| 313 | - | GSettings* desktop_settings = g_settings_new("org.gnome.desktop.interface"); | |
| 314 | - | if (desktop_settings) { | |
| 315 | - | gchar* color_scheme = g_settings_get_string(desktop_settings, "color-scheme"); | |
| 316 | - | if (color_scheme) { | |
| 317 | - | gboolean prefer_dark = (g_strcmp0(color_scheme, "prefer-dark") == 0); | |
| 318 | - | g_free(color_scheme); | |
| 319 | - | g_object_unref(desktop_settings); | |
| 320 | - | if (prefer_dark) return TRUE; | |
| 321 | - | } | |
| 322 | - | g_object_unref(desktop_settings); | |
| 323 | - | } | |
| 324 | - | ||
| 325 | - | // Method 2: Check GTK theme name for dark variants | |
| 326 | - | GtkSettings* settings = gtk_settings_get_default(); | |
| 327 | - | if (settings) { | |
| 328 | - | gchar* theme_name = NULL; | |
| 329 | - | g_object_get(settings, "gtk-theme-name", &theme_name, NULL); | |
| 330 | - | if (theme_name) { | |
| 331 | - | gboolean is_dark = (strstr(theme_name, "-dark") != NULL || | |
| 332 | - | strstr(theme_name, "-Dark") != NULL); | |
| 333 | - | g_free(theme_name); | |
| 334 | - | if (is_dark) return TRUE; | |
| 335 | - | } | |
| 336 | - | ||
| 337 | - | // Method 3: Check gtk-application-prefer-dark-theme (fallback) | |
| 338 | - | gboolean prefer = FALSE; | |
| 339 | - | g_object_get(settings, "gtk-application-prefer-dark-theme", &prefer, NULL); | |
| 340 | - | if (prefer) return TRUE; | |
| 341 | - | } | |
| 342 | - | ||
| 343 | - | return FALSE; | |
| 344 | - | } | |
| 345 | - | ||
| 346 | - | // Note: preferred color scheme is handled via a user script injection to support | |
| 347 | - | // older WebKitGTK versions without the color-scheme API. See enableUserContentManager. | |
| 348 | - | ||
| 349 | - | extern void goOnUcmMessage(unsigned long id, char* json); | |
| 350 | - | ||
| 351 | - | // Helper functions to connect signals using static callbacks | |
| 352 | - | static void connect_title_notify(GtkWidget* widget, GtkWindow* window) { | |
| 353 | - | if (!widget) return; | |
| 354 | - | g_signal_connect_data(G_OBJECT(widget), "notify::title", G_CALLBACK(on_title_notify), G_OBJECT(window), NULL, 0); | |
| 355 | - | } | |
| 356 | - | ||
| 357 | - | static void connect_title_notify_with_id(GtkWidget* widget, unsigned long id) { | |
| 358 | - | if (!widget) return; | |
| 359 | - | g_signal_connect_data(G_OBJECT(widget), "notify::title", G_CALLBACK(on_title_notify_id), (gpointer)id, NULL, 0); | |
| 360 | - | } | |
| 361 | - | ||
| 362 | - | static void connect_uri_notify_with_id(GtkWidget* widget, unsigned long id) { | |
| 363 | - | if (!widget) return; | |
| 364 | - | g_signal_connect_data(G_OBJECT(widget), "notify::uri", G_CALLBACK(on_uri_notify), (gpointer)id, NULL, 0); | |
| 365 | - | } | |
| 366 | - | ||
| 367 | - | static void connect_favicon_changed_with_id(GtkWidget* widget, unsigned long id) { | |
| 368 | - | if (!widget) return; | |
| 369 | - | WebKitWebView* web_view = WEBKIT_WEB_VIEW(widget); | |
| 370 | - | WebKitNetworkSession* network_session = webkit_web_view_get_network_session(web_view); | |
| 371 | - | if (network_session) { | |
| 372 | - | WebKitWebsiteDataManager* data_manager = webkit_network_session_get_website_data_manager(network_session); | |
| 373 | - | if (data_manager) { | |
| 374 | - | WebKitFaviconDatabase* favicon_db = webkit_website_data_manager_get_favicon_database(data_manager); | |
| 375 | - | if (favicon_db) { | |
| 376 | - | g_signal_connect_data(G_OBJECT(favicon_db), "favicon-changed", G_CALLBACK(on_favicon_changed), (gpointer)id, NULL, 0); | |
| 377 | - | printf("[favicon-c] Connected favicon-changed signal for WebView ID %lu\n", id); | |
| 378 | - | } else { | |
| 379 | - | printf("[favicon-c] Failed to get favicon database\n"); | |
| 380 | - | } | |
| 381 | - | } else { | |
| 382 | - | printf("[favicon-c] Failed to get website data manager for favicon signal\n"); | |
| 383 | - | } | |
| 384 | - | } else { | |
| 385 | - | printf("[favicon-c] Failed to get network session\n"); | |
| 386 | - | } | |
| 387 | - | } | |
| 388 | - | ||
| 389 | - | static void connect_theme_changed_with_id(GtkSettings* settings, unsigned long id) { | |
| 390 | - | if (!settings) return; | |
| 391 | - | g_signal_connect_data(G_OBJECT(settings), "notify::gtk-application-prefer-dark-theme", G_CALLBACK(on_theme_changed), (gpointer)id, NULL, 0); | |
| 392 | - | } | |
| 393 | - | ||
| 394 | - | static void register_uri_scheme_handler(WebKitWebContext* context, const char* scheme) { | |
| 395 | - | if (!context || !scheme) return; | |
| 396 | - | webkit_web_context_register_uri_scheme(context, scheme, (WebKitURISchemeRequestCallback)on_uri_scheme, NULL, NULL); | |
| 397 | - | } | |
| 398 | - | static void register_uri_scheme_security(WebKitWebContext* context, const char* scheme) { | |
| 399 | - | if (!context || !scheme) return; | |
| 400 | - | WebKitSecurityManager* sm = webkit_web_context_get_security_manager(context); | |
| 401 | - | if (!sm) return; | |
| 402 | - | ||
| 403 | - | // Mark as secure to avoid mixed-content warnings when requested by https pages | |
| 404 | - | webkit_security_manager_register_uri_scheme_as_secure(sm, scheme); | |
| 405 | - | // Treat as local scheme | |
| 406 | - | webkit_security_manager_register_uri_scheme_as_local(sm, scheme); | |
| 407 | - | // Allow cross-origin requests to this scheme (bypass CORS header checks for this scheme) | |
| 408 | - | webkit_security_manager_register_uri_scheme_as_cors_enabled(sm, scheme); | |
| 409 | - | } | |
| 410 | - | ||
| 411 | - | static void enable_favicon_database(WebKitNetworkSession* session) { | |
| 412 | - | if (!session) return; | |
| 413 | - | WebKitWebsiteDataManager* data_manager = webkit_network_session_get_website_data_manager(session); | |
| 414 | - | if (data_manager) { | |
| 415 | - | webkit_website_data_manager_set_favicons_enabled(data_manager, TRUE); | |
| 416 | - | printf("[favicon-c] Favicon database enabled\n"); | |
| 417 | - | } else { | |
| 418 | - | printf("[favicon-c] Failed to get website data manager\n"); | |
| 419 | - | } | |
| 420 | - | } | |
| 421 | - | ||
| 422 | - | // Script message handler wiring for WebKitGTK 6 | |
| 423 | - | // Forward declaration for callback used in g_signal_connect_data | |
| 424 | - | // WebKitGTK 6 delivers JSCValue* for script-message-received::NAME | |
| 425 | - | static void ucm_on_script_message_cb(WebKitUserContentManager* ucm, JSCValue* val, gpointer user_data); | |
| 426 | - | // Uses WebKitScriptMessage and script-message-received::NAME signal. | |
| 427 | - | static void ucm_on_script_message_cb(WebKitUserContentManager* ucm, JSCValue* val, gpointer user_data) { | |
| 428 | - | (void)ucm; | |
| 429 | - | if (!val) return; | |
| 430 | - | gchar* s = jsc_value_to_string(val); | |
| 431 | - | if (!s) return; | |
| 432 | - | goOnUcmMessage((unsigned long)user_data, s); | |
| 433 | - | g_free(s); | |
| 434 | - | } | |
| 435 | - | ||
| 436 | - | static gboolean register_ucm_handler(WebKitUserContentManager* ucm, const char* name, unsigned long id) { | |
| 437 | - | if (!ucm || !name) return FALSE; | |
| 438 | - | // WebKitGTK 6 signature: (ucm, name, world_name) | |
| 439 | - | webkit_user_content_manager_register_script_message_handler(ucm, name, NULL); | |
| 440 | - | gchar* signal = g_strdup_printf("script-message-received::%s", name); | |
| 441 | - | if (!signal) return FALSE; | |
| 442 | - | g_signal_connect_data(G_OBJECT(ucm), signal, G_CALLBACK(ucm_on_script_message_cb), (gpointer)id, NULL, 0); | |
| 443 | - | g_free(signal); | |
| 444 | - | return TRUE; | |
| 445 | - | } | |
| 446 | - | ||
| 447 | - | // Register a script message handler for a specific JavaScript world | |
| 448 | - | static gboolean register_ucm_handler_world(WebKitUserContentManager* ucm, const char* name, const char* world_name, unsigned long id) { | |
| 449 | - | if (!ucm || !name || !world_name) return FALSE; | |
| 450 | - | webkit_user_content_manager_register_script_message_handler(ucm, name, world_name); | |
| 451 | - | gchar* signal = g_strdup_printf("script-message-received::%s", name); | |
| 452 | - | if (!signal) return FALSE; | |
| 453 | - | g_signal_connect_data(G_OBJECT(ucm), signal, G_CALLBACK(ucm_on_script_message_cb), (gpointer)id, NULL, 0); | |
| 454 | - | g_free(signal); | |
| 455 | - | return TRUE; | |
| 456 | - | } | |
| 457 | - | ||
| 458 | - | // ----- Conditional helpers for settings across WebKit versions ----- | |
| 459 | - | #ifndef WEBKIT_CHECK_VERSION | |
| 460 | - | #define WEBKIT_CHECK_VERSION(major,minor,micro) (0) | |
| 461 | - | #endif | |
| 462 | - | ||
| 463 | - | static void maybe_set_hw_policy(WebKitSettings* settings, int mode) { | |
| 464 | - | #if WEBKIT_CHECK_VERSION(2,40,0) | |
| 465 | - | // mode: 0=ON_DEMAND, 1=ALWAYS, 2=NEVER | |
| 466 | - | if (!settings) return; | |
| 467 | - | if (mode == 1) { | |
| 468 | - | webkit_settings_set_hardware_acceleration_policy(settings, WEBKIT_HARDWARE_ACCELERATION_POLICY_ALWAYS); | |
| 469 | - | } else if (mode == 2) { | |
| 470 | - | webkit_settings_set_hardware_acceleration_policy(settings, WEBKIT_HARDWARE_ACCELERATION_POLICY_NEVER); | |
| 471 | - | } else { | |
| 472 | - | // Leave default policy (auto) without forcing a value to avoid referencing | |
| 473 | - | // WEBKIT_HARDWARE_ACCELERATION_POLICY_ON_DEMAND on older headers. | |
| 474 | - | } | |
| 475 | - | #else | |
| 476 | - | (void)settings; (void)mode; | |
| 477 | - | #endif | |
| 478 | - | } | |
| 479 | - | ||
| 480 | - | static void maybe_set_draw_indicators(WebKitSettings* settings, int enable) { | |
| 481 | - | #if WEBKIT_CHECK_VERSION(2,36,0) | |
| 482 | - | if (!settings) return; | |
| 483 | - | webkit_settings_set_draw_compositing_indicators(settings, enable ? 1 : 0); | |
| 484 | - | #else | |
| 485 | - | (void)settings; (void)enable; | |
| 486 | - | #endif | |
| 487 | - | } | |
| 488 | - | ||
| 489 | - | static void maybe_set_webgl(WebKitSettings* settings, int enable) { | |
| 490 | - | #if WEBKIT_CHECK_VERSION(2,20,0) | |
| 491 | - | if (!settings) return; | |
| 492 | - | webkit_settings_set_enable_webgl(settings, enable ? 1 : 0); | |
| 493 | - | #else | |
| 494 | - | (void)settings; (void)enable; | |
| 495 | - | #endif | |
| 496 | - | } | |
| 497 | - | ||
| 498 | - | static void maybe_set_canvas_accel(WebKitSettings* settings, int enable) { | |
| 499 | - | #if WEBKIT_CHECK_VERSION(2,20,0) | |
| 500 | - | if (!settings) return; | |
| 501 | - | webkit_settings_set_enable_2d_canvas_acceleration(settings, enable ? 1 : 0); | |
| 502 | - | #else | |
| 503 | - | (void)settings; (void)enable; | |
| 504 | - | #endif | |
| 505 | - | } | |
| 506 | - | ||
| 507 | - | static void maybe_set_media_user_gesture(WebKitSettings* settings, int require) { | |
| 508 | - | #if WEBKIT_CHECK_VERSION(2,20,0) | |
| 509 | - | if (!settings) return; | |
| 510 | - | webkit_settings_set_media_playback_requires_user_gesture(settings, require ? 1 : 0); | |
| 511 | - | #else | |
| 512 | - | (void)settings; (void)require; | |
| 513 | - | #endif | |
| 514 | - | } | |
| 515 | - | ||
| 516 | - | static void maybe_set_smooth_scrolling(WebKitSettings* settings, int enable) { | |
| 517 | - | #if WEBKIT_CHECK_VERSION(2,10,0) | |
| 518 | - | if (!settings) return; | |
| 519 | - | webkit_settings_set_enable_smooth_scrolling(settings, enable ? TRUE : FALSE); | |
| 520 | - | #else | |
| 521 | - | (void)settings; (void)enable; | |
| 522 | - | #endif | |
| 523 | - | } | |
| 524 | - | ||
| 525 | - | // Enable two-finger swipe back/forward navigation gestures when supported | |
| 526 | - | static void maybe_set_back_forward_gestures(WebKitSettings* settings, int enable) { | |
| 527 | - | #if WEBKIT_CHECK_VERSION(2,38,0) | |
| 528 | - | if (!settings) return; | |
| 529 | - | webkit_settings_set_enable_back_forward_navigation_gestures(settings, enable ? 1 : 0); | |
| 530 | - | #else | |
| 531 | - | (void)settings; (void)enable; | |
| 532 | - | #endif | |
| 533 | - | } | |
| 534 | - | ||
| 535 | - | // Set cookie accept policy with compile-time compatibility across WebKit versions | |
| 536 | - | static void maybe_set_cookie_policy(WebKitCookieManager* cm, int policy) { | |
| 537 | - | if (!cm) return; | |
| 538 | - | #if defined(WEBKIT_COOKIE_ACCEPT_POLICY_ALWAYS) | |
| 539 | - | WebKitCookieAcceptPolicy p = WEBKIT_COOKIE_ACCEPT_POLICY_ALWAYS; | |
| 540 | - | if (policy == 1) p = WEBKIT_COOKIE_ACCEPT_POLICY_NEVER; // never | |
| 541 | - | else if (policy == 2) p = WEBKIT_COOKIE_ACCEPT_POLICY_NO_THIRD_PARTY; // no third-party | |
| 542 | - | webkit_cookie_manager_set_accept_policy(cm, p); | |
| 543 | - | #elif defined(WEBKIT_COOKIE_POLICY_ACCEPT_ALWAYS) | |
| 544 | - | WebKitCookieAcceptPolicy p = WEBKIT_COOKIE_POLICY_ACCEPT_ALWAYS; | |
| 545 | - | if (policy == 1) p = WEBKIT_COOKIE_POLICY_ACCEPT_NEVER; | |
| 546 | - | else if (policy == 2) p = WEBKIT_COOKIE_POLICY_ACCEPT_NO_THIRD_PARTY; | |
| 547 | - | webkit_cookie_manager_set_accept_policy(cm, p); | |
| 548 | - | #else | |
| 549 | - | (void)policy; // Not supported in this header version | |
| 550 | - | #endif | |
| 551 | - | } | |
| 552 | - | ||
| 553 | - | // Memory pressure settings helpers (version-guarded) | |
| 554 | - | static WebKitMemoryPressureSettings* create_memory_pressure_settings( | |
| 555 | - | unsigned int memory_limit_mb, | |
| 556 | - | double conservative_threshold, | |
| 557 | - | double strict_threshold, | |
| 558 | - | double kill_threshold, | |
| 559 | - | double poll_interval) { | |
| 560 | - | // If headers don't expose memory pressure APIs, return NULL to signal unsupported | |
| 561 | - | #if WEBKIT_CHECK_VERSION(2,44,0) | |
| 562 | - | WebKitMemoryPressureSettings* settings = webkit_memory_pressure_settings_new(); | |
| 563 | - | if (!settings) return NULL; | |
| 564 | - | ||
| 565 | - | if (memory_limit_mb > 0) { | |
| 566 | - | webkit_memory_pressure_settings_set_memory_limit(settings, (guint)(memory_limit_mb * 1024 * 1024)); | |
| 567 | - | } | |
| 568 | - | ||
| 569 | - | webkit_memory_pressure_settings_set_conservative_threshold(settings, conservative_threshold); | |
| 570 | - | webkit_memory_pressure_settings_set_strict_threshold(settings, strict_threshold); | |
| 571 | - | ||
| 572 | - | if (kill_threshold > 0) { | |
| 573 | - | webkit_memory_pressure_settings_set_kill_threshold(settings, kill_threshold); | |
| 574 | - | } | |
| 575 | - | ||
| 576 | - | webkit_memory_pressure_settings_set_poll_interval(settings, poll_interval); | |
| 577 | - | ||
| 578 | - | return settings; | |
| 579 | - | #else | |
| 580 | - | (void)memory_limit_mb; (void)conservative_threshold; (void)strict_threshold; (void)kill_threshold; (void)poll_interval; | |
| 581 | - | return NULL; | |
| 582 | - | #endif | |
| 583 | - | } | |
| 584 | - | ||
| 585 | - | // Cache model configuration | |
| 586 | - | static void maybe_set_cache_model(WebKitWebContext* context, int model) { | |
| 587 | - | if (!context) return; | |
| 588 | - | WebKitCacheModel cache_model = WEBKIT_CACHE_MODEL_WEB_BROWSER; // default | |
| 589 | - | ||
| 590 | - | switch (model) { | |
| 591 | - | case 0: cache_model = WEBKIT_CACHE_MODEL_DOCUMENT_VIEWER; break; | |
| 592 | - | case 1: cache_model = WEBKIT_CACHE_MODEL_WEB_BROWSER; break; | |
| 593 | - | case 2: | |
| 594 | - | #ifdef WEBKIT_CACHE_MODEL_PRIMARY_WEB_BROWSER | |
| 595 | - | cache_model = WEBKIT_CACHE_MODEL_PRIMARY_WEB_BROWSER; | |
| 596 | - | #else | |
| 597 | - | cache_model = WEBKIT_CACHE_MODEL_WEB_BROWSER; | |
| 598 | - | #endif | |
| 599 | - | break; | |
| 600 | - | } | |
| 601 | - | ||
| 602 | - | webkit_web_context_set_cache_model(context, cache_model); | |
| 603 | - | } | |
| 604 | - | ||
| 605 | - | // Additional settings for memory optimization | |
| 606 | - | static void maybe_set_page_cache(WebKitSettings* settings, int enable) { | |
| 607 | - | #if WEBKIT_CHECK_VERSION(2,6,0) | |
| 608 | - | if (!settings) return; | |
| 609 | - | webkit_settings_set_enable_page_cache(settings, enable ? TRUE : FALSE); | |
| 610 | - | #else | |
| 611 | - | (void)settings; (void)enable; | |
| 612 | - | #endif | |
| 613 | - | } | |
| 614 | - | ||
| 615 | - | // Offline Web Application Cache is deprecated in modern WebKit; keep as no-op to avoid warnings. | |
| 616 | - | static void maybe_set_offline_app_cache(WebKitSettings* settings, int enable) { | |
| 617 | - | (void)settings; (void)enable; | |
| 618 | - | } | |
| 619 | - | ||
| 620 | - | // Apply memory pressure settings to context if available | |
| 621 | - | static void maybe_set_memory_pressure_settings(WebKitWebContext* context, WebKitMemoryPressureSettings* settings) { | |
| 622 | - | // No-op wrapper kept for compatibility; settings applied to NetworkSession instead. | |
| 623 | - | (void)context; (void)settings; | |
| 624 | - | } | |
| 625 | - | ||
| 626 | - | // Trigger JavaScript GC if supported by headers | |
| 627 | - | static void maybe_collect_js(WebKitWebContext* context) { | |
| 628 | - | // Disabled by default due to API availability variance across 6.0 builds. | |
| 629 | - | (void)context; | |
| 630 | - | } | |
| 631 | - | ||
| 632 | - | // TLS error handling implementation | |
| 633 | - | static gboolean on_load_failed_with_tls_errors(WebKitWebView *web_view, | |
| 634 | - | const char *failing_uri, | |
| 635 | - | GTlsCertificate *certificate, | |
| 636 | - | GTlsCertificateFlags errors, | |
| 637 | - | gpointer user_data) { | |
| 638 | - | (void)user_data; | |
| 639 | - | ||
| 640 | - | // Extract host from URI for the Go callback | |
| 641 | - | const char* host = failing_uri; | |
| 642 | - | char* host_copy = NULL; | |
| 643 | - | ||
| 644 | - | if (strstr(failing_uri, "://")) { | |
| 645 | - | host = strstr(failing_uri, "://") + 3; | |
| 646 | - | char* slash = strchr(host, '/'); | |
| 647 | - | if (slash) { | |
| 648 | - | // Create a temporary null-terminated host string | |
| 649 | - | size_t host_len = slash - host; | |
| 650 | - | host_copy = malloc(host_len + 1); | |
| 651 | - | if (host_copy) { | |
| 652 | - | strncpy(host_copy, host, host_len); | |
| 653 | - | host_copy[host_len] = '\0'; | |
| 654 | - | host = host_copy; | |
| 655 | - | } | |
| 656 | - | } | |
| 657 | - | } | |
| 658 | - | ||
| 659 | - | // Extract certificate information | |
| 660 | - | char* cert_info = extract_certificate_info(certificate); | |
| 661 | - | ||
| 662 | - | gboolean should_proceed = goHandleTLSError((char*)failing_uri, (char*)host, (int)errors, cert_info); | |
| 663 | - | ||
| 664 | - | // If user accepted, allow the certificate for this host | |
| 665 | - | if (should_proceed && certificate) { | |
| 666 | - | printf("[dumber] User accepted certificate - adding exception and triggering new load\n"); | |
| 667 | - | fflush(stdout); | |
| 668 | - | // Get the network session from the web view | |
| 669 | - | WebKitNetworkSession* session = webkit_web_view_get_network_session(web_view); | |
| 670 | - | if (session) { | |
| 671 | - | webkit_network_session_allow_tls_certificate_for_host(session, certificate, host); | |
| 672 | - | printf("[dumber] Certificate exception added for host: %s\n", host); | |
| 673 | - | fflush(stdout); | |
| 674 | - | ||
| 675 | - | // Try loading the URL again after adding the exception | |
| 676 | - | webkit_web_view_load_uri(web_view, failing_uri); | |
| 677 | - | printf("[dumber] Triggered new load of %s with certificate exception\n", failing_uri); | |
| 678 | - | fflush(stdout); | |
| 679 | - | } | |
| 680 | - | } | |
| 681 | - | ||
| 682 | - | // Free the certificate info string | |
| 683 | - | if (cert_info) { | |
| 684 | - | g_free(cert_info); | |
| 685 | - | } | |
| 686 | - | ||
| 687 | - | if (host_copy) { | |
| 688 | - | free(host_copy); | |
| 689 | - | } | |
| 690 | - | ||
| 691 | - | return should_proceed; | |
| 692 | - | } | |
| 693 | - | ||
| 694 | - | // Extract certificate information for display | |
| 695 | - | static char* extract_certificate_info(GTlsCertificate* certificate) { | |
| 696 | - | if (!certificate) { | |
| 697 | - | return g_strdup("Certificate information not available"); | |
| 698 | - | } | |
| 699 | - | ||
| 700 | - | printf("[dumber] Extracting certificate information...\n"); | |
| 701 | - | fflush(stdout); | |
| 702 | - | ||
| 703 | - | GString* info = g_string_new("Certificate Information:\n"); | |
| 704 | - | ||
| 705 | - | // Try to get certificate properties - but these might not be available in all GTK versions | |
| 706 | - | gchar* subject = NULL; | |
| 707 | - | gchar* issuer = NULL; | |
| 708 | - | GDateTime* not_valid_before = NULL; | |
| 709 | - | GDateTime* not_valid_after = NULL; | |
| 710 | - | ||
| 711 | - | // Try property access but handle failures gracefully | |
| 712 | - | g_object_get(certificate, | |
| 713 | - | "subject-name", &subject, | |
| 714 | - | "issuer-name", &issuer, | |
| 715 | - | "not-valid-before", ¬_valid_before, | |
| 716 | - | "not-valid-after", ¬_valid_after, | |
| 717 | - | NULL); | |
| 718 | - | ||
| 719 | - | if (subject) { | |
| 720 | - | g_string_append_printf(info, "Subject: %s\n", subject); | |
| 721 | - | g_free(subject); | |
| 722 | - | printf("[dumber] Found subject info\n"); | |
| 723 | - | } else { | |
| 724 | - | g_string_append(info, "Subject: Information not available\n"); | |
| 725 | - | } | |
| 726 | - | ||
| 727 | - | if (issuer) { | |
| 728 | - | g_string_append_printf(info, "Issued by: %s\n", issuer); | |
| 729 | - | g_free(issuer); | |
| 730 | - | printf("[dumber] Found issuer info\n"); | |
| 731 | - | } else { | |
| 732 | - | g_string_append(info, "Issued by: Information not available\n"); | |
| 733 | - | } | |
| 734 | - | ||
| 735 | - | if (not_valid_before) { | |
| 736 | - | gchar* date_str = g_date_time_format(not_valid_before, "%Y-%m-%d %H:%M:%S UTC"); | |
| 737 | - | g_string_append_printf(info, "Valid from: %s\n", date_str); | |
| 738 | - | g_free(date_str); | |
| 739 | - | g_date_time_unref(not_valid_before); | |
| 740 | - | } else { | |
| 741 | - | g_string_append(info, "Valid from: Information not available\n"); | |
| 742 | - | } | |
| 743 | - | ||
| 744 | - | if (not_valid_after) { | |
| 745 | - | gchar* date_str = g_date_time_format(not_valid_after, "%Y-%m-%d %H:%M:%S UTC"); | |
| 746 | - | g_string_append_printf(info, "Valid until: %s\n", date_str); | |
| 747 | - | g_free(date_str); | |
| 748 | - | g_date_time_unref(not_valid_after); | |
| 749 | - | } else { | |
| 750 | - | g_string_append(info, "Valid until: Information not available\n"); | |
| 751 | - | } | |
| 752 | - | ||
| 753 | - | char* result = g_string_free(info, FALSE); | |
| 754 | - | printf("[dumber] Certificate info: %s\n", result); | |
| 755 | - | fflush(stdout); | |
| 756 | - | return result; | |
| 757 | - | } | |
| 758 | - | ||
| 759 | - | static void connect_tls_error_handler(WebKitWebView* wv) { | |
| 760 | - | if (!wv) return; | |
| 761 | - | g_signal_connect(G_OBJECT(wv), "load-failed-with-tls-errors", | |
| 762 | - | G_CALLBACK(on_load_failed_with_tls_errors), NULL); | |
| 763 | - | } | |
| 764 | - | ||
| 765 | - | // Modern AlertDialog async callback | |
| 766 | - | static void on_alert_dialog_choose_done(GObject *source, GAsyncResult *result, gpointer user_data) { | |
| 767 | - | AlertDialogResponse *data = (AlertDialogResponse*)user_data; | |
| 768 | - | GError *error = NULL; | |
| 769 | - | data->response = gtk_alert_dialog_choose_finish(GTK_ALERT_DIALOG(source), result, &error); | |
| 770 | - | if (error) { | |
| 771 | - | printf("[dumber] AlertDialog error: %s\n", error->message); | |
| 772 | - | g_error_free(error); | |
| 773 | - | data->response = 0; | |
| 774 | - | } | |
| 775 | - | data->completed = TRUE; | |
| 776 | - | const char *response_names[] = {"GO_BACK", "PROCEED_ONCE", "ALWAYS_ACCEPT"}; | |
| 777 | - | const char *response_name = (data->response >= 0 && data->response <= 2) ? | |
| 778 | - | response_names[data->response] : "UNKNOWN"; | |
| 779 | - | printf("[dumber] TLS dialog response: %s (button_index=%d)\n", response_name, data->response); | |
| 780 | - | fflush(stdout); | |
| 781 | - | if (data->loop) { | |
| 782 | - | g_main_loop_quit(data->loop); | |
| 783 | - | } | |
| 784 | - | } | |
| 785 | - | ||
| 786 | - | // Show TLS warning dialog synchronously using modern GtkAlertDialog API | |
| 787 | - | static gboolean show_tls_warning_dialog_sync(GtkWindow *parent, const char* hostname, const char* error_msg) { | |
| 788 | - | printf("[dumber] Creating TLS warning dialog for hostname: %s\n", hostname); | |
| 789 | - | printf("[dumber] Error message: %s\n", error_msg); | |
| 790 | - | fflush(stdout); | |
| 791 | - | ||
| 792 | - | // Create the modern AlertDialog | |
| 793 | - | GtkAlertDialog *dialog = gtk_alert_dialog_new("Certificate Error for %s", hostname); | |
| 794 | - | ||
| 795 | - | // Set modal behavior and error details | |
| 796 | - | gtk_alert_dialog_set_modal(dialog, TRUE); | |
| 797 | - | gtk_alert_dialog_set_detail(dialog, error_msg); | |
| 798 | - | ||
| 799 | - | // Configure buttons (Go Back = 0, Proceed Once = 1, Always Accept = 2) | |
| 800 | - | const char *buttons[] = {"Go Back", "Proceed Once (Unsafe)", "Always Accept This Site", NULL}; | |
| 801 | - | gtk_alert_dialog_set_buttons(dialog, buttons); | |
| 802 | - | gtk_alert_dialog_set_default_button(dialog, 0); | |
| 803 | - | gtk_alert_dialog_set_cancel_button(dialog, 0); | |
| 804 | - | ||
| 805 | - | // Set up response data with main loop for sync behavior | |
| 806 | - | AlertDialogResponse response_data = {0, FALSE, g_main_loop_new(NULL, FALSE)}; | |
| 807 | - | ||
| 808 | - | // Start async operation | |
| 809 | - | gtk_alert_dialog_choose(dialog, parent, NULL, | |
| 810 | - | on_alert_dialog_choose_done, &response_data); | |
| 811 | - | ||
| 812 | - | // Run event loop until dialog completes | |
| 813 | - | g_main_loop_run(response_data.loop); | |
| 814 | - | g_main_loop_unref(response_data.loop); | |
| 815 | - | ||
| 816 | - | // Clean up | |
| 817 | - | g_object_unref(dialog); | |
| 818 | - | ||
| 819 | - | // Return response code: 0=Go Back, 1=Proceed Once, 2=Always Accept | |
| 820 | - | return response_data.response; | |
| 821 | - | } | |
| 822 | - | ||
| 823 | - | */ | |
| 824 | - | import "C" | |
| 825 | - | ||
| 826 | - | import ( | |
| 827 | - | "context" | |
| 828 | - | "crypto/sha256" | |
| 829 | - | "database/sql" | |
| 830 | - | "encoding/json" | |
| 831 | - | "errors" | |
| 832 | - | "fmt" | |
| 833 | - | "log" | |
| 834 | - | "math" | |
| 835 | - | "os" | |
| 836 | - | "path/filepath" | |
| 837 | - | "runtime" | |
| 838 | - | "runtime/cgo" | |
| 839 | - | "strconv" | |
| 840 | - | "strings" | |
| 841 | - | "sync" | |
| 842 | - | "time" | |
| 843 | - | "unsafe" | |
| 844 | - | ||
| 845 | - | "github.com/bnema/dumber/internal/config" | |
| 846 | - | "github.com/bnema/dumber/internal/db" | |
| 847 | - | "github.com/bnema/dumber/internal/logging" | |
| 848 | - | _ "github.com/ncruces/go-sqlite3" | |
| 849 | - | ) | |
| 850 | - | ||
| 851 | - | // FilterManager interface for content blocking integration | |
| 852 | - | type FilterManager interface { | |
| 853 | - | GetNetworkFilters() ([]byte, error) | |
| 854 | - | GetCosmeticScript() string | |
| 855 | - | GetCosmeticScriptForDomain(domain string) string | |
| 856 | - | } | |
| 857 | - | ||
| 858 | - | // domBridgeTemplate removed - now loading compiled main-world.min.js | |
| 859 | - | ||
| 860 | - | var registerSchemeOnce sync.Once | |
| 861 | - | ||
| 862 | - | func registerDumbScheme(ctx *C.WebKitWebContext) { | |
| 863 | - | if ctx == nil { | |
| 864 | - | return | |
| 865 | - | } | |
| 866 | - | ||
| 867 | - | registerSchemeOnce.Do(func() { | |
| 868 | - | sch := C.CString("dumb") | |
| 869 | - | defer C.free(unsafe.Pointer(sch)) | |
| 870 | - | C.register_uri_scheme_handler(ctx, sch) | |
| 871 | - | C.register_uri_scheme_security(ctx, sch) | |
| 872 | - | }) | |
| 873 | - | } | |
| 874 | - | ||
| 875 | - | // Helper function to convert bool to int for C interop | |
| 876 | - | func boolToInt(b bool) int { | |
| 877 | - | if b { | |
| 878 | - | return 1 | |
| 879 | - | } | |
| 880 | - | return 0 | |
| 881 | - | } | |
| 882 | - | ||
| 883 | - | //export goInvokeHandle | |
| 884 | - | func goInvokeHandle(handle C.uintptr_t) { | |
| 885 | - | h := cgo.Handle(handle) | |
| 886 | - | if fn, ok := h.Value().(func()); ok { | |
| 887 | - | fn() | |
| 888 | - | } | |
| 889 | - | h.Delete() | |
| 890 | - | } | |
| 891 | - | ||
| 892 | - | // getCertificateHash generates a SHA256 hash of the certificate info for storage | |
| 893 | - | func getCertificateHash(certificateInfo string) string { | |
| 894 | - | hash := sha256.Sum256([]byte(certificateInfo)) | |
| 895 | - | return fmt.Sprintf("%x", hash) | |
| 896 | - | } | |
| 897 | - | ||
| 898 | - | // getDBConnection opens a database connection and returns queries | |
| 899 | - | func getDBConnection() (*db.Queries, *sql.DB, error) { | |
| 900 | - | cfg := config.Get() | |
| 901 | - | if cfg == nil { | |
| 902 | - | return nil, nil, fmt.Errorf("config not available") | |
| 903 | - | } | |
| 904 | - | ||
| 905 | - | dbPath := cfg.Database.Path | |
| 906 | - | if dbPath == "" { | |
| 907 | - | var err error | |
| 908 | - | dbPath, err = config.GetDatabaseFile() | |
| 909 | - | if err != nil { | |
| 910 | - | return nil, nil, fmt.Errorf("failed to get database path: %w", err) | |
| 911 | - | } | |
| 912 | - | } | |
| 913 | - | ||
| 914 | - | database, err := sql.Open("sqlite3", dbPath) | |
| 915 | - | if err != nil { | |
| 916 | - | return nil, nil, fmt.Errorf("failed to open database: %w", err) | |
| 917 | - | } | |
| 918 | - | ||
| 919 | - | queries := db.New(database) | |
| 920 | - | return queries, database, nil | |
| 921 | - | } | |
| 922 | - | ||
| 923 | - | // checkStoredCertificateDecision checks if user has previously decided on this certificate | |
| 924 | - | func checkStoredCertificateDecision(hostname, certificateInfo string) (string, bool) { | |
| 925 | - | queries, database, err := getDBConnection() | |
| 926 | - | if err != nil { | |
| 927 | - | log.Printf("[tls] Failed to connect to database: %v", err) | |
| 928 | - | return "", false | |
| 929 | - | } | |
| 930 | - | defer database.Close() | |
| 931 | - | ||
| 932 | - | certHash := getCertificateHash(certificateInfo) | |
| 933 | - | ctx := context.Background() | |
| 934 | - | ||
| 935 | - | validation, err := queries.GetCertificateValidation(ctx, hostname, certHash) | |
| 936 | - | if err != nil { | |
| 937 | - | if err == sql.ErrNoRows { | |
| 938 | - | return "", false // No previous decision | |
| 939 | - | } | |
| 940 | - | log.Printf("[tls] Failed to query certificate validation: %v", err) | |
| 941 | - | return "", false | |
| 942 | - | } | |
| 943 | - | ||
| 944 | - | log.Printf("[tls] Found stored certificate decision for %s: %s", hostname, validation.UserDecision) | |
| 945 | - | return validation.UserDecision, true | |
| 946 | - | } | |
| 947 | - | ||
| 948 | - | // storeCertificateDecision stores the user's decision about a certificate | |
| 949 | - | func storeCertificateDecision(hostname, certificateInfo, decision string, permanent bool) error { | |
| 950 | - | queries, database, err := getDBConnection() | |
| 951 | - | if err != nil { | |
| 952 | - | return fmt.Errorf("failed to connect to database: %w", err) | |
| 953 | - | } | |
| 954 | - | defer database.Close() | |
| 955 | - | ||
| 956 | - | certHash := getCertificateHash(certificateInfo) | |
| 957 | - | ctx := context.Background() | |
| 958 | - | ||
| 959 | - | var expiresAt sql.NullTime | |
| 960 | - | if !permanent { | |
| 961 | - | // For temporary decisions, expire after 24 hours | |
| 962 | - | expiresAt = sql.NullTime{ | |
| 963 | - | Time: time.Now().Add(24 * time.Hour), | |
| 964 | - | Valid: true, | |
| 965 | - | } | |
| 966 | - | } | |
| 967 | - | ||
| 968 | - | err = queries.StoreCertificateValidation(ctx, hostname, certHash, decision, expiresAt) | |
| 969 | - | if err != nil { | |
| 970 | - | return fmt.Errorf("failed to store certificate validation: %w", err) | |
| 971 | - | } | |
| 972 | - | ||
| 973 | - | log.Printf("[tls] Stored certificate decision for %s: %s (permanent: %t)", hostname, decision, permanent) | |
| 974 | - | return nil | |
| 975 | - | } | |
| 976 | - | ||
| 977 | - | type nativeView struct { | |
| 978 | - | win *C.GtkWidget | |
| 979 | - | container *C.GtkWidget | |
| 980 | - | view *C.GtkWidget | |
| 981 | - | wv *C.WebKitWebView | |
| 982 | - | ucm *C.WebKitUserContentManager | |
| 983 | - | } | |
| 984 | - | ||
| 985 | - | type memoryStats struct { | |
| 986 | - | pageLoadCount int | |
| 987 | - | lastGCTime time.Time | |
| 988 | - | memoryPressureSettings *C.WebKitMemoryPressureSettings | |
| 989 | - | } | |
| 990 | - | ||
| 991 | - | // WebView represents a browser view powered by WebKit2GTK. | |
| 992 | - | type WebView struct { | |
| 993 | - | config *Config | |
| 994 | - | zoom float64 | |
| 995 | - | url string | |
| 996 | - | destroyed bool | |
| 997 | - | native *nativeView | |
| 998 | - | window *Window | |
| 999 | - | id uintptr | |
| 1000 | - | msgHandler func(payload string) | |
| 1001 | - | titleHandler func(title string) | |
| 1002 | - | uriHandler func(uri string) | |
| 1003 | - | faviconHandler func(data []byte) | |
| 1004 | - | faviconURIHandler func(pageURI, faviconURI string) | |
| 1005 | - | zoomHandler func(level float64) | |
| 1006 | - | popupHandler func(string) *WebView | |
| 1007 | - | closeHandler func() | |
| 1008 | - | memStats *memoryStats | |
| 1009 | - | gcTicker *time.Ticker | |
| 1010 | - | gcDone chan struct{} | |
| 1011 | - | tlsExceptions map[string]bool // host -> whether user allowed certificate exception | |
| 1012 | - | useDomZoom bool | |
| 1013 | - | domZoomSeed float64 | |
| 1014 | - | domBridgeScript *C.WebKitUserScript | |
| 1015 | - | wasReparented bool // Track if this WebView has been reparented | |
| 1016 | - | ||
| 1017 | - | // Window type detection and relationships | |
| 1018 | - | windowType WindowType | |
| 1019 | - | windowFeatures *WindowFeatures | |
| 1020 | - | windowTypeCallback func(WindowType, *WindowFeatures) | |
| 1021 | - | parentWebView *WebView | |
| 1022 | - | isRelated bool | |
| 1023 | - | isActive bool // Track if this WebView is currently active/focused | |
| 1024 | - | navigationPolicyHandler func(url string, isUserGesture bool) bool // Handler to decide if navigation should be allowed | |
| 1025 | - | } | |
| 1026 | - | ||
| 1027 | - | // NewWebView constructs a new WebView instance with native WebKit2GTK widgets. | |
| 1028 | - | func NewWebView(cfg *Config) (*WebView, error) { | |
| 1029 | - | log.Printf("[webkit] Initializing GTK and creating WebView (CGO)") | |
| 1030 | - | if C.gtk_init_check() == 0 { | |
| 1031 | - | return nil, errors.New("failed to initialize GTK") | |
| 1032 | - | } | |
| 1033 | - | ||
| 1034 | - | InitMainThread() | |
| 1035 | - | ||
| 1036 | - | if cfg == nil { | |
| 1037 | - | cfg = &Config{} | |
| 1038 | - | } | |
| 1039 | - | ||
| 1040 | - | // Setup video acceleration environment variables before creating WebView | |
| 1041 | - | if cfg.VideoAcceleration.EnableVAAPI { | |
| 1042 | - | driverName := C.CString(cfg.VideoAcceleration.VAAPIDriverName) | |
| 1043 | - | enableAll := 0 | |
| 1044 | - | if cfg.VideoAcceleration.EnableAllDrivers { | |
| 1045 | - | enableAll = 1 | |
| 1046 | - | } | |
| 1047 | - | legacy := 0 | |
| 1048 | - | if cfg.VideoAcceleration.LegacyVAAPI { | |
| 1049 | - | legacy = 1 | |
| 1050 | - | } | |
| 1051 | - | C.setup_video_acceleration(driverName, C.int(enableAll), C.int(legacy)) | |
| 1052 | - | C.free(unsafe.Pointer(driverName)) | |
| 1053 | - | } | |
| 1054 | - | ||
| 1055 | - | // Prepare persistent website data/caches | |
| 1056 | - | dataDir := cfg.DataDir | |
| 1057 | - | cacheDir := cfg.CacheDir | |
| 1058 | - | if dataDir == "" { | |
| 1059 | - | dataDir = filepath.Join(os.TempDir(), "dumber-webkit-data") | |
| 1060 | - | } | |
| 1061 | - | if cacheDir == "" { | |
| 1062 | - | cacheDir = filepath.Join(os.TempDir(), "dumber-webkit-cache") | |
| 1063 | - | } | |
| 1064 | - | _ = os.MkdirAll(dataDir, 0o755) | |
| 1065 | - | _ = os.MkdirAll(cacheDir, 0o755) | |
| 1066 | - | ||
| 1067 | - | cData := C.CString(dataDir) | |
| 1068 | - | cCache := C.CString(cacheDir) | |
| 1069 | - | cookieFile := filepath.Join(dataDir, "cookies.db") | |
| 1070 | - | cCookie := C.CString(cookieFile) | |
| 1071 | - | defer C.free(unsafe.Pointer(cData)) | |
| 1072 | - | defer C.free(unsafe.Pointer(cCache)) | |
| 1073 | - | defer C.free(unsafe.Pointer(cCookie)) | |
| 1074 | - | // Use default WebContext to register the custom URI scheme handler; the | |
| 1075 | - | // WebView itself is created with a WebKitNetworkSession for persistence. | |
| 1076 | - | ctx := C.webkit_web_context_get_default() | |
| 1077 | - | ||
| 1078 | - | // Enable favicon database - we'll do this per WebView when created since we need NetworkSession | |
| 1079 | - | ||
| 1080 | - | // Apply cache model to WebContext | |
| 1081 | - | // Default to WEBKIT_CACHE_MODEL_WEB_BROWSER to keep caching enabled | |
| 1082 | - | cacheModel := 1 | |
| 1083 | - | if cfg.Memory.CacheModel != "" { | |
| 1084 | - | switch cfg.Memory.CacheModel { | |
| 1085 | - | case CacheModelWebBrowser: | |
| 1086 | - | cacheModel = 1 | |
| 1087 | - | case CacheModelPrimaryWebBrowser: | |
| 1088 | - | cacheModel = 2 | |
| 1089 | - | default: // CacheModelDocumentViewer or unset | |
| 1090 | - | cacheModel = 0 | |
| 1091 | - | } | |
| 1092 | - | } | |
| 1093 | - | C.maybe_set_cache_model(ctx, C.int(cacheModel)) | |
| 1094 | - | ||
| 1095 | - | // Configure memory pressure settings if specified | |
| 1096 | - | var pressureSettings *C.WebKitMemoryPressureSettings | |
| 1097 | - | // Only configure memory pressure if explicitly requested via config | |
| 1098 | - | if cfg.Memory.MemoryLimitMB > 0 || cfg.Memory.ConservativeThreshold > 0 || cfg.Memory.StrictThreshold > 0 || cfg.Memory.KillThreshold > 0 || cfg.Memory.PollIntervalSeconds > 0 { | |
| 1099 | - | memLimitMB := cfg.Memory.MemoryLimitMB | |
| 1100 | - | conservativeThreshold := cfg.Memory.ConservativeThreshold | |
| 1101 | - | strictThreshold := cfg.Memory.StrictThreshold | |
| 1102 | - | killThreshold := cfg.Memory.KillThreshold | |
| 1103 | - | pollInterval := cfg.Memory.PollIntervalSeconds | |
| 1104 | - | ||
| 1105 | - | pressureSettings = C.create_memory_pressure_settings( | |
| 1106 | - | C.uint(memLimitMB), | |
| 1107 | - | C.double(conservativeThreshold), | |
| 1108 | - | C.double(strictThreshold), | |
| 1109 | - | C.double(killThreshold), | |
| 1110 | - | C.double(pollInterval)) | |
| 1111 | - | ||
| 1112 | - | if pressureSettings != nil { | |
| 1113 | - | C.maybe_set_memory_pressure_settings(ctx, pressureSettings) | |
| 1114 | - | if cfg.Memory.EnableMemoryMonitoring { | |
| 1115 | - | log.Printf("[webkit] Memory pressure settings applied: limit=%dMB, conservative=%.2f, strict=%.2f, poll=%.1fs", | |
| 1116 | - | memLimitMB, conservativeThreshold, strictThreshold, pollInterval) | |
| 1117 | - | } | |
| 1118 | - | } | |
| 1119 | - | } | |
| 1120 | - | ||
| 1121 | - | registerDumbScheme(ctx) | |
| 1122 | - | // Cookie manager persistent storage handled via NetworkSession in new_webview_with_ucm_and_session | |
| 1123 | - | ||
| 1124 | - | // Create WebView through g_object_new with a fresh UCM (GTK4/WebKit 6 style) | |
| 1125 | - | var createdUcm *C.WebKitUserContentManager | |
| 1126 | - | viewWidget := C.new_webview_with_ucm_and_session(cData, cCache, cCookie, &createdUcm, pressureSettings) | |
| 1127 | - | if viewWidget == nil { | |
| 1128 | - | return nil, errors.New("failed to create WebKitWebView") | |
| 1129 | - | } | |
| 1130 | - | ||
| 1131 | - | var win *C.GtkWidget | |
| 1132 | - | var window *Window | |
| 1133 | - | container := C.gtk_box_new(C.GtkOrientation(C.GTK_ORIENTATION_VERTICAL), 0) | |
| 1134 | - | if container == nil { | |
| 1135 | - | return nil, errors.New("failed to create GtkBox container") | |
| 1136 | - | } | |
| 1137 | - | // Hold a strong reference so the WebView keeps the container alive across reparenting. | |
| 1138 | - | C.g_object_ref_sink(C.gpointer(container)) | |
| 1139 | - | C.gtk_widget_set_hexpand(container, C.gboolean(1)) | |
| 1140 | - | C.gtk_widget_set_vexpand(container, C.gboolean(1)) | |
| 1141 | - | C.gtk_widget_set_hexpand(viewWidget, C.gboolean(1)) | |
| 1142 | - | C.gtk_widget_set_vexpand(viewWidget, C.gboolean(1)) | |
| 1143 | - | C.gtk_box_append((*C.GtkBox)(unsafe.Pointer(container)), viewWidget) | |
| 1144 | - | ||
| 1145 | - | // Only create window if requested | |
| 1146 | - | if cfg.CreateWindow { | |
| 1147 | - | // Create a top-level window to host the view | |
| 1148 | - | win = C.new_window() | |
| 1149 | - | if win == nil { | |
| 1150 | - | return nil, errors.New("failed to create GtkWindow") | |
| 1151 | - | } | |
| 1152 | - | ||
| 1153 | - | // Pack view widget into the window | |
| 1154 | - | // GTK4: containers removed; use gtk_window_set_child | |
| 1155 | - | C.gtk_window_set_child((*C.GtkWindow)(unsafe.Pointer(win)), container) | |
| 1156 | - | C.gtk_window_set_default_size((*C.GtkWindow)(unsafe.Pointer(win)), 1024, 768) | |
| 1157 | - | C.connect_destroy_quit(win) | |
| 1158 | - | window = &Window{win: win} | |
| 1159 | - | } | |
| 1160 | - | ||
| 1161 | - | seed := 1.0 | |
| 1162 | - | if cfg.ZoomDefault > 0 { | |
| 1163 | - | seed = cfg.ZoomDefault | |
| 1164 | - | } | |
| 1165 | - | ||
| 1166 | - | v := &WebView{ | |
| 1167 | - | config: cfg, | |
| 1168 | - | zoom: 1.0, | |
| 1169 | - | useDomZoom: cfg.UseDomZoom, | |
| 1170 | - | domZoomSeed: seed, | |
| 1171 | - | native: &nativeView{win: win, container: container, view: viewWidget, wv: C.as_webview(viewWidget), ucm: createdUcm}, | |
| 1172 | - | window: window, | |
| 1173 | - | memStats: &memoryStats{ | |
| 1174 | - | pageLoadCount: 0, | |
| 1175 | - | lastGCTime: time.Now(), | |
| 1176 | - | memoryPressureSettings: pressureSettings, | |
| 1177 | - | }, | |
| 1178 | - | gcDone: make(chan struct{}), | |
| 1179 | - | tlsExceptions: make(map[string]bool), | |
| 1180 | - | } | |
| 1181 | - | // Assign an ID for accelerator dispatch | |
| 1182 | - | v.id = nextViewID() | |
| 1183 | - | registerView(v.id, v) | |
| 1184 | - | C.connect_policy_handler(v.native.wv, C.ulong(v.id)) | |
| 1185 | - | ConnectCreateSignal(v) // Connect create signal for window.open support | |
| 1186 | - | ||
| 1187 | - | // Register with global memory manager if monitoring is enabled | |
| 1188 | - | if cfg.Memory.EnableMemoryMonitoring { | |
| 1189 | - | if globalMemoryManager == nil { | |
| 1190 | - | InitializeGlobalMemoryManager( | |
| 1191 | - | cfg.Memory.EnableMemoryMonitoring, | |
| 1192 | - | cfg.Memory.ProcessRecycleThreshold, | |
| 1193 | - | 30*time.Second, // Default monitoring interval | |
| 1194 | - | ) | |
| 1195 | - | } | |
| 1196 | - | if globalMemoryManager != nil { | |
| 1197 | - | globalMemoryManager.RegisterWebView(v) | |
| 1198 | - | } | |
| 1199 | - | } | |
| 1200 | - | // Attach GTK4 input controllers (keyboard, mouse) | |
| 1201 | - | AttachKeyboardControllers(v) | |
| 1202 | - | ||
| 1203 | - | // Enable print interception to prevent Ctrl+P conflicts with pane mode | |
| 1204 | - | v.EnablePrintInterception() | |
| 1205 | - | ||
| 1206 | - | // Watch for GTK theme changes and propagate to page at runtime | |
| 1207 | - | if settings := C.gtk_settings_get_default(); settings != nil { | |
| 1208 | - | C.connect_theme_changed_with_id(settings, C.ulong(v.id)) | |
| 1209 | - | } | |
| 1210 | - | ||
| 1211 | - | // Zoom shortcuts are now handled by application-level workspace shortcuts | |
| 1212 | - | // in workspace_manager.go to ensure proper active pane targeting | |
| 1213 | - | // Find in page (Ctrl/Cmd+F): handled by window-level shortcuts in window_shortcuts.go | |
| 1214 | - | // Navigation with Ctrl/Cmd + Arrow keys: forward to keyboard service bridge | |
| 1215 | - | _ = v.RegisterKeyboardShortcut("cmdorctrl+ArrowLeft", func() { | |
| 1216 | - | _ = v.InjectScript("document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'cmdorctrl+arrowleft'}}))") | |
| 1217 | - | }) | |
| 1218 | - | _ = v.RegisterKeyboardShortcut("cmdorctrl+ArrowRight", func() { | |
| 1219 | - | _ = v.InjectScript("document.dispatchEvent(new CustomEvent('dumber:key',{detail:{shortcut:'cmdorctrl+arrowright'}}))") | |
| 1220 | - | }) | |
| 1221 | - | // Update window title when page title changes | |
| 1222 | - | C.connect_title_notify(viewWidget, (*C.GtkWindow)(unsafe.Pointer(win))) | |
| 1223 | - | // Also dispatch title change to Go with view id | |
| 1224 | - | C.connect_title_notify_with_id(viewWidget, C.ulong(v.id)) | |
| 1225 | - | // Notify URI changes to Go to keep current URL in sync | |
| 1226 | - | C.connect_uri_notify_with_id(viewWidget, C.ulong(v.id)) | |
| 1227 | - | // Enable favicon database for this WebView's NetworkSession | |
| 1228 | - | networkSession := C.webkit_web_view_get_network_session(v.native.wv) | |
| 1229 | - | C.enable_favicon_database(networkSession) | |
| 1230 | - | ||
| 1231 | - | // Notify favicon changes to Go for caching | |
| 1232 | - | C.connect_favicon_changed_with_id(viewWidget, C.ulong(v.id)) | |
| 1233 | - | // Apply hardware acceleration and related settings based on cfg.Rendering | |
| 1234 | - | if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil { | |
| 1235 | - | // Hardware acceleration policy (guarded by version) | |
| 1236 | - | switch cfg.Rendering.Mode { | |
| 1237 | - | case "gpu": | |
| 1238 | - | log.Printf("[webkit] Applying GPU rendering mode") | |
| 1239 | - | C.maybe_set_hw_policy(settings, 1) | |
| 1240 | - | C.maybe_set_webgl(settings, 1) | |
| 1241 | - | C.maybe_set_canvas_accel(settings, 1) | |
| 1242 | - | case "cpu": | |
| 1243 | - | log.Printf("[webkit] Applying CPU rendering mode") | |
| 1244 | - | C.maybe_set_hw_policy(settings, 2) | |
| 1245 | - | C.maybe_set_webgl(settings, 0) | |
| 1246 | - | C.maybe_set_canvas_accel(settings, 0) | |
| 1247 | - | default: // auto | |
| 1248 | - | log.Printf("[webkit] Applying AUTO rendering mode - detecting actual backend...") | |
| 1249 | - | C.maybe_set_hw_policy(settings, 0) | |
| 1250 | - | C.maybe_set_webgl(settings, 1) | |
| 1251 | - | C.maybe_set_canvas_accel(settings, 1) | |
| 1252 | - | // Detect actual rendering backend after applying auto settings | |
| 1253 | - | v.detectAndLogRenderingBackend() | |
| 1254 | - | } | |
| 1255 | - | // Optional compositing indicators for debugging (guarded by version) | |
| 1256 | - | if cfg.Rendering.DebugGPU { | |
| 1257 | - | C.maybe_set_draw_indicators(settings, 1) | |
| 1258 | - | } | |
| 1259 | - | // Reduce media pipeline churn by requiring a user gesture for playback | |
| 1260 | - | C.maybe_set_media_user_gesture(settings, 1) | |
| 1261 | - | // Enable trackpad back/forward gestures when available | |
| 1262 | - | C.maybe_set_back_forward_gestures(settings, 1) | |
| 1263 | - | // Smooth scrolling for wheel/pinch gestures when supported | |
| 1264 | - | C.maybe_set_smooth_scrolling(settings, 1) | |
| 1265 | - | ||
| 1266 | - | // Keep page cache enabled by default for performance | |
| 1267 | - | enablePageCache := true | |
| 1268 | - | if cfg.Memory.EnablePageCache { | |
| 1269 | - | enablePageCache = true | |
| 1270 | - | } | |
| 1271 | - | C.maybe_set_page_cache(settings, C.int(boolToInt(enablePageCache))) | |
| 1272 | - | ||
| 1273 | - | // Offline app cache is deprecated; keep as no-op | |
| 1274 | - | C.maybe_set_offline_app_cache(settings, C.int(0)) | |
| 1275 | - | ||
| 1276 | - | if cfg.Memory.EnableMemoryMonitoring { | |
| 1277 | - | log.Printf("[webkit] Memory settings applied: page_cache=%v", enablePageCache) | |
| 1278 | - | } | |
| 1279 | - | } | |
| 1280 | - | ||
| 1281 | - | if cfg.ZoomDefault > 0 { | |
| 1282 | - | v.zoom = cfg.ZoomDefault | |
| 1283 | - | C.webkit_web_view_set_zoom_level(v.native.wv, C.gdouble(v.zoom)) | |
| 1284 | - | } | |
| 1285 | - | if cfg.EnableDeveloperExtras { | |
| 1286 | - | settings := C.webkit_web_view_get_settings(v.native.wv) | |
| 1287 | - | if settings != nil { | |
| 1288 | - | C.webkit_settings_set_enable_developer_extras(settings, C.gboolean(1)) | |
| 1289 | - | } | |
| 1290 | - | } | |
| 1291 | - | ||
| 1292 | - | // Enable JavaScript popup windows for workspace popup handling | |
| 1293 | - | settings := C.webkit_web_view_get_settings(v.native.wv) | |
| 1294 | - | if settings != nil { | |
| 1295 | - | C.webkit_settings_set_javascript_can_open_windows_automatically(settings, C.gboolean(1)) | |
| 1296 | - | log.Printf("[webkit] Enabled JavaScript popup windows") | |
| 1297 | - | } | |
| 1298 | - | ||
| 1299 | - | // Enable critical web platform features for modern websites (Twitch, YouTube, etc.) | |
| 1300 | - | if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil { | |
| 1301 | - | // MediaSource Extensions - CRITICAL for video streaming (Twitch, YouTube, Netflix) | |
| 1302 | - | C.webkit_settings_set_enable_mediasource(settings, C.gboolean(1)) | |
| 1303 | - | ||
| 1304 | - | // Encrypted Media Extensions - for DRM protected content | |
| 1305 | - | C.webkit_settings_set_enable_encrypted_media(settings, C.gboolean(1)) | |
| 1306 | - | ||
| 1307 | - | // Media Capabilities API - for codec/quality detection | |
| 1308 | - | C.webkit_settings_set_enable_media_capabilities(settings, C.gboolean(1)) | |
| 1309 | - | ||
| 1310 | - | // WebAudio API - for advanced audio processing | |
| 1311 | - | C.webkit_settings_set_enable_webaudio(settings, C.gboolean(1)) | |
| 1312 | - | ||
| 1313 | - | // HTML5 LocalStorage - for site data persistence | |
| 1314 | - | C.webkit_settings_set_enable_html5_local_storage(settings, C.gboolean(1)) | |
| 1315 | - | ||
| 1316 | - | // HTML5 Database/IndexedDB - for offline storage | |
| 1317 | - | C.webkit_settings_set_enable_html5_database(settings, C.gboolean(1)) | |
| 1318 | - | ||
| 1319 | - | // WebRTC - for real-time communication | |
| 1320 | - | C.webkit_settings_set_enable_webrtc(settings, C.gboolean(1)) | |
| 1321 | - | ||
| 1322 | - | // Media Stream API - for camera/microphone access | |
| 1323 | - | C.webkit_settings_set_enable_media_stream(settings, C.gboolean(1)) | |
| 1324 | - | ||
| 1325 | - | // JavaScript Clipboard API - for copy/paste in web apps | |
| 1326 | - | C.webkit_settings_set_javascript_can_access_clipboard(settings, C.gboolean(1)) | |
| 1327 | - | ||
| 1328 | - | log.Printf("[webkit] Enabled web platform features: MSE, EME, MediaCapabilities, WebAudio, Storage, WebRTC, Clipboard") | |
| 1329 | - | } | |
| 1330 | - | ||
| 1331 | - | // Apply custom User-Agent for codec negotiation | |
| 1332 | - | if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil { | |
| 1333 | - | if cfg.CodecPreferences.CustomUserAgent != "" { | |
| 1334 | - | cUserAgent := C.CString(cfg.CodecPreferences.CustomUserAgent) | |
| 1335 | - | C.webkit_settings_set_user_agent(settings, (*C.gchar)(cUserAgent)) | |
| 1336 | - | C.free(unsafe.Pointer(cUserAgent)) | |
| 1337 | - | log.Printf("[webkit] Set custom User-Agent: %s", cfg.CodecPreferences.CustomUserAgent) | |
| 1338 | - | } else if cfg.CodecPreferences.ForceAV1 { | |
| 1339 | - | // Use modern Chrome UA that signals AV1 support | |
| 1340 | - | av1UA := "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/141.0.0.0 Safari/537.36" | |
| 1341 | - | cAV1UA := C.CString(av1UA) | |
| 1342 | - | C.webkit_settings_set_user_agent(settings, (*C.gchar)(cAV1UA)) | |
| 1343 | - | C.free(unsafe.Pointer(cAV1UA)) | |
| 1344 | - | log.Printf("[webkit] Set AV1-optimized User-Agent") | |
| 1345 | - | } | |
| 1346 | - | } | |
| 1347 | - | ||
| 1348 | - | // Apply default fonts if provided in cfg | |
| 1349 | - | if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil { | |
| 1350 | - | if cfg.DefaultSansFont != "" { | |
| 1351 | - | csans := C.CString(cfg.DefaultSansFont) | |
| 1352 | - | C.webkit_settings_set_sans_serif_font_family(settings, (*C.gchar)(csans)) | |
| 1353 | - | C.free(unsafe.Pointer(csans)) | |
| 1354 | - | // Also set default font family to sans | |
| 1355 | - | csans2 := C.CString(cfg.DefaultSansFont) | |
| 1356 | - | C.webkit_settings_set_default_font_family(settings, (*C.gchar)(csans2)) | |
| 1357 | - | C.free(unsafe.Pointer(csans2)) | |
| 1358 | - | } | |
| 1359 | - | if cfg.DefaultSerifFont != "" { | |
| 1360 | - | cserif := C.CString(cfg.DefaultSerifFont) | |
| 1361 | - | C.webkit_settings_set_serif_font_family(settings, (*C.gchar)(cserif)) | |
| 1362 | - | C.free(unsafe.Pointer(cserif)) | |
| 1363 | - | } | |
| 1364 | - | if cfg.DefaultMonospaceFont != "" { | |
| 1365 | - | cmono := C.CString(cfg.DefaultMonospaceFont) | |
| 1366 | - | C.webkit_settings_set_monospace_font_family(settings, (*C.gchar)(cmono)) | |
| 1367 | - | C.free(unsafe.Pointer(cmono)) | |
| 1368 | - | } | |
| 1369 | - | if cfg.DefaultFontSize > 0 { | |
| 1370 | - | C.webkit_settings_set_default_font_size(settings, C.guint(cfg.DefaultFontSize)) | |
| 1371 | - | } | |
| 1372 | - | } | |
| 1373 | - | if cfg.InitialURL != "" { | |
| 1374 | - | _ = v.LoadURL(cfg.InitialURL) | |
| 1375 | - | } | |
| 1376 | - | // Ensure native resources are released if user forgets to call Destroy | |
| 1377 | - | runtime.SetFinalizer(v, func(v *WebView) { _ = v.Destroy() }) | |
| 1378 | - | log.Printf("[webkit] WebView created (CGO)") | |
| 1379 | - | ||
| 1380 | - | // Initialize UCM scripts and handlers | |
| 1381 | - | if v.native != nil && v.native.ucm != nil { | |
| 1382 | - | v.enableUserContentManager(cfg) | |
| 1383 | - | } | |
| 1384 | - | ||
| 1385 | - | // Start periodic JavaScript garbage collection if configured | |
| 1386 | - | if cfg.Memory.EnableGCInterval > 0 { | |
| 1387 | - | v.startPeriodicGC(time.Duration(cfg.Memory.EnableGCInterval) * time.Second) | |
| 1388 | - | if cfg.Memory.EnableMemoryMonitoring { | |
| 1389 | - | log.Printf("[webkit] Periodic GC enabled: interval=%ds", cfg.Memory.EnableGCInterval) | |
| 1390 | - | } | |
| 1391 | - | } | |
| 1392 | - | ||
| 1393 | - | // Setup console message logging | |
| 1394 | - | v.setupConsoleLogging() | |
| 1395 | - | ||
| 1396 | - | return v, nil | |
| 1397 | - | } | |
| 1398 | - | ||
| 1399 | - | // setupConsoleLogging enables console message capture for the WebView | |
| 1400 | - | func (v *WebView) setupConsoleLogging() { | |
| 1401 | - | if v.native == nil || v.native.wv == nil { | |
| 1402 | - | return | |
| 1403 | - | } | |
| 1404 | - | ||
| 1405 | - | // Check if console capture is enabled in config | |
| 1406 | - | cfg := config.Get() | |
| 1407 | - | if !cfg.Logging.CaptureConsole { | |
| 1408 | - | // Disable console messages to stdout (always, to keep backend clean) | |
| 1409 | - | if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil { | |
| 1410 | - | C.webkit_settings_set_enable_write_console_messages_to_stdout(settings, C.gboolean(0)) | |
| 1411 | - | } | |
| 1412 | - | return | |
| 1413 | - | } | |
| 1414 | - | ||
| 1415 | - | // If console capture is enabled, disable stdout and enable our capture | |
| 1416 | - | if settings := C.webkit_web_view_get_settings(v.native.wv); settings != nil { | |
| 1417 | - | C.webkit_settings_set_enable_write_console_messages_to_stdout(settings, C.gboolean(0)) | |
| 1418 | - | log.Printf("[webkit] Console message capture enabled - redirecting to console.log file") | |
| 1419 | - | } | |
| 1420 | - | ||
| 1421 | - | // TODO: Implement JavaScript injection for console capture | |
| 1422 | - | // This will inject code to intercept console.log/error/warn and send to Go | |
| 1423 | - | } | |
| 1424 | - | ||
| 1425 | - | // setupGlobalConsoleCapture sets up a global stdout filter to capture console messages | |
| 1426 | - | func setupGlobalConsoleCapture() { | |
| 1427 | - | // This should be called once globally, not per WebView | |
| 1428 | - | logging.StartConsoleCapture() | |
| 1429 | - | } | |
| 1430 | - | ||
| 1431 | - | // injectConsoleCapture injects JavaScript to capture console messages | |
| 1432 | - | func (v *WebView) injectConsoleCapture() { | |
| 1433 | - | consoleScript := ` | |
| 1434 | - | (function() { | |
| 1435 | - | const originalLog = console.log; | |
| 1436 | - | const originalWarn = console.warn; | |
| 1437 | - | const originalError = console.error; | |
| 1438 | - | const originalInfo = console.info; | |
| 1439 | - | const originalDebug = console.debug; | |
| 1440 | - | ||
| 1441 | - | function sendToGo(level, args) { | |
| 1442 | - | const message = args.map(arg => | |
| 1443 | - | typeof arg === 'object' ? JSON.stringify(arg) : String(arg) | |
| 1444 | - | ).join(' '); | |
| 1445 | - | ||
| 1446 | - | const formattedMessage = '[' + level + '] ' + window.location.href + ' ' + message; | |
| 1447 | - | ||
| 1448 | - | // Send to Go via user script message | |
| 1449 | - | if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.dumberBridge) { | |
| 1450 | - | window.webkit.messageHandlers.dumberBridge.postMessage({ | |
| 1451 | - | type: 'console', | |
| 1452 | - | data: formattedMessage | |
| 1453 | - | }); | |
| 1454 | - | } | |
| 1455 | - | } | |
| 1456 | - | ||
| 1457 | - | console.log = function(...args) { | |
| 1458 | - | originalLog.apply(console, args); | |
| 1459 | - | sendToGo('LOG', args); | |
| 1460 | - | }; | |
| 1461 | - | ||
| 1462 | - | console.warn = function(...args) { | |
| 1463 | - | originalWarn.apply(console, args); | |
| 1464 | - | sendToGo('WARN', args); | |
| 1465 | - | }; | |
| 1466 | - | ||
| 1467 | - | console.error = function(...args) { | |
| 1468 | - | originalError.apply(console, args); | |
| 1469 | - | sendToGo('ERROR', args); | |
| 1470 | - | }; | |
| 1471 | - | ||
| 1472 | - | console.info = function(...args) { | |
| 1473 | - | originalInfo.apply(console, args); | |
| 1474 | - | sendToGo('INFO', args); | |
| 1475 | - | }; | |
| 1476 | - | ||
| 1477 | - | console.debug = function(...args) { | |
| 1478 | - | originalDebug.apply(console, args); | |
| 1479 | - | sendToGo('DEBUG', args); | |
| 1480 | - | }; | |
| 1481 | - | })(); | |
| 1482 | - | ` | |
| 1483 | - | _ = v.InjectScript(consoleScript) | |
| 1484 | - | } | |
| 1485 | - | ||
| 1486 | - | // handleConsoleMessage processes console messages from JavaScript | |
| 1487 | - | func (v *WebView) handleConsoleMessage(payload string) { | |
| 1488 | - | // Parse the message payload to check if it's a console message | |
| 1489 | - | var msg struct { | |
| 1490 | - | Type string `json:"type"` | |
| 1491 | - | Data string `json:"data"` | |
| 1492 | - | } | |
| 1493 | - | ||
| 1494 | - | if err := json.Unmarshal([]byte(payload), &msg); err != nil { | |
| 1495 | - | return | |
| 1496 | - | } | |
| 1497 | - | ||
| 1498 | - | if msg.Type == "console" { | |
| 1499 | - | // Send to logging system with [CONSOLE] tag | |
| 1500 | - | logging.CaptureWebKitLog(msg.Data) | |
| 1501 | - | } | |
| 1502 | - | } | |
| 1503 | - | ||
| 1504 | - | // startConsoleCapture starts capturing stdout to filter console messages | |
| 1505 | - | func (v *WebView) startConsoleCapture() { | |
| 1506 | - | // This is a simplified approach - we'll rely on the existing OutputCapture | |
| 1507 | - | // mechanism in internal/logging/capture.go to handle stdout capture | |
| 1508 | - | // and filter for console messages in the log processing | |
| 1509 | - | } | |
| 1510 | - | ||
| 1511 | - | // NewWebViewWithRelated creates a new WebView related to an existing one for process sharing | |
| 1512 | - | func NewWebViewWithRelated(cfg *Config, relatedView *WebView) (*WebView, error) { | |
| 1513 | - | if relatedView == nil || relatedView.native == nil || relatedView.native.wv == nil { | |
| 1514 | - | // Fallback to regular WebView creation | |
| 1515 | - | return NewWebView(cfg) | |
| 1516 | - | } | |
| 1517 | - | ||
| 1518 | - | log.Printf("[webkit] Creating related WebView for process sharing (CGO)") | |
| 1519 | - | if C.gtk_init_check() == 0 { | |
| 1520 | - | return nil, errors.New("failed to initialize GTK") | |
| 1521 | - | } | |
| 1522 | - | ||
| 1523 | - | InitMainThread() | |
| 1524 | - | ||
| 1525 | - | if cfg == nil { | |
| 1526 | - | cfg = &Config{} | |
| 1527 | - | } | |
| 1528 | - | ||
| 1529 | - | // Create WebView related to the existing one | |
| 1530 | - | var createdUcm *C.WebKitUserContentManager | |
| 1531 | - | viewWidget := C.new_webview_related(relatedView.native.wv, &createdUcm) | |
| 1532 | - | if viewWidget == nil { | |
| 1533 | - | return nil, errors.New("failed to create related WebKitWebView") | |
| 1534 | - | } | |
| 1535 | - | ||
| 1536 | - | var win *C.GtkWidget | |
| 1537 | - | var window *Window | |
| 1538 | - | container := C.gtk_box_new(C.GtkOrientation(C.GTK_ORIENTATION_VERTICAL), 0) | |
| 1539 | - | if container == nil { | |
| 1540 | - | return nil, errors.New("failed to create GtkBox container") | |
| 1541 | - | } | |
| 1542 | - | ||
| 1543 | - | C.gtk_box_append((*C.GtkBox)(unsafe.Pointer(container)), viewWidget) | |
| 1544 | - | ||
| 1545 | - | // Make the WebView widget visible and expandable for popup rendering | |
| 1546 | - | C.gtk_widget_set_visible(viewWidget, C.gboolean(1)) | |
| 1547 | - | C.gtk_widget_set_visible(container, C.gboolean(1)) | |
| 1548 | - | C.gtk_widget_set_hexpand(viewWidget, C.gboolean(1)) | |
| 1549 | - | C.gtk_widget_set_vexpand(viewWidget, C.gboolean(1)) | |
| 1550 | - | C.gtk_widget_set_hexpand(container, C.gboolean(1)) | |
| 1551 | - | C.gtk_widget_set_vexpand(container, C.gboolean(1)) | |
| 1552 | - | // Remove any size constraints from popup WebView to let it fill the pane | |
| 1553 | - | C.gtk_widget_set_size_request(viewWidget, -1, -1) | |
| 1554 | - | C.gtk_widget_set_size_request(container, -1, -1) | |
| 1555 | - | ||
| 1556 | - | if cfg.CreateWindow { | |
| 1557 | - | win = C.new_window() | |
| 1558 | - | if win == nil { | |
| 1559 | - | return nil, errors.New("failed to create native window") | |
| 1560 | - | } | |
| 1561 | - | C.connect_destroy_quit(win) | |
| 1562 | - | C.gtk_window_set_child((*C.GtkWindow)(unsafe.Pointer(win)), container) | |
| 1563 | - | window = &Window{win: win} | |
| 1564 | - | } else { | |
| 1565 | - | window = &Window{} | |
| 1566 | - | } | |
| 1567 | - | ||
| 1568 | - | v := &WebView{ | |
| 1569 | - | config: cfg, | |
| 1570 | - | window: window, | |
| 1571 | - | zoom: cfg.ZoomDefault, | |
| 1572 | - | native: &nativeView{ | |
| 1573 | - | win: win, | |
| 1574 | - | container: container, | |
| 1575 | - | view: viewWidget, | |
| 1576 | - | wv: (*C.WebKitWebView)(unsafe.Pointer(viewWidget)), | |
| 1577 | - | ucm: createdUcm, | |
| 1578 | - | }, | |
| 1579 | - | useDomZoom: cfg.UseDomZoom, | |
| 1580 | - | domZoomSeed: cfg.ZoomDefault, | |
| 1581 | - | gcDone: make(chan struct{}), | |
| 1582 | - | tlsExceptions: make(map[string]bool), | |
| 1583 | - | } | |
| 1584 | - | ||
| 1585 | - | // Enable JavaScript popup windows for workspace popup handling | |
| 1586 | - | settings := C.webkit_web_view_get_settings(v.native.wv) | |
| 1587 | - | if settings != nil { | |
| 1588 | - | C.webkit_settings_set_javascript_can_open_windows_automatically(settings, C.gboolean(1)) | |
| 1589 | - | log.Printf("[webkit] Enabled JavaScript popup windows for related WebView") | |
| 1590 | - | } | |
| 1591 | - | ||
| 1592 | - | // Assign an ID for accelerator dispatch | |
| 1593 | - | v.id = nextViewID() | |
| 1594 | - | registerView(v.id, v) | |
| 1595 | - | C.connect_policy_handler(v.native.wv, C.ulong(v.id)) | |
| 1596 | - | ConnectCreateSignal(v) // Connect create signal for window.open support | |
| 1597 | - | ||
| 1598 | - | // Register with memory manager if enabled | |
| 1599 | - | if cfg.Memory.EnableMemoryMonitoring { | |
| 1600 | - | if globalMemoryManager == nil { | |
| 1601 | - | InitializeGlobalMemoryManager( | |
| 1602 | - | cfg.Memory.EnableMemoryMonitoring, | |
| 1603 | - | cfg.Memory.ProcessRecycleThreshold, | |
| 1604 | - | 30*time.Second, | |
| 1605 | - | ) | |
| 1606 | - | } | |
| 1607 | - | if globalMemoryManager != nil { | |
| 1608 | - | globalMemoryManager.RegisterWebView(v) | |
| 1609 | - | } | |
| 1610 | - | } | |
| 1611 | - | ||
| 1612 | - | if cfg.ZoomDefault <= 0 { | |
| 1613 | - | v.zoom = 1.0 | |
| 1614 | - | } | |
| 1615 | - | v.domZoomSeed = v.zoom | |
| 1616 | - | ||
| 1617 | - | // Initialize UCM scripts and handlers (same as NewWebView) | |
| 1618 | - | if v.native != nil && v.native.ucm != nil { | |
| 1619 | - | v.enableUserContentManager(cfg) | |
| 1620 | - | } | |
| 1621 | - | ||
| 1622 | - | return v, nil | |
| 1623 | - | } | |
| 1624 | - | ||
| 1625 | - | func (w *WebView) LoadURL(rawURL string) error { | |
| 1626 | - | if w == nil || w.destroyed || w.native == nil || w.native.wv == nil { | |
| 1627 | - | return ErrNotImplemented | |
| 1628 | - | } | |
| 1629 | - | ||
| 1630 | - | // Track page loads for process recycling | |
| 1631 | - | if w.memStats != nil { | |
| 1632 | - | w.memStats.pageLoadCount++ | |
| 1633 | - | ||
| 1634 | - | // Check if we need to recycle this WebView | |
| 1635 | - | if w.config.Memory.ProcessRecycleThreshold > 0 && | |
| 1636 | - | w.memStats.pageLoadCount >= w.config.Memory.ProcessRecycleThreshold { | |
| 1637 | - | if w.config.Memory.EnableMemoryMonitoring { | |
| 1638 | - | log.Printf("[webkit] Process recycle threshold reached (%d pages), consider creating new WebView", | |
| 1639 | - | w.memStats.pageLoadCount) | |
| 1640 | - | } | |
| 1641 | - | } | |
| 1642 | - | } | |
| 1643 | - | ||
| 1644 | - | w.url = rawURL | |
| 1645 | - | curl := C.CString(rawURL) | |
| 1646 | - | defer C.free(unsafe.Pointer(curl)) | |
| 1647 | - | C.webkit_web_view_load_uri(w.native.wv, (*C.gchar)(curl)) | |
| 1648 | - | log.Printf("[webkit] LoadURL: %s", rawURL) | |
| 1649 | - | return nil | |
| 1650 | - | } | |
| 1651 | - | ||
| 1652 | - | func (w *WebView) Show() error { | |
| 1653 | - | if w == nil || w.destroyed || w.native == nil { | |
| 1654 | - | return ErrNotImplemented | |
| 1655 | - | } | |
| 1656 | - | if w.native.container != nil { | |
| 1657 | - | C.gtk_widget_set_visible(w.native.container, C.gboolean(1)) | |
| 1658 | - | } | |
| 1659 | - | if w.native.view != nil { | |
| 1660 | - | C.gtk_widget_set_visible(w.native.view, C.gboolean(1)) | |
| 1661 | - | } | |
| 1662 | - | if w.native.win == nil { | |
| 1663 | - | return ErrNotImplemented | |
| 1664 | - | } | |
| 1665 | - | C.gtk_widget_set_visible(w.native.win, C.gboolean(1)) | |
| 1666 | - | C.gtk_window_present((*C.GtkWindow)(unsafe.Pointer(w.native.win))) | |
| 1667 | - | log.Printf("[webkit] Show window") | |
| 1668 | - | return nil | |
| 1669 | - | } | |
| 1670 | - | ||
| 1671 | - | func (w *WebView) Hide() error { | |
| 1672 | - | if w == nil || w.destroyed || w.native == nil { | |
| 1673 | - | return ErrNotImplemented | |
| 1674 | - | } | |
| 1675 | - | if w.native.container != nil { | |
| 1676 | - | C.gtk_widget_set_visible(w.native.container, C.gboolean(0)) | |
| 1677 | - | } | |
| 1678 | - | if w.native.view != nil { | |
| 1679 | - | C.gtk_widget_set_visible(w.native.view, C.gboolean(0)) | |
| 1680 | - | } | |
| 1681 | - | if w.native.win == nil { | |
| 1682 | - | return ErrNotImplemented | |
| 1683 | - | } | |
| 1684 | - | C.gtk_widget_set_visible(w.native.win, C.gboolean(0)) | |
| 1685 | - | return nil | |
| 1686 | - | } | |
| 1687 | - | ||
| 1688 | - | func (w *WebView) Destroy() error { | |
| 1689 | - | if w == nil || w.native == nil { | |
| 1690 | - | return ErrNotImplemented | |
| 1691 | - | } | |
| 1692 | - | ||
| 1693 | - | log.Printf("[webkit-destroy] begin destroy: id=%d container=%p win=%p view=%p", w.id, w.native.container, w.native.win, w.native.view) | |
| 1694 | - | // Stop periodic GC if running | |
| 1695 | - | w.stopPeriodicGC() | |
| 1696 | - | log.Printf("[webkit-destroy] periodic GC stopped: id=%d", w.id) | |
| 1697 | - | ||
| 1698 | - | // Clean up memory pressure settings | |
| 1699 | - | if w.memStats != nil && w.memStats.memoryPressureSettings != nil { | |
| 1700 | - | // WebKit will clean up the memory pressure settings when context is destroyed | |
| 1701 | - | w.memStats.memoryPressureSettings = nil | |
| 1702 | - | log.Printf("[webkit-destroy] cleared memory pressure settings: id=%d", w.id) | |
| 1703 | - | } | |
| 1704 | - | ||
| 1705 | - | // Unregister from memory manager | |
| 1706 | - | if globalMemoryManager != nil { | |
| 1707 | - | globalMemoryManager.UnregisterWebView(w.id) | |
| 1708 | - | log.Printf("[webkit-destroy] unregistered from memory manager: id=%d", w.id) | |
| 1709 | - | } | |
| 1710 | - | ||
| 1711 | - | if w.native != nil && w.native.ucm != nil && w.domBridgeScript != nil { | |
| 1712 | - | log.Printf("[webkit-destroy] removing DOM bridge script: id=%d", w.id) | |
| 1713 | - | C.webkit_user_content_manager_remove_script(w.native.ucm, w.domBridgeScript) | |
| 1714 | - | C.webkit_user_script_unref(w.domBridgeScript) | |
| 1715 | - | w.domBridgeScript = nil | |
| 1716 | - | } | |
| 1717 | - | ||
| 1718 | - | // GTK4: destroy windows via gtk_window_destroy if we created one | |
| 1719 | - | if w.native.win != nil && w.config != nil && w.config.CreateWindow { | |
| 1720 | - | log.Printf("[webkit-destroy] destroying GTK window: id=%d win=%p", w.id, w.native.win) | |
| 1721 | - | C.gtk_window_destroy((*C.GtkWindow)(unsafe.Pointer(w.native.win))) | |
| 1722 | - | w.native.win = nil | |
| 1723 | - | if w.window != nil { | |
| 1724 | - | w.window.win = nil | |
| 1725 | - | } | |
| 1726 | - | log.Printf("[webkit] Destroy window") | |
| 1727 | - | } | |
| 1728 | - | ||
| 1729 | - | log.Printf("[webkit-destroy] releasing native widgets: id=%d", w.id) | |
| 1730 | - | w.releaseNativeWidgets() | |
| 1731 | - | log.Printf("[webkit-destroy] native widgets released: id=%d", w.id) | |
| 1732 | - | w.destroyed = true | |
| 1733 | - | ||
| 1734 | - | // Unregister from dynamic protection updates | |
| 1735 | - | UnregisterActiveWebView(w) | |
| 1736 | - | log.Printf("[webkit-destroy] unregistered active view: id=%d", w.id) | |
| 1737 | - | ||
| 1738 | - | unregisterView(w.id) | |
| 1739 | - | log.Printf("[webkit-destroy] destroy complete: id=%d", w.id) | |
| 1740 | - | return nil | |
| 1741 | - | } | |
| 1742 | - | ||
| 1743 | - | func (w *WebView) releaseNativeWidgets() { | |
| 1744 | - | if w == nil || w.native == nil { | |
| 1745 | - | return | |
| 1746 | - | } | |
| 1747 | - | if w.native.container != nil { | |
| 1748 | - | containerPtr := uintptr(unsafe.Pointer(w.native.container)) | |
| 1749 | - | if containerPtr != 0 && widgetIsValid(containerPtr) { | |
| 1750 | - | C.g_object_unref(C.gpointer(w.native.container)) | |
| 1751 | - | } else { | |
| 1752 | - | log.Printf("[webkit] skipping container unref; widget invalid=%#x", containerPtr) | |
| 1753 | - | } | |
| 1754 | - | w.native.container = nil | |
| 1755 | - | } | |
| 1756 | - | } | |
| 1757 | - | ||
| 1758 | - | // Window returns the associated native window. | |
| 1759 | - | func (w *WebView) Window() *Window { return w.window } | |
| 1760 | - | ||
| 1761 | - | // GetCurrentURL returns the current URI from WebKit. | |
| 1762 | - | func (w *WebView) GetCurrentURL() string { | |
| 1763 | - | if w == nil || w.native == nil || w.native.wv == nil { | |
| 1764 | - | return "" | |
| 1765 | - | } | |
| 1766 | - | uri := C.webkit_web_view_get_uri(w.native.wv) | |
| 1767 | - | if uri == nil { | |
| 1768 | - | return "" | |
| 1769 | - | } | |
| 1770 | - | return C.GoString((*C.char)(unsafe.Pointer(uri))) | |
| 1771 | - | } | |
| 1772 | - | ||
| 1773 | - | // GetTitle returns the current page title from WebKit. | |
| 1774 | - | func (w *WebView) GetTitle() string { | |
| 1775 | - | if w == nil || w.native == nil || w.native.wv == nil { | |
| 1776 | - | return "" | |
| 1777 | - | } | |
| 1778 | - | title := C.webkit_web_view_get_title(w.native.wv) | |
| 1779 | - | if title == nil { | |
| 1780 | - | return "" | |
| 1781 | - | } | |
| 1782 | - | return C.GoString((*C.char)(unsafe.Pointer(title))) | |
| 1783 | - | } | |
| 1784 | - | ||
| 1785 | - | func (w *WebView) GoBack() error { | |
| 1786 | - | if w == nil || w.native == nil || w.native.wv == nil { | |
| 1787 | - | return ErrNotImplemented | |
| 1788 | - | } | |
| 1789 | - | C.webkit_web_view_go_back(w.native.wv) | |
| 1790 | - | return nil | |
| 1791 | - | } | |
| 1792 | - | ||
| 1793 | - | func (w *WebView) GoForward() error { | |
| 1794 | - | if w == nil || w.native == nil || w.native.wv == nil { | |
| 1795 | - | return ErrNotImplemented | |
| 1796 | - | } | |
| 1797 | - | C.webkit_web_view_go_forward(w.native.wv) | |
| 1798 | - | return nil | |
| 1799 | - | } | |
| 1800 | - | ||
| 1801 | - | func (w *WebView) Reload() error { | |
| 1802 | - | if w == nil || w.destroyed || w.native == nil || w.native.wv == nil { | |
| 1803 | - | return ErrNotImplemented | |
| 1804 | - | } | |
| 1805 | - | C.webkit_web_view_reload(w.native.wv) | |
| 1806 | - | log.Printf("[webkit] Reload page") | |
| 1807 | - | return nil | |
| 1808 | - | } | |
| 1809 | - | ||
| 1810 | - | func (w *WebView) ReloadBypassCache() error { | |
| 1811 | - | if w == nil || w.destroyed || w.native == nil || w.native.wv == nil { | |
| 1812 | - | return ErrNotImplemented | |
| 1813 | - | } | |
| 1814 | - | C.webkit_web_view_reload_bypass_cache(w.native.wv) | |
| 1815 | - | log.Printf("[webkit] Hard reload page (bypass cache)") | |
| 1816 | - | return nil | |
| 1817 | - | } | |
| 1818 | - | ||
| 1819 | - | // RegisterScriptMessageHandler registers a callback invoked when the content script posts a message. | |
| 1820 | - | func (w *WebView) RegisterScriptMessageHandler(cb func(payload string)) { w.msgHandler = cb } | |
| 1821 | - | ||
| 1822 | - | func (w *WebView) RegisterPopupHandler(cb func(string) *WebView) { w.popupHandler = cb } | |
| 1823 | - | ||
| 1824 | - | func (w *WebView) RegisterCloseHandler(cb func()) { | |
| 1825 | - | w.closeHandler = cb | |
| 1826 | - | // Also connect the CGO close signal to ensure window.close() is detected | |
| 1827 | - | if w.native != nil && w.native.wv != nil { | |
| 1828 | - | C.connect_close_signal(w.native.wv, C.ulong(w.id)) | |
| 1829 | - | } | |
| 1830 | - | } | |
| 1831 | - | ||
| 1832 | - | // RegisterNavigationPolicyHandler sets a handler to control navigation decisions | |
| 1833 | - | func (w *WebView) RegisterNavigationPolicyHandler(cb func(url string, isUserGesture bool) bool) { | |
| 1834 | - | w.navigationPolicyHandler = cb | |
| 1835 | - | } | |
| 1836 | - | ||
| 1837 | - | // EnablePrintInterception connects the print signal to intercept Ctrl+P | |
| 1838 | - | func (w *WebView) EnablePrintInterception() { | |
| 1839 | - | if w.native != nil && w.native.wv != nil { | |
| 1840 | - | C.connect_print_signal(w.native.wv, C.ulong(w.id)) | |
| 1841 | - | } | |
| 1842 | - | } | |
| 1843 | - | ||
| 1844 | - | func (w *WebView) dispatchScriptMessage(payload string) { | |
| 1845 | - | if w != nil && w.msgHandler != nil { | |
| 1846 | - | w.msgHandler(payload) | |
| 1847 | - | } | |
| 1848 | - | } | |
| 1849 | - | ||
| 1850 | - | func (w *WebView) dispatchPopupRequest(uri string) *WebView { | |
| 1851 | - | if w != nil && w.popupHandler != nil { | |
| 1852 | - | return w.popupHandler(uri) | |
| 1853 | - | } | |
| 1854 | - | return nil | |
| 1855 | - | } | |
| 1856 | - | ||
| 1857 | - | // CreateRelatedView creates a WebView that shares process/context with this view | |
| 1858 | - | func (w *WebView) CreateRelatedView() *WebView { | |
| 1859 | - | if w == nil || w.native == nil || w.native.wv == nil { | |
| 1860 | - | return nil | |
| 1861 | - | } | |
| 1862 | - | // Build a minimal config inheriting from current view | |
| 1863 | - | cfg := &Config{ | |
| 1864 | - | Assets: w.config.Assets, | |
| 1865 | - | ZoomDefault: w.config.ZoomDefault, | |
| 1866 | - | EnableDeveloperExtras: w.config.EnableDeveloperExtras, | |
| 1867 | - | DataDir: w.config.DataDir, | |
| 1868 | - | CacheDir: w.config.CacheDir, | |
| 1869 | - | APIToken: w.config.APIToken, | |
| 1870 | - | DefaultSansFont: w.config.DefaultSansFont, | |
| 1871 | - | DefaultSerifFont: w.config.DefaultSerifFont, | |
| 1872 | - | DefaultMonospaceFont: w.config.DefaultMonospaceFont, | |
| 1873 | - | DefaultFontSize: w.config.DefaultFontSize, | |
| 1874 | - | Rendering: w.config.Rendering, | |
| 1875 | - | UseDomZoom: w.config.UseDomZoom, | |
| 1876 | - | VideoAcceleration: w.config.VideoAcceleration, | |
| 1877 | - | Memory: w.config.Memory, | |
| 1878 | - | CodecPreferences: w.config.CodecPreferences, | |
| 1879 | - | Colors: w.config.Colors, | |
| 1880 | - | CreateWindow: false, | |
| 1881 | - | } | |
| 1882 | - | related, err := NewWebViewWithRelated(cfg, w) | |
| 1883 | - | if err != nil { | |
| 1884 | - | log.Printf("[webkit] CreateRelatedView failed: %v", err) | |
| 1885 | - | return nil | |
| 1886 | - | } | |
| 1887 | - | return related | |
| 1888 | - | } | |
| 1889 | - | ||
| 1890 | - | // OnWindowTypeDetected registers a callback invoked when window type is detected | |
| 1891 | - | func (w *WebView) OnWindowTypeDetected(callback func(WindowType, *WindowFeatures)) { | |
| 1892 | - | if w == nil { | |
| 1893 | - | return | |
| 1894 | - | } | |
| 1895 | - | w.windowTypeCallback = callback | |
| 1896 | - | } | |
| 1897 | - | ||
| 1898 | - | // GetNativePointer returns the native WebView pointer for CGO callbacks | |
| 1899 | - | func (w *WebView) GetNativePointer() unsafe.Pointer { | |
| 1900 | - | if w != nil && w.native != nil && w.native.wv != nil { | |
| 1901 | - | return unsafe.Pointer(w.native.wv) | |
| 1902 | - | } | |
| 1903 | - | return nil | |
| 1904 | - | } | |
| 1905 | - | ||
| 1906 | - | // RegisterTitleChangedHandler registers a callback invoked when the page title changes. | |
| 1907 | - | func (w *WebView) RegisterTitleChangedHandler(cb func(title string)) { w.titleHandler = cb } | |
| 1908 | - | ||
| 1909 | - | func (w *WebView) dispatchTitleChanged(title string) { | |
| 1910 | - | if w != nil && w.titleHandler != nil { | |
| 1911 | - | w.titleHandler(title) | |
| 1912 | - | } | |
| 1913 | - | } | |
| 1914 | - | ||
| 1915 | - | // RegisterURIChangedHandler registers a callback invoked when the current page URI changes. | |
| 1916 | - | func (w *WebView) RegisterURIChangedHandler(cb func(uri string)) { w.uriHandler = cb } | |
| 1917 | - | ||
| 1918 | - | func (w *WebView) dispatchURIChanged(uri string) { | |
| 1919 | - | if w != nil && w.uriHandler != nil { | |
| 1920 | - | w.uriHandler(uri) | |
| 1921 | - | } | |
| 1922 | - | } | |
| 1923 | - | ||
| 1924 | - | // RegisterFaviconChangedHandler registers a callback invoked when the page favicon changes. | |
| 1925 | - | func (w *WebView) RegisterFaviconChangedHandler(cb func(data []byte)) { | |
| 1926 | - | if w != nil { | |
| 1927 | - | w.faviconHandler = cb | |
| 1928 | - | } | |
| 1929 | - | } | |
| 1930 | - | ||
| 1931 | - | // RegisterFaviconURIChangedHandler registers a callback invoked when WebKit detects a favicon URI. | |
| 1932 | - | func (w *WebView) RegisterFaviconURIChangedHandler(cb func(pageURI, faviconURI string)) { | |
| 1933 | - | if w != nil { | |
| 1934 | - | w.faviconURIHandler = cb | |
| 1935 | - | } | |
| 1936 | - | } | |
| 1937 | - | ||
| 1938 | - | func (w *WebView) dispatchFaviconChanged(data []byte) { | |
| 1939 | - | if w != nil && w.faviconHandler != nil { | |
| 1940 | - | w.faviconHandler(data) | |
| 1941 | - | } | |
| 1942 | - | } | |
| 1943 | - | ||
| 1944 | - | func (w *WebView) dispatchFaviconURIChanged(pageURI, faviconURI string) { | |
| 1945 | - | if w != nil && w.faviconURIHandler != nil { | |
| 1946 | - | w.faviconURIHandler(pageURI, faviconURI) | |
| 1947 | - | } | |
| 1948 | - | } | |
| 1949 | - | ||
| 1950 | - | // RunOnMainThread schedules fn to execute on the GTK main thread. | |
| 1951 | - | func (w *WebView) RunOnMainThread(fn func()) { | |
| 1952 | - | if w == nil || fn == nil { | |
| 1953 | - | return | |
| 1954 | - | } | |
| 1955 | - | h := cgo.NewHandle(fn) | |
| 1956 | - | C.schedule_on_main_thread(C.uintptr_t(h)) | |
| 1957 | - | } | |
| 1958 | - | ||
| 1959 | - | // RegisterZoomChangedHandler registers a callback invoked when zoom level changes. | |
| 1960 | - | func (w *WebView) RegisterZoomChangedHandler(cb func(level float64)) { w.zoomHandler = cb } | |
| 1961 | - | ||
| 1962 | - | func (w *WebView) dispatchZoomChanged(level float64) { | |
| 1963 | - | if w != nil && w.zoomHandler != nil { | |
| 1964 | - | w.zoomHandler(level) | |
| 1965 | - | } | |
| 1966 | - | } | |
| 1967 | - | ||
| 1968 | - | // Widget returns the underlying GtkWidget pointer for the WebView. | |
| 1969 | - | func (w *WebView) Widget() uintptr { | |
| 1970 | - | if w == nil || w.native == nil || w.native.view == nil { | |
| 1971 | - | return 0 | |
| 1972 | - | } | |
| 1973 | - | return uintptr(unsafe.Pointer(w.native.view)) | |
| 1974 | - | } | |
| 1975 | - | ||
| 1976 | - | // RootWidget returns the container widget that should be reparented for pane management. | |
| 1977 | - | func (w *WebView) RootWidget() uintptr { | |
| 1978 | - | if w == nil || w.native == nil || w.native.container == nil { | |
| 1979 | - | return 0 | |
| 1980 | - | } | |
| 1981 | - | return uintptr(unsafe.Pointer(w.native.container)) | |
| 1982 | - | } | |
| 1983 | - | ||
| 1984 | - | // DestroyWindow destroys the toplevel window hosting this WebView, if any. | |
| 1985 | - | func (w *WebView) DestroyWindow() { | |
| 1986 | - | if w == nil || w.native == nil || w.native.win == nil { | |
| 1987 | - | return | |
| 1988 | - | } | |
| 1989 | - | // Only destroy if we actually created a window | |
| 1990 | - | if w.config != nil && w.config.CreateWindow { | |
| 1991 | - | C.gtk_window_destroy((*C.GtkWindow)(unsafe.Pointer(w.native.win))) | |
| 1992 | - | w.native.win = nil | |
| 1993 | - | if w.window != nil { | |
| 1994 | - | w.window.win = nil | |
| 1995 | - | } | |
| 1996 | - | } | |
| 1997 | - | } | |
| 1998 | - | ||
| 1999 | - | // PrepareForReparenting prepares the WebView widget for being moved to a new parent | |
| 2000 | - | func (w *WebView) PrepareForReparenting() { | |
| 2001 | - | if w == nil || w.native == nil || w.native.container == nil { | |
| 2002 | - | return | |
| 2003 | - | } | |
| 2004 | - | ||
| 2005 | - | // Mark that this view is being reparented | |
| 2006 | - | w.wasReparented = true | |
| 2007 | - | ||
| 2008 | - | // Ensure the widget is visible and properly sized for reparenting | |
| 2009 | - | C.gtk_widget_set_visible(w.native.container, C.gboolean(1)) | |
| 2010 | - | C.gtk_widget_set_size_request(w.native.container, 100, 100) | |
| 2011 | - | C.gtk_widget_queue_draw(w.native.container) | |
| 2012 | - | } | |
| 2013 | - | ||
| 2014 | - | // RefreshAfterReparenting refreshes the WebView after it has been reparented | |
| 2015 | - | func (w *WebView) RefreshAfterReparenting() { | |
| 2016 | - | if w == nil || w.native == nil || w.native.wv == nil { | |
| 2017 | - | return | |
| 2018 | - | } | |
| 2019 | - | ||
| 2020 | - | if w.wasReparented { | |
| 2021 | - | widgetHandle := w.RootWidget() | |
| 2022 | - | parentHandle := WidgetGetParent(widgetHandle) | |
| 2023 | - | if parentHandle == 0 { | |
| 2024 | - | log.Printf("[workspace] RefreshAfterReparenting deferred: widget=%#x parent=%#x url=%s", widgetHandle, parentHandle, w.url) | |
| 2025 | - | return | |
| 2026 | - | } | |
| 2027 | - | log.Printf("[workspace] RefreshAfterReparenting: widget=%#x parent=%#x url=%s", widgetHandle, parentHandle, w.url) | |
| 2028 | - | // Force WebKit to refresh its rendering context by triggering a reload | |
| 2029 | - | // This ensures the rendering context is properly reinitialized after reparenting | |
| 2030 | - | if w.url != "" { | |
| 2031 | - | w.LoadURL(w.url) // Reload current URL to refresh rendering context | |
| 2032 | - | } | |
| 2033 | - | w.wasReparented = false | |
| 2034 | - | } | |
| 2035 | - | } | |
| 2036 | - | ||
| 2037 | - | // enableUserContentManager registers the 'dumber' message handler and injects the omnibox script. | |
| 2038 | - | func (w *WebView) enableUserContentManager(cfg *Config) { | |
| 2039 | - | if w == nil || w.native == nil || w.native.ucm == nil { | |
| 2040 | - | return | |
| 2041 | - | } | |
| 2042 | - | // Register handler "dumber" for main world | |
| 2043 | - | cname := C.CString("dumber") | |
| 2044 | - | ok := C.register_ucm_handler(w.native.ucm, cname, C.ulong(w.id)) | |
| 2045 | - | if ok == 0 { | |
| 2046 | - | log.Printf("[webkit] Failed to register UCM handler; fallback bridge will handle messages") | |
| 2047 | - | } | |
| 2048 | - | C.free(unsafe.Pointer(cname)) | |
| 2049 | - | ||
| 2050 | - | // Also register handler for the isolated GUI world so GUI can post messages to native | |
| 2051 | - | cworld := C.CString("dumber-gui") | |
| 2052 | - | cname2 := C.CString("dumber") | |
| 2053 | - | ok2 := C.register_ucm_handler_world(w.native.ucm, cname2, cworld, C.ulong(w.id)) | |
| 2054 | - | if ok2 == 0 { | |
| 2055 | - | log.Printf("[webkit] Failed to register UCM handler for isolated world 'dumber-gui'") | |
| 2056 | - | } | |
| 2057 | - | C.free(unsafe.Pointer(cworld)) | |
| 2058 | - | C.free(unsafe.Pointer(cname2)) | |
| 2059 | - | ||
| 2060 | - | // Inject color-scheme preference script at document-start to inform sites of system theme | |
| 2061 | - | preferDark := C.gtk_prefers_dark() != 0 | |
| 2062 | - | if preferDark { | |
| 2063 | - | log.Printf("[theme] GTK prefers: dark") | |
| 2064 | - | } else { | |
| 2065 | - | log.Printf("[theme] GTK prefers: light") | |
| 2066 | - | } | |
| 2067 | - | // Load color scheme script from compiled GUI assets instead of inline | |
| 2068 | - | var schemeJS string | |
| 2069 | - | if cfg != nil && cfg.Assets != nil { | |
| 2070 | - | if schemeBytes, err := cfg.Assets.ReadFile("assets/gui/color-scheme.js"); err == nil { | |
| 2071 | - | baseScript := string(schemeBytes) | |
| 2072 | - | // Inject the GTK preference as a parameter | |
| 2073 | - | if preferDark { | |
| 2074 | - | schemeJS = fmt.Sprintf("window.__dumber_gtk_prefers_dark = true; %s", baseScript) | |
| 2075 | - | } else { | |
| 2076 | - | schemeJS = fmt.Sprintf("window.__dumber_gtk_prefers_dark = false; %s", baseScript) | |
| 2077 | - | } | |
| 2078 | - | } else { | |
| 2079 | - | log.Printf("[webkit] Warning: Failed to load color-scheme.js from assets, falling back to basic theme detection: %v", err) | |
| 2080 | - | // Simple fallback if the compiled script is not available | |
| 2081 | - | if preferDark { | |
| 2082 | - | schemeJS = `console.log('[dumber] GTK detected color mode: dark'); document.documentElement.classList.add('dark');` | |
| 2083 | - | } else { | |
| 2084 | - | schemeJS = `console.log('[dumber] GTK detected color mode: light'); document.documentElement.classList.remove('dark');` | |
| 2085 | - | } | |
| 2086 | - | } | |
| 2087 | - | } | |
| 2088 | - | cScheme := C.CString(schemeJS) | |
| 2089 | - | defer C.free(unsafe.Pointer(cScheme)) | |
| 2090 | - | schemeScript := C.webkit_user_script_new((*C.gchar)(cScheme), C.WEBKIT_USER_CONTENT_INJECT_TOP_FRAME, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil) | |
| 2091 | - | if schemeScript != nil { | |
| 2092 | - | C.webkit_user_content_manager_add_script(w.native.ucm, schemeScript) | |
| 2093 | - | C.webkit_user_script_unref(schemeScript) | |
| 2094 | - | } | |
| 2095 | - | ||
| 2096 | - | // Color palette injection now handled in main-world.ts via __PALETTE_JSON__ placeholder | |
| 2097 | - | ||
| 2098 | - | // Add GUI bundle as user script at document-start (contains toast, omnibox, and controls) | |
| 2099 | - | if cfg != nil && cfg.Assets != nil { | |
| 2100 | - | log.Printf("[webkit] Attempting to load GUI bundle from assets/gui/gui.min.js") | |
| 2101 | - | if guiBytes, err := cfg.Assets.ReadFile("assets/gui/gui.min.js"); err == nil { | |
| 2102 | - | guiScript := string(guiBytes) | |
| 2103 | - | log.Printf("[webkit] GUI bundle loaded successfully, size: %d bytes", len(guiBytes)) | |
| 2104 | - | ||
| 2105 | - | cGui := C.CString(guiScript) | |
| 2106 | - | defer C.free(unsafe.Pointer(cGui)) | |
| 2107 | - | ||
| 2108 | - | log.Printf("[webkit] Creating GUI user script for document-start injection in isolated world 'dumber-gui'") | |
| 2109 | - | world := C.CString("dumber-gui") | |
| 2110 | - | defer C.free(unsafe.Pointer(world)) | |
| 2111 | - | guiUserScript := C.webkit_user_script_new_for_world((*C.gchar)(cGui), C.WEBKIT_USER_CONTENT_INJECT_TOP_FRAME, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, (*C.gchar)(world), nil, nil) | |
| 2112 | - | if guiUserScript != nil { | |
| 2113 | - | C.webkit_user_content_manager_add_script(w.native.ucm, guiUserScript) | |
| 2114 | - | C.webkit_user_script_unref(guiUserScript) | |
| 2115 | - | log.Printf("[webkit] GUI bundle successfully injected as user script at document-start in isolated world") | |
| 2116 | - | } else { | |
| 2117 | - | log.Printf("[webkit] ERROR: Failed to create GUI user script") | |
| 2118 | - | } | |
| 2119 | - | ||
| 2120 | - | // Inject a unified page-world bridge to dispatch standard events and expose unified API | |
| 2121 | - | w.installDomBridgeScript() | |
| 2122 | - | ||
| 2123 | - | // If an API token is provided, inject a fetch wrapper in the isolated GUI world | |
| 2124 | - | if cfg.APIToken != "" { | |
| 2125 | - | wrapper := "(() => { try {" + | |
| 2126 | - | "const TOKEN='" + cfg.APIToken + "';" + | |
| 2127 | - | "const appendToken=(u)=>{ try { let url; try { url = new URL(u); } catch(_) { url = new URL(u, 'dumb://homepage'); } if(url.protocol==='dumb:' && url.pathname.startsWith('/api')){ url.searchParams.set('token', TOKEN); return url.toString(); } } catch(_){} return u; };" + | |
| 2128 | - | "const _fetch=window.fetch.bind(window); window.fetch=(input, init)=>{ try { if(typeof input==='string'){ input=appendToken(input); } else if(input && input.url){ const nu=appendToken(input.url); if(nu!==input.url){ input=new Request(nu, input); } } } catch(_){} return _fetch(input, init); };" + | |
| 2129 | - | "} catch(e) { console.warn('[dumber] api token wrapper failed', e); } })();" | |
| 2130 | - | cWrap := C.CString(wrapper) | |
| 2131 | - | defer C.free(unsafe.Pointer(cWrap)) | |
| 2132 | - | world := C.CString("dumber-gui") | |
| 2133 | - | defer C.free(unsafe.Pointer(world)) | |
| 2134 | - | wrapScript := C.webkit_user_script_new_for_world((*C.gchar)(cWrap), C.WEBKIT_USER_CONTENT_INJECT_TOP_FRAME, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, (*C.gchar)(world), nil, nil) | |
| 2135 | - | if wrapScript != nil { | |
| 2136 | - | C.webkit_user_content_manager_add_script(w.native.ucm, wrapScript) | |
| 2137 | - | C.webkit_user_script_unref(wrapScript) | |
| 2138 | - | log.Printf("[webkit] API token fetch wrapper injected in isolated GUI world") | |
| 2139 | - | } | |
| 2140 | - | } | |
| 2141 | - | } else { | |
| 2142 | - | log.Printf("[webkit] ERROR: Failed to load GUI bundle from assets: %v", err) | |
| 2143 | - | } | |
| 2144 | - | ||
| 2145 | - | // Add GUI CSS styles as user stylesheet when a legacy bundle is present. | |
| 2146 | - | // Modern builds inline Tailwind output inside gui.min.js, so this file is optional. | |
| 2147 | - | if cssBytes, err := cfg.Assets.ReadFile("assets/gui/style.css"); err == nil { | |
| 2148 | - | cssContent := string(cssBytes) | |
| 2149 | - | cCss := C.CString(cssContent) | |
| 2150 | - | defer C.free(unsafe.Pointer(cCss)) | |
| 2151 | - | ||
| 2152 | - | styleSheet := C.webkit_user_style_sheet_new( | |
| 2153 | - | (*C.gchar)(cCss), | |
| 2154 | - | C.WEBKIT_USER_CONTENT_INJECT_TOP_FRAME, | |
| 2155 | - | C.WEBKIT_USER_STYLE_LEVEL_AUTHOR, | |
| 2156 | - | nil, // allow_list | |
| 2157 | - | nil, // block_list | |
| 2158 | - | ) | |
| 2159 | - | if styleSheet != nil { | |
| 2160 | - | C.webkit_user_content_manager_add_style_sheet(w.native.ucm, styleSheet) | |
| 2161 | - | C.webkit_user_style_sheet_unref(styleSheet) | |
| 2162 | - | } else { | |
| 2163 | - | log.Printf("[webkit] ERROR: Failed to create GUI stylesheet") | |
| 2164 | - | } | |
| 2165 | - | } else if errors.Is(err, os.ErrNotExist) { | |
| 2166 | - | log.Printf("[webkit] GUI stylesheet not found; assuming inline Tailwind bundle and skipping separate injection") | |
| 2167 | - | } else { | |
| 2168 | - | log.Printf("[webkit] Warning: Failed to load GUI CSS from assets, skipping separate stylesheet injection: %v", err) | |
| 2169 | - | } | |
| 2170 | - | } | |
| 2171 | - | ||
| 2172 | - | // Inject Wails runtime fetch interceptor for homepage bridging | |
| 2173 | - | wailsBridge := `(() => { try { const origFetch = window.fetch.bind(window); const waiters = Object.create(null); window.__dumber_wails_resolve = (id, json) => { const w = waiters[id]; if(!w) return; delete waiters[id]; try { const headers = new Headers({"Content-Type":"application/json"}); w.resolve(new Response(json, { headers })); } catch(e){ w.reject(e); } }; window.fetch = (input, init) => { try { const url = new URL(input instanceof Request ? input.url : input, window.location.origin); if (url.pathname === '/wails/runtime') { const args = url.searchParams.get('args'); let payload = {}; try { payload = args ? JSON.parse(args) : {}; } catch(_){} const id = String(Date.now()) + '-' + Math.random().toString(36).slice(2); return new Promise((resolve, reject) => { waiters[id] = { resolve, reject }; try { window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({ type: 'wails', id, payload })); } catch (e) { reject(e); } }); } } catch(_){} return origFetch(input, init); }; } catch(_){} })();` | |
| 2174 | - | cWails := C.CString(wailsBridge) | |
| 2175 | - | defer C.free(unsafe.Pointer(cWails)) | |
| 2176 | - | wailsScript := C.webkit_user_script_new((*C.gchar)(cWails), C.WEBKIT_USER_CONTENT_INJECT_TOP_FRAME, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil) | |
| 2177 | - | if wailsScript != nil { | |
| 2178 | - | C.webkit_user_content_manager_add_script(w.native.ucm, wailsScript) | |
| 2179 | - | C.webkit_user_script_unref(wailsScript) | |
| 2180 | - | } | |
| 2181 | - | ||
| 2182 | - | // Inject codec control script if codec preferences are configured | |
| 2183 | - | if cfg != nil && (cfg.CodecPreferences.ForceAV1 || len(cfg.CodecPreferences.BlockedCodecs) > 0 || len(cfg.CodecPreferences.PreferredCodecs) > 0) { | |
| 2184 | - | codecScript := GenerateCodecControlScript(cfg.CodecPreferences) | |
| 2185 | - | cCodec := C.CString(codecScript) | |
| 2186 | - | defer C.free(unsafe.Pointer(cCodec)) | |
| 2187 | - | codecUserScript := C.webkit_user_script_new( | |
| 2188 | - | (*C.gchar)(cCodec), | |
| 2189 | - | C.WEBKIT_USER_CONTENT_INJECT_ALL_FRAMES, | |
| 2190 | - | C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, | |
| 2191 | - | nil, nil) | |
| 2192 | - | if codecUserScript != nil { | |
| 2193 | - | C.webkit_user_content_manager_add_script(w.native.ucm, codecUserScript) | |
| 2194 | - | C.webkit_user_script_unref(codecUserScript) | |
| 2195 | - | log.Printf("[webkit] Codec control script injected") | |
| 2196 | - | } | |
| 2197 | - | } | |
| 2198 | - | ||
| 2199 | - | // Enable global shortcuts protection to prevent shortcuts from leaking to webpages | |
| 2200 | - | if err := w.EnableGlobalShortcutsProtection(); err != nil { | |
| 2201 | - | log.Printf("[webkit] Warning: Failed to enable global shortcuts protection: %v", err) | |
| 2202 | - | } | |
| 2203 | - | ||
| 2204 | - | // Register this WebView for dynamic protection updates | |
| 2205 | - | RegisterActiveWebView(w) | |
| 2206 | - | ||
| 2207 | - | // No JS fallback bridge — native UCM is active | |
| 2208 | - | } | |
| 2209 | - | ||
| 2210 | - | func clampDomZoom(level float64) float64 { | |
| 2211 | - | if level < 0.25 { | |
| 2212 | - | return 0.25 | |
| 2213 | - | } | |
| 2214 | - | if level > 5.0 { | |
| 2215 | - | return 5.0 | |
| 2216 | - | } | |
| 2217 | - | return level | |
| 2218 | - | } | |
| 2219 | - | ||
| 2220 | - | func buildDomBridgeScript(cfg *Config, initialZoom float64, webViewId uintptr, w *WebView) string { | |
| 2221 | - | if cfg == nil || cfg.Assets == nil { | |
| 2222 | - | log.Printf("[webkit] Config or assets not available for main-world script") | |
| 2223 | - | return "" | |
| 2224 | - | } | |
| 2225 | - | ||
| 2226 | - | // Load the compiled main-world script | |
| 2227 | - | scriptBytes, err := cfg.Assets.ReadFile("assets/gui/main-world.min.js") | |
| 2228 | - | if err != nil { | |
| 2229 | - | log.Printf("[webkit] Failed to load main-world script: %v", err) | |
| 2230 | - | return "" | |
| 2231 | - | } | |
| 2232 | - | ||
| 2233 | - | // Replace the zoom placeholder with the actual value | |
| 2234 | - | zoom := clampDomZoom(initialZoom) | |
| 2235 | - | zoomStr := strconv.FormatFloat(zoom, 'f', -1, 64) | |
| 2236 | - | script := strings.ReplaceAll(string(scriptBytes), "__DOM_ZOOM_DEFAULT__", zoomStr) | |
| 2237 | - | ||
| 2238 | - | // Inject WebView ID and active state | |
| 2239 | - | webViewIdStr := strconv.FormatUint(uint64(webViewId), 10) | |
| 2240 | - | script = strings.ReplaceAll(script, "__WEBVIEW_ID__", webViewIdStr) | |
| 2241 | - | // Default to active for the initial WebView, since it will be focused immediately | |
| 2242 | - | // For single WebView scenarios, assume it should be active | |
| 2243 | - | activeState := "true" | |
| 2244 | - | log.Printf("[webkit] Building script with WebView %s, active state: %s", webViewIdStr, activeState) | |
| 2245 | - | script = strings.ReplaceAll(script, "\"__WEBVIEW_ACTIVE__\"", activeState) | |
| 2246 | - | ||
| 2247 | - | // Color palettes and search shortcuts are now loaded via messaging bridge | |
| 2248 | - | ||
| 2249 | - | log.Printf("[webkit] Loaded main-world script successfully, size: %d bytes", len(scriptBytes)) | |
| 2250 | - | return script | |
| 2251 | - | } | |
| 2252 | - | ||
| 2253 | - | func (w *WebView) UsesDomZoom() bool { | |
| 2254 | - | return w != nil && w.useDomZoom | |
| 2255 | - | } | |
| 2256 | - | ||
| 2257 | - | func (w *WebView) installDomBridgeScript() { | |
| 2258 | - | if w == nil || w.native == nil || w.native.ucm == nil { | |
| 2259 | - | return | |
| 2260 | - | } | |
| 2261 | - | ||
| 2262 | - | w.domZoomSeed = clampDomZoom(w.domZoomSeed) | |
| 2263 | - | ||
| 2264 | - | if w.domBridgeScript != nil { | |
| 2265 | - | C.webkit_user_content_manager_remove_script(w.native.ucm, w.domBridgeScript) | |
| 2266 | - | C.webkit_user_script_unref(w.domBridgeScript) | |
| 2267 | - | w.domBridgeScript = nil | |
| 2268 | - | } | |
| 2269 | - | ||
| 2270 | - | source := buildDomBridgeScript(w.config, w.domZoomSeed, w.id, w) | |
| 2271 | - | cBridge := C.CString(source) | |
| 2272 | - | defer C.free(unsafe.Pointer(cBridge)) | |
| 2273 | - | ||
| 2274 | - | script := C.webkit_user_script_new((*C.gchar)(cBridge), C.WEBKIT_USER_CONTENT_INJECT_TOP_FRAME, C.WEBKIT_USER_SCRIPT_INJECT_AT_DOCUMENT_START, nil, nil) | |
| 2275 | - | if script == nil { | |
| 2276 | - | log.Printf("[webkit] ERROR: Failed to create GUI user script") | |
| 2277 | - | return | |
| 2278 | - | } | |
| 2279 | - | ||
| 2280 | - | C.webkit_user_content_manager_add_script(w.native.ucm, script) | |
| 2281 | - | w.domBridgeScript = script | |
| 2282 | - | log.Printf("[webkit] Page-world toast bridge injected (dom zoom seed=%.2f)", w.domZoomSeed) | |
| 2283 | - | } | |
| 2284 | - | ||
| 2285 | - | // SeedDomZoom updates the document-start bridge so the upcoming page paints with the saved DOM zoom level. | |
| 2286 | - | func (w *WebView) SeedDomZoom(level float64) { | |
| 2287 | - | if w == nil || w.destroyed || !w.useDomZoom { | |
| 2288 | - | return | |
| 2289 | - | } | |
| 2290 | - | ||
| 2291 | - | seed := clampDomZoom(level) | |
| 2292 | - | if math.Abs(w.domZoomSeed-seed) < 1e-6 { | |
| 2293 | - | return | |
| 2294 | - | } | |
| 2295 | - | w.domZoomSeed = seed | |
| 2296 | - | // Instead of re-injecting the entire script, just update the zoom level | |
| 2297 | - | w.RunOnMainThread(func() { | |
| 2298 | - | zoomStr := strconv.FormatFloat(seed, 'f', -1, 64) | |
| 2299 | - | script := fmt.Sprintf("if (window.__dumber_applyDomZoom) { window.__dumber_applyDomZoom(%s); }", zoomStr) | |
| 2300 | - | w.InjectScript(script) | |
| 2301 | - | }) | |
| 2302 | - | } | |
| 2303 | - | ||
| 2304 | - | // startPeriodicGC starts a ticker to trigger JavaScript garbage collection periodically | |
| 2305 | - | func (w *WebView) startPeriodicGC(interval time.Duration) { | |
| 2306 | - | if w == nil || w.gcTicker != nil { | |
| 2307 | - | return // Already running or invalid | |
| 2308 | - | } | |
| 2309 | - | ||
| 2310 | - | w.gcTicker = time.NewTicker(interval) | |
| 2311 | - | ||
| 2312 | - | go func() { | |
| 2313 | - | defer func() { | |
| 2314 | - | if w.gcTicker != nil { | |
| 2315 | - | w.gcTicker.Stop() | |
| 2316 | - | w.gcTicker = nil | |
| 2317 | - | } | |
| 2318 | - | }() | |
| 2319 | - | ||
| 2320 | - | for { | |
| 2321 | - | select { | |
| 2322 | - | case <-w.gcTicker.C: | |
| 2323 | - | w.triggerGC() | |
| 2324 | - | case <-w.gcDone: | |
| 2325 | - | return | |
| 2326 | - | } | |
| 2327 | - | } | |
| 2328 | - | }() | |
| 2329 | - | } | |
| 2330 | - | ||
| 2331 | - | // stopPeriodicGC stops the periodic garbage collection | |
| 2332 | - | func (w *WebView) stopPeriodicGC() { | |
| 2333 | - | if w == nil { | |
| 2334 | - | return | |
| 2335 | - | } | |
| 2336 | - | ||
| 2337 | - | if w.gcDone != nil { | |
| 2338 | - | close(w.gcDone) | |
| 2339 | - | w.gcDone = nil | |
| 2340 | - | } | |
| 2341 | - | ||
| 2342 | - | if w.gcTicker != nil { | |
| 2343 | - | w.gcTicker.Stop() | |
| 2344 | - | w.gcTicker = nil | |
| 2345 | - | } | |
| 2346 | - | } | |
| 2347 | - | ||
| 2348 | - | // triggerGC manually triggers JavaScript garbage collection | |
| 2349 | - | func (w *WebView) triggerGC() { | |
| 2350 | - | if w == nil || w.memStats == nil { | |
| 2351 | - | return | |
| 2352 | - | } | |
| 2353 | - | ||
| 2354 | - | // Get default WebContext and trigger GC | |
| 2355 | - | ctx := C.webkit_web_context_get_default() | |
| 2356 | - | if ctx != nil { | |
| 2357 | - | C.maybe_collect_js(ctx) | |
| 2358 | - | w.memStats.lastGCTime = time.Now() | |
| 2359 | - | ||
| 2360 | - | if w.config.Memory.EnableMemoryMonitoring { | |
| 2361 | - | log.Printf("[webkit] JavaScript GC triggered (page loads: %d)", w.memStats.pageLoadCount) | |
| 2362 | - | } | |
| 2363 | - | } | |
| 2364 | - | } | |
| 2365 | - | ||
| 2366 | - | // GetMemoryStats returns current memory statistics for this WebView | |
| 2367 | - | func (w *WebView) GetMemoryStats() map[string]interface{} { | |
| 2368 | - | if w == nil || w.memStats == nil { | |
| 2369 | - | return nil | |
| 2370 | - | } | |
| 2371 | - | ||
| 2372 | - | return map[string]interface{}{ | |
| 2373 | - | "page_load_count": w.memStats.pageLoadCount, | |
| 2374 | - | "last_gc_time": w.memStats.lastGCTime, | |
| 2375 | - | "has_memory_pressure_settings": w.memStats.memoryPressureSettings != nil, | |
| 2376 | - | } | |
| 2377 | - | } | |
| 2378 | - | ||
| 2379 | - | // TriggerMemoryCleanup manually triggers garbage collection and memory cleanup | |
| 2380 | - | func (w *WebView) TriggerMemoryCleanup() { | |
| 2381 | - | if w == nil { | |
| 2382 | - | return | |
| 2383 | - | } | |
| 2384 | - | ||
| 2385 | - | w.triggerGC() | |
| 2386 | - | ||
| 2387 | - | if w.config.Memory.EnableMemoryMonitoring { | |
| 2388 | - | log.Printf("[webkit] Manual memory cleanup triggered") | |
| 2389 | - | } | |
| 2390 | - | } | |
| 2391 | - | ||
| 2392 | - | //export goHandleTLSError | |
| 2393 | - | func goHandleTLSError(failingURI *C.char, host *C.char, errorFlags C.int, certInfo *C.char) C.gboolean { | |
| 2394 | - | uri := C.GoString(failingURI) | |
| 2395 | - | hostname := C.GoString(host) | |
| 2396 | - | certificateInfo := C.GoString(certInfo) | |
| 2397 | - | ||
| 2398 | - | log.Printf("[tls] Certificate error for %s (URI: %s, flags: %d)", hostname, uri, int(errorFlags)) | |
| 2399 | - | ||
| 2400 | - | // Find the WebView that triggered this error | |
| 2401 | - | // For now, we'll get the most recently created view | |
| 2402 | - | // In a more sophisticated implementation, we'd pass the view ID through user_data | |
| 2403 | - | var webView *WebView | |
| 2404 | - | regMu.RLock() | |
| 2405 | - | for _, v := range viewByID { | |
| 2406 | - | if v != nil { | |
| 2407 | - | webView = v | |
| 2408 | - | break | |
| 2409 | - | } | |
| 2410 | - | } | |
| 2411 | - | regMu.RUnlock() | |
| 2412 | - | ||
| 2413 | - | if webView == nil { | |
| 2414 | - | log.Printf("[tls] No WebView found to handle TLS error") | |
| 2415 | - | return C.FALSE // Don't proceed | |
| 2416 | - | } | |
| 2417 | - | ||
| 2418 | - | // Check if we've already allowed this host | |
| 2419 | - | if allowed, exists := webView.tlsExceptions[hostname]; exists && allowed { | |
| 2420 | - | log.Printf("[tls] Certificate exception already granted for %s", hostname) | |
| 2421 | - | return C.TRUE // Proceed with the load | |
| 2422 | - | } | |
| 2423 | - | ||
| 2424 | - | // Show warning dialog and get user decision | |
| 2425 | - | if webView.showTLSWarningDialog(hostname, uri, int(errorFlags), certificateInfo) { | |
| 2426 | - | webView.tlsExceptions[hostname] = true | |
| 2427 | - | log.Printf("[tls] User accepted certificate exception for %s", hostname) | |
| 2428 | - | return C.TRUE // Proceed with the load | |
| 2429 | - | } | |
| 2430 | - | ||
| 2431 | - | log.Printf("[tls] User rejected certificate for %s", hostname) | |
| 2432 | - | return C.FALSE // Don't proceed | |
| 2433 | - | } | |
| 2434 | - | ||
| 2435 | - | //export goHandleConsoleMessage | |
| 2436 | - | //export goHandleConsoleMessage | |
| 2437 | - | func goHandleConsoleMessage(message *C.char) { | |
| 2438 | - | messageText := C.GoString(message) | |
| 2439 | - | logging.CaptureWebKitLog(messageText) | |
| 2440 | - | } | |
| 2441 | - | ||
| 2442 | - | //export goHandleNavigationPolicy | |
| 2443 | - | func goHandleNavigationPolicy(viewID C.ulong, uri *C.char, isUserGesture C.gboolean) C.gboolean { | |
| 2444 | - | id := uintptr(viewID) | |
| 2445 | - | urlStr := C.GoString(uri) | |
| 2446 | - | userGesture := isUserGesture != 0 | |
| 2447 | - | ||
| 2448 | - | log.Printf("[navigation-policy] WebView %d navigating to: %s (user_gesture=%t)", id, urlStr, userGesture) | |
| 2449 | - | ||
| 2450 | - | // Find the WebView | |
| 2451 | - | regMu.RLock() | |
| 2452 | - | view, exists := viewByID[id] | |
| 2453 | - | regMu.RUnlock() | |
| 2454 | - | ||
| 2455 | - | if !exists || view == nil { | |
| 2456 | - | log.Printf("[navigation-policy] WebView %d not found, allowing navigation", id) | |
| 2457 | - | return C.TRUE // Allow by default if view not found | |
| 2458 | - | } | |
| 2459 | - | ||
| 2460 | - | // Check if this webview has a navigation policy handler | |
| 2461 | - | if view.navigationPolicyHandler != nil { | |
| 2462 | - | allowed := view.navigationPolicyHandler(urlStr, userGesture) | |
| 2463 | - | if !allowed { | |
| 2464 | - | log.Printf("[navigation-policy] WebView %d: Navigation BLOCKED by handler", id) | |
| 2465 | - | return C.FALSE | |
| 2466 | - | } | |
| 2467 | - | } | |
| 2468 | - | ||
| 2469 | - | // Allow navigation | |
| 2470 | - | return C.TRUE | |
| 2471 | - | } | |
| 2472 | - | ||
| 2473 | - | // showTLSWarningDialog displays a warning dialog for TLS certificate errors with storage capability | |
| 2474 | - | func (w *WebView) showTLSWarningDialog(hostname, uri string, errorFlags int, certificateInfo string) bool { | |
| 2475 | - | if w == nil || w.native == nil || w.native.win == nil { | |
| 2476 | - | log.Printf("[tls] Cannot show dialog: WebView or window is nil") | |
| 2477 | - | return false | |
| 2478 | - | } | |
| 2479 | - | ||
| 2480 | - | // Check if we have a stored decision for this certificate | |
| 2481 | - | if storedDecision, hasStored := checkStoredCertificateDecision(hostname, certificateInfo); hasStored { | |
| 2482 | - | switch storedDecision { | |
| 2483 | - | case "accepted": | |
| 2484 | - | log.Printf("[tls] Using stored ACCEPT decision for %s", hostname) | |
| 2485 | - | return true | |
| 2486 | - | case "rejected": | |
| 2487 | - | log.Printf("[tls] Using stored REJECT decision for %s", hostname) | |
| 2488 | - | return false | |
| 2489 | - | default: | |
| 2490 | - | log.Printf("[tls] Unknown stored decision '%s' for %s, showing dialog", storedDecision, hostname) | |
| 2491 | - | } | |
| 2492 | - | } | |
| 2493 | - | ||
| 2494 | - | log.Printf("[tls] Showing certificate error dialog for %s", hostname) | |
| 2495 | - | ||
| 2496 | - | // Format the error message with certificate information | |
| 2497 | - | errorMsg := formatTLSErrorMessage(hostname, uri, errorFlags, certificateInfo) | |
| 2498 | - | ||
| 2499 | - | // Convert strings to C strings | |
| 2500 | - | cHostname := C.CString(hostname) | |
| 2501 | - | defer C.free(unsafe.Pointer(cHostname)) | |
| 2502 | - | ||
| 2503 | - | cErrorMsg := C.CString(errorMsg) | |
| 2504 | - | defer C.free(unsafe.Pointer(cErrorMsg)) | |
| 2505 | - | ||
| 2506 | - | // Show the dialog and get user response (0=Go Back, 1=Proceed Once, 2=Always Accept) | |
| 2507 | - | result := C.show_tls_warning_dialog_sync( | |
| 2508 | - | (*C.GtkWindow)(unsafe.Pointer(w.native.win)), | |
| 2509 | - | cHostname, | |
| 2510 | - | cErrorMsg, | |
| 2511 | - | ) | |
| 2512 | - | ||
| 2513 | - | responseCode := int(result) | |
| 2514 | - | ||
| 2515 | - | switch responseCode { | |
| 2516 | - | case 0: // Go Back | |
| 2517 | - | log.Printf("[tls] User chose to GO BACK for %s", hostname) | |
| 2518 | - | return false | |
| 2519 | - | ||
| 2520 | - | case 1: // Proceed Once | |
| 2521 | - | log.Printf("[tls] User chose to PROCEED ONCE for %s", hostname) | |
| 2522 | - | // Store temporary decision (expires in 24 hours) | |
| 2523 | - | if err := storeCertificateDecision(hostname, certificateInfo, "accepted", false); err != nil { | |
| 2524 | - | log.Printf("[tls] Warning: Failed to store temporary certificate decision: %v", err) | |
| 2525 | - | } | |
| 2526 | - | return true | |
| 2527 | - | ||
| 2528 | - | case 2: // Always Accept | |
| 2529 | - | log.Printf("[tls] User chose to ALWAYS ACCEPT for %s", hostname) | |
| 2530 | - | // Store permanent decision | |
| 2531 | - | if err := storeCertificateDecision(hostname, certificateInfo, "accepted", true); err != nil { | |
| 2532 | - | log.Printf("[tls] Warning: Failed to store permanent certificate decision: %v", err) | |
| 2533 | - | } | |
| 2534 | - | return true | |
| 2535 | - | ||
| 2536 | - | default: | |
| 2537 | - | log.Printf("[tls] Unknown response code %d for %s, defaulting to reject", responseCode, hostname) | |
| 2538 | - | return false | |
| 2539 | - | } | |
| 2540 | - | } | |
| 2541 | - | ||
| 2542 | - | // formatTLSErrorMessage creates a detailed error message based on the error flags | |
| 2543 | - | func formatTLSErrorMessage(hostname, uri string, errorFlags int, certificateInfo string) string { | |
| 2544 | - | msg := "Website: " + hostname + "\n\n" | |
| 2545 | - | ||
| 2546 | - | // Add certificate information | |
| 2547 | - | if certificateInfo != "" { | |
| 2548 | - | msg += "Certificate Details:\n" + certificateInfo + "\n" | |
| 2549 | - | } | |
| 2550 | - | ||
| 2551 | - | msg += "Security Issues:\n" | |
| 2552 | - | // Decode GTlsCertificateFlags (common values) | |
| 2553 | - | if errorFlags&1 != 0 { // G_TLS_CERTIFICATE_UNKNOWN_CA | |
| 2554 | - | msg += "• Certificate authority is not trusted\n" | |
| 2555 | - | } | |
| 2556 | - | if errorFlags&2 != 0 { // G_TLS_CERTIFICATE_BAD_IDENTITY | |
| 2557 | - | msg += "• Certificate does not match the website identity\n" | |
| 2558 | - | } | |
| 2559 | - | if errorFlags&4 != 0 { // G_TLS_CERTIFICATE_NOT_ACTIVATED | |
| 2560 | - | msg += "• Certificate is not yet valid\n" | |
| 2561 | - | } | |
| 2562 | - | if errorFlags&8 != 0 { // G_TLS_CERTIFICATE_EXPIRED | |
| 2563 | - | msg += "• Certificate has expired\n" | |
| 2564 | - | } | |
| 2565 | - | if errorFlags&16 != 0 { // G_TLS_CERTIFICATE_REVOKED | |
| 2566 | - | msg += "• Certificate has been revoked\n" | |
| 2567 | - | } | |
| 2568 | - | if errorFlags&32 != 0 { // G_TLS_CERTIFICATE_INSECURE | |
| 2569 | - | msg += "• Certificate uses weak cryptography\n" | |
| 2570 | - | } | |
| 2571 | - | ||
| 2572 | - | msg += "\nProceeding may expose your data to attackers.\nOnly continue if you trust this website." | |
| 2573 | - | ||
| 2574 | - | return msg | |
| 2575 | - | } | |
| 2576 | - | ||
| 2577 | - | // InitializeContentBlocking initializes WebKit content blocking with filter manager | |
| 2578 | - | func (w *WebView) InitializeContentBlocking(filterManager FilterManager) error { | |
| 2579 | - | if w == nil || w.destroyed || w.native == nil || w.native.wv == nil { | |
| 2580 | - | return ErrWebViewNotInitialized | |
| 2581 | - | } | |
| 2582 | - | ||
| 2583 | - | // Load filters asynchronously with proper error recovery | |
| 2584 | - | go func() { | |
| 2585 | - | defer func() { | |
| 2586 | - | if r := recover(); r != nil { | |
| 2587 | - | logging.Error(fmt.Sprintf("[webkit] Content blocking initialization panic: %v", r)) | |
| 2588 | - | } | |
| 2589 | - | }() | |
| 2590 | - | ||
| 2591 | - | // Wait longer for WebView to be ready before applying filters on first load | |
| 2592 | - | // This prevents interference with WebKit's preconnect operations | |
| 2593 | - | time.Sleep(1500 * time.Millisecond) | |
| 2594 | - | ||
| 2595 | - | // Retry mechanism with exponential backoff | |
| 2596 | - | maxRetries := 5 | |
| 2597 | - | baseDelay := 200 * time.Millisecond | |
| 2598 | - | ||
| 2599 | - | // Apply network filters with error recovery | |
| 2600 | - | for i := 0; i < maxRetries; i++ { | |
| 2601 | - | if w.destroyed { | |
| 2602 | - | logging.Debug("[webkit] WebView destroyed, aborting filter loading") | |
| 2603 | - | return | |
| 2604 | - | } | |
| 2605 | - | ||
| 2606 | - | if filters, err := filterManager.GetNetworkFilters(); err == nil { | |
| 2607 | - | if len(filters) > 10 && string(filters) != "null" { | |
| 2608 | - | logging.Info("[webkit] Loading network filters from filter manager (" + fmt.Sprintf("%d", len(filters)) + " bytes)") | |
| 2609 | - | if err := w.ApplyContentFilters(filters, "network-filters"); err != nil { | |
| 2610 | - | logging.Error("Failed to apply network filters (retry " + fmt.Sprintf("%d", i+1) + "): " + err.Error()) | |
| 2611 | - | if i == maxRetries-1 { | |
| 2612 | - | logging.Warn("[webkit] Network filtering disabled due to errors") | |
| 2613 | - | break | |
| 2614 | - | } | |
| 2615 | - | time.Sleep(baseDelay * time.Duration(1<<uint(i))) // Exponential backoff | |
| 2616 | - | continue | |
| 2617 | - | } | |
| 2618 | - | break | |
| 2619 | - | } | |
| 2620 | - | } else if i == maxRetries-1 { | |
| 2621 | - | logging.Error("Failed to get network filters after retries: " + err.Error()) | |
| 2622 | - | } | |
| 2623 | - | } | |
| 2624 | - | ||
| 2625 | - | // Apply cosmetic filtering with error recovery | |
| 2626 | - | time.Sleep(200 * time.Millisecond) // Additional delay to ensure network setup is complete | |
| 2627 | - | ||
| 2628 | - | for i := 0; i < maxRetries; i++ { | |
| 2629 | - | if w.destroyed { | |
| 2630 | - | logging.Debug("[webkit] WebView destroyed, aborting cosmetic filter loading") | |
| 2631 | - | return | |
| 2632 | - | } | |
| 2633 | - | ||
| 2634 | - | script := filterManager.GetCosmeticScript() | |
| 2635 | - | if len(script) > 0 { | |
| 2636 | - | logging.Info("[webkit] Loading cosmetic script from filter manager (" + fmt.Sprintf("%d", len(script)) + " chars)") | |
| 2637 | - | if err := w.InjectCosmeticFilter(script); err != nil { | |
| 2638 | - | logging.Error("Failed to inject cosmetic filter (retry " + fmt.Sprintf("%d", i+1) + "): " + err.Error()) | |
| 2639 | - | if i == maxRetries-1 { | |
| 2640 | - | logging.Warn("[webkit] Cosmetic filtering disabled due to errors") | |
| 2641 | - | } else { | |
| 2642 | - | time.Sleep(baseDelay * time.Duration(1<<uint(i))) | |
| 2643 | - | continue | |
| 2644 | - | } | |
| 2645 | - | } | |
| 2646 | - | break | |
| 2647 | - | } | |
| 2648 | - | } | |
| 2649 | - | ||
| 2650 | - | logging.Info("Content blocking initialization completed") | |
| 2651 | - | }() | |
| 2652 | - | ||
| 2653 | - | return nil | |
| 2654 | - | } | |
| 2655 | - | ||
| 2656 | - | // OnNavigate sets up domain-specific cosmetic filtering on navigation | |
| 2657 | - | func (w *WebView) OnNavigate(url string, filterManager FilterManager, whitelist []string) { | |
| 2658 | - | if w == nil || w.destroyed { | |
| 2659 | - | return | |
| 2660 | - | } | |
| 2661 | - | ||
| 2662 | - | domain := extractDomain(url) | |
| 2663 | - | if domain == "" { | |
| 2664 | - | return | |
| 2665 | - | } | |
| 2666 | - | ||
| 2667 | - | // Skip cosmetic filtering for whitelisted domains (e.g., Twitch with Arkose Labs bot detection) | |
| 2668 | - | for _, whitelistedDomain := range whitelist { | |
| 2669 | - | if domain == whitelistedDomain || strings.HasSuffix(domain, "."+whitelistedDomain) { | |
| 2670 | - | logging.Info("[webkit] Skipping cosmetic filtering for whitelisted domain: " + domain) | |
| 2671 | - | return | |
| 2672 | - | } | |
| 2673 | - | } | |
| 2674 | - | ||
| 2675 | - | // Get domain-specific cosmetic rules | |
| 2676 | - | script := filterManager.GetCosmeticScriptForDomain(domain) | |
| 2677 | - | if script != "" { | |
| 2678 | - | // Inject domain-specific cosmetic rules | |
| 2679 | - | logging.Info("[webkit] Applying domain-specific cosmetic rules for: " + domain + " (" + fmt.Sprintf("%d", len(script)) + " chars)") | |
| 2680 | - | w.InjectScript(fmt.Sprintf( | |
| 2681 | - | "if (typeof window.__dumber_cosmetic_init === 'function') { %s }", | |
| 2682 | - | script, | |
| 2683 | - | )) | |
| 2684 | - | } else { | |
| 2685 | - | logging.Debug("[webkit] No domain-specific cosmetic rules found for: " + domain) | |
| 2686 | - | } | |
| 2687 | - | } | |
| 2688 | - | ||
| 2689 | - | // UpdateContentFilters updates the content filters dynamically | |
| 2690 | - | func (w *WebView) UpdateContentFilters(filterManager FilterManager) error { | |
| 2691 | - | if w == nil || w.destroyed || w.native == nil || w.native.wv == nil { | |
| 2692 | - | return ErrWebViewNotInitialized | |
| 2693 | - | } | |
| 2694 | - | ||
| 2695 | - | // Clear existing filters first | |
| 2696 | - | if err := w.ClearAllFilters(); err != nil { | |
| 2697 | - | return fmt.Errorf("failed to clear existing filters: %w", err) | |
| 2698 | - | } | |
| 2699 | - | ||
| 2700 | - | // Apply new filters | |
| 2701 | - | if filters, err := filterManager.GetNetworkFilters(); err == nil { | |
| 2702 | - | logging.Info("[webkit] Updating network filters (" + fmt.Sprintf("%d", len(filters)) + " bytes)") | |
| 2703 | - | if err := w.ApplyContentFilters(filters, "network-filters-updated"); err != nil { | |
| 2704 | - | return fmt.Errorf("failed to apply updated network filters: %w", err) | |
| 2705 | - | } | |
| 2706 | - | } | |
| 2707 | - | ||
| 2708 | - | // Update cosmetic filtering | |
| 2709 | - | script := filterManager.GetCosmeticScript() | |
| 2710 | - | logging.Info("[webkit] Updating cosmetic script (" + fmt.Sprintf("%d", len(script)) + " chars)") | |
| 2711 | - | if err := w.InjectCosmeticFilter(script); err != nil { | |
| 2712 | - | return fmt.Errorf("failed to inject updated cosmetic filter: %w", err) | |
| 2713 | - | } | |
| 2714 | - | ||
| 2715 | - | logging.Info("Content filters updated successfully") | |
| 2716 | - | return nil | |
| 2717 | - | } | |
| 2718 | - | ||
| 2719 | - | // extractDomain extracts the domain from a URL | |
| 2720 | - | func extractDomain(url string) string { | |
| 2721 | - | if url == "" { | |
| 2722 | - | return "" | |
| 2723 | - | } | |
| 2724 | - | ||
| 2725 | - | // Simple domain extraction - remove protocol and path | |
| 2726 | - | start := strings.Index(url, "://") | |
| 2727 | - | if start != -1 { | |
| 2728 | - | url = url[start+3:] | |
| 2729 | - | } | |
| 2730 | - | ||
| 2731 | - | end := strings.Index(url, "/") | |
| 2732 | - | if end != -1 { | |
| 2733 | - | url = url[:end] | |
| 2734 | - | } | |
| 2735 | - | ||
| 2736 | - | // Remove port if present | |
| 2737 | - | if portIdx := strings.Index(url, ":"); portIdx != -1 { | |
| 2738 | - | url = url[:portIdx] | |
| 2739 | - | } | |
| 2740 | - | ||
| 2741 | - | return url | |
| 2742 | - | } | |
| 2743 | - | ||
| 2744 | - | // detectAndLogRenderingBackend detects and logs the actual rendering backend being used | |
| 2745 | - | func (w *WebView) detectAndLogRenderingBackend() { | |
| 2746 | - | if w == nil { | |
| 2747 | - | return | |
| 2748 | - | } | |
| 2749 | - | ||
| 2750 | - | // Use a small delay to let WebKit initialize | |
| 2751 | - | go func() { | |
| 2752 | - | time.Sleep(500 * time.Millisecond) | |
| 2753 | - | ||
| 2754 | - | // Inject a script to detect rendering capabilities | |
| 2755 | - | detectionScript := ` | |
| 2756 | - | (() => { | |
| 2757 | - | try { | |
| 2758 | - | const canvas = document.createElement('canvas'); | |
| 2759 | - | const gl = canvas.getContext('webgl') || canvas.getContext('experimental-webgl'); | |
| 2760 | - | const info = { | |
| 2761 | - | webgl_available: !!gl, | |
| 2762 | - | renderer: gl ? gl.getParameter(gl.RENDERER) : 'N/A', | |
| 2763 | - | vendor: gl ? gl.getParameter(gl.VENDOR) : 'N/A', | |
| 2764 | - | version: gl ? gl.getParameter(gl.VERSION) : 'N/A', | |
| 2765 | - | shading_language_version: gl ? gl.getParameter(gl.SHADING_LANGUAGE_VERSION) : 'N/A', | |
| 2766 | - | max_texture_size: gl ? gl.getParameter(gl.MAX_TEXTURE_SIZE) : 0, | |
| 2767 | - | hardware_accelerated: false | |
| 2768 | - | }; | |
| 2769 | - | ||
| 2770 | - | // Check if renderer indicates hardware acceleration | |
| 2771 | - | if (gl && info.renderer) { | |
| 2772 | - | const renderer = info.renderer.toLowerCase(); | |
| 2773 | - | // Look for GPU indicators | |
| 2774 | - | info.hardware_accelerated = ( | |
| 2775 | - | renderer.includes('nvidia') || | |
| 2776 | - | renderer.includes('amd') || | |
| 2777 | - | renderer.includes('intel') || | |
| 2778 | - | renderer.includes('radeon') || | |
| 2779 | - | renderer.includes('geforce') || | |
| 2780 | - | renderer.includes('quadro') || | |
| 2781 | - | (renderer.includes('mesa') && !renderer.includes('software')) || | |
| 2782 | - | !renderer.includes('software') | |
| 2783 | - | ); | |
| 2784 | - | } | |
| 2785 | - | ||
| 2786 | - | window.webkit?.messageHandlers?.dumber?.postMessage(JSON.stringify({ | |
| 2787 | - | type: 'rendering_backend_detection', | |
| 2788 | - | data: info | |
| 2789 | - | })); | |
| 2790 | - | } catch (e) { | |
| 2791 | - | console.error('[dumber] Rendering backend detection failed:', e); | |
| 2792 | - | } | |
| 2793 | - | })(); | |
| 2794 | - | ` | |
| 2795 | - | ||
| 2796 | - | w.InjectScript(detectionScript) | |
| 2797 | - | }() | |
| 2798 | - | } | |
| 2799 | - | ||
| 2800 | - | // SetWindowFeatures sets the window features for this WebView | |
| 2801 | - | func (w *WebView) SetWindowFeatures(features *WindowFeatures) { | |
| 2802 | - | w.windowFeatures = features | |
| 2803 | - | } | |
| 2804 | - | ||
| 2805 | - | // SetActive sets whether this WebView is currently active/focused | |
| 2806 | - | func (w *WebView) SetActive(active bool) { | |
| 2807 | - | if w == nil || w.destroyed { | |
| 2808 | - | return | |
| 2809 | - | } | |
| 2810 | - | ||
| 2811 | - | if w.isActive != active { | |
| 2812 | - | w.isActive = active | |
| 2813 | - | log.Printf("[webkit] WebView %d active state changed to: %v", w.id, active) | |
| 2814 | - | ||
| 2815 | - | // Update the active state in the JavaScript context | |
| 2816 | - | activeStr := "false" | |
| 2817 | - | if active { | |
| 2818 | - | activeStr = "true" | |
| 2819 | - | } | |
| 2820 | - | script := fmt.Sprintf("window.__dumber_is_active = %s;", activeStr) | |
| 2821 | - | w.InjectScript(script) | |
| 2822 | - | } | |
| 2823 | - | } | |
| 2824 | - | ||
| 2825 | - | // IsActive returns whether this WebView is currently active/focused | |
| 2826 | - | func (w *WebView) IsActive() bool { | |
| 2827 | - | return w != nil && w.isActive | |
| 2828 | - | } | |
| 2829 | - | ||
| 2830 | - | // IsDestroyed returns whether this WebView has been destroyed | |
| 2831 | - | func (w *WebView) IsDestroyed() bool { | |
| 2832 | - | return w == nil || w.destroyed | |
| 2833 | - | } | |
| 2834 | - | ||
| 2835 | - | // ID returns the unique identifier for this WebView as a string | |
| 2836 | - | func (w *WebView) ID() string { | |
| 2837 | - | if w == nil { | |
| 2838 | - | return "" | |
| 2839 | - | } | |
| 2840 | - | return strconv.FormatUint(uint64(w.id), 10) | |
| 2841 | - | } | |
| 2842 | - | ||
| 2843 | - | // PrefersDarkTheme returns true if GTK is configured to prefer dark theme | |
| 2844 | - | func PrefersDarkTheme() bool { | |
| 2845 | - | return C.gtk_prefers_dark() != 0 | |
| 2846 | - | } | |
A pkg/webkit/webview_related.go +118 -0
| @@ -0,0 +1,118 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | /* | |
| 4 | + | #cgo pkg-config: webkitgtk-6.0 gtk4 | |
| 5 | + | #include <webkit/webkit.h> | |
| 6 | + | #include <gtk/gtk.h> | |
| 7 | + | #include <stdlib.h> | |
| 8 | + | ||
| 9 | + | // create_related_web_view creates a WebView with the related-view property set | |
| 10 | + | // This is the WebKitGTK 6.0 way to share session/cookies between views (for popups) | |
| 11 | + | // The related-view property is construct-only, so it must be set during g_object_new | |
| 12 | + | static inline WebKitWebView* create_related_web_view(WebKitWebView* parent) { | |
| 13 | + | return WEBKIT_WEB_VIEW(g_object_new( | |
| 14 | + | WEBKIT_TYPE_WEB_VIEW, | |
| 15 | + | "related-view", parent, | |
| 16 | + | NULL | |
| 17 | + | )); | |
| 18 | + | } | |
| 19 | + | */ | |
| 20 | + | import "C" | |
| 21 | + | import ( | |
| 22 | + | "log" | |
| 23 | + | "runtime" | |
| 24 | + | "unsafe" | |
| 25 | + | ||
| 26 | + | webkit "github.com/diamondburned/gotk4-webkitgtk/pkg/webkit/v6" | |
| 27 | + | coreglib "github.com/diamondburned/gotk4/pkg/core/glib" | |
| 28 | + | "github.com/diamondburned/gotk4/pkg/gtk/v4" | |
| 29 | + | ) | |
| 30 | + | ||
| 31 | + | // NewBareRelatedWebView creates a bare gotk4 WebView with the related-view property set | |
| 32 | + | // This ensures the popup shares the same session/process context as the parent | |
| 33 | + | // The returned WebView is bare (no wrapper, no initialization) and must be initialized later | |
| 34 | + | // | |
| 35 | + | // Since gotk4-webkitgtk's wrapWebView() is internal and cannot be accessed, | |
| 36 | + | // we manually construct the WebView struct hierarchy using CGO. | |
| 37 | + | func NewBareRelatedWebView(parentView *webkit.WebView) *webkit.WebView { | |
| 38 | + | if parentView == nil { | |
| 39 | + | log.Printf("[webkit] NewBareRelatedWebView: parent is nil, creating unrelated WebView") | |
| 40 | + | return webkit.NewWebView() | |
| 41 | + | } | |
| 42 | + | ||
| 43 | + | // Get the native C pointer from the gotk4 WebView | |
| 44 | + | parentObj := coreglib.InternObject(parentView) | |
| 45 | + | if parentObj == nil { | |
| 46 | + | log.Printf("[webkit] NewBareRelatedWebView: failed to get parent object, creating unrelated WebView") | |
| 47 | + | return webkit.NewWebView() | |
| 48 | + | } | |
| 49 | + | parentNative := (*C.WebKitWebView)(unsafe.Pointer(parentObj.Native())) | |
| 50 | + | if parentNative == nil { | |
| 51 | + | log.Printf("[webkit] NewBareRelatedWebView: parent native pointer is nil, creating unrelated WebView") | |
| 52 | + | return webkit.NewWebView() | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | log.Printf("[webkit] Creating bare WebView with related-view property (parent=%p)", parentNative) | |
| 56 | + | ||
| 57 | + | // Create related WebView using CGO helper with g_object_new | |
| 58 | + | log.Printf("[webkit] Step 3: About to call C.create_related_web_view") | |
| 59 | + | webViewNative := C.create_related_web_view(parentNative) | |
| 60 | + | log.Printf("[webkit] Step 4: C.create_related_web_view returned: %p", webViewNative) | |
| 61 | + | ||
| 62 | + | if webViewNative == nil { | |
| 63 | + | log.Printf("[webkit] NewBareRelatedWebView: C.create_related_web_view returned nil") | |
| 64 | + | return nil | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | // Wrap the C object in a coreglib.Object | |
| 68 | + | // This takes ownership of the floating reference | |
| 69 | + | log.Printf("[webkit] Step 5: About to call coreglib.Take") | |
| 70 | + | obj := coreglib.Take(unsafe.Pointer(webViewNative)) | |
| 71 | + | log.Printf("[webkit] Step 6: coreglib.Take succeeded, obj=%p", obj) | |
| 72 | + | ||
| 73 | + | // Manually construct the WebView struct hierarchy | |
| 74 | + | // WebView -> WebViewBase -> gtk.Widget -> coreglib.InitiallyUnowned -> coreglib.Object | |
| 75 | + | // | |
| 76 | + | // Since we cannot call the internal wrapWebView() function, we must | |
| 77 | + | // manually construct the full struct hierarchy using the same pattern | |
| 78 | + | // that gotk4 uses internally. | |
| 79 | + | ||
| 80 | + | log.Printf("[webkit] Step 7: About to create gtk.Widget base") | |
| 81 | + | // Create the gtk.Widget base | |
| 82 | + | widget := gtk.Widget{ | |
| 83 | + | InitiallyUnowned: coreglib.InitiallyUnowned{ | |
| 84 | + | Object: obj, | |
| 85 | + | }, | |
| 86 | + | Object: obj, | |
| 87 | + | Accessible: gtk.Accessible{ | |
| 88 | + | Object: obj, | |
| 89 | + | }, | |
| 90 | + | Buildable: gtk.Buildable{ | |
| 91 | + | Object: obj, | |
| 92 | + | }, | |
| 93 | + | ConstraintTarget: gtk.ConstraintTarget{ | |
| 94 | + | Object: obj, | |
| 95 | + | }, | |
| 96 | + | } | |
| 97 | + | log.Printf("[webkit] Step 8: Created gtk.Widget base") | |
| 98 | + | ||
| 99 | + | // Create the WebViewBase | |
| 100 | + | log.Printf("[webkit] Step 9: About to create WebViewBase") | |
| 101 | + | webViewBase := webkit.WebViewBase{ | |
| 102 | + | Widget: widget, | |
| 103 | + | } | |
| 104 | + | log.Printf("[webkit] Step 10: Created WebViewBase") | |
| 105 | + | ||
| 106 | + | // Create the final WebView | |
| 107 | + | log.Printf("[webkit] Step 11: About to create final WebView") | |
| 108 | + | relatedView := &webkit.WebView{ | |
| 109 | + | WebViewBase: webViewBase, | |
| 110 | + | } | |
| 111 | + | log.Printf("[webkit] Step 12: Created final WebView: %p", relatedView) | |
| 112 | + | ||
| 113 | + | // Ensure parentView is kept alive until after the CGO call completes | |
| 114 | + | runtime.KeepAlive(parentView) | |
| 115 | + | ||
| 116 | + | log.Printf("[webkit] Created bare related WebView (parent=%p)", parentNative) | |
| 117 | + | return relatedView | |
| 118 | + | } | |
A pkg/webkit/widgets.go +290 -0
| @@ -0,0 +1,290 @@ | |||
| 1 | + | package webkit | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "github.com/diamondburned/gotk4/pkg/gtk/v4" | |
| 5 | + | "github.com/diamondburned/gotk4/pkg/pango" | |
| 6 | + | ) | |
| 7 | + | ||
| 8 | + | // Paned wraps gtk.Paned for split pane layouts | |
| 9 | + | type Paned struct { | |
| 10 | + | paned *gtk.Paned | |
| 11 | + | } | |
| 12 | + | ||
| 13 | + | // NewPaned creates a new Paned widget with the given orientation | |
| 14 | + | func NewPaned(orientation gtk.Orientation) *Paned { | |
| 15 | + | return &Paned{ | |
| 16 | + | paned: gtk.NewPaned(orientation), | |
| 17 | + | } | |
| 18 | + | } | |
| 19 | + | ||
| 20 | + | // SetStartChild sets the first child of the paned | |
| 21 | + | func (p *Paned) SetStartChild(child gtk.Widgetter) { | |
| 22 | + | if p == nil || p.paned == nil { | |
| 23 | + | return | |
| 24 | + | } | |
| 25 | + | p.paned.SetStartChild(child) | |
| 26 | + | } | |
| 27 | + | ||
| 28 | + | // SetEndChild sets the second child of the paned | |
| 29 | + | func (p *Paned) SetEndChild(child gtk.Widgetter) { | |
| 30 | + | if p == nil || p.paned == nil { | |
| 31 | + | return | |
| 32 | + | } | |
| 33 | + | p.paned.SetEndChild(child) | |
| 34 | + | } | |
| 35 | + | ||
| 36 | + | // SetPosition sets the divider position | |
| 37 | + | func (p *Paned) SetPosition(pos int) { | |
| 38 | + | if p == nil || p.paned == nil { | |
| 39 | + | return | |
| 40 | + | } | |
| 41 | + | p.paned.SetPosition(pos) | |
| 42 | + | } | |
| 43 | + | ||
| 44 | + | // GetPosition returns the divider position | |
| 45 | + | func (p *Paned) GetPosition() int { | |
| 46 | + | if p == nil || p.paned == nil { | |
| 47 | + | return 0 | |
| 48 | + | } | |
| 49 | + | return p.paned.Position() | |
| 50 | + | } | |
| 51 | + | ||
| 52 | + | // SetResizeStartChild sets whether the start child should resize | |
| 53 | + | func (p *Paned) SetResizeStartChild(resize bool) { | |
| 54 | + | if p == nil || p.paned == nil { | |
| 55 | + | return | |
| 56 | + | } | |
| 57 | + | p.paned.SetResizeStartChild(resize) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | // SetResizeEndChild sets whether the end child should resize | |
| 61 | + | func (p *Paned) SetResizeEndChild(resize bool) { | |
| 62 | + | if p == nil || p.paned == nil { | |
| 63 | + | return | |
| 64 | + | } | |
| 65 | + | p.paned.SetResizeEndChild(resize) | |
| 66 | + | } | |
| 67 | + | ||
| 68 | + | // AsWidget returns the paned as a Widgetter | |
| 69 | + | func (p *Paned) AsWidget() gtk.Widgetter { | |
| 70 | + | if p == nil || p.paned == nil { | |
| 71 | + | return nil | |
| 72 | + | } | |
| 73 | + | return p.paned | |
| 74 | + | } | |
| 75 | + | ||
| 76 | + | // StartChild returns the start child | |
| 77 | + | func (p *Paned) StartChild() gtk.Widgetter { | |
| 78 | + | if p == nil || p.paned == nil { | |
| 79 | + | return nil | |
| 80 | + | } | |
| 81 | + | return p.paned.StartChild() | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | // EndChild returns the end child | |
| 85 | + | func (p *Paned) EndChild() gtk.Widgetter { | |
| 86 | + | if p == nil || p.paned == nil { | |
| 87 | + | return nil | |
| 88 | + | } | |
| 89 | + | return p.paned.EndChild() | |
| 90 | + | } | |
| 91 | + | ||
| 92 | + | // Box wraps gtk.Box for layout management | |
| 93 | + | type Box struct { | |
| 94 | + | box *gtk.Box | |
| 95 | + | } | |
| 96 | + | ||
| 97 | + | // NewBox creates a new Box with the given orientation and spacing | |
| 98 | + | func NewBox(orientation gtk.Orientation, spacing int) *Box { | |
| 99 | + | return &Box{ | |
| 100 | + | box: gtk.NewBox(orientation, spacing), | |
| 101 | + | } | |
| 102 | + | } | |
| 103 | + | ||
| 104 | + | // Append adds a child to the end of the box | |
| 105 | + | func (b *Box) Append(child gtk.Widgetter) { | |
| 106 | + | if b == nil || b.box == nil { | |
| 107 | + | return | |
| 108 | + | } | |
| 109 | + | b.box.Append(child) | |
| 110 | + | } | |
| 111 | + | ||
| 112 | + | // Prepend adds a child to the beginning of the box | |
| 113 | + | func (b *Box) Prepend(child gtk.Widgetter) { | |
| 114 | + | if b == nil || b.box == nil { | |
| 115 | + | return | |
| 116 | + | } | |
| 117 | + | b.box.Prepend(child) | |
| 118 | + | } | |
| 119 | + | ||
| 120 | + | // Remove removes a child from the box | |
| 121 | + | func (b *Box) Remove(child gtk.Widgetter) { | |
| 122 | + | if b == nil || b.box == nil { | |
| 123 | + | return | |
| 124 | + | } | |
| 125 | + | b.box.Remove(child) | |
| 126 | + | } | |
| 127 | + | ||
| 128 | + | // InsertChildAfter inserts a child after the specified sibling | |
| 129 | + | func (b *Box) InsertChildAfter(child, sibling gtk.Widgetter) { | |
| 130 | + | if b == nil || b.box == nil { | |
| 131 | + | return | |
| 132 | + | } | |
| 133 | + | b.box.InsertChildAfter(child, sibling) | |
| 134 | + | } | |
| 135 | + | ||
| 136 | + | // AsWidget returns the box as a Widgetter | |
| 137 | + | func (b *Box) AsWidget() gtk.Widgetter { | |
| 138 | + | if b == nil || b.box == nil { | |
| 139 | + | return nil | |
| 140 | + | } | |
| 141 | + | return b.box | |
| 142 | + | } | |
| 143 | + | ||
| 144 | + | // Label wraps gtk.Label for text display | |
| 145 | + | type Label struct { | |
| 146 | + | label *gtk.Label | |
| 147 | + | } | |
| 148 | + | ||
| 149 | + | // NewLabel creates a new Label with the given text | |
| 150 | + | func NewLabel(text string) *Label { | |
| 151 | + | return &Label{ | |
| 152 | + | label: gtk.NewLabel(text), | |
| 153 | + | } | |
| 154 | + | } | |
| 155 | + | ||
| 156 | + | // SetText updates the label text | |
| 157 | + | func (l *Label) SetText(text string) { | |
| 158 | + | if l == nil || l.label == nil { | |
| 159 | + | return | |
| 160 | + | } | |
| 161 | + | l.label.SetText(text) | |
| 162 | + | } | |
| 163 | + | ||
| 164 | + | // GetText returns the current label text | |
| 165 | + | func (l *Label) GetText() string { | |
| 166 | + | if l == nil || l.label == nil { | |
| 167 | + | return "" | |
| 168 | + | } | |
| 169 | + | return l.label.Text() | |
| 170 | + | } | |
| 171 | + | ||
| 172 | + | // SetEllipsize sets the ellipsization mode | |
| 173 | + | func (l *Label) SetEllipsize(mode pango.EllipsizeMode) { | |
| 174 | + | if l == nil || l.label == nil { | |
| 175 | + | return | |
| 176 | + | } | |
| 177 | + | l.label.SetEllipsize(mode) | |
| 178 | + | } | |
| 179 | + | ||
| 180 | + | // SetMaxWidthChars sets the maximum width in characters | |
| 181 | + | func (l *Label) SetMaxWidthChars(nChars int) { | |
| 182 | + | if l == nil || l.label == nil { | |
| 183 | + | return | |
| 184 | + | } | |
| 185 | + | l.label.SetMaxWidthChars(nChars) | |
| 186 | + | } | |
| 187 | + | ||
| 188 | + | // AsWidget returns the label as a Widgetter | |
| 189 | + | func (l *Label) AsWidget() gtk.Widgetter { | |
| 190 | + | if l == nil || l.label == nil { | |
| 191 | + | return nil | |
| 192 | + | } | |
| 193 | + | return l.label | |
| 194 | + | } | |
| 195 | + | ||
| 196 | + | // Image wraps gtk.Image for displaying images (e.g., favicons) | |
| 197 | + | type Image struct { | |
| 198 | + | image *gtk.Image | |
| 199 | + | } | |
| 200 | + | ||
| 201 | + | // NewImage creates a new empty Image | |
| 202 | + | func NewImage() *Image { | |
| 203 | + | return &Image{ | |
| 204 | + | image: gtk.NewImage(), | |
| 205 | + | } | |
| 206 | + | } | |
| 207 | + | ||
| 208 | + | // NewImageFromFile creates a new Image from a file | |
| 209 | + | func NewImageFromFile(filename string) *Image { | |
| 210 | + | return &Image{ | |
| 211 | + | image: gtk.NewImageFromFile(filename), | |
| 212 | + | } | |
| 213 | + | } | |
| 214 | + | ||
| 215 | + | // SetFromFile loads an image from a file | |
| 216 | + | func (i *Image) SetFromFile(filename string) { | |
| 217 | + | if i == nil || i.image == nil { | |
| 218 | + | return | |
| 219 | + | } | |
| 220 | + | i.image.SetFromFile(filename) | |
| 221 | + | } | |
| 222 | + | ||
| 223 | + | // SetPixelSize sets the pixel size for the image | |
| 224 | + | func (i *Image) SetPixelSize(size int) { | |
| 225 | + | if i == nil || i.image == nil { | |
| 226 | + | return | |
| 227 | + | } | |
| 228 | + | i.image.SetPixelSize(size) | |
| 229 | + | } | |
| 230 | + | ||
| 231 | + | // AsWidget returns the image as a Widgetter | |
| 232 | + | func (i *Image) AsWidget() gtk.Widgetter { | |
| 233 | + | if i == nil || i.image == nil { | |
| 234 | + | return nil | |
| 235 | + | } | |
| 236 | + | return i.image | |
| 237 | + | } | |
| 238 | + | ||
| 239 | + | // Widget helper functions for common operations | |
| 240 | + | ||
| 241 | + | // getWidget extracts the underlying *gtk.Widget from a Widgetter | |
| 242 | + | // This is needed because some methods are only available on *gtk.Widget | |
| 243 | + | func getWidget(w gtk.Widgetter) *gtk.Widget { | |
| 244 | + | if w == nil { | |
| 245 | + | return nil | |
| 246 | + | } | |
| 247 | + | return gtk.BaseWidget(w) | |
| 248 | + | } | |
| 249 | + | ||
| 250 | + | // WidgetShow makes a widget visible | |
| 251 | + | func WidgetShow(w gtk.Widgetter) { | |
| 252 | + | if widget := getWidget(w); widget != nil { | |
| 253 | + | widget.SetVisible(true) | |
| 254 | + | } | |
| 255 | + | } | |
| 256 | + | ||
| 257 | + | // WidgetHide makes a widget invisible | |
| 258 | + | func WidgetHide(w gtk.Widgetter) { | |
| 259 | + | if widget := getWidget(w); widget != nil { | |
| 260 | + | widget.SetVisible(false) | |
| 261 | + | } | |
| 262 | + | } | |
| 263 | + | ||
| 264 | + | // WidgetSetVisible sets the visibility of a widget | |
| 265 | + | func WidgetSetVisible(w gtk.Widgetter, visible bool) { | |
| 266 | + | if widget := getWidget(w); widget != nil { | |
| 267 | + | widget.SetVisible(visible) | |
| 268 | + | } | |
| 269 | + | } | |
| 270 | + | ||
| 271 | + | // WidgetSetSizeRequest sets the minimum size request | |
| 272 | + | func WidgetSetSizeRequest(w gtk.Widgetter, width, height int) { | |
| 273 | + | if widget := getWidget(w); widget != nil { | |
| 274 | + | widget.SetSizeRequest(width, height) | |
| 275 | + | } | |
| 276 | + | } | |
| 277 | + | ||
| 278 | + | // WidgetQueueResize queues a resize operation | |
| 279 | + | func WidgetQueueResize(w gtk.Widgetter) { | |
| 280 | + | if widget := getWidget(w); widget != nil { | |
| 281 | + | widget.QueueResize() | |
| 282 | + | } | |
| 283 | + | } | |
| 284 | + | ||
| 285 | + | // WidgetGrabFocus gives focus to a widget | |
| 286 | + | func WidgetGrabFocus(w gtk.Widgetter) { | |
| 287 | + | if widget := getWidget(w); widget != nil { | |
| 288 | + | widget.GrabFocus() | |
| 289 | + | } | |
| 290 | + | } | |
M pkg/webkit/window.go +71 -38
| @@ -1,59 +1,92 @@ | |||
| 1 | - | //go:build !webkit_cgo | |
| 2 | - | ||
| 3 | 1 | 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. | |
| 8 | - | // Stub for future GTK application window management. | |
| 7 | + | // Window wraps a GTK Window for the browser | |
| 9 | 8 | type Window struct { | |
| 10 | - | Title string | |
| 11 | - | child uintptr | |
| 12 | - | mu sync.Mutex | |
| 9 | + | win *gtk.Window | |
| 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 | 13 | func NewWindow(title string) (*Window, error) { | |
| 17 | - | _ = title | |
| 18 | - | return &Window{Title: title}, ErrNotImplemented | |
| 14 | + | InitMainThread() | |
| 15 | + | ||
| 16 | + | win := gtk.NewWindow() | |
| 17 | + | if win == nil { | |
| 18 | + | return nil, ErrWebViewNotInitialized | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | win.SetTitle(title) | |
| 22 | + | win.SetDefaultSize(1024, 768) | |
| 23 | + | ||
| 24 | + | return &Window{win: win}, nil | |
| 19 | 25 | } | |
| 20 | 26 | ||
| 21 | - | // SetTitle updates the window title (no-op in non-CGO build). | |
| 27 | + | // SetTitle updates the window title | |
| 22 | 28 | func (w *Window) SetTitle(title string) { | |
| 23 | - | w.Title = title | |
| 29 | + | if w == nil || w.win == nil { | |
| 30 | + | return | |
| 31 | + | } | |
| 32 | + | w.win.SetTitle(title) | |
| 24 | 33 | } | |
| 25 | 34 | ||
| 26 | - | // SetChild is a stubbed window child setter for non-CGO builds. | |
| 27 | - | // Simulates GTK4 behavior where setting child to 0 unparents the current child. | |
| 28 | - | func (w *Window) SetChild(child uintptr) { | |
| 29 | - | if w == nil { | |
| 35 | + | // SetChild sets the child widget of the window | |
| 36 | + | func (w *Window) SetChild(child gtk.Widgetter) { | |
| 37 | + | if w == nil || w.win == nil { | |
| 30 | 38 | return | |
| 31 | 39 | } | |
| 40 | + | w.win.SetChild(child) | |
| 41 | + | } | |
| 32 | 42 | ||
| 33 | - | w.mu.Lock() | |
| 34 | - | defer w.mu.Unlock() | |
| 43 | + | // Show makes the window visible | |
| 44 | + | // Note: In GTK4, Show() is deprecated. Use SetVisible(true) instead. | |
| 45 | + | func (w *Window) Show() { | |
| 46 | + | if w == nil || w.win == nil { | |
| 47 | + | return | |
| 48 | + | } | |
| 49 | + | w.win.SetVisible(true) | |
| 50 | + | } | |
| 35 | 51 | ||
| 36 | - | // GTK4 behavior: Setting child to 0 removes and potentially invalidates the current child | |
| 37 | - | if child == 0 && w.child != 0 { | |
| 38 | - | // GTK4 automatically unparents the current child but keeps the widget alive | |
| 39 | - | widgetMu.Lock() | |
| 40 | - | if stub, ok := widgetState[w.child]; ok { | |
| 41 | - | stub.parent = 0 | |
| 42 | - | } | |
| 43 | - | widgetMu.Unlock() | |
| 52 | + | // Present brings the window to the front | |
| 53 | + | func (w *Window) Present() { | |
| 54 | + | if w == nil || w.win == nil { | |
| 55 | + | return | |
| 44 | 56 | } | |
| 57 | + | w.win.Present() | |
| 58 | + | } | |
| 45 | 59 | ||
| 46 | - | // GTK4 validation: if child is not 0, it must be a valid widget | |
| 47 | - | if child != 0 { | |
| 48 | - | widgetMu.Lock() | |
| 49 | - | childStub, childExists := widgetState[child] | |
| 50 | - | if !childExists || childStub.isDestroyed { | |
| 51 | - | widgetMu.Unlock() | |
| 52 | - | panic("GTK-CRITICAL simulation: gtk_window_set_child: assertion 'GTK_IS_WIDGET (widget)' failed - child is invalid") | |
| 53 | - | } | |
| 54 | - | childStub.parent = 1 // Simplified window handle | |
| 55 | - | widgetMu.Unlock() | |
| 60 | + | // Close destroys the window | |
| 61 | + | func (w *Window) Close() { | |
| 62 | + | if w == nil || w.win == nil { | |
| 63 | + | return | |
| 56 | 64 | } | |
| 65 | + | w.win.Close() | |
| 66 | + | } | |
| 57 | 67 | ||
| 58 | - | w.child = child | |
| 68 | + | // AsWindow returns the underlying gtk.Window for advanced operations | |
| 69 | + | func (w *Window) AsWindow() *gtk.Window { | |
| 70 | + | if w == nil { | |
| 71 | + | return nil | |
| 72 | + | } | |
| 73 | + | return w.win | |
| 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 | - | } | |